Skip to main content

stygian_browser/
tls.rs

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