stygian_browser/tls_validation.rs
1//! Automated TLS fingerprint validation suite.
2//!
3//! Verifies that stygian's TLS profiles produce correct JA3/JA4 hashes and
4//! HTTP/2 SETTINGS frames when compared against real browser captures.
5//!
6//! Unit tests validate comparison logic and format of reference hashes. Network
7//! integration tests are `#[ignore]`-gated to avoid CI flakiness.
8//!
9//! # Example
10//!
11//! ```
12//! use stygian_browser::tls_validation::{TlsValidationReport, CHROME_136_JA3};
13//!
14//! let report = TlsValidationReport {
15//! ja3_expected: CHROME_136_JA3.to_string(),
16//! ja3_actual: CHROME_136_JA3.to_string(),
17//! ja3_match: true,
18//! ja4_expected: String::new(),
19//! ja4_actual: String::new(),
20//! ja4_match: true,
21//! http2_settings_match: true,
22//! alpn_match: true,
23//! issues: vec![],
24//! };
25//! assert!(report.is_ok());
26//! ```
27
28use serde::{Deserialize, Serialize};
29
30use crate::tls::TlsProfile;
31
32// ---------------------------------------------------------------------------
33// Reference hashes from real browser captures
34// ---------------------------------------------------------------------------
35
36/// JA3 hash for Google Chrome 131 (captured from real browser traffic).
37///
38/// Source: Chrome 131 on Linux/x86-64 — tls.peet.ws capture 2025-01.
39pub const CHROME_131_JA3: &str = "cd08e31494f9531f560d64c695473da9";
40
41/// JA3 hash for Google Chrome 136 (captured from real browser traffic).
42///
43/// Source: Chrome 136 on Windows/x86-64 — tls.peet.ws capture 2025-04.
44pub const CHROME_136_JA3: &str = "b32309a26951912be7dba376398abc3b";
45
46/// JA4 fingerprint for Google Chrome 136.
47///
48/// Format: `t<TLS_ver><SNI><cipher_cnt><ext_cnt>_<sorted_ciphers_sha256_prefix>_<sorted_exts_sha256_prefix>`
49pub const CHROME_136_JA4: &str = "t13d1516h2_8daaf6152771_b1ff8ab37d37";
50
51/// Chrome 136 HTTP/2 SETTINGS frame — ordered `(id, value)` pairs that the
52/// browser sends in its initial SETTINGS frame.
53///
54/// Values captured from a real Chrome 136 session. Order matters for anti-bot
55/// fingerprinting.
56pub const CHROME_136_HTTP2_SETTINGS: &[(u32, u32)] = &[
57 (1, 65_536), // HEADER_TABLE_SIZE
58 (2, 0), // ENABLE_PUSH (disabled)
59 (3, 1_000), // MAX_CONCURRENT_STREAMS
60 (4, 6_291_456), // INITIAL_WINDOW_SIZE
61 (6, 262_144), // MAX_HEADER_LIST_SIZE
62];
63
64// ---------------------------------------------------------------------------
65// TlsValidationReport
66// ---------------------------------------------------------------------------
67
68/// Result of validating a [`TlsProfile`] against expected browser fingerprints.
69///
70/// # Example
71///
72/// ```
73/// use stygian_browser::tls_validation::TlsValidationReport;
74///
75/// let report = TlsValidationReport::default();
76/// assert!(report.is_ok()); // empty report with all-match defaults is ok
77/// ```
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
79pub struct TlsValidationReport {
80 /// The expected JA3 hash (from reference captures).
81 pub ja3_expected: String,
82 /// The JA3 hash computed from the profile or observed from a live connection.
83 pub ja3_actual: String,
84 /// `true` when `ja3_expected == ja3_actual`.
85 pub ja3_match: bool,
86 /// The expected JA4 fingerprint.
87 pub ja4_expected: String,
88 /// The JA4 fingerprint computed from the profile or observed live.
89 pub ja4_actual: String,
90 /// `true` when `ja4_expected == ja4_actual`.
91 pub ja4_match: bool,
92 /// `true` when HTTP/2 SETTINGS match expected Chrome values.
93 pub http2_settings_match: bool,
94 /// `true` when ALPN protocol ordering matches expected values.
95 pub alpn_match: bool,
96 /// Human-readable list of mismatches. Empty when all checks pass.
97 pub issues: Vec<String>,
98}
99
100impl TlsValidationReport {
101 /// `true` when all checks passed (no issues).
102 #[must_use]
103 pub const fn is_ok(&self) -> bool {
104 self.issues.is_empty()
105 }
106}
107
108// ---------------------------------------------------------------------------
109// TlsValidationConfig
110// ---------------------------------------------------------------------------
111
112/// Configuration for live TLS validation against an echo service.
113///
114/// # Example
115///
116/// ```
117/// use stygian_browser::tls_validation::TlsValidationConfig;
118///
119/// let cfg = TlsValidationConfig::default();
120/// assert!(cfg.echo_service_url.contains("tls.peet.ws"));
121/// ```
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct TlsValidationConfig {
124 /// URL of a TLS fingerprint echo service.
125 ///
126 /// Must return JSON with at minimum a `ja3` field containing the observed hash.
127 pub echo_service_url: String,
128 /// Connection timeout in seconds.
129 pub timeout_secs: u64,
130}
131
132impl Default for TlsValidationConfig {
133 fn default() -> Self {
134 Self {
135 echo_service_url: "https://tls.peet.ws/api/all".into(),
136 timeout_secs: 10,
137 }
138 }
139}
140
141// ---------------------------------------------------------------------------
142// HTTP/2 SETTINGS comparison
143// ---------------------------------------------------------------------------
144
145/// Compare observed HTTP/2 SETTINGS against a reference list.
146///
147/// Returns `(matches, issues)` where `issues` contains human-readable
148/// descriptions of each mismatch.
149///
150/// # Example
151///
152/// ```
153/// use stygian_browser::tls_validation::{compare_http2_settings, CHROME_136_HTTP2_SETTINGS};
154///
155/// let (ok, issues) = compare_http2_settings(CHROME_136_HTTP2_SETTINGS, CHROME_136_HTTP2_SETTINGS);
156/// assert!(ok);
157/// assert!(issues.is_empty());
158/// ```
159#[must_use]
160pub fn compare_http2_settings(
161 expected: &[(u32, u32)],
162 observed: &[(u32, u32)],
163) -> (bool, Vec<String>) {
164 let mut issues = Vec::new();
165
166 // Check length
167 if expected.len() != observed.len() {
168 issues.push(format!(
169 "HTTP/2 SETTINGS count mismatch: expected {}, got {}",
170 expected.len(),
171 observed.len()
172 ));
173 }
174
175 // Compare by id (order-independent value check)
176 for &(exp_id, exp_val) in expected {
177 match observed.iter().find(|&&(id, _)| id == exp_id) {
178 None => issues.push(format!(
179 "HTTP/2 SETTINGS missing id={exp_id} (expected value {exp_val})"
180 )),
181 Some(&(_, obs_val)) if obs_val != exp_val => issues.push(format!(
182 "HTTP/2 SETTINGS id={exp_id}: expected {exp_val}, got {obs_val}"
183 )),
184 _ => {}
185 }
186 }
187
188 // Check for unexpected extra settings
189 for &(obs_id, _) in observed {
190 if !expected.iter().any(|&(id, _)| id == obs_id) {
191 issues.push(format!("HTTP/2 SETTINGS unexpected id={obs_id}"));
192 }
193 }
194
195 (issues.is_empty(), issues)
196}
197
198// ---------------------------------------------------------------------------
199// Profile static validation (no network)
200// ---------------------------------------------------------------------------
201
202/// Validate a [`TlsProfile`] against reference hashes without making a network
203/// connection.
204///
205/// The `expected_ja3` and `expected_ja4` parameters are compared against the
206/// hashes computed from the profile's cipher/extension/group fields.
207///
208/// # Example
209///
210/// ```
211/// use stygian_browser::tls::{CHROME_131, TlsProfile};
212/// use stygian_browser::tls_validation::{validate_profile_static, CHROME_131_JA3};
213///
214/// let report = validate_profile_static(
215/// &CHROME_131,
216/// CHROME_131_JA3,
217/// "",
218/// &[("h2", "http/1.1")],
219/// );
220/// // JA3 match depends on whether the profile matches the reference capture
221/// // (may differ across Chrome versions — see issues for details)
222/// let _ = report.is_ok();
223/// ```
224#[must_use]
225pub fn validate_profile_static(
226 profile: &TlsProfile,
227 expected_ja3: &str,
228 expected_ja4: &str,
229 expected_alpn: &[(&str, &str)],
230) -> TlsValidationReport {
231 let ja3 = profile.ja3();
232 let ja4 = profile.ja4();
233
234 let ja3_match = expected_ja3.is_empty() || ja3.hash == expected_ja3;
235 let ja4_match = expected_ja4.is_empty() || ja4.fingerprint == expected_ja4;
236
237 let profile_alpn: Vec<String> = profile
238 .alpn_protocols
239 .iter()
240 .map(|a| format!("{a:?}").to_lowercase())
241 .collect();
242 let expected_alpn_strs: Vec<String> = expected_alpn
243 .iter()
244 .map(|(a, _)| (*a).to_string())
245 .collect();
246 let alpn_match = expected_alpn.is_empty()
247 || profile_alpn
248 .iter()
249 .zip(expected_alpn_strs.iter())
250 .all(|(a, b)| a == b);
251
252 let mut issues = Vec::new();
253 if !ja3_match {
254 issues.push(format!(
255 "JA3 mismatch: expected `{expected_ja3}`, computed `{}`",
256 ja3.hash
257 ));
258 }
259 if !ja4_match {
260 issues.push(format!(
261 "JA4 mismatch: expected `{expected_ja4}`, computed `{}`",
262 ja4.fingerprint
263 ));
264 }
265 if !alpn_match {
266 issues.push(format!(
267 "ALPN mismatch: expected {expected_alpn_strs:?}, profile has {profile_alpn:?}"
268 ));
269 }
270
271 TlsValidationReport {
272 ja3_expected: expected_ja3.to_string(),
273 ja3_actual: ja3.hash,
274 ja3_match,
275 ja4_expected: expected_ja4.to_string(),
276 ja4_actual: ja4.fingerprint,
277 ja4_match,
278 http2_settings_match: true, // only testable live
279 alpn_match,
280 issues,
281 }
282}
283
284// ---------------------------------------------------------------------------
285// TlsProfile::validate extension
286// ---------------------------------------------------------------------------
287
288/// Extension trait that adds `validate_static()` to [`TlsProfile`].
289pub trait TlsProfileValidate {
290 /// Validate this profile against known reference hashes (no network required).
291 ///
292 /// # Example
293 ///
294 /// ```
295 /// use stygian_browser::tls::CHROME_131;
296 /// use stygian_browser::tls_validation::{TlsProfileValidate, CHROME_131_JA3};
297 ///
298 /// let report = CHROME_131.validate_static(CHROME_131_JA3, "");
299 /// let _ = report.is_ok(); // diff may exist across capture dates
300 /// ```
301 fn validate_static(&self, expected_ja3: &str, expected_ja4: &str) -> TlsValidationReport;
302}
303
304impl TlsProfileValidate for TlsProfile {
305 fn validate_static(&self, expected_ja3: &str, expected_ja4: &str) -> TlsValidationReport {
306 validate_profile_static(self, expected_ja3, expected_ja4, &[])
307 }
308}
309
310// ---------------------------------------------------------------------------
311// Tests
312// ---------------------------------------------------------------------------
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 // ── Reference hash format ─────────────────────────────────────────────────
319
320 #[test]
321 fn chrome_131_ja3_is_valid_md5_hex() {
322 assert_eq!(CHROME_131_JA3.len(), 32, "JA3 must be 32-char MD5 hex");
323 assert!(
324 CHROME_131_JA3.chars().all(|c| c.is_ascii_hexdigit()),
325 "JA3 must be hex"
326 );
327 }
328
329 #[test]
330 fn chrome_136_ja3_is_valid_md5_hex() {
331 assert_eq!(CHROME_136_JA3.len(), 32, "JA3 must be 32-char MD5 hex");
332 assert!(
333 CHROME_136_JA3.chars().all(|c| c.is_ascii_hexdigit()),
334 "JA3 must be hex"
335 );
336 }
337
338 #[test]
339 fn chrome_136_ja4_format() {
340 // JA4 starts with 't' (TLS) + version chars
341 assert!(
342 CHROME_136_JA4.starts_with('t'),
343 "JA4 must start with 't' for TLS"
344 );
345 // Must contain at least two underscore separators
346 assert_eq!(
347 CHROME_136_JA4.matches('_').count(),
348 2,
349 "JA4 must have 2 underscore separators"
350 );
351 }
352
353 // ── HTTP/2 SETTINGS comparison ────────────────────────────────────────────
354
355 #[test]
356 fn http2_settings_identical_match() {
357 let (ok, issues) =
358 compare_http2_settings(CHROME_136_HTTP2_SETTINGS, CHROME_136_HTTP2_SETTINGS);
359 assert!(ok);
360 assert!(issues.is_empty());
361 }
362
363 #[test]
364 fn http2_settings_missing_key_is_reported() {
365 let observed: Vec<(u32, u32)> = CHROME_136_HTTP2_SETTINGS.iter().copied().take(2).collect();
366 let (ok, issues) = compare_http2_settings(CHROME_136_HTTP2_SETTINGS, &observed);
367 assert!(!ok);
368 assert!(!issues.is_empty());
369 assert!(
370 issues
371 .iter()
372 .any(|i| i.contains("count mismatch") || i.contains("missing"))
373 );
374 }
375
376 #[test]
377 fn http2_settings_wrong_value_is_reported() {
378 let mut bad = CHROME_136_HTTP2_SETTINGS.to_vec();
379 // Corrupt INITIAL_WINDOW_SIZE
380 if let Some(slot) = bad.get_mut(3) {
381 *slot = (4, 65535);
382 }
383 let (ok, issues) = compare_http2_settings(CHROME_136_HTTP2_SETTINGS, &bad);
384 assert!(!ok);
385 assert!(issues.iter().any(|i| i.contains("id=4")));
386 }
387
388 #[test]
389 fn http2_settings_extra_key_is_reported() {
390 let mut extra = CHROME_136_HTTP2_SETTINGS.to_vec();
391 extra.push((99, 0));
392 let (ok, issues) = compare_http2_settings(CHROME_136_HTTP2_SETTINGS, &extra);
393 assert!(!ok);
394 assert!(issues.iter().any(|i| i.contains("unexpected id=99")));
395 }
396
397 // ── TlsValidationReport ───────────────────────────────────────────────────
398
399 #[test]
400 fn report_is_ok_when_no_issues() {
401 let report = TlsValidationReport {
402 ja3_expected: "abc".into(),
403 ja3_actual: "abc".into(),
404 ja3_match: true,
405 ja4_expected: String::new(),
406 ja4_actual: String::new(),
407 ja4_match: true,
408 http2_settings_match: true,
409 alpn_match: true,
410 issues: vec![],
411 };
412 assert!(report.is_ok());
413 }
414
415 #[test]
416 fn report_not_ok_when_has_issues() {
417 let report = TlsValidationReport {
418 ja3_match: false,
419 issues: vec!["JA3 mismatch".into()],
420 ..Default::default()
421 };
422 assert!(!report.is_ok());
423 }
424
425 #[test]
426 fn report_serde_round_trip() {
427 let report = TlsValidationReport {
428 ja3_expected: CHROME_131_JA3.into(),
429 ja3_actual: CHROME_136_JA3.into(),
430 ja3_match: false,
431 ja4_expected: CHROME_136_JA4.into(),
432 ja4_actual: CHROME_136_JA4.into(),
433 ja4_match: true,
434 http2_settings_match: false,
435 alpn_match: true,
436 issues: vec!["JA3 mismatch".into()],
437 };
438 let json_result = serde_json::to_string(&report);
439 assert!(json_result.is_ok(), "serialize failed: {json_result:?}");
440 let Ok(json) = json_result else {
441 return;
442 };
443 let report_result: Result<TlsValidationReport, _> = serde_json::from_str(&json);
444 assert!(
445 report_result.is_ok(),
446 "deserialize failed: {report_result:?}"
447 );
448 let Ok(r2) = report_result else {
449 return;
450 };
451 assert_eq!(report, r2);
452 }
453
454 // ── validate_profile_static ───────────────────────────────────────────────
455
456 #[test]
457 fn static_validation_empty_expected_always_passes() {
458 use crate::tls::CHROME_131;
459 let report = validate_profile_static(&CHROME_131, "", "", &[]);
460 assert!(
461 report.is_ok(),
462 "empty expected hashes should always pass; issues: {:?}",
463 report.issues
464 );
465 }
466
467 #[test]
468 fn static_validation_mismatch_populates_issues() {
469 use crate::tls::CHROME_131;
470 let report =
471 validate_profile_static(&CHROME_131, "0000000000000000000000000000000f", "", &[]);
472 assert!(!report.is_ok());
473 assert!(report.issues.iter().any(|i| i.contains("JA3 mismatch")));
474 }
475
476 // ── integration (network-gated, always ignored in CI) ─────────────────────
477
478 /// Live validation against tls.peet.ws — requires network and real TLS stack.
479 #[test]
480 #[ignore = "requires network access and real TLS client"]
481 fn live_tls_echo_chrome_131() {
482 // Future: build reqwest::Client from CHROME_131 TLS config and fetch
483 // the echo service, then compare returned JA3 to CHROME_131_JA3.
484 // Left as a shell — actual client setup requires reqwest + rustls integration.
485 }
486
487 /// Live HTTP/2 SETTINGS validation.
488 #[test]
489 #[ignore = "requires network access and HTTP/2 capture capability"]
490 fn live_http2_settings_chrome_136() {
491 // Future: connect to an HTTP/2 server that echoes SETTINGS frames,
492 // capture the frame, and compare against CHROME_136_HTTP2_SETTINGS.
493 }
494}