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)]
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}