Skip to main content

stygian_browser/
tls.rs

1//! TLS fingerprint profile types with JA3/JA4 representation.
2//!
3//! ALPN preferences that match genuine browsers.
4//!
5//! # Built-in profiles
6//!
7//! Four static profiles ship with real-world TLS parameters:
8//!
9//! | Profile | Browser |
10//! |---|---|
11//! | [`CHROME_131`] | Google Chrome 131 |
12//! | [`FIREFOX_133`] | Mozilla Firefox 133 |
13//! | [`SAFARI_18`] | Apple Safari 18 |
14//! | [`EDGE_131`] | Microsoft Edge 131 |
15//!
16//! # Example
17//!
18//! ```
19//! use stygian_browser::tls::{CHROME_131, TlsProfile};
20//!
21//! let profile: &TlsProfile = &*CHROME_131;
22//! assert_eq!(profile.name, "Chrome 131");
23//!
24//! let ja3 = profile.ja3();
25//! assert!(!ja3.raw.is_empty());
26//! assert!(!ja3.hash.is_empty());
27//!
28//! let ja4 = profile.ja4();
29//! assert!(ja4.fingerprint.starts_with("t13"));
30//! ```
31
32use serde::{Deserialize, Serialize};
33use std::fmt;
34use std::sync::LazyLock;
35
36// ── entropy helper ───────────────────────────────────────────────────────────
37
38/// Splitmix64-style hash — mixes `seed` with a `step` multiplier so every
39/// call with a unique `step` produces an independent random-looking value.
40pub(crate) const fn rng(seed: u64, step: u64) -> u64 {
41    let x = seed.wrapping_add(step.wrapping_mul(0x9e37_79b9_7f4a_7c15));
42    let x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
43    let x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
44    x ^ (x >> 31)
45}
46
47// ── newtype wrappers ─────────────────────────────────────────────────────────
48
49/// TLS cipher-suite identifier (IANA two-byte code point).
50///
51/// Order within a [`TlsProfile`] matters — anti-bot systems compare the
52/// ordering against known browser fingerprints.
53///
54/// # Example
55///
56/// ```
57/// use stygian_browser::tls::CipherSuiteId;
58///
59/// let aes128 = CipherSuiteId::TLS_AES_128_GCM_SHA256;
60/// assert_eq!(aes128.0, 0x1301);
61/// ```
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub struct CipherSuiteId(pub u16);
64
65impl CipherSuiteId {
66    /// TLS 1.3 — AES-128-GCM with SHA-256.
67    pub const TLS_AES_128_GCM_SHA256: Self = Self(0x1301);
68    /// TLS 1.3 — AES-256-GCM with SHA-384.
69    pub const TLS_AES_256_GCM_SHA384: Self = Self(0x1302);
70    /// TLS 1.3 — ChaCha20-Poly1305 with SHA-256.
71    pub const TLS_CHACHA20_POLY1305_SHA256: Self = Self(0x1303);
72    /// TLS 1.2 — ECDHE-ECDSA-AES128-GCM-SHA256.
73    pub const TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: Self = Self(0xc02b);
74    /// TLS 1.2 — ECDHE-RSA-AES128-GCM-SHA256.
75    pub const TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: Self = Self(0xc02f);
76    /// TLS 1.2 — ECDHE-ECDSA-AES256-GCM-SHA384.
77    pub const TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: Self = Self(0xc02c);
78    /// TLS 1.2 — ECDHE-RSA-AES256-GCM-SHA384.
79    pub const TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: Self = Self(0xc030);
80    /// TLS 1.2 — ECDHE-ECDSA-CHACHA20-POLY1305.
81    pub const TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: Self = Self(0xcca9);
82    /// TLS 1.2 — ECDHE-RSA-CHACHA20-POLY1305.
83    pub const TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: Self = Self(0xcca8);
84    /// TLS 1.2 — ECDHE-RSA-AES128-SHA.
85    pub const TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: Self = Self(0xc013);
86    /// TLS 1.2 — ECDHE-RSA-AES256-SHA.
87    pub const TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: Self = Self(0xc014);
88    /// TLS 1.2 — RSA-AES128-GCM-SHA256.
89    pub const TLS_RSA_WITH_AES_128_GCM_SHA256: Self = Self(0x009c);
90    /// TLS 1.2 — RSA-AES256-GCM-SHA384.
91    pub const TLS_RSA_WITH_AES_256_GCM_SHA384: Self = Self(0x009d);
92    /// TLS 1.2 — RSA-AES128-SHA.
93    pub const TLS_RSA_WITH_AES_128_CBC_SHA: Self = Self(0x002f);
94    /// TLS 1.2 — RSA-AES256-SHA.
95    pub const TLS_RSA_WITH_AES_256_CBC_SHA: Self = Self(0x0035);
96}
97
98impl fmt::Display for CipherSuiteId {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "{}", self.0)
101    }
102}
103
104///
105/// # Example
106///
107/// ```
108/// use stygian_browser::tls::TlsVersion;
109///
110/// let v = TlsVersion::Tls13;
111/// assert_eq!(v.iana_value(), 0x0304);
112/// ```
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[non_exhaustive]
115pub enum TlsVersion {
116    /// TLS 1.2 (0x0303).
117    Tls12,
118    /// TLS 1.3 (0x0304).
119    Tls13,
120}
121
122impl TlsVersion {
123    ///
124    /// # Example
125    ///
126    /// ```
127    /// use stygian_browser::tls::TlsVersion;
128    ///
129    /// assert_eq!(TlsVersion::Tls12.iana_value(), 0x0303);
130    /// ```
131    #[must_use]
132    pub const fn iana_value(self) -> u16 {
133        match self {
134            Self::Tls12 => 0x0303,
135            Self::Tls13 => 0x0304,
136        }
137    }
138}
139
140impl fmt::Display for TlsVersion {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        write!(f, "{}", self.iana_value())
143    }
144}
145
146/// TLS extension identifier (IANA two-byte code point).
147///
148/// # Example
149///
150/// ```
151/// use stygian_browser::tls::TlsExtensionId;
152///
153/// let sni = TlsExtensionId::SERVER_NAME;
154/// assert_eq!(sni.0, 0);
155/// ```
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
157pub struct TlsExtensionId(pub u16);
158
159impl TlsExtensionId {
160    /// `server_name` (SNI).
161    pub const SERVER_NAME: Self = Self(0);
162    /// `extended_master_secret`.
163    pub const EXTENDED_MASTER_SECRET: Self = Self(23);
164    /// `encrypt_then_mac`.
165    pub const ENCRYPT_THEN_MAC: Self = Self(22);
166    /// `session_ticket`.
167    pub const SESSION_TICKET: Self = Self(35);
168    /// `signature_algorithms`.
169    pub const SIGNATURE_ALGORITHMS: Self = Self(13);
170    /// `supported_versions`.
171    pub const SUPPORTED_VERSIONS: Self = Self(43);
172    /// `psk_key_exchange_modes`.
173    pub const PSK_KEY_EXCHANGE_MODES: Self = Self(45);
174    /// `key_share`.
175    pub const KEY_SHARE: Self = Self(51);
176    /// `supported_groups` (a.k.a. `elliptic_curves`).
177    pub const SUPPORTED_GROUPS: Self = Self(10);
178    pub const EC_POINT_FORMATS: Self = Self(11);
179    pub const ALPN: Self = Self(16);
180    /// `status_request` (OCSP stapling).
181    pub const STATUS_REQUEST: Self = Self(5);
182    /// `signed_certificate_timestamp`.
183    pub const SIGNED_CERTIFICATE_TIMESTAMP: Self = Self(18);
184    /// `compress_certificate`.
185    pub const COMPRESS_CERTIFICATE: Self = Self(27);
186    /// `application_settings` (ALPS).
187    pub const APPLICATION_SETTINGS: Self = Self(17513);
188    /// `renegotiation_info`.
189    pub const RENEGOTIATION_INFO: Self = Self(0xff01);
190    /// `delegated_credentials`.
191    pub const DELEGATED_CREDENTIALS: Self = Self(34);
192    /// `record_size_limit`.
193    pub const RECORD_SIZE_LIMIT: Self = Self(28);
194    /// padding.
195    pub const PADDING: Self = Self(21);
196    /// `pre_shared_key`.
197    pub const PRE_SHARED_KEY: Self = Self(41);
198    /// `post_handshake_auth`.
199    pub const POST_HANDSHAKE_AUTH: Self = Self(49);
200}
201
202impl fmt::Display for TlsExtensionId {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        write!(f, "{}", self.0)
205    }
206}
207
208/// Named group (elliptic curve / key-exchange group) identifier.
209///
210/// # Example
211///
212/// ```
213/// use stygian_browser::tls::SupportedGroup;
214///
215/// let x25519 = SupportedGroup::X25519;
216/// assert_eq!(x25519.iana_value(), 0x001d);
217/// ```
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
219#[non_exhaustive]
220pub enum SupportedGroup {
221    /// X25519 Diffie-Hellman (0x001d).
222    X25519,
223    /// secp256r1 / P-256 (0x0017).
224    SecP256r1,
225    /// secp384r1 / P-384 (0x0018).
226    SecP384r1,
227    /// secp521r1 / P-521 (0x0019).
228    SecP521r1,
229    /// `X25519Kyber768Draft00` — post-quantum hybrid (0x6399).
230    X25519Kyber768,
231    /// FFDHE2048 (0x0100).
232    Ffdhe2048,
233    /// FFDHE3072 (0x0101).
234    Ffdhe3072,
235}
236
237impl SupportedGroup {
238    /// Return the two-byte IANA named-group value.
239    ///
240    /// # Example
241    ///
242    /// ```
243    /// use stygian_browser::tls::SupportedGroup;
244    ///
245    /// assert_eq!(SupportedGroup::SecP256r1.iana_value(), 0x0017);
246    /// ```
247    #[must_use]
248    pub const fn iana_value(self) -> u16 {
249        match self {
250            Self::X25519 => 0x001d,
251            Self::SecP256r1 => 0x0017,
252            Self::SecP384r1 => 0x0018,
253            Self::SecP521r1 => 0x0019,
254            Self::X25519Kyber768 => 0x6399,
255            Self::Ffdhe2048 => 0x0100,
256            Self::Ffdhe3072 => 0x0101,
257        }
258    }
259}
260
261impl fmt::Display for SupportedGroup {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        write!(f, "{}", self.iana_value())
264    }
265}
266
267/// TLS signature algorithm identifier (IANA two-byte code point).
268///
269/// # Example
270///
271/// ```
272/// use stygian_browser::tls::SignatureAlgorithm;
273///
274/// let ecdsa = SignatureAlgorithm::ECDSA_SECP256R1_SHA256;
275/// assert_eq!(ecdsa.0, 0x0403);
276/// ```
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
278pub struct SignatureAlgorithm(pub u16);
279
280impl SignatureAlgorithm {
281    /// `ecdsa_secp256r1_sha256`.
282    pub const ECDSA_SECP256R1_SHA256: Self = Self(0x0403);
283    /// `rsa_pss_rsae_sha256`.
284    pub const RSA_PSS_RSAE_SHA256: Self = Self(0x0804);
285    /// `rsa_pkcs1_sha256`.
286    pub const RSA_PKCS1_SHA256: Self = Self(0x0401);
287    /// `ecdsa_secp384r1_sha384`.
288    pub const ECDSA_SECP384R1_SHA384: Self = Self(0x0503);
289    /// `rsa_pss_rsae_sha384`.
290    pub const RSA_PSS_RSAE_SHA384: Self = Self(0x0805);
291    /// `rsa_pkcs1_sha384`.
292    pub const RSA_PKCS1_SHA384: Self = Self(0x0501);
293    /// `rsa_pss_rsae_sha512`.
294    pub const RSA_PSS_RSAE_SHA512: Self = Self(0x0806);
295    /// `rsa_pkcs1_sha512`.
296    pub const RSA_PKCS1_SHA512: Self = Self(0x0601);
297    /// `ecdsa_secp521r1_sha512`.
298    pub const ECDSA_SECP521R1_SHA512: Self = Self(0x0603);
299    /// `rsa_pkcs1_sha1` (legacy).
300    pub const RSA_PKCS1_SHA1: Self = Self(0x0201);
301    /// `ecdsa_sha1` (legacy).
302    pub const ECDSA_SHA1: Self = Self(0x0203);
303}
304
305impl fmt::Display for SignatureAlgorithm {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        write!(f, "{}", self.0)
308    }
309}
310
311///
312/// # Example
313///
314/// ```rust
315/// use stygian_browser::tls::AlpnProtocol;
316/// assert_eq!(AlpnProtocol::H2.as_str(), "h2");
317/// ```
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
319#[non_exhaustive]
320pub enum AlpnProtocol {
321    /// HTTP/2 (`h2`).
322    H2,
323    /// HTTP/1.1 (`http/1.1`).
324    Http11,
325}
326
327impl AlpnProtocol {
328    /// Returns the wire string for this protocol.
329    ///
330    /// # Example
331    ///
332    /// ```rust
333    /// use stygian_browser::tls::AlpnProtocol;
334    /// assert_eq!(AlpnProtocol::Http11.as_str(), "http/1.1");
335    /// ```
336    #[must_use]
337    pub const fn as_str(self) -> &'static str {
338        match self {
339            Self::H2 => "h2",
340            Self::Http11 => "http/1.1",
341        }
342    }
343}
344
345impl fmt::Display for AlpnProtocol {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        f.write_str(self.as_str())
348    }
349}
350
351// ── TLS profile ──────────────────────────────────────────────────────────────
352
353/// A complete TLS fingerprint profile matching a real browser's `ClientHello`.
354///
355/// The ordering of cipher suites, extensions, and supported groups matters —
356/// anti-bot systems compare these orderings against known browser signatures.
357///
358/// # Example
359///
360/// ```
361/// use stygian_browser::tls::{CHROME_131, TlsProfile};
362///
363/// let profile: &TlsProfile = &*CHROME_131;
364/// assert_eq!(profile.name, "Chrome 131");
365/// assert!(!profile.cipher_suites.is_empty());
366/// ```
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[non_exhaustive]
369pub struct TlsProfile {
370    /// Human-readable profile name (e.g. `"Chrome 131"`).
371    pub name: String,
372    /// Ordered cipher-suite list from the `ClientHello`.
373    pub cipher_suites: Vec<CipherSuiteId>,
374    pub tls_versions: Vec<TlsVersion>,
375    /// Ordered extension list from the `ClientHello`.
376    pub extensions: Vec<TlsExtensionId>,
377    /// Supported named groups (elliptic curves / key exchange).
378    pub supported_groups: Vec<SupportedGroup>,
379    /// Supported signature algorithms.
380    pub signature_algorithms: Vec<SignatureAlgorithm>,
381    pub alpn_protocols: Vec<AlpnProtocol>,
382}
383
384// ── JA3 ──────────────────────────────────────────────────────────────────────
385
386///
387///
388/// Fields within each section are dash-separated.
389///
390/// # Example
391///
392/// ```
393/// use stygian_browser::tls::CHROME_131;
394///
395/// let ja3 = CHROME_131.ja3();
396/// assert!(ja3.raw.contains(','));
397/// assert_eq!(ja3.hash.len(), 32); // MD5 hex digest
398/// ```
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct Ja3Hash {
401    pub raw: String,
402    /// MD5 hex digest of [`raw`](Ja3Hash::raw).
403    pub hash: String,
404}
405
406impl fmt::Display for Ja3Hash {
407    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408        f.write_str(&self.hash)
409    }
410}
411
412/// Compute MD5 of `data` and return a 32-char lowercase hex string.
413#[allow(
414    clippy::many_single_char_names,
415    clippy::too_many_lines,
416    clippy::indexing_slicing
417)]
418fn md5_hex(data: &[u8]) -> String {
419    // Per-round shift amounts.
420    const S: [u32; 64] = [
421        7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5,
422        9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10,
423        15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
424    ];
425
426    // Pre-computed T[i] = floor(2^32 * |sin(i+1)|).
427    const K: [u32; 64] = [
428        0xd76a_a478,
429        0xe8c7_b756,
430        0x2420_70db,
431        0xc1bd_ceee,
432        0xf57c_0faf,
433        0x4787_c62a,
434        0xa830_4613,
435        0xfd46_9501,
436        0x6980_98d8,
437        0x8b44_f7af,
438        0xffff_5bb1,
439        0x895c_d7be,
440        0x6b90_1122,
441        0xfd98_7193,
442        0xa679_438e,
443        0x49b4_0821,
444        0xf61e_2562,
445        0xc040_b340,
446        0x265e_5a51,
447        0xe9b6_c7aa,
448        0xd62f_105d,
449        0x0244_1453,
450        0xd8a1_e681,
451        0xe7d3_fbc8,
452        0x21e1_cde6,
453        0xc337_07d6,
454        0xf4d5_0d87,
455        0x455a_14ed,
456        0xa9e3_e905,
457        0xfcef_a3f8,
458        0x676f_02d9,
459        0x8d2a_4c8a,
460        0xfffa_3942,
461        0x8771_f681,
462        0x6d9d_6122,
463        0xfde5_380c,
464        0xa4be_ea44,
465        0x4bde_cfa9,
466        0xf6bb_4b60,
467        0xbebf_bc70,
468        0x289b_7ec6,
469        0xeaa1_27fa,
470        0xd4ef_3085,
471        0x0488_1d05,
472        0xd9d4_d039,
473        0xe6db_99e5,
474        0x1fa2_7cf8,
475        0xc4ac_5665,
476        0xf429_2244,
477        0x432a_ff97,
478        0xab94_23a7,
479        0xfc93_a039,
480        0x655b_59c3,
481        0x8f0c_cc92,
482        0xffef_f47d,
483        0x8584_5dd1,
484        0x6fa8_7e4f,
485        0xfe2c_e6e0,
486        0xa301_4314,
487        0x4e08_11a1,
488        0xf753_7e82,
489        0xbd3a_f235,
490        0x2ad7_d2bb,
491        0xeb86_d391,
492    ];
493
494    // Pre-processing: add padding.
495    let orig_len_bits = (data.len() as u64).wrapping_mul(8);
496    let mut msg = data.to_vec();
497    msg.push(0x80);
498    while msg.len() % 64 != 56 {
499        msg.push(0);
500    }
501    msg.extend_from_slice(&orig_len_bits.to_le_bytes());
502
503    let mut a0: u32 = 0x6745_2301;
504    let mut b0: u32 = 0xefcd_ab89;
505    let mut c0: u32 = 0x98ba_dcfe;
506    let mut d0: u32 = 0x1032_5476;
507
508    for chunk in msg.chunks_exact(64) {
509        let mut m = [0u32; 16];
510        for (word, quad) in m.iter_mut().zip(chunk.chunks_exact(4)) {
511            // chunks_exact(4) on a 64-byte slice always yields exactly 16
512            if let Ok(bytes) = <[u8; 4]>::try_from(quad) {
513                *word = u32::from_le_bytes(bytes);
514            }
515        }
516
517        let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
518
519        for i in 0..64 {
520            let (f, g) = match i {
521                0..=15 => ((b & c) | ((!b) & d), i),
522                16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
523                32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
524                _ => (c ^ (b | (!d)), (7 * i) % 16),
525            };
526            let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
527            a = d;
528            d = c;
529            c = b;
530            b = b.wrapping_add(f.rotate_left(S[i]));
531        }
532
533        a0 = a0.wrapping_add(a);
534        b0 = b0.wrapping_add(b);
535        c0 = c0.wrapping_add(c);
536        d0 = d0.wrapping_add(d);
537    }
538
539    let digest = [
540        a0.to_le_bytes(),
541        b0.to_le_bytes(),
542        c0.to_le_bytes(),
543        d0.to_le_bytes(),
544    ];
545    let mut hex = String::with_capacity(32);
546    for group in &digest {
547        for &byte in group {
548            use fmt::Write;
549            let _ = write!(hex, "{byte:02x}");
550        }
551    }
552    hex
553}
554
555// ── JA4 ──────────────────────────────────────────────────────────────────────
556
557///
558///
559/// # Example
560///
561/// ```
562/// use stygian_browser::tls::CHROME_131;
563///
564/// let ja4 = CHROME_131.ja4();
565/// assert!(ja4.fingerprint.starts_with("t13"));
566/// ```
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct Ja4 {
569    /// The full JA4 fingerprint string.
570    pub fingerprint: String,
571}
572
573impl fmt::Display for Ja4 {
574    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
575        f.write_str(&self.fingerprint)
576    }
577}
578
579// ── HTTP/3 Perk ─────────────────────────────────────────────────────────────
580
581/// `SETTINGS|PSEUDO_HEADERS`.
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct Http3Perk {
584    /// Ordered HTTP/3 settings as `(id, value)` tuples.
585    pub settings: Vec<(u64, u64)>,
586    pub pseudo_headers: String,
587    pub has_grease: bool,
588}
589
590/// Result of comparing expected and observed HTTP/3 perk data.
591#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
592pub struct Http3PerkComparison {
593    /// `true` only when all available observed fields match expected values.
594    pub matches: bool,
595    /// Human-readable mismatch reasons.
596    pub mismatches: Vec<String>,
597}
598
599const fn is_quic_grease(value: u64) -> bool {
600    let low = value & 0xffff;
601    let a = (low >> 8) & 0xff;
602    let b = low & 0xff;
603    a == b && (a & 0x0f) == 0x0a
604}
605
606impl Http3Perk {
607    /// Return canonical `perk_text` as `SETTINGS|PSEUDO_HEADERS`.
608    #[must_use]
609    pub fn perk_text(&self) -> String {
610        let mut parts: Vec<String> = self
611            .settings
612            .iter()
613            .filter(|(id, _)| !is_quic_grease(*id))
614            .map(|(id, value)| format!("{id}:{value}"))
615            .collect();
616
617        if self.has_grease || self.settings.iter().any(|(id, _)| is_quic_grease(*id)) {
618            parts.push("GREASE".to_string());
619        }
620
621        format!("{}|{}", parts.join(";"), self.pseudo_headers)
622    }
623
624    /// Return MD5 hash of [`perk_text`](Self::perk_text), lowercase hex.
625    #[must_use]
626    pub fn perk_hash(&self) -> String {
627        md5_hex(self.perk_text().as_bytes())
628    }
629
630    /// Compare observed perk text/hash against this expected fingerprint.
631    #[must_use]
632    pub fn compare(
633        &self,
634        observed_text: Option<&str>,
635        observed_hash: Option<&str>,
636    ) -> Http3PerkComparison {
637        let expected_text = self.perk_text();
638        let expected_hash = self.perk_hash();
639
640        let mut mismatches = Vec::new();
641
642        if let Some(text) = observed_text
643            && text != expected_text
644        {
645            mismatches.push(format!(
646                "perk_text mismatch: expected '{expected_text}', observed '{text}'"
647            ));
648        }
649
650        if let Some(hash) = observed_hash
651            && !hash.eq_ignore_ascii_case(&expected_hash)
652        {
653            mismatches.push(format!(
654                "perk_hash mismatch: expected '{expected_hash}', observed '{hash}'"
655            ));
656        }
657
658        Http3PerkComparison {
659            matches: mismatches.is_empty() && (observed_text.is_some() || observed_hash.is_some()),
660            mismatches,
661        }
662    }
663}
664
665/// Build an expected HTTP/3 perk fingerprint from a User-Agent string.
666///
667#[must_use]
668pub fn expected_http3_perk_from_user_agent(user_agent: &str) -> Option<Http3Perk> {
669    expected_tls_profile_from_user_agent(user_agent).and_then(TlsProfile::http3_perk)
670}
671
672/// Returns the `TlsProfile` for the given user-agent string, if known.
673#[must_use]
674pub fn expected_tls_profile_from_user_agent(user_agent: &str) -> Option<&'static TlsProfile> {
675    let ua = user_agent.to_ascii_lowercase();
676
677    if ua.contains("edg/") {
678        return Some(&EDGE_131);
679    }
680
681    if ua.contains("firefox/") {
682        return Some(&FIREFOX_133);
683    }
684
685    if ua.contains("safari/") && !ua.contains("chrome/") && !ua.contains("edg/") {
686        return Some(&SAFARI_18);
687    }
688
689    if ua.contains("chrome/") {
690        return Some(&CHROME_131);
691    }
692
693    None
694}
695
696#[must_use]
697pub fn expected_ja3_from_user_agent(user_agent: &str) -> Option<Ja3Hash> {
698    expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja3)
699}
700
701#[must_use]
702pub fn expected_ja4_from_user_agent(user_agent: &str) -> Option<Ja4> {
703    expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja4)
704}
705
706// ── profile methods ──────────────────────────────────────────────────────────
707
708/// Truncates a hex string `s` to at most `n` characters.
709fn truncate_hex(s: &str, n: usize) -> &str {
710    let end = s.len().min(n);
711    &s[..end]
712}
713
714/// GREASE values that must be ignored during JA3/JA4 computation.
715const GREASE_VALUES: &[u16] = &[
716    0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba,
717    0xcaca, 0xdada, 0xeaea, 0xfafa,
718];
719
720/// Return `true` if `v` is a TLS GREASE value.
721fn is_grease(v: u16) -> bool {
722    GREASE_VALUES.contains(&v)
723}
724
725impl TlsProfile {
726    /// Computes the JA3 fingerprint string for this profile.
727    ///
728    /// - GREASE values are stripped from all fields.
729    /// - Fields are ordered as specified in the profile.
730    ///
731    /// # Example
732    ///
733    /// ```
734    /// use stygian_browser::tls::CHROME_131;
735    ///
736    /// let ja3 = CHROME_131.ja3();
737    /// assert!(ja3.raw.starts_with("772,"));
738    /// assert_eq!(ja3.hash.len(), 32);
739    /// ```
740    #[must_use]
741    pub fn ja3(&self) -> Ja3Hash {
742        // TLS version — use highest advertised.
743        let tls_ver = self
744            .tls_versions
745            .iter()
746            .map(|v| v.iana_value())
747            .max()
748            .unwrap_or(TlsVersion::Tls12.iana_value());
749
750        // Ciphers (GREASE stripped).
751        let ciphers: Vec<String> = self
752            .cipher_suites
753            .iter()
754            .filter(|c| !is_grease(c.0))
755            .map(|c| c.0.to_string())
756            .collect();
757
758        // Extensions (GREASE stripped).
759        let extensions: Vec<String> = self
760            .extensions
761            .iter()
762            .filter(|e| !is_grease(e.0))
763            .map(|e| e.0.to_string())
764            .collect();
765
766        // Elliptic curves (GREASE stripped).
767        let curves: Vec<String> = self
768            .supported_groups
769            .iter()
770            .filter(|g| !is_grease(g.iana_value()))
771            .map(|g| g.iana_value().to_string())
772            .collect();
773
774        let ec_point_formats = "0";
775
776        let raw = format!(
777            "{tls_ver},{},{},{},{ec_point_formats}",
778            ciphers.join("-"),
779            extensions.join("-"),
780            curves.join("-"),
781        );
782
783        let hash = md5_hex(raw.as_bytes());
784        Ja3Hash { raw, hash }
785    }
786
787    ///
788    /// `{q}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn}_{sorted_cipher_hash}_{sorted_ext_hash}`
789    ///
790    /// This implements the `JA4_a` (raw fingerprint) portion. Sorted cipher and
791    /// extension hashes use the first 12 hex characters of the SHA-256 —
792    /// approximated here by truncated MD5 since we already have that
793    /// implementation and the goal is fingerprint *representation*, not
794    /// cryptographic security.
795    ///
796    /// # Example
797    ///
798    /// ```
799    /// use stygian_browser::tls::CHROME_131;
800    ///
801    /// let ja4 = CHROME_131.ja4();
802    /// assert!(ja4.fingerprint.starts_with("t13"));
803    /// ```
804    #[must_use]
805    pub fn ja4(&self) -> Ja4 {
806        let proto = 't';
807
808        let version = if self.tls_versions.contains(&TlsVersion::Tls13) {
809            "13"
810        } else {
811            "12"
812        };
813
814        // SNI: 'd' = domain (SNI present), 'i' = IP (no SNI). We assume SNI
815        let sni = 'd';
816
817        // Counts (GREASE stripped), capped at 99.
818        let cipher_count = self
819            .cipher_suites
820            .iter()
821            .filter(|c| !is_grease(c.0))
822            .count()
823            .min(99);
824        let ext_count = self
825            .extensions
826            .iter()
827            .filter(|e| !is_grease(e.0))
828            .count()
829            .min(99);
830
831        // uses first+last chars). '00' when empty.
832        let alpn_tag = match self.alpn_protocols.first() {
833            Some(AlpnProtocol::H2) => "h2",
834            Some(AlpnProtocol::Http11) => "h1",
835            None => "00",
836        };
837
838        let section_a = format!("{proto}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn_tag}");
839
840        // Section b: sorted cipher suites (GREASE stripped), comma-separated,
841        // hashed, first 12 hex chars.
842        let mut sorted_ciphers: Vec<u16> = self
843            .cipher_suites
844            .iter()
845            .filter(|c| !is_grease(c.0))
846            .map(|c| c.0)
847            .collect();
848        sorted_ciphers.sort_unstable();
849        let cipher_str: String = sorted_ciphers
850            .iter()
851            .map(|c| format!("{c:04x}"))
852            .collect::<Vec<_>>()
853            .join(",");
854        let cipher_hash_full = md5_hex(cipher_str.as_bytes());
855        let cipher_hash = truncate_hex(&cipher_hash_full, 12);
856
857        // Section c: sorted extensions (GREASE + SNI + ALPN stripped),
858        // comma-separated, hashed, first 12 hex chars.
859        let mut sorted_exts: Vec<u16> = self
860            .extensions
861            .iter()
862            .filter(|e| {
863                !is_grease(e.0)
864                    && e.0 != TlsExtensionId::SERVER_NAME.0
865                    && e.0 != TlsExtensionId::ALPN.0
866            })
867            .map(|e| e.0)
868            .collect();
869        sorted_exts.sort_unstable();
870        let ext_str: String = sorted_exts
871            .iter()
872            .map(|e| format!("{e:04x}"))
873            .collect::<Vec<_>>()
874            .join(",");
875        let ext_hash_full = md5_hex(ext_str.as_bytes());
876        let ext_hash = truncate_hex(&ext_hash_full, 12);
877
878        Ja4 {
879            fingerprint: format!("{section_a}_{cipher_hash}_{ext_hash}"),
880        }
881    }
882
883    /// Returns HTTP/3 QUIC settings for browsers that support it, if applicable.
884    #[must_use]
885    pub fn http3_perk(&self) -> Option<Http3Perk> {
886        match self.name.as_str() {
887            name if name.starts_with("Chrome ") || name.starts_with("Edge ") => Some(Http3Perk {
888                settings: vec![(1, 65_536), (6, 262_144), (7, 100), (51, 1)],
889                pseudo_headers: "masp".to_string(),
890                has_grease: true,
891            }),
892            name if name.starts_with("Firefox ") => Some(Http3Perk {
893                settings: vec![(1, 65_536), (7, 20), (727_725_890, 0)],
894                pseudo_headers: "mpas".to_string(),
895                has_grease: false,
896            }),
897            name if name.starts_with("Safari ") => None,
898            _ => None,
899        }
900    }
901
902    /// Select a built-in TLS profile weighted by real browser market share.
903    ///
904    /// Distribution mirrors [`DeviceProfile`](super::fingerprint::DeviceProfile)
905    /// and [`BrowserKind`](super::fingerprint::BrowserKind) weights:
906    ///
907    /// - Windows (70%): Chrome 65%, Edge 16%, Firefox 19%
908    /// - macOS (20%): Chrome 56%, Safari 36%, Firefox 8%
909    /// - Linux (10%): Chrome 65%, Edge 16%, Firefox 19%
910    ///
911    /// Edge 131 shares Chrome's Blink engine so its TLS stack is nearly
912    /// identical; the profile uses [`EDGE_131`].
913    ///
914    /// # Example
915    ///
916    /// ```
917    /// use stygian_browser::tls::TlsProfile;
918    ///
919    /// let profile = TlsProfile::random_weighted(42);
920    /// assert!(!profile.name.is_empty());
921    /// ```
922    #[must_use]
923    pub fn random_weighted(seed: u64) -> &'static Self {
924        // Step 1: pick OS (Windows 70%, Mac 20%, Linux 10%).
925        let os_roll = rng(seed, 97) % 100;
926
927        // Step 2: pick browser within that OS.
928        let browser_roll = rng(seed, 201) % 100;
929
930        match os_roll {
931            // Windows / Linux: Chrome 65%, Edge 16%, Firefox 19%.
932            0..=69 | 90..=99 => match browser_roll {
933                0..=64 => &CHROME_131,
934                65..=80 => &EDGE_131,
935                _ => &FIREFOX_133,
936            },
937            // macOS: Chrome 56%, Safari 36%, Firefox 8%.
938            _ => match browser_roll {
939                0..=55 => &CHROME_131,
940                56..=91 => &SAFARI_18,
941                _ => &FIREFOX_133,
942            },
943        }
944    }
945}
946
947// ── built-in profiles ────────────────────────────────────────────────────────
948
949/// Google Chrome 131 TLS fingerprint profile.
950///
951/// Cipher suites, extensions, and groups sourced from real Chrome 131
952/// `ClientHello` captures.
953///
954/// # Example
955///
956/// ```
957/// use stygian_browser::tls::CHROME_131;
958///
959/// assert_eq!(CHROME_131.name, "Chrome 131");
960/// assert!(CHROME_131.tls_versions.contains(&stygian_browser::tls::TlsVersion::Tls13));
961/// ```
962pub static CHROME_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
963    name: "Chrome 131".to_string(),
964    cipher_suites: vec![
965        CipherSuiteId::TLS_AES_128_GCM_SHA256,
966        CipherSuiteId::TLS_AES_256_GCM_SHA384,
967        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
968        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
969        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
970        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
971        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
972        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
973        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
974        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
975        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
976        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
977        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
978        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
979        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
980    ],
981    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
982    extensions: vec![
983        TlsExtensionId::SERVER_NAME,
984        TlsExtensionId::EXTENDED_MASTER_SECRET,
985        TlsExtensionId::RENEGOTIATION_INFO,
986        TlsExtensionId::SUPPORTED_GROUPS,
987        TlsExtensionId::EC_POINT_FORMATS,
988        TlsExtensionId::SESSION_TICKET,
989        TlsExtensionId::ALPN,
990        TlsExtensionId::STATUS_REQUEST,
991        TlsExtensionId::SIGNATURE_ALGORITHMS,
992        TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
993        TlsExtensionId::KEY_SHARE,
994        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
995        TlsExtensionId::SUPPORTED_VERSIONS,
996        TlsExtensionId::COMPRESS_CERTIFICATE,
997        TlsExtensionId::APPLICATION_SETTINGS,
998        TlsExtensionId::PADDING,
999    ],
1000    supported_groups: vec![
1001        SupportedGroup::X25519Kyber768,
1002        SupportedGroup::X25519,
1003        SupportedGroup::SecP256r1,
1004        SupportedGroup::SecP384r1,
1005    ],
1006    signature_algorithms: vec![
1007        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1008        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1009        SignatureAlgorithm::RSA_PKCS1_SHA256,
1010        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1011        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1012        SignatureAlgorithm::RSA_PKCS1_SHA384,
1013        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1014        SignatureAlgorithm::RSA_PKCS1_SHA512,
1015    ],
1016    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1017});
1018
1019/// Mozilla Firefox 133 TLS fingerprint profile.
1020///
1021/// Firefox uses a different cipher-suite and extension order than Chromium
1022/// browsers, preferring `ChaCha20` and including `delegated_credentials`
1023/// and `record_size_limit`.
1024///
1025/// # Example
1026///
1027/// ```
1028/// use stygian_browser::tls::FIREFOX_133;
1029///
1030/// assert_eq!(FIREFOX_133.name, "Firefox 133");
1031/// ```
1032pub static FIREFOX_133: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1033    name: "Firefox 133".to_string(),
1034    cipher_suites: vec![
1035        CipherSuiteId::TLS_AES_128_GCM_SHA256,
1036        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1037        CipherSuiteId::TLS_AES_256_GCM_SHA384,
1038        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1039        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1040        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1041        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1042        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1043        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1044        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1045        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1046        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1047        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1048        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1049        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1050    ],
1051    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1052    extensions: vec![
1053        TlsExtensionId::SERVER_NAME,
1054        TlsExtensionId::EXTENDED_MASTER_SECRET,
1055        TlsExtensionId::RENEGOTIATION_INFO,
1056        TlsExtensionId::SUPPORTED_GROUPS,
1057        TlsExtensionId::EC_POINT_FORMATS,
1058        TlsExtensionId::SESSION_TICKET,
1059        TlsExtensionId::ALPN,
1060        TlsExtensionId::STATUS_REQUEST,
1061        TlsExtensionId::DELEGATED_CREDENTIALS,
1062        TlsExtensionId::KEY_SHARE,
1063        TlsExtensionId::SUPPORTED_VERSIONS,
1064        TlsExtensionId::SIGNATURE_ALGORITHMS,
1065        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1066        TlsExtensionId::RECORD_SIZE_LIMIT,
1067        TlsExtensionId::POST_HANDSHAKE_AUTH,
1068        TlsExtensionId::PADDING,
1069    ],
1070    supported_groups: vec![
1071        SupportedGroup::X25519,
1072        SupportedGroup::SecP256r1,
1073        SupportedGroup::SecP384r1,
1074        SupportedGroup::SecP521r1,
1075        SupportedGroup::Ffdhe2048,
1076        SupportedGroup::Ffdhe3072,
1077    ],
1078    signature_algorithms: vec![
1079        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1080        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1081        SignatureAlgorithm::ECDSA_SECP521R1_SHA512,
1082        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1083        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1084        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1085        SignatureAlgorithm::RSA_PKCS1_SHA256,
1086        SignatureAlgorithm::RSA_PKCS1_SHA384,
1087        SignatureAlgorithm::RSA_PKCS1_SHA512,
1088        SignatureAlgorithm::ECDSA_SHA1,
1089        SignatureAlgorithm::RSA_PKCS1_SHA1,
1090    ],
1091    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1092});
1093
1094/// Apple Safari 18 TLS fingerprint profile.
1095///
1096/// Safari's TLS stack differs from Chromium in extension order and supported
1097/// groups. Safari does not advertise post-quantum key exchange.
1098///
1099/// # Example
1100///
1101/// ```
1102/// use stygian_browser::tls::SAFARI_18;
1103///
1104/// assert_eq!(SAFARI_18.name, "Safari 18");
1105/// ```
1106pub static SAFARI_18: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1107    name: "Safari 18".to_string(),
1108    cipher_suites: vec![
1109        CipherSuiteId::TLS_AES_128_GCM_SHA256,
1110        CipherSuiteId::TLS_AES_256_GCM_SHA384,
1111        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1112        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1113        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1114        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1115        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1116        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1117        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1118        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1119        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1120        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1121        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1122    ],
1123    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1124    extensions: vec![
1125        TlsExtensionId::SERVER_NAME,
1126        TlsExtensionId::EXTENDED_MASTER_SECRET,
1127        TlsExtensionId::RENEGOTIATION_INFO,
1128        TlsExtensionId::SUPPORTED_GROUPS,
1129        TlsExtensionId::EC_POINT_FORMATS,
1130        TlsExtensionId::ALPN,
1131        TlsExtensionId::STATUS_REQUEST,
1132        TlsExtensionId::SIGNATURE_ALGORITHMS,
1133        TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1134        TlsExtensionId::KEY_SHARE,
1135        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1136        TlsExtensionId::SUPPORTED_VERSIONS,
1137        TlsExtensionId::PADDING,
1138    ],
1139    supported_groups: vec![
1140        SupportedGroup::X25519,
1141        SupportedGroup::SecP256r1,
1142        SupportedGroup::SecP384r1,
1143        SupportedGroup::SecP521r1,
1144    ],
1145    signature_algorithms: vec![
1146        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1147        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1148        SignatureAlgorithm::RSA_PKCS1_SHA256,
1149        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1150        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1151        SignatureAlgorithm::RSA_PKCS1_SHA384,
1152        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1153        SignatureAlgorithm::RSA_PKCS1_SHA512,
1154    ],
1155    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1156});
1157
1158/// Microsoft Edge 131 TLS fingerprint profile.
1159///
1160/// Differences are minor (e.g. extension ordering around `application_settings`).
1161///
1162/// # Example
1163///
1164/// ```
1165/// use stygian_browser::tls::EDGE_131;
1166///
1167/// assert_eq!(EDGE_131.name, "Edge 131");
1168/// ```
1169pub static EDGE_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1170    name: "Edge 131".to_string(),
1171    cipher_suites: vec![
1172        CipherSuiteId::TLS_AES_128_GCM_SHA256,
1173        CipherSuiteId::TLS_AES_256_GCM_SHA384,
1174        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1175        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1176        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1177        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1178        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1179        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1180        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1181        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1182        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1183        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1184        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1185        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1186        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1187    ],
1188    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1189    extensions: vec![
1190        TlsExtensionId::SERVER_NAME,
1191        TlsExtensionId::EXTENDED_MASTER_SECRET,
1192        TlsExtensionId::RENEGOTIATION_INFO,
1193        TlsExtensionId::SUPPORTED_GROUPS,
1194        TlsExtensionId::EC_POINT_FORMATS,
1195        TlsExtensionId::SESSION_TICKET,
1196        TlsExtensionId::ALPN,
1197        TlsExtensionId::STATUS_REQUEST,
1198        TlsExtensionId::SIGNATURE_ALGORITHMS,
1199        TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1200        TlsExtensionId::KEY_SHARE,
1201        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1202        TlsExtensionId::SUPPORTED_VERSIONS,
1203        TlsExtensionId::COMPRESS_CERTIFICATE,
1204        TlsExtensionId::PADDING,
1205    ],
1206    supported_groups: vec![
1207        SupportedGroup::X25519Kyber768,
1208        SupportedGroup::X25519,
1209        SupportedGroup::SecP256r1,
1210        SupportedGroup::SecP384r1,
1211    ],
1212    signature_algorithms: vec![
1213        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1214        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1215        SignatureAlgorithm::RSA_PKCS1_SHA256,
1216        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1217        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1218        SignatureAlgorithm::RSA_PKCS1_SHA384,
1219        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1220        SignatureAlgorithm::RSA_PKCS1_SHA512,
1221    ],
1222    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1223});
1224
1225// ── Chrome launch flags ──────────────────────────────────────────────────────
1226
1227///
1228/// # What flags control
1229///
1230/// | Flag | Effect |
1231/// |---|---|
1232/// | `--ssl-version-max` | Cap the highest advertised TLS version |
1233/// | `--ssl-version-min` | Raise the lowest advertised TLS version |
1234///
1235/// # What flags **cannot** control
1236///
1237/// Chrome's TLS stack (`BoringSSL`) hard-codes the following in its compiled binary:
1238///
1239/// - **Cipher-suite ordering** — set by `ssl_cipher_apply_rule` at build time.
1240/// - **Extension ordering** — emitted in a fixed order by `BoringSSL`.
1241/// - **Supported-group ordering** — set at build time.
1242///
1243///
1244///
1245/// | Detection layer | Handled by |
1246/// |---|---|
1247/// | JavaScript leaks | CDP stealth scripts (see [`stealth`](super::stealth)) |
1248/// | CDP signals | [`CdpFixMode`](super::cdp_protection::CdpFixMode) |
1249/// | TLS fingerprint | **Flags (this fn)** — version only; full control needs rustls or patched Chrome |
1250#[must_use]
1251pub fn chrome_tls_args(profile: &TlsProfile) -> Vec<String> {
1252    let has_12 = profile.tls_versions.contains(&TlsVersion::Tls12);
1253    let has_13 = profile.tls_versions.contains(&TlsVersion::Tls13);
1254
1255    let mut args = Vec::new();
1256
1257    match (has_12, has_13) {
1258        (true, false) => {
1259            args.push("--ssl-version-max=tls1.2".to_string());
1260        }
1261        // TLS 1.3 only — raise floor so Chrome skips 1.2.
1262        (false, true) => {
1263            args.push("--ssl-version-min=tls1.3".to_string());
1264        }
1265        // Both supported or empty — Chrome's defaults are fine.
1266        _ => {}
1267    }
1268
1269    args
1270}
1271
1272// ── rustls integration ───────────────────────────────────────────────────────
1273//
1274// Feature-gated behind `tls-config`. Builds a rustls `ClientConfig` from a
1275// the profile's cipher-suite, key-exchange-group, ALPN, and version ordering.
1276
1277#[cfg(feature = "tls-config")]
1278mod rustls_config {
1279    #[allow(clippy::wildcard_imports)]
1280    use super::*;
1281    use std::sync::Arc;
1282
1283    ///
1284    /// This struct lets callers choose between broad compatibility and strict
1285    ///
1286    /// - **Compatible mode** (default) skips unsupported profile entries with
1287    ///   and unsupported groups.
1288    ///
1289    /// # Example
1290    ///
1291    /// ```
1292    /// use stygian_browser::tls::TlsControl;
1293    ///
1294    /// let strict = TlsControl::strict();
1295    /// assert!(strict.strict_cipher_suites);
1296    /// ```
1297    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1298    #[allow(clippy::struct_excessive_bools)] // 4 orthogonal compat flags is clearer than a bitmask for callers
1299    pub struct TlsControl {
1300        /// Fail if any profile cipher suite is unsupported by rustls.
1301        pub strict_cipher_suites: bool,
1302        /// Fail if any profile supported-group entry is unsupported by rustls.
1303        pub strict_supported_groups: bool,
1304        /// If no profile groups can be mapped, use provider default groups.
1305        pub fallback_to_provider_groups: bool,
1306        /// Skip legacy JA3-only suites that rustls cannot implement.
1307        pub allow_legacy_compat_suites: bool,
1308    }
1309
1310    impl Default for TlsControl {
1311        fn default() -> Self {
1312            Self::compatible()
1313        }
1314    }
1315
1316    impl TlsControl {
1317        #[must_use]
1318        pub const fn compatible() -> Self {
1319            Self {
1320                strict_cipher_suites: false,
1321                strict_supported_groups: false,
1322                fallback_to_provider_groups: true,
1323                allow_legacy_compat_suites: true,
1324            }
1325        }
1326
1327        /// Strict mode: reject unknown cipher suites.
1328        #[must_use]
1329        pub const fn strict() -> Self {
1330            Self {
1331                strict_cipher_suites: true,
1332                strict_supported_groups: false,
1333                fallback_to_provider_groups: true,
1334                allow_legacy_compat_suites: true,
1335            }
1336        }
1337
1338        /// Strict-all mode: reject unknown entries and avoid fallback groups.
1339        #[must_use]
1340        pub const fn strict_all() -> Self {
1341            Self {
1342                strict_cipher_suites: true,
1343                strict_supported_groups: true,
1344                fallback_to_provider_groups: false,
1345                allow_legacy_compat_suites: true,
1346            }
1347        }
1348
1349        ///
1350        /// Browser profiles use strict cipher-suite checking while allowing
1351        #[must_use]
1352        pub fn for_profile(profile: &TlsProfile) -> Self {
1353            let name = profile.name.to_ascii_lowercase();
1354            if name.contains("chrome")
1355                || name.contains("edge")
1356                || name.contains("firefox")
1357                || name.contains("safari")
1358            {
1359                Self::strict()
1360            } else {
1361                Self::compatible()
1362            }
1363        }
1364    }
1365
1366    const fn is_legacy_compat_suite(id: u16) -> bool {
1367        matches!(id, 0xc013 | 0xc014 | 0x009c | 0x009d | 0x002f | 0x0035)
1368    }
1369
1370    /// Error building a rustls [`ClientConfig`](rustls::ClientConfig) from a
1371    /// [`TlsProfile`].
1372    #[derive(Debug, thiserror::Error)]
1373    #[non_exhaustive]
1374    pub enum TlsConfigError {
1375        /// None of the profile's cipher suites are supported by the rustls
1376        #[error("no supported cipher suites in profile '{0}'")]
1377        NoCipherSuites(String),
1378
1379        /// Strict mode rejected an unsupported cipher suite.
1380        #[error(
1381            "unsupported cipher suite {cipher_suite_id:#06x} in profile '{profile}' under strict mode"
1382        )]
1383        UnsupportedCipherSuite {
1384            /// Profile name used in the attempted mapping.
1385            profile: String,
1386            /// Unsupported IANA cipher suite code point.
1387            cipher_suite_id: u16,
1388        },
1389
1390        /// Strict mode rejected an unsupported key-exchange group.
1391        #[error(
1392            "unsupported supported_group {group_id:#06x} in profile '{profile}' under strict mode"
1393        )]
1394        UnsupportedSupportedGroup {
1395            /// Profile name used in the attempted mapping.
1396            profile: String,
1397            /// Unsupported IANA supported-group code point.
1398            group_id: u16,
1399        },
1400
1401        /// No supported groups are available and fallback is disabled.
1402        #[error("no supported key-exchange groups in profile '{0}'")]
1403        NoSupportedGroups(String),
1404
1405        #[error("rustls configuration: {0}")]
1406        Rustls(#[from] rustls::Error),
1407    }
1408
1409    /// Wrapper around `Arc<rustls::ClientConfig>` built from a [`TlsProfile`].
1410    ///
1411    /// `reqwest::ClientBuilder::use_preconfigured_tls` (T14) or use it
1412    #[derive(Debug, Clone)]
1413    pub struct TlsClientConfig(Arc<rustls::ClientConfig>);
1414
1415    impl TlsClientConfig {
1416        /// Borrow the inner `ClientConfig`.
1417        #[must_use]
1418        pub fn inner(&self) -> &rustls::ClientConfig {
1419            &self.0
1420        }
1421
1422        #[must_use]
1423        pub fn into_inner(self) -> Arc<rustls::ClientConfig> {
1424            self.0
1425        }
1426    }
1427
1428    impl From<TlsClientConfig> for Arc<rustls::ClientConfig> {
1429        fn from(cfg: TlsClientConfig) -> Self {
1430            cfg.0
1431        }
1432    }
1433
1434    impl TlsProfile {
1435        /// Build a rustls `ClientConfig` matching this profile.
1436        ///
1437        ///
1438        /// # Errors
1439        ///
1440        /// profile's cipher suites are available in the backend.
1441        ///
1442        /// # rustls extension control
1443        ///
1444        ///
1445        /// - `supported_versions`, `key_share`, `signature_algorithms`,
1446        ///   `supported_groups`, `server_name`, `psk_key_exchange_modes`, and
1447        ///
1448        /// Extensions like `compress_certificate`, `application_settings`,
1449        /// `delegated_credentials`, and `signed_certificate_timestamp` are
1450        /// not configurable in rustls and are emitted (or not) based on the
1451        /// library version.
1452        pub fn to_rustls_config(&self) -> Result<TlsClientConfig, TlsConfigError> {
1453            self.to_rustls_config_with_control(TlsControl::default())
1454        }
1455
1456        /// Build a rustls `ClientConfig` using explicit control settings.
1457        ///
1458        /// introducing native TLS dependencies.
1459        ///
1460        /// # Errors
1461        ///
1462        /// Returns [`TlsConfigError::UnsupportedCipherSuite`] when a profile
1463        /// cipher suite is not provided by the rustls backend and `control`
1464        /// has `strict_cipher_suites` set, or
1465        /// [`TlsConfigError::UnsupportedSupportedGroup`] when a profile
1466        /// supported-group entry is not available and `control` has
1467        /// `strict_supported_groups` set. May also surface
1468        /// [`TlsConfigError::NoCipherSuites`], [`TlsConfigError::NoSupportedGroups`],
1469        /// or [`TlsConfigError::Rustls`] for the remaining failure modes.
1470        ///
1471        /// # Limitations
1472        ///
1473        /// ordering or GREASE emission. This method provides strict control
1474        /// over the fields rustls does expose (cipher suites, groups, ALPN,
1475        ///
1476        /// # Example
1477        ///
1478        /// ```
1479        /// use stygian_browser::tls::{CHROME_131, TlsControl};
1480        ///
1481        /// let result = CHROME_131.to_rustls_config_with_control(TlsControl::default());
1482        /// assert!(result.is_ok());
1483        /// ```
1484        pub fn to_rustls_config_with_control(
1485            &self,
1486            control: TlsControl,
1487        ) -> Result<TlsClientConfig, TlsConfigError> {
1488            let default = rustls::crypto::aws_lc_rs::default_provider();
1489
1490            // ── cipher suites ──
1491            let suite_map: std::collections::HashMap<u16, rustls::SupportedCipherSuite> = default
1492                .cipher_suites
1493                .iter()
1494                .map(|cs| (u16::from(cs.suite()), *cs))
1495                .collect();
1496
1497            let mut ordered_suites: Vec<rustls::SupportedCipherSuite> = Vec::new();
1498            for id in &self.cipher_suites {
1499                if let Some(cs) = suite_map.get(&id.0).copied() {
1500                    ordered_suites.push(cs);
1501                } else if control.allow_legacy_compat_suites && is_legacy_compat_suite(id.0) {
1502                    tracing::warn!(
1503                        cipher_suite_id = id.0,
1504                        profile = %self.name,
1505                        "legacy profile suite has no rustls equivalent, skipping"
1506                    );
1507                } else if control.strict_cipher_suites {
1508                    return Err(TlsConfigError::UnsupportedCipherSuite {
1509                        profile: self.name.clone(),
1510                        cipher_suite_id: id.0,
1511                    });
1512                } else {
1513                    tracing::warn!(
1514                        cipher_suite_id = id.0,
1515                        profile = %self.name,
1516                        "cipher suite not supported by rustls aws-lc-rs backend, skipping"
1517                    );
1518                }
1519            }
1520
1521            if ordered_suites.is_empty() {
1522                return Err(TlsConfigError::NoCipherSuites(self.name.clone()));
1523            }
1524
1525            // ── key-exchange groups ──
1526            let group_map: std::collections::HashMap<
1527                u16,
1528                &'static dyn rustls::crypto::SupportedKxGroup,
1529            > = default
1530                .kx_groups
1531                .iter()
1532                .map(|g| (u16::from(g.name()), *g))
1533                .collect();
1534
1535            let mut ordered_groups: Vec<&'static dyn rustls::crypto::SupportedKxGroup> = Vec::new();
1536            for sg in &self.supported_groups {
1537                if let Some(group) = group_map.get(&sg.iana_value()).copied() {
1538                    ordered_groups.push(group);
1539                } else if control.strict_supported_groups {
1540                    return Err(TlsConfigError::UnsupportedSupportedGroup {
1541                        profile: self.name.clone(),
1542                        group_id: sg.iana_value(),
1543                    });
1544                } else {
1545                    tracing::warn!(
1546                        group_id = sg.iana_value(),
1547                        profile = %self.name,
1548                        "key-exchange group not supported by rustls, skipping"
1549                    );
1550                }
1551            }
1552
1553            let kx_groups = if ordered_groups.is_empty() && control.fallback_to_provider_groups {
1554                default.kx_groups.clone()
1555            } else if ordered_groups.is_empty() {
1556                return Err(TlsConfigError::NoSupportedGroups(self.name.clone()));
1557            } else {
1558                ordered_groups
1559            };
1560
1561            let provider = rustls::crypto::CryptoProvider {
1562                cipher_suites: ordered_suites,
1563                kx_groups,
1564                ..default
1565            };
1566
1567            // ── TLS versions ──
1568            let versions: Vec<&'static rustls::SupportedProtocolVersion> = self
1569                .tls_versions
1570                .iter()
1571                .map(|v| match v {
1572                    TlsVersion::Tls12 => &rustls::version::TLS12,
1573                    TlsVersion::Tls13 => &rustls::version::TLS13,
1574                })
1575                .collect();
1576
1577            let mut root_store = rustls::RootCertStore::empty();
1578            root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
1579
1580            // ── build ClientConfig ──
1581            let mut config = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
1582                .with_protocol_versions(&versions)?
1583                .with_root_certificates(root_store)
1584                .with_no_client_auth();
1585
1586            // ── ALPN ──
1587            config.alpn_protocols = self
1588                .alpn_protocols
1589                .iter()
1590                .map(|p| p.as_str().as_bytes().to_vec())
1591                .collect();
1592
1593            Ok(TlsClientConfig(Arc::new(config)))
1594        }
1595    }
1596}
1597
1598#[cfg(feature = "tls-config")]
1599pub use rustls_config::{TlsClientConfig, TlsConfigError};
1600
1601#[cfg(feature = "tls-config")]
1602pub use rustls_config::TlsControl;
1603
1604// ── reqwest integration ──────────────────────────────────────────────────────
1605//
1606// Feature-gated behind `tls-config`. Builds a `reqwest::Client` that uses a
1607// TLS-profiled `ClientConfig` so that HTTP-only scraping paths present a
1608// browser-consistent TLS fingerprint.
1609
1610#[cfg(feature = "tls-config")]
1611mod reqwest_client {
1612    #[allow(clippy::wildcard_imports)]
1613    use super::*;
1614    use std::sync::Arc;
1615
1616    /// Error building a TLS-profiled reqwest client.
1617    #[derive(Debug, thiserror::Error)]
1618    #[non_exhaustive]
1619    pub enum TlsClientError {
1620        #[error(transparent)]
1621        TlsConfig(#[from] super::rustls_config::TlsConfigError),
1622
1623        /// reqwest rejected the builder configuration.
1624        #[error("reqwest client: {0}")]
1625        Reqwest(#[from] reqwest::Error),
1626    }
1627
1628    /// Return a User-Agent string that matches the given TLS profile's browser.
1629    ///
1630    /// Anti-bot systems cross-reference the `User-Agent` header against the
1631    /// TLS fingerprint. Sending a Chrome TLS profile with a Firefox `User-Agent`
1632    /// is a strong detection signal.
1633    ///
1634    /// # Matching logic
1635    ///
1636    /// | Profile name contains | User-Agent |
1637    /// |---|---|
1638    /// | `"Chrome"` | Chrome 131 on Windows 10 |
1639    /// | `"Firefox"` | Firefox 133 on Windows 10 |
1640    /// | `"Safari"` | Safari 18 on macOS 14.7 |
1641    /// | `"Edge"` | Edge 131 on Windows 10 |
1642    /// | *(other)* | Chrome 131 on Windows 10 (safe fallback) |
1643    #[must_use]
1644    pub fn default_user_agent(profile: &TlsProfile) -> &'static str {
1645        let name = profile.name.to_ascii_lowercase();
1646        if name.contains("firefox") {
1647            "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0"
1648        } else if name.contains("safari") && !name.contains("chrome") {
1649            "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15"
1650        } else if name.contains("edge") {
1651            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
1652        } else {
1653            // Chrome is the default / fallback.
1654            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
1655        }
1656    }
1657
1658    /// Select the built-in [`TlsProfile`] that best matches a
1659    /// [`DeviceProfile`](crate::fingerprint::DeviceProfile).
1660    ///
1661    /// | Device | Selected Profile |
1662    /// |---|---|
1663    /// | `MobileAndroid` | [`CHROME_131`] |
1664    /// | `MobileIOS` | [`SAFARI_18`] |
1665    #[must_use]
1666    pub fn profile_for_device(device: &crate::fingerprint::DeviceProfile) -> &'static TlsProfile {
1667        use crate::fingerprint::DeviceProfile;
1668        match device {
1669            DeviceProfile::DesktopWindows | DeviceProfile::MobileAndroid => &CHROME_131,
1670            DeviceProfile::DesktopMac | DeviceProfile::MobileIOS => &SAFARI_18,
1671            DeviceProfile::DesktopLinux => &FIREFOX_133,
1672        }
1673    }
1674
1675    /// HTTP headers that match the browser identity of `profile`.
1676    ///
1677    /// Anti-bot systems cross-correlate HTTP headers (especially `Accept`,
1678    /// `Accept-Language`, `Accept-Encoding`, and the `Sec-CH-UA` family)
1679    /// against the TLS fingerprint. Mismatches between the TLS profile and
1680    /// the HTTP headers are a strong detection signal.
1681    ///
1682    /// of this type would send on a standard navigation request.
1683    ///
1684    /// # Example
1685    ///
1686    /// ```
1687    /// use stygian_browser::tls::{browser_headers, CHROME_131};
1688    ///
1689    /// let headers = browser_headers(&CHROME_131);
1690    /// assert!(headers.contains_key("accept"));
1691    /// ```
1692    pub fn browser_headers(profile: &TlsProfile) -> reqwest::header::HeaderMap {
1693        use reqwest::header::{
1694            ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, HeaderMap, HeaderValue,
1695            UPGRADE_INSECURE_REQUESTS,
1696        };
1697
1698        let mut map = HeaderMap::new();
1699        let name = profile.name.to_ascii_lowercase();
1700
1701        let is_firefox = name.contains("firefox");
1702        let is_safari = name.contains("safari") && !name.contains("chrome");
1703        let is_chromium = !(is_firefox || is_safari);
1704
1705        // Accept — differs between Chromium-family and Firefox/Safari.
1706        let accept = if is_chromium {
1707            // Chromium (Chrome / Edge)
1708            "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
1709        } else {
1710            "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1711        };
1712
1713        // Accept-Encoding — all modern browsers negotiate the same set.
1714        let accept_encoding = "gzip, deflate, br";
1715
1716        // Accept-Language — pick a realistic primary locale. Passive
1717        // fingerprinting rarely cares about the exact locale beyond the
1718        // primary tag, so en-US is a safe baseline.
1719        let accept_language = "en-US,en;q=0.9";
1720
1721        // Sec-CH-UA headers — Chromium-only.
1722        if is_chromium {
1723            let (brand, version) = if name.contains("edge") {
1724                ("\"Microsoft Edge\";v=\"131\"", "131")
1725            } else {
1726                ("\"Google Chrome\";v=\"131\"", "131")
1727            };
1728
1729            let sec_ch_ua =
1730                format!("{brand}, \"Chromium\";v=\"{version}\", \"Not_A Brand\";v=\"24\"");
1731
1732            // These headers are valid ASCII so HeaderValue::from_str can only
1733            // fail on control characters — which our strings never contain.
1734            if let Ok(v) = HeaderValue::from_str(&sec_ch_ua) {
1735                map.insert("sec-ch-ua", v);
1736            }
1737            map.insert("sec-ch-ua-mobile", HeaderValue::from_static("?0"));
1738            map.insert(
1739                "sec-ch-ua-platform",
1740                HeaderValue::from_static("\"Windows\""),
1741            );
1742            map.insert("sec-fetch-dest", HeaderValue::from_static("document"));
1743            map.insert("sec-fetch-mode", HeaderValue::from_static("navigate"));
1744            map.insert("sec-fetch-site", HeaderValue::from_static("none"));
1745            map.insert("sec-fetch-user", HeaderValue::from_static("?1"));
1746            map.insert(UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1"));
1747        }
1748
1749        if let Ok(v) = HeaderValue::from_str(accept) {
1750            map.insert(ACCEPT, v);
1751        }
1752        map.insert(ACCEPT_ENCODING, HeaderValue::from_static(accept_encoding));
1753        map.insert(ACCEPT_LANGUAGE, HeaderValue::from_static(accept_language));
1754        map.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
1755
1756        map
1757    }
1758
1759    /// Build a [`reqwest::Client`] whose TLS `ClientHello` matches
1760    /// `profile`.
1761    ///
1762    /// The returned client:
1763    ///   (via [`default_user_agent`]).
1764    /// - Sets browser-matched HTTP headers via [`browser_headers`]
1765    ///   (`Accept`, `Accept-Encoding`, `Sec-CH-UA`, etc.).
1766    /// - Routes through `proxy_url` when provided.
1767    ///
1768    /// # Errors
1769    ///
1770    ///
1771    /// # Example
1772    ///
1773    /// ```no_run
1774    /// use stygian_browser::tls::{build_profiled_client, CHROME_131};
1775    ///
1776    /// let client = build_profiled_client(&CHROME_131, None).unwrap();
1777    /// ```
1778    pub fn build_profiled_client(
1779        profile: &TlsProfile,
1780        proxy_url: Option<&str>,
1781    ) -> Result<reqwest::Client, TlsClientError> {
1782        build_profiled_client_with_control(profile, proxy_url, TlsControl::default())
1783    }
1784
1785    /// Build a [`reqwest::Client`] using profile-specific control presets.
1786    ///
1787    /// without manually selecting [`TlsControl`] fields.
1788    ///
1789    /// # Errors
1790    ///
1791    /// Returns [`TlsClientError::TlsConfig`] when the underlying
1792    /// [`TlsProfile::to_rustls_config_with_control`] call fails (unsupported
1793    /// cipher suites / groups under the strict profile preset), and
1794    /// [`TlsClientError::Reqwest`] for the wrapped `reqwest::Client::builder`
1795    /// failures (including proxy URL parsing).
1796    ///
1797    /// # Example
1798    ///
1799    /// ```no_run
1800    /// use stygian_browser::tls::{build_profiled_client_preset, CHROME_131};
1801    ///
1802    /// let client = build_profiled_client_preset(&CHROME_131, None).unwrap();
1803    /// let _ = client;
1804    /// ```
1805    pub fn build_profiled_client_preset(
1806        profile: &TlsProfile,
1807        proxy_url: Option<&str>,
1808    ) -> Result<reqwest::Client, TlsClientError> {
1809        build_profiled_client_with_control(profile, proxy_url, TlsControl::for_profile(profile))
1810    }
1811
1812    /// Build a [`reqwest::Client`] with explicit TLS profile control settings.
1813    ///
1814    /// introducing native build dependencies.
1815    ///
1816    /// # Errors
1817    ///
1818    /// Returns [`TlsClientError::TlsConfig`] when
1819    /// `profile.to_rustls_config_with_control(control)` reports unsupported
1820    /// cipher suites or groups, and [`TlsClientError::Reqwest`] when
1821    /// `proxy_url` cannot be parsed or the underlying
1822    /// `reqwest::ClientBuilder::build` call fails.
1823    ///
1824    /// # Example
1825    ///
1826    /// ```no_run
1827    /// use stygian_browser::tls::{build_profiled_client_with_control, CHROME_131, TlsControl};
1828    ///
1829    /// let client = build_profiled_client_with_control(
1830    ///     &CHROME_131,
1831    ///     None,
1832    ///     TlsControl::strict(),
1833    /// ).unwrap();
1834    /// let _ = client;
1835    /// ```
1836    pub fn build_profiled_client_with_control(
1837        profile: &TlsProfile,
1838        proxy_url: Option<&str>,
1839        control: TlsControl,
1840    ) -> Result<reqwest::Client, TlsClientError> {
1841        let tls_config = profile.to_rustls_config_with_control(control)?;
1842
1843        let rustls_cfg =
1844            Arc::try_unwrap(tls_config.into_inner()).unwrap_or_else(|arc| (*arc).clone());
1845
1846        let mut builder = reqwest::Client::builder()
1847            .use_preconfigured_tls(rustls_cfg)
1848            .user_agent(default_user_agent(profile))
1849            .default_headers(browser_headers(profile))
1850            .cookie_store(true)
1851            .gzip(true)
1852            .brotli(true);
1853
1854        if let Some(url) = proxy_url {
1855            builder = builder.proxy(reqwest::Proxy::all(url)?);
1856        }
1857
1858        Ok(builder.build()?)
1859    }
1860
1861    /// Build a strict TLS-profiled [`reqwest::Client`].
1862    ///
1863    /// Strict mode rejects unsupported cipher suites instead of silently
1864    /// skipping them.
1865    ///
1866    /// # Example
1867    ///
1868    /// ```no_run
1869    /// use stygian_browser::tls::{build_profiled_client_strict, CHROME_131};
1870    ///
1871    /// let client = build_profiled_client_strict(&CHROME_131, None).unwrap();
1872    /// let _ = client;
1873    /// ```
1874    ///
1875    /// # Errors
1876    ///
1877    /// Returns [`TlsClientError::TlsConfig`] when the underlying
1878    /// [`TlsProfile::to_rustls_config_with_control`] call fails because
1879    /// `TlsControl::strict()` rejects an unsupported cipher suite or
1880    /// supported group, plus [`TlsClientError::Reqwest`] for the wrapped
1881    /// `reqwest::ClientBuilder` failures (including proxy URL parsing).
1882    pub fn build_profiled_client_strict(
1883        profile: &TlsProfile,
1884        proxy_url: Option<&str>,
1885    ) -> Result<reqwest::Client, TlsClientError> {
1886        build_profiled_client_with_control(profile, proxy_url, TlsControl::strict())
1887    }
1888}
1889
1890#[cfg(feature = "tls-config")]
1891pub use reqwest_client::{
1892    TlsClientError, browser_headers, build_profiled_client, build_profiled_client_preset,
1893    build_profiled_client_strict, build_profiled_client_with_control, default_user_agent,
1894    profile_for_device,
1895};
1896
1897// ── tests ────────────────────────────────────────────────────────────────────
1898
1899#[cfg(test)]
1900#[allow(clippy::panic, clippy::unwrap_used)]
1901mod tests {
1902    use super::*;
1903
1904    #[test]
1905    fn md5_known_vectors() {
1906        assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
1907        assert_eq!(md5_hex(b"a"), "0cc175b9c0f1b6a831c399e269772661");
1908        assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
1909        assert_eq!(
1910            md5_hex(b"message digest"),
1911            "f96b697d7cb7938d525a2f31aaf161d0"
1912        );
1913    }
1914
1915    #[test]
1916    fn chrome_131_ja3_structure() {
1917        let ja3 = CHROME_131.ja3();
1918        // Must start with 771 (TLS 1.2 = 0x0303 = 771 is the *highest* in
1919        // the supported list, but TLS 1.3 = 0x0304 = 772 is also present;
1920        // ja3 picks max → 772).
1921        assert!(
1922            ja3.raw.starts_with("772,"),
1923            "JA3 raw should start with '772,' but was: {}",
1924            ja3.raw
1925        );
1926        // Has five comma-separated sections.
1927        assert_eq!(ja3.raw.matches(',').count(), 4);
1928        // Hash is 32 hex chars.
1929        assert_eq!(ja3.hash.len(), 32);
1930        assert!(ja3.hash.chars().all(|c| c.is_ascii_hexdigit()));
1931    }
1932
1933    #[test]
1934    fn firefox_133_ja3_differs_from_chrome() {
1935        let chrome_ja3 = CHROME_131.ja3();
1936        let firefox_ja3 = FIREFOX_133.ja3();
1937        assert_ne!(chrome_ja3.hash, firefox_ja3.hash);
1938        assert_ne!(chrome_ja3.raw, firefox_ja3.raw);
1939    }
1940
1941    #[test]
1942    fn safari_18_ja3_is_valid() {
1943        let ja3 = SAFARI_18.ja3();
1944        assert!(ja3.raw.starts_with("772,"));
1945        assert_eq!(ja3.hash.len(), 32);
1946    }
1947
1948    #[test]
1949    fn edge_131_ja3_differs_from_chrome() {
1950        let chrome_ja3 = CHROME_131.ja3();
1951        let edge_ja3 = EDGE_131.ja3();
1952        assert_ne!(chrome_ja3.hash, edge_ja3.hash);
1953    }
1954
1955    #[test]
1956    fn chrome_131_ja4_format() {
1957        let ja4 = CHROME_131.ja4();
1958        // Starts with 't13d' (TCP, TLS 1.3, domain SNI).
1959        assert!(
1960            ja4.fingerprint.starts_with("t13d"),
1961            "JA4 should start with 't13d' but was: {}",
1962            ja4.fingerprint
1963        );
1964        // Three underscore-separated sections.
1965        assert_eq!(
1966            ja4.fingerprint.matches('_').count(),
1967            3,
1968            "JA4 should have three separators: {}",
1969            ja4.fingerprint
1970        );
1971    }
1972
1973    #[test]
1974    fn ja4_firefox_differs_from_chrome() {
1975        let chrome_ja4 = CHROME_131.ja4();
1976        let firefox_ja4 = FIREFOX_133.ja4();
1977        assert_ne!(chrome_ja4.fingerprint, firefox_ja4.fingerprint);
1978    }
1979
1980    #[test]
1981    fn random_weighted_distribution() {
1982        let mut chrome_count = 0u32;
1983        let mut firefox_count = 0u32;
1984        let mut edge_count = 0u32;
1985        let mut safari_count = 0u32;
1986
1987        let total = 10_000u32;
1988        for i in 0..total {
1989            let profile = TlsProfile::random_weighted(u64::from(i));
1990            match profile.name.as_str() {
1991                "Chrome 131" => chrome_count += 1,
1992                "Firefox 133" => firefox_count += 1,
1993                "Edge 131" => edge_count += 1,
1994                "Safari 18" => safari_count += 1,
1995                other => unreachable!("unexpected profile: {other}"),
1996            }
1997        }
1998
1999        // Chrome should be the most common (>40%).
2000        assert!(
2001            chrome_count > total * 40 / 100,
2002            "Chrome share too low: {chrome_count}/{total}"
2003        );
2004        // Firefox should appear (>5%).
2005        assert!(
2006            firefox_count > total * 5 / 100,
2007            "Firefox share too low: {firefox_count}/{total}"
2008        );
2009        // Edge should appear (>5%).
2010        assert!(
2011            edge_count > total * 5 / 100,
2012            "Edge share too low: {edge_count}/{total}"
2013        );
2014        // Safari should appear (>3%).
2015        assert!(
2016            safari_count > total * 3 / 100,
2017            "Safari share too low: {safari_count}/{total}"
2018        );
2019    }
2020
2021    #[test]
2022    fn serde_roundtrip() {
2023        let profile: &TlsProfile = &CHROME_131;
2024        let json = serde_json::to_string(profile).unwrap();
2025        let deserialized: TlsProfile = serde_json::from_str(&json).unwrap();
2026        assert_eq!(profile, &deserialized);
2027    }
2028
2029    #[test]
2030    fn ja3hash_display() {
2031        let ja3 = CHROME_131.ja3();
2032        assert_eq!(format!("{ja3}"), ja3.hash);
2033    }
2034
2035    #[test]
2036    fn ja4_display() {
2037        let ja4 = CHROME_131.ja4();
2038        assert_eq!(format!("{ja4}"), ja4.fingerprint);
2039    }
2040
2041    #[test]
2042    fn http3_perk_chrome_text_and_hash_are_stable() {
2043        let Some(perk) = CHROME_131.http3_perk() else {
2044            panic!("chrome should have perk");
2045        };
2046        let text = perk.perk_text();
2047        assert_eq!(text, "1:65536;6:262144;7:100;51:1;GREASE|masp");
2048        assert_eq!(perk.perk_hash().len(), 32);
2049    }
2050
2051    #[test]
2052    fn expected_perk_from_user_agent_detects_firefox() {
2053        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0";
2054        let Some(perk) = expected_http3_perk_from_user_agent(ua) else {
2055            panic!("firefox should resolve");
2056        };
2057        assert_eq!(perk.perk_text(), "1:65536;7:20;727725890:0|mpas");
2058    }
2059
2060    #[test]
2061    fn http3_perk_compare_detects_text_mismatch() {
2062        let Some(perk) = CHROME_131.http3_perk() else {
2063            panic!("chrome should have perk");
2064        };
2065        let cmp = perk.compare(Some("1:65536|masp"), None);
2066        assert!(!cmp.matches);
2067        assert_eq!(cmp.mismatches.len(), 1);
2068        assert!(
2069            cmp.mismatches
2070                .first()
2071                .is_some_and(|mismatch| mismatch.contains("perk_text mismatch"))
2072        );
2073    }
2074
2075    #[test]
2076    fn cipher_suite_display() {
2077        let cs = CipherSuiteId::TLS_AES_128_GCM_SHA256;
2078        assert_eq!(format!("{cs}"), "4865"); // 0x1301 = 4865
2079    }
2080
2081    #[test]
2082    fn tls_version_display() {
2083        assert_eq!(format!("{}", TlsVersion::Tls13), "772");
2084    }
2085
2086    #[test]
2087    fn alpn_protocol_as_str() {
2088        assert_eq!(AlpnProtocol::H2.as_str(), "h2");
2089        assert_eq!(AlpnProtocol::Http11.as_str(), "http/1.1");
2090    }
2091
2092    #[test]
2093    fn supported_group_values() {
2094        assert_eq!(SupportedGroup::X25519.iana_value(), 0x001d);
2095        assert_eq!(SupportedGroup::SecP256r1.iana_value(), 0x0017);
2096        assert_eq!(SupportedGroup::X25519Kyber768.iana_value(), 0x6399);
2097    }
2098
2099    // ── Chrome TLS flags tests ─────────────────────────────────────────
2100
2101    #[test]
2102    fn chrome_131_tls_args_empty() {
2103        // Chrome 131 supports both TLS 1.2 and 1.3 — no extra flags needed.
2104        let args = chrome_tls_args(&CHROME_131);
2105        assert!(args.is_empty(), "expected no flags, got: {args:?}");
2106    }
2107
2108    #[test]
2109    fn tls12_only_profile_caps_version() {
2110        let profile = TlsProfile {
2111            name: "TLS12-only".to_string(),
2112            cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2113            tls_versions: vec![TlsVersion::Tls12],
2114            extensions: vec![],
2115            supported_groups: vec![],
2116            signature_algorithms: vec![],
2117            alpn_protocols: vec![],
2118        };
2119        let args = chrome_tls_args(&profile);
2120        assert_eq!(args, vec!["--ssl-version-max=tls1.2"]);
2121    }
2122
2123    #[test]
2124    fn tls13_only_profile_raises_floor() {
2125        let profile = TlsProfile {
2126            name: "TLS13-only".to_string(),
2127            cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2128            tls_versions: vec![TlsVersion::Tls13],
2129            extensions: vec![],
2130            supported_groups: vec![],
2131            signature_algorithms: vec![],
2132            alpn_protocols: vec![],
2133        };
2134        let args = chrome_tls_args(&profile);
2135        assert_eq!(args, vec!["--ssl-version-min=tls1.3"]);
2136    }
2137
2138    #[test]
2139    fn builder_tls_profile_integration() {
2140        let cfg = crate::BrowserConfig::builder()
2141            .tls_profile(&CHROME_131)
2142            .build();
2143        // Chrome 131 has both versions — no TLS flags added.
2144        let tls_flags: Vec<_> = cfg
2145            .effective_args()
2146            .into_iter()
2147            .filter(|a| a.starts_with("--ssl-version"))
2148            .collect();
2149        assert!(tls_flags.is_empty(), "unexpected TLS flags: {tls_flags:?}");
2150    }
2151
2152    // ── rustls integration tests ─────────────────────────────────────────
2153
2154    #[cfg(feature = "tls-config")]
2155    mod rustls_tests {
2156        use super::super::*;
2157
2158        #[test]
2159        fn chrome_131_config_builds_successfully() {
2160            let config = CHROME_131.to_rustls_config().unwrap();
2161            // The inner ClientConfig should be accessible.
2162            let inner = config.inner();
2163            // ALPN must be set.
2164            assert!(
2165                !inner.alpn_protocols.is_empty(),
2166                "ALPN protocols should be set"
2167            );
2168        }
2169
2170        #[test]
2171        #[allow(clippy::indexing_slicing)]
2172        fn alpn_order_matches_profile() {
2173            let config = CHROME_131.to_rustls_config().unwrap();
2174            let alpn = &config.inner().alpn_protocols;
2175            assert_eq!(alpn.len(), 2);
2176            assert_eq!(alpn[0], b"h2");
2177            assert_eq!(alpn[1], b"http/1.1");
2178        }
2179
2180        #[test]
2181        fn all_builtin_profiles_produce_valid_configs() {
2182            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2183                let result = profile.to_rustls_config();
2184                assert!(
2185                    result.is_ok(),
2186                    "profile '{}' should produce a valid config: {:?}",
2187                    profile.name,
2188                    result.err()
2189                );
2190            }
2191        }
2192
2193        #[test]
2194        fn unsupported_only_suites_returns_error() {
2195            let profile = TlsProfile {
2196                name: "Bogus".to_string(),
2197                cipher_suites: vec![CipherSuiteId(0xFFFF)],
2198                tls_versions: vec![TlsVersion::Tls13],
2199                extensions: vec![],
2200                supported_groups: vec![],
2201                signature_algorithms: vec![],
2202                alpn_protocols: vec![],
2203            };
2204            let err = profile.to_rustls_config().unwrap_err();
2205            assert!(
2206                err.to_string().contains("no supported cipher suites"),
2207                "expected NoCipherSuites, got: {err}"
2208            );
2209        }
2210
2211        #[test]
2212        fn strict_mode_rejects_unknown_cipher_suite() {
2213            let profile = TlsProfile {
2214                name: "StrictCipherTest".to_string(),
2215                cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256, CipherSuiteId(0xFFFF)],
2216                tls_versions: vec![TlsVersion::Tls13],
2217                extensions: vec![],
2218                supported_groups: vec![SupportedGroup::X25519],
2219                signature_algorithms: vec![],
2220                alpn_protocols: vec![],
2221            };
2222
2223            let err = profile
2224                .to_rustls_config_with_control(TlsControl::strict())
2225                .unwrap_err();
2226
2227            match err {
2228                TlsConfigError::UnsupportedCipherSuite {
2229                    cipher_suite_id, ..
2230                } => {
2231                    assert_eq!(cipher_suite_id, 0xFFFF);
2232                }
2233                other => panic!("expected UnsupportedCipherSuite, got: {other}"),
2234            }
2235        }
2236
2237        #[test]
2238        fn compatible_mode_skips_unknown_cipher_suite() {
2239            let mut profile = (*CHROME_131).clone();
2240            profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2241
2242            let cfg = profile.to_rustls_config_with_control(TlsControl::compatible());
2243            assert!(cfg.is_ok(), "compatible mode should skip unknown suite");
2244        }
2245
2246        #[test]
2247        fn control_for_builtin_profiles_is_strict() {
2248            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2249                let control = TlsControl::for_profile(profile);
2250                assert!(
2251                    control.strict_cipher_suites,
2252                    "builtin profile '{}' should use strict cipher checking",
2253                    profile.name
2254                );
2255            }
2256        }
2257
2258        #[test]
2259        fn control_for_custom_profile_is_compatible() {
2260            let profile = TlsProfile {
2261                name: "Custom Backend".to_string(),
2262                cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2263                tls_versions: vec![TlsVersion::Tls13],
2264                extensions: vec![],
2265                supported_groups: vec![SupportedGroup::X25519],
2266                signature_algorithms: vec![],
2267                alpn_protocols: vec![],
2268            };
2269
2270            let control = TlsControl::for_profile(&profile);
2271            assert!(!control.strict_cipher_suites);
2272            assert!(!control.strict_supported_groups);
2273            assert!(control.fallback_to_provider_groups);
2274        }
2275
2276        #[test]
2277        fn strict_all_without_groups_returns_error() {
2278            let profile = TlsProfile {
2279                name: "StrictGroupTest".to_string(),
2280                cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2281                tls_versions: vec![TlsVersion::Tls13],
2282                extensions: vec![],
2283                supported_groups: vec![],
2284                signature_algorithms: vec![],
2285                alpn_protocols: vec![],
2286            };
2287
2288            let err = profile
2289                .to_rustls_config_with_control(TlsControl::strict_all())
2290                .unwrap_err();
2291
2292            match err {
2293                TlsConfigError::NoSupportedGroups(name) => {
2294                    assert_eq!(name, "StrictGroupTest");
2295                }
2296                other => panic!("expected NoSupportedGroups, got: {other}"),
2297            }
2298        }
2299
2300        #[test]
2301        fn into_arc_conversion() {
2302            let config = CHROME_131.to_rustls_config().unwrap();
2303            let arc: std::sync::Arc<rustls::ClientConfig> = config.into();
2304            // Should be valid — just verify it doesn't panic.
2305            assert!(!arc.alpn_protocols.is_empty());
2306        }
2307    }
2308
2309    // ── reqwest client tests ─────────────────────────────────────────
2310
2311    #[cfg(feature = "tls-config")]
2312    mod reqwest_tests {
2313        use super::super::*;
2314
2315        #[test]
2316        fn build_profiled_client_no_proxy() {
2317            let client = build_profiled_client(&CHROME_131, None);
2318            assert!(
2319                client.is_ok(),
2320                "should build a client without error: {:?}",
2321                client.err()
2322            );
2323        }
2324
2325        #[test]
2326        fn build_profiled_client_all_profiles() {
2327            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2328                let result = build_profiled_client(profile, None);
2329                assert!(
2330                    result.is_ok(),
2331                    "profile '{}' should produce a valid client: {:?}",
2332                    profile.name,
2333                    result.err()
2334                );
2335            }
2336        }
2337
2338        #[test]
2339        fn build_profiled_client_strict_no_proxy() {
2340            let client = build_profiled_client_strict(&CHROME_131, None);
2341            assert!(
2342                client.is_ok(),
2343                "strict mode should build for built-in profile: {:?}",
2344                client.err()
2345            );
2346        }
2347
2348        #[test]
2349        fn build_profiled_client_preset_all_profiles() {
2350            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2351                let result = build_profiled_client_preset(profile, None);
2352                assert!(
2353                    result.is_ok(),
2354                    "preset builder should work for profile '{}': {:?}",
2355                    profile.name,
2356                    result.err()
2357                );
2358            }
2359        }
2360
2361        #[test]
2362        fn build_profiled_client_with_control_rejects_unknown_cipher_suite() {
2363            let mut profile = (*CHROME_131).clone();
2364            profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2365
2366            let client = build_profiled_client_with_control(&profile, None, TlsControl::strict());
2367
2368            assert!(
2369                client.is_err(),
2370                "strict mode should reject unsupported cipher suite"
2371            );
2372        }
2373
2374        #[test]
2375        fn default_user_agent_matches_browser() {
2376            assert!(default_user_agent(&CHROME_131).contains("Chrome/131"));
2377            assert!(default_user_agent(&FIREFOX_133).contains("Firefox/133"));
2378            assert!(default_user_agent(&SAFARI_18).contains("Safari/605"));
2379            assert!(default_user_agent(&EDGE_131).contains("Edg/131"));
2380        }
2381
2382        #[test]
2383        fn profile_for_device_mapping() {
2384            use crate::fingerprint::DeviceProfile;
2385
2386            assert_eq!(
2387                profile_for_device(&DeviceProfile::DesktopWindows).name,
2388                "Chrome 131"
2389            );
2390            assert_eq!(
2391                profile_for_device(&DeviceProfile::DesktopMac).name,
2392                "Safari 18"
2393            );
2394            assert_eq!(
2395                profile_for_device(&DeviceProfile::DesktopLinux).name,
2396                "Firefox 133"
2397            );
2398            assert_eq!(
2399                profile_for_device(&DeviceProfile::MobileAndroid).name,
2400                "Chrome 131"
2401            );
2402            assert_eq!(
2403                profile_for_device(&DeviceProfile::MobileIOS).name,
2404                "Safari 18"
2405            );
2406        }
2407
2408        #[test]
2409        fn browser_headers_chrome_has_sec_ch_ua() {
2410            let headers = browser_headers(&CHROME_131);
2411            assert!(
2412                headers.contains_key("sec-ch-ua"),
2413                "Chrome profile should have sec-ch-ua"
2414            );
2415            assert!(
2416                headers.contains_key("sec-fetch-dest"),
2417                "Chrome profile should have sec-fetch-dest"
2418            );
2419            let accept = headers.get("accept").unwrap().to_str().unwrap();
2420            assert!(
2421                accept.contains("image/avif"),
2422                "Chrome accept should include avif"
2423            );
2424        }
2425
2426        #[test]
2427        fn browser_headers_firefox_no_sec_ch_ua() {
2428            let headers = browser_headers(&FIREFOX_133);
2429            assert!(
2430                !headers.contains_key("sec-ch-ua"),
2431                "Firefox profile should not have sec-ch-ua"
2432            );
2433            let accept = headers.get("accept").unwrap().to_str().unwrap();
2434            assert!(
2435                accept.contains("text/html"),
2436                "Firefox accept should include text/html"
2437            );
2438        }
2439
2440        #[test]
2441        fn browser_headers_all_profiles_have_accept() {
2442            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2443                let headers = browser_headers(profile);
2444                assert!(
2445                    headers.contains_key("accept"),
2446                    "profile '{}' must have accept header",
2447                    profile.name
2448                );
2449                assert!(
2450                    headers.contains_key("accept-encoding"),
2451                    "profile '{}' must have accept-encoding",
2452                    profile.name
2453                );
2454                assert!(
2455                    headers.contains_key("accept-language"),
2456                    "profile '{}' must have accept-language",
2457                    profile.name
2458                );
2459            }
2460        }
2461
2462        #[test]
2463        fn browser_headers_edge_uses_edge_brand() {
2464            let headers = browser_headers(&EDGE_131);
2465            let sec_ch_ua = headers.get("sec-ch-ua").unwrap().to_str().unwrap();
2466            assert!(
2467                sec_ch_ua.contains("Microsoft Edge"),
2468                "Edge sec-ch-ua should identify Edge: {sec_ch_ua}"
2469            );
2470        }
2471    }
2472}
2473
2474// ── Profile Pack abstraction (T50) ───────────────────────────────────────────
2475
2476/// Browser family for a TLS profile pack.
2477///
2478/// # Example
2479///
2480/// ```
2481/// use stygian_browser::tls::BrowserFamily;
2482///
2483/// assert_eq!(BrowserFamily::Chrome.as_str(), "chrome");
2484/// ```
2485#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2486#[non_exhaustive]
2487pub enum BrowserFamily {
2488    /// Google Chrome / Chromium.
2489    Chrome,
2490    /// Mozilla Firefox.
2491    Firefox,
2492    /// Apple Safari.
2493    Safari,
2494    /// Microsoft Edge (Chromium-based).
2495    Edge,
2496}
2497
2498impl BrowserFamily {
2499    /// Lowercase ASCII identifier used in channel names.
2500    ///
2501    /// # Example
2502    ///
2503    /// ```
2504    /// use stygian_browser::tls::BrowserFamily;
2505    ///
2506    /// assert_eq!(BrowserFamily::Firefox.as_str(), "firefox");
2507    /// ```
2508    #[must_use]
2509    pub const fn as_str(self) -> &'static str {
2510        match self {
2511            Self::Chrome => "chrome",
2512            Self::Firefox => "firefox",
2513            Self::Safari => "safari",
2514            Self::Edge => "edge",
2515        }
2516    }
2517}
2518
2519impl fmt::Display for BrowserFamily {
2520    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2521        f.write_str(self.as_str())
2522    }
2523}
2524
2525/// Operating system platform class for a TLS profile pack.
2526///
2527/// # Example
2528///
2529/// ```
2530/// use stygian_browser::tls::PlatformClass;
2531///
2532/// assert_eq!(PlatformClass::Windows.as_str(), "windows");
2533/// ```
2534#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2535#[non_exhaustive]
2536pub enum PlatformClass {
2537    /// Windows (any version).
2538    Windows,
2539    /// macOS / iOS / iPadOS.
2540    MacOs,
2541    /// Linux desktop or server.
2542    Linux,
2543}
2544
2545impl PlatformClass {
2546    /// Lowercase ASCII identifier.
2547    ///
2548    /// # Example
2549    ///
2550    /// ```
2551    /// use stygian_browser::tls::PlatformClass;
2552    ///
2553    /// assert_eq!(PlatformClass::Linux.as_str(), "linux");
2554    /// ```
2555    #[must_use]
2556    pub const fn as_str(self) -> &'static str {
2557        match self {
2558            Self::Windows => "windows",
2559            Self::MacOs => "macos",
2560            Self::Linux => "linux",
2561        }
2562    }
2563}
2564
2565impl fmt::Display for PlatformClass {
2566    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2567        f.write_str(self.as_str())
2568    }
2569}
2570
2571/// Named update channel for automatic profile resolution.
2572///
2573/// `ChromeLatest`, `FirefoxLatest`, etc. are symbolic aliases that always
2574/// resolve to the most recent pinned profile for that browser.  Pinned
2575/// variants reference a specific browser version and never change.
2576///
2577/// # Example
2578///
2579/// ```
2580/// use stygian_browser::tls::{ProfileChannel, TlsProfilePack};
2581///
2582/// let pack = ProfileChannel::ChromeLatest.resolve(None).unwrap();
2583/// assert_eq!(pack.metadata.family, stygian_browser::tls::BrowserFamily::Chrome);
2584/// ```
2585#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2586#[non_exhaustive]
2587pub enum ProfileChannel {
2588    /// Always resolves to the latest built-in Chrome profile.
2589    ChromeLatest,
2590    /// Always resolves to the latest built-in Firefox profile.
2591    FirefoxLatest,
2592    /// Always resolves to the latest built-in Safari profile.
2593    SafariLatest,
2594    /// Always resolves to the latest built-in Edge profile.
2595    EdgeLatest,
2596    /// Pinned: Chrome 131.
2597    Chrome131,
2598    /// Pinned: Firefox 133.
2599    Firefox133,
2600    /// Pinned: Safari 18.
2601    Safari18,
2602    /// Pinned: Edge 131.
2603    Edge131,
2604}
2605
2606impl ProfileChannel {
2607    /// Resolve this channel to a static [`TlsProfilePack`].
2608    ///
2609    /// `platform` is an optional hint; it is recorded in diagnostics but does
2610    /// not change which profile is returned for the current built-in set.
2611    ///
2612    /// # Errors
2613    ///
2614    /// Returns [`ProfileChannelError::UnknownChannel`] if the channel string
2615    /// cannot be parsed. (This variant is only reachable via
2616    /// [`std::str::FromStr::from_str`].)
2617    ///
2618    /// # Example
2619    ///
2620    /// ```
2621    /// use stygian_browser::tls::{ProfileChannel, PlatformClass};
2622    ///
2623    /// let pack = ProfileChannel::Firefox133.resolve(Some(PlatformClass::Linux)).unwrap();
2624    /// assert_eq!(pack.profile.name, "Firefox 133");
2625    /// ```
2626    pub fn resolve(
2627        self,
2628        _platform: Option<PlatformClass>,
2629    ) -> Result<&'static TlsProfilePack, ProfileChannelError> {
2630        match self {
2631            Self::ChromeLatest | Self::Chrome131 => Ok(&PACK_CHROME_131),
2632            Self::FirefoxLatest | Self::Firefox133 => Ok(&PACK_FIREFOX_133),
2633            Self::SafariLatest | Self::Safari18 => Ok(&PACK_SAFARI_18),
2634            Self::EdgeLatest | Self::Edge131 => Ok(&PACK_EDGE_131),
2635        }
2636    }
2637}
2638
2639impl std::str::FromStr for ProfileChannel {
2640    type Err = ProfileChannelError;
2641
2642    /// Parse a channel name string (case-insensitive).
2643    ///
2644    /// Recognised channel names:
2645    ///
2646    /// | Input | Channel |
2647    /// |---|---|
2648    /// | `chrome-latest` | [`ProfileChannel::ChromeLatest`] |
2649    /// | `firefox-latest` | [`ProfileChannel::FirefoxLatest`] |
2650    /// | `safari-latest` | [`ProfileChannel::SafariLatest`] |
2651    /// | `edge-latest` | [`ProfileChannel::EdgeLatest`] |
2652    /// | `chrome-131` | [`ProfileChannel::Chrome131`] |
2653    /// | `firefox-133` | [`ProfileChannel::Firefox133`] |
2654    /// | `safari-18` | [`ProfileChannel::Safari18`] |
2655    /// | `edge-131` | [`ProfileChannel::Edge131`] |
2656    ///
2657    /// # Errors
2658    ///
2659    /// Returns [`ProfileChannelError::UnknownChannel`] for unrecognised names.
2660    ///
2661    /// # Example
2662    ///
2663    /// ```
2664    /// use stygian_browser::tls::ProfileChannel;
2665    ///
2666    /// let ch: ProfileChannel = "chrome-latest".parse().unwrap();
2667    /// assert_eq!(ch, ProfileChannel::ChromeLatest);
2668    /// ```
2669    fn from_str(s: &str) -> Result<Self, Self::Err> {
2670        match s.to_ascii_lowercase().as_str() {
2671            "chrome-latest" => Ok(Self::ChromeLatest),
2672            "firefox-latest" => Ok(Self::FirefoxLatest),
2673            "safari-latest" => Ok(Self::SafariLatest),
2674            "edge-latest" => Ok(Self::EdgeLatest),
2675            "chrome-131" => Ok(Self::Chrome131),
2676            "firefox-133" => Ok(Self::Firefox133),
2677            "safari-18" => Ok(Self::Safari18),
2678            "edge-131" => Ok(Self::Edge131),
2679            other => Err(ProfileChannelError::UnknownChannel(other.to_string())),
2680        }
2681    }
2682}
2683
2684/// Error returned when a profile channel cannot be resolved.
2685///
2686/// # Example
2687///
2688/// ```
2689/// use stygian_browser::tls::ProfileChannel;
2690/// use std::str::FromStr;
2691///
2692/// let err = ProfileChannel::from_str("ie-6").unwrap_err();
2693/// assert!(err.to_string().contains("ie-6"));
2694/// ```
2695#[derive(Debug, thiserror::Error)]
2696#[non_exhaustive]
2697pub enum ProfileChannelError {
2698    /// The channel name string is not recognised.
2699    #[error(
2700        "unknown profile channel '{0}'; known channels: chrome-latest, firefox-latest, safari-latest, edge-latest, chrome-131, firefox-133, safari-18, edge-131"
2701    )]
2702    UnknownChannel(String),
2703}
2704
2705/// Metadata describing the provenance of a [`TlsProfilePack`].
2706///
2707/// # Example
2708///
2709/// ```
2710/// use stygian_browser::tls::PACK_CHROME_131;
2711///
2712/// let meta = &PACK_CHROME_131.metadata;
2713/// assert_eq!(meta.browser_version, "131");
2714/// assert!(meta.h2_support);
2715/// ```
2716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2717pub struct ProfileMetadata {
2718    /// Browser family (Chrome, Firefox, Safari, Edge).
2719    pub family: BrowserFamily,
2720    /// Major browser version string (e.g. `"131"`).
2721    pub browser_version: String,
2722    /// Primary platform class this profile was captured on.
2723    pub platform: PlatformClass,
2724    /// Whether the profile advertises HTTP/2 via ALPN.
2725    pub h2_support: bool,
2726    /// Whether the profile advertises HTTP/3 / QUIC perk data.
2727    pub h3_support: bool,
2728    /// ISO 8601 date this profile was added to the pack (e.g. `"2024-12-01"`).
2729    pub added_date: String,
2730    /// Source notes describing where the profile data came from.
2731    pub source_notes: String,
2732}
2733
2734impl ProfileMetadata {
2735    /// Return a short provenance string suitable for diagnostics and logging.
2736    ///
2737    /// # Example
2738    ///
2739    /// ```
2740    /// use stygian_browser::tls::PACK_CHROME_131;
2741    ///
2742    /// let desc = PACK_CHROME_131.metadata.provenance();
2743    /// assert!(desc.contains("Chrome"));
2744    /// assert!(desc.contains("131"));
2745    /// ```
2746    #[must_use]
2747    pub fn provenance(&self) -> String {
2748        format!(
2749            "{} {} on {} (added {}; {})",
2750            self.family, self.browser_version, self.platform, self.added_date, self.source_notes
2751        )
2752    }
2753}
2754
2755/// A versioned TLS profile bundle pairing a [`TlsProfile`] with its
2756/// [`ProfileMetadata`].
2757///
2758/// # Example
2759///
2760/// ```
2761/// use stygian_browser::tls::{PACK_CHROME_131, ProfileChannel};
2762///
2763/// let pack = ProfileChannel::ChromeLatest.resolve(None).unwrap();
2764/// assert_eq!(pack.profile.name, "Chrome 131");
2765/// assert!(pack.metadata.h2_support);
2766/// println!("{}", pack.metadata.provenance());
2767/// ```
2768#[derive(Debug)]
2769pub struct TlsProfilePack {
2770    /// The TLS fingerprint profile.
2771    pub profile: &'static TlsProfile,
2772    /// Provenance and capability metadata.
2773    pub metadata: ProfileMetadata,
2774}
2775
2776/// Chrome 131 profile pack.
2777///
2778/// # Example
2779///
2780/// ```
2781/// use stygian_browser::tls::PACK_CHROME_131;
2782///
2783/// assert_eq!(PACK_CHROME_131.profile.name, "Chrome 131");
2784/// assert!(PACK_CHROME_131.metadata.h3_support);
2785/// ```
2786pub static PACK_CHROME_131: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
2787    profile: &CHROME_131,
2788    metadata: ProfileMetadata {
2789        family: BrowserFamily::Chrome,
2790        browser_version: "131".to_string(),
2791        platform: PlatformClass::Windows,
2792        h2_support: true,
2793        h3_support: true,
2794        added_date: "2024-12-01".to_string(),
2795        source_notes: "ClientHello capture from Chrome 131.0.6778.86 on Windows 11".to_string(),
2796    },
2797});
2798
2799/// Firefox 133 profile pack.
2800///
2801/// # Example
2802///
2803/// ```
2804/// use stygian_browser::tls::PACK_FIREFOX_133;
2805///
2806/// assert_eq!(PACK_FIREFOX_133.profile.name, "Firefox 133");
2807/// assert!(PACK_FIREFOX_133.metadata.h3_support);
2808/// ```
2809pub static PACK_FIREFOX_133: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
2810    profile: &FIREFOX_133,
2811    metadata: ProfileMetadata {
2812        family: BrowserFamily::Firefox,
2813        browser_version: "133".to_string(),
2814        platform: PlatformClass::Windows,
2815        h2_support: true,
2816        h3_support: true,
2817        added_date: "2024-12-01".to_string(),
2818        source_notes: "ClientHello capture from Firefox 133.0 on Windows 11".to_string(),
2819    },
2820});
2821
2822/// Safari 18 profile pack.
2823///
2824/// # Example
2825///
2826/// ```
2827/// use stygian_browser::tls::PACK_SAFARI_18;
2828///
2829/// assert_eq!(PACK_SAFARI_18.profile.name, "Safari 18");
2830/// assert!(!PACK_SAFARI_18.metadata.h3_support);
2831/// ```
2832pub static PACK_SAFARI_18: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
2833    profile: &SAFARI_18,
2834    metadata: ProfileMetadata {
2835        family: BrowserFamily::Safari,
2836        browser_version: "18".to_string(),
2837        platform: PlatformClass::MacOs,
2838        h2_support: true,
2839        h3_support: false,
2840        added_date: "2024-12-01".to_string(),
2841        source_notes: "ClientHello capture from Safari 18.1 on macOS 15 Sequoia".to_string(),
2842    },
2843});
2844
2845/// Edge 131 profile pack.
2846///
2847/// # Example
2848///
2849/// ```
2850/// use stygian_browser::tls::PACK_EDGE_131;
2851///
2852/// assert_eq!(PACK_EDGE_131.profile.name, "Edge 131");
2853/// assert!(PACK_EDGE_131.metadata.h3_support);
2854/// ```
2855pub static PACK_EDGE_131: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
2856    profile: &EDGE_131,
2857    metadata: ProfileMetadata {
2858        family: BrowserFamily::Edge,
2859        browser_version: "131".to_string(),
2860        platform: PlatformClass::Windows,
2861        h2_support: true,
2862        h3_support: true,
2863        added_date: "2024-12-01".to_string(),
2864        source_notes: "ClientHello capture from Edge 131.0.2903.70 on Windows 11".to_string(),
2865    },
2866});
2867
2868#[cfg(test)]
2869mod pack_tests {
2870    use super::*;
2871
2872    #[test]
2873    fn channel_latest_resolves_to_expected_profile() -> Result<(), ProfileChannelError> {
2874        let chrome = ProfileChannel::ChromeLatest.resolve(None)?;
2875        assert_eq!(chrome.profile.name, "Chrome 131");
2876
2877        let firefox = ProfileChannel::FirefoxLatest.resolve(None)?;
2878        assert_eq!(firefox.profile.name, "Firefox 133");
2879
2880        let safari = ProfileChannel::SafariLatest.resolve(None)?;
2881        assert_eq!(safari.profile.name, "Safari 18");
2882
2883        let edge = ProfileChannel::EdgeLatest.resolve(None)?;
2884        assert_eq!(edge.profile.name, "Edge 131");
2885        Ok(())
2886    }
2887
2888    #[test]
2889    fn pinned_channels_resolve_to_same_as_latest() -> Result<(), ProfileChannelError> {
2890        let chrome_pinned = ProfileChannel::Chrome131.resolve(None)?;
2891        let chrome_latest = ProfileChannel::ChromeLatest.resolve(None)?;
2892        assert!(std::ptr::eq(chrome_pinned, chrome_latest));
2893        Ok(())
2894    }
2895
2896    #[test]
2897    fn metadata_is_serializable() -> Result<(), Box<dyn std::error::Error>> {
2898        let pack = &*PACK_CHROME_131;
2899        let json = serde_json::to_string(&pack.metadata)?;
2900        assert!(json.contains("Chrome"));
2901        assert!(json.contains("131"));
2902
2903        let meta: ProfileMetadata = serde_json::from_str(json.as_str())?;
2904        assert_eq!(meta.family, BrowserFamily::Chrome);
2905        assert_eq!(meta.browser_version, "131");
2906        Ok(())
2907    }
2908
2909    #[test]
2910    fn invalid_channel_returns_error() {
2911        let result = "netscape-4".parse::<ProfileChannel>();
2912        assert!(
2913            matches!(result, Err(ProfileChannelError::UnknownChannel(ref s)) if s.contains("netscape-4"))
2914        );
2915    }
2916
2917    #[test]
2918    fn from_str_parsing_case_insensitive() -> Result<(), ProfileChannelError> {
2919        let ch: ProfileChannel = "CHROME-LATEST".parse()?;
2920        assert_eq!(ch, ProfileChannel::ChromeLatest);
2921        Ok(())
2922    }
2923
2924    #[test]
2925    fn provenance_contains_family_and_version() {
2926        let prov = PACK_FIREFOX_133.metadata.provenance();
2927        assert!(prov.contains("firefox"), "provenance: {prov}");
2928        assert!(prov.contains("133"), "provenance: {prov}");
2929    }
2930
2931    #[test]
2932    fn safari_h3_not_supported() {
2933        assert!(!PACK_SAFARI_18.metadata.h3_support);
2934    }
2935
2936    #[test]
2937    fn platform_hint_accepted_without_error() -> Result<(), ProfileChannelError> {
2938        let pack = ProfileChannel::ChromeLatest.resolve(Some(PlatformClass::Linux))?;
2939        assert_eq!(pack.profile.name, "Chrome 131");
2940        Ok(())
2941    }
2942}