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