Skip to main content

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}