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// ── profile methods ──────────────────────────────────────────────────────────
598
599/// Truncate a hex string to at most `n` characters on a char boundary.
600///
601/// Returns the full string when it is shorter than `n`.
602fn truncate_hex(s: &str, n: usize) -> &str {
603    // Hex strings are ASCII so floor_char_boundary is equivalent to min(n, len),
604    // but this is safe for any UTF-8 string.
605    let end = s.len().min(n);
606    &s[..end]
607}
608
609/// GREASE values that must be ignored during JA3/JA4 computation.
610const GREASE_VALUES: &[u16] = &[
611    0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba,
612    0xcaca, 0xdada, 0xeaea, 0xfafa,
613];
614
615/// Return `true` if `v` is a TLS GREASE value.
616fn is_grease(v: u16) -> bool {
617    GREASE_VALUES.contains(&v)
618}
619
620impl TlsProfile {
621    /// Compute the JA3 fingerprint for this profile.
622    ///
623    /// JA3 format: `TLSVersion,Ciphers,Extensions,EllipticCurves,EcPointFormats`
624    ///
625    /// - GREASE values are stripped from all fields.
626    /// - EC point formats default to `0` (uncompressed) when not otherwise
627    ///   specified in the profile.
628    ///
629    /// # Example
630    ///
631    /// ```
632    /// use stygian_browser::tls::CHROME_131;
633    ///
634    /// let ja3 = CHROME_131.ja3();
635    /// assert!(ja3.raw.starts_with("772,"));
636    /// assert_eq!(ja3.hash.len(), 32);
637    /// ```
638    pub fn ja3(&self) -> Ja3Hash {
639        // TLS version — use highest advertised.
640        let tls_ver = self
641            .tls_versions
642            .iter()
643            .map(|v| v.iana_value())
644            .max()
645            .unwrap_or(TlsVersion::Tls12.iana_value());
646
647        // Ciphers (GREASE stripped).
648        let ciphers: Vec<String> = self
649            .cipher_suites
650            .iter()
651            .filter(|c| !is_grease(c.0))
652            .map(|c| c.0.to_string())
653            .collect();
654
655        // Extensions (GREASE stripped).
656        let extensions: Vec<String> = self
657            .extensions
658            .iter()
659            .filter(|e| !is_grease(e.0))
660            .map(|e| e.0.to_string())
661            .collect();
662
663        // Elliptic curves (GREASE stripped).
664        let curves: Vec<String> = self
665            .supported_groups
666            .iter()
667            .filter(|g| !is_grease(g.iana_value()))
668            .map(|g| g.iana_value().to_string())
669            .collect();
670
671        // EC point formats — default to uncompressed (0).
672        let ec_point_formats = "0";
673
674        let raw = format!(
675            "{tls_ver},{},{},{},{ec_point_formats}",
676            ciphers.join("-"),
677            extensions.join("-"),
678            curves.join("-"),
679        );
680
681        let hash = md5_hex(raw.as_bytes());
682        Ja3Hash { raw, hash }
683    }
684
685    /// Compute the JA4 fingerprint for this profile.
686    ///
687    /// JA4 format (`JA4_a` section):
688    /// `{q}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn}_{sorted_cipher_hash}_{sorted_ext_hash}`
689    ///
690    /// This implements the `JA4_a` (raw fingerprint) portion. Sorted cipher and
691    /// extension hashes use the first 12 hex characters of the SHA-256 —
692    /// approximated here by truncated MD5 since we already have that
693    /// implementation and the goal is fingerprint *representation*, not
694    /// cryptographic security.
695    ///
696    /// # Example
697    ///
698    /// ```
699    /// use stygian_browser::tls::CHROME_131;
700    ///
701    /// let ja4 = CHROME_131.ja4();
702    /// assert!(ja4.fingerprint.starts_with("t13"));
703    /// ```
704    pub fn ja4(&self) -> Ja4 {
705        // Protocol: 't' for TCP TLS.
706        let proto = 't';
707
708        // TLS version: highest advertised, mapped to two-char code.
709        let version = if self.tls_versions.contains(&TlsVersion::Tls13) {
710            "13"
711        } else {
712            "12"
713        };
714
715        // SNI: 'd' = domain (SNI present), 'i' = IP (no SNI). We assume SNI
716        // is present for browser profiles.
717        let sni = 'd';
718
719        // Counts (GREASE stripped), capped at 99.
720        let cipher_count = self
721            .cipher_suites
722            .iter()
723            .filter(|c| !is_grease(c.0))
724            .count()
725            .min(99);
726        let ext_count = self
727            .extensions
728            .iter()
729            .filter(|e| !is_grease(e.0))
730            .count()
731            .min(99);
732
733        // ALPN: first protocol letter ('h' for h2, 'h' for http/1.1 — JA4
734        // uses first+last chars). '00' when empty.
735        let alpn_tag = match self.alpn_protocols.first() {
736            Some(AlpnProtocol::H2) => "h2",
737            Some(AlpnProtocol::Http11) => "h1",
738            None => "00",
739        };
740
741        // Section a (the short fingerprint before hashes).
742        let section_a = format!("{proto}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn_tag}",);
743
744        // Section b: sorted cipher suites (GREASE stripped), comma-separated,
745        // hashed, first 12 hex chars.
746        let mut sorted_ciphers: Vec<u16> = self
747            .cipher_suites
748            .iter()
749            .filter(|c| !is_grease(c.0))
750            .map(|c| c.0)
751            .collect();
752        sorted_ciphers.sort_unstable();
753        let cipher_str: String = sorted_ciphers
754            .iter()
755            .map(|c| format!("{c:04x}"))
756            .collect::<Vec<_>>()
757            .join(",");
758        let cipher_hash_full = md5_hex(cipher_str.as_bytes());
759        let cipher_hash = truncate_hex(&cipher_hash_full, 12);
760
761        // Section c: sorted extensions (GREASE + SNI + ALPN stripped),
762        // comma-separated, hashed, first 12 hex chars.
763        let mut sorted_exts: Vec<u16> = self
764            .extensions
765            .iter()
766            .filter(|e| {
767                !is_grease(e.0)
768                    && e.0 != TlsExtensionId::SERVER_NAME.0
769                    && e.0 != TlsExtensionId::ALPN.0
770            })
771            .map(|e| e.0)
772            .collect();
773        sorted_exts.sort_unstable();
774        let ext_str: String = sorted_exts
775            .iter()
776            .map(|e| format!("{e:04x}"))
777            .collect::<Vec<_>>()
778            .join(",");
779        let ext_hash_full = md5_hex(ext_str.as_bytes());
780        let ext_hash = truncate_hex(&ext_hash_full, 12);
781
782        Ja4 {
783            fingerprint: format!("{section_a}_{cipher_hash}_{ext_hash}"),
784        }
785    }
786
787    /// Select a built-in TLS profile weighted by real browser market share.
788    ///
789    /// Distribution mirrors [`DeviceProfile`](super::fingerprint::DeviceProfile)
790    /// and [`BrowserKind`](super::fingerprint::BrowserKind) weights:
791    ///
792    /// - Windows (70%): Chrome 65%, Edge 16%, Firefox 19%
793    /// - macOS (20%): Chrome 56%, Safari 36%, Firefox 8%
794    /// - Linux (10%): Chrome 65%, Edge 16%, Firefox 19%
795    ///
796    /// Edge 131 shares Chrome's Blink engine so its TLS stack is nearly
797    /// identical; the profile uses [`EDGE_131`].
798    ///
799    /// # Example
800    ///
801    /// ```
802    /// use stygian_browser::tls::TlsProfile;
803    ///
804    /// let profile = TlsProfile::random_weighted(42);
805    /// assert!(!profile.name.is_empty());
806    /// ```
807    pub fn random_weighted(seed: u64) -> &'static Self {
808        // Step 1: pick OS (Windows 70%, Mac 20%, Linux 10%).
809        let os_roll = rng(seed, 97) % 100;
810
811        // Step 2: pick browser within that OS.
812        let browser_roll = rng(seed, 201) % 100;
813
814        match os_roll {
815            // Windows / Linux: Chrome 65%, Edge 16%, Firefox 19%.
816            0..=69 | 90..=99 => match browser_roll {
817                0..=64 => &CHROME_131,
818                65..=80 => &EDGE_131,
819                _ => &FIREFOX_133,
820            },
821            // macOS: Chrome 56%, Safari 36%, Firefox 8%.
822            _ => match browser_roll {
823                0..=55 => &CHROME_131,
824                56..=91 => &SAFARI_18,
825                _ => &FIREFOX_133,
826            },
827        }
828    }
829}
830
831// ── built-in profiles ────────────────────────────────────────────────────────
832
833/// Google Chrome 131 TLS fingerprint profile.
834///
835/// Cipher suites, extensions, and groups sourced from real Chrome 131
836/// `ClientHello` captures.
837///
838/// # Example
839///
840/// ```
841/// use stygian_browser::tls::CHROME_131;
842///
843/// assert_eq!(CHROME_131.name, "Chrome 131");
844/// assert!(CHROME_131.tls_versions.contains(&stygian_browser::tls::TlsVersion::Tls13));
845/// ```
846pub static CHROME_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
847    name: "Chrome 131".to_string(),
848    cipher_suites: vec![
849        CipherSuiteId::TLS_AES_128_GCM_SHA256,
850        CipherSuiteId::TLS_AES_256_GCM_SHA384,
851        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
852        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
853        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
854        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
855        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
856        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
857        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
858        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
859        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
860        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
861        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
862        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
863        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
864    ],
865    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
866    extensions: vec![
867        TlsExtensionId::SERVER_NAME,
868        TlsExtensionId::EXTENDED_MASTER_SECRET,
869        TlsExtensionId::RENEGOTIATION_INFO,
870        TlsExtensionId::SUPPORTED_GROUPS,
871        TlsExtensionId::EC_POINT_FORMATS,
872        TlsExtensionId::SESSION_TICKET,
873        TlsExtensionId::ALPN,
874        TlsExtensionId::STATUS_REQUEST,
875        TlsExtensionId::SIGNATURE_ALGORITHMS,
876        TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
877        TlsExtensionId::KEY_SHARE,
878        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
879        TlsExtensionId::SUPPORTED_VERSIONS,
880        TlsExtensionId::COMPRESS_CERTIFICATE,
881        TlsExtensionId::APPLICATION_SETTINGS,
882        TlsExtensionId::PADDING,
883    ],
884    supported_groups: vec![
885        SupportedGroup::X25519Kyber768,
886        SupportedGroup::X25519,
887        SupportedGroup::SecP256r1,
888        SupportedGroup::SecP384r1,
889    ],
890    signature_algorithms: vec![
891        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
892        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
893        SignatureAlgorithm::RSA_PKCS1_SHA256,
894        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
895        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
896        SignatureAlgorithm::RSA_PKCS1_SHA384,
897        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
898        SignatureAlgorithm::RSA_PKCS1_SHA512,
899    ],
900    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
901});
902
903/// Mozilla Firefox 133 TLS fingerprint profile.
904///
905/// Firefox uses a different cipher-suite and extension order than Chromium
906/// browsers, notably preferring `ChaCha20` and including `delegated_credentials`
907/// and `record_size_limit`.
908///
909/// # Example
910///
911/// ```
912/// use stygian_browser::tls::FIREFOX_133;
913///
914/// assert_eq!(FIREFOX_133.name, "Firefox 133");
915/// ```
916pub static FIREFOX_133: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
917    name: "Firefox 133".to_string(),
918    cipher_suites: vec![
919        CipherSuiteId::TLS_AES_128_GCM_SHA256,
920        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
921        CipherSuiteId::TLS_AES_256_GCM_SHA384,
922        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
923        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
924        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
925        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
926        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
927        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
928        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
929        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
930        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
931        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
932        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
933        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
934    ],
935    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
936    extensions: vec![
937        TlsExtensionId::SERVER_NAME,
938        TlsExtensionId::EXTENDED_MASTER_SECRET,
939        TlsExtensionId::RENEGOTIATION_INFO,
940        TlsExtensionId::SUPPORTED_GROUPS,
941        TlsExtensionId::EC_POINT_FORMATS,
942        TlsExtensionId::SESSION_TICKET,
943        TlsExtensionId::ALPN,
944        TlsExtensionId::STATUS_REQUEST,
945        TlsExtensionId::DELEGATED_CREDENTIALS,
946        TlsExtensionId::KEY_SHARE,
947        TlsExtensionId::SUPPORTED_VERSIONS,
948        TlsExtensionId::SIGNATURE_ALGORITHMS,
949        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
950        TlsExtensionId::RECORD_SIZE_LIMIT,
951        TlsExtensionId::POST_HANDSHAKE_AUTH,
952        TlsExtensionId::PADDING,
953    ],
954    supported_groups: vec![
955        SupportedGroup::X25519,
956        SupportedGroup::SecP256r1,
957        SupportedGroup::SecP384r1,
958        SupportedGroup::SecP521r1,
959        SupportedGroup::Ffdhe2048,
960        SupportedGroup::Ffdhe3072,
961    ],
962    signature_algorithms: vec![
963        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
964        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
965        SignatureAlgorithm::ECDSA_SECP521R1_SHA512,
966        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
967        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
968        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
969        SignatureAlgorithm::RSA_PKCS1_SHA256,
970        SignatureAlgorithm::RSA_PKCS1_SHA384,
971        SignatureAlgorithm::RSA_PKCS1_SHA512,
972        SignatureAlgorithm::ECDSA_SHA1,
973        SignatureAlgorithm::RSA_PKCS1_SHA1,
974    ],
975    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
976});
977
978/// Apple Safari 18 TLS fingerprint profile.
979///
980/// Safari's TLS stack differs from Chromium in extension order and supported
981/// groups. Notably Safari does not advertise post-quantum key exchange.
982///
983/// # Example
984///
985/// ```
986/// use stygian_browser::tls::SAFARI_18;
987///
988/// assert_eq!(SAFARI_18.name, "Safari 18");
989/// ```
990pub static SAFARI_18: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
991    name: "Safari 18".to_string(),
992    cipher_suites: vec![
993        CipherSuiteId::TLS_AES_128_GCM_SHA256,
994        CipherSuiteId::TLS_AES_256_GCM_SHA384,
995        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
996        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
997        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
998        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
999        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1000        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1001        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1002        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1003        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1004        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1005        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1006    ],
1007    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1008    extensions: vec![
1009        TlsExtensionId::SERVER_NAME,
1010        TlsExtensionId::EXTENDED_MASTER_SECRET,
1011        TlsExtensionId::RENEGOTIATION_INFO,
1012        TlsExtensionId::SUPPORTED_GROUPS,
1013        TlsExtensionId::EC_POINT_FORMATS,
1014        TlsExtensionId::ALPN,
1015        TlsExtensionId::STATUS_REQUEST,
1016        TlsExtensionId::SIGNATURE_ALGORITHMS,
1017        TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1018        TlsExtensionId::KEY_SHARE,
1019        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1020        TlsExtensionId::SUPPORTED_VERSIONS,
1021        TlsExtensionId::PADDING,
1022    ],
1023    supported_groups: vec![
1024        SupportedGroup::X25519,
1025        SupportedGroup::SecP256r1,
1026        SupportedGroup::SecP384r1,
1027        SupportedGroup::SecP521r1,
1028    ],
1029    signature_algorithms: vec![
1030        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1031        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1032        SignatureAlgorithm::RSA_PKCS1_SHA256,
1033        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1034        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1035        SignatureAlgorithm::RSA_PKCS1_SHA384,
1036        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1037        SignatureAlgorithm::RSA_PKCS1_SHA512,
1038    ],
1039    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1040});
1041
1042/// Microsoft Edge 131 TLS fingerprint profile.
1043///
1044/// Edge is Chromium-based so its TLS stack is nearly identical to Chrome.
1045/// Differences are minor (e.g. extension ordering around `application_settings`).
1046///
1047/// # Example
1048///
1049/// ```
1050/// use stygian_browser::tls::EDGE_131;
1051///
1052/// assert_eq!(EDGE_131.name, "Edge 131");
1053/// ```
1054pub static EDGE_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1055    name: "Edge 131".to_string(),
1056    cipher_suites: vec![
1057        CipherSuiteId::TLS_AES_128_GCM_SHA256,
1058        CipherSuiteId::TLS_AES_256_GCM_SHA384,
1059        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1060        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1061        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1062        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1063        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1064        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1065        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1066        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1067        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1068        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1069        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1070        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1071        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1072    ],
1073    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1074    extensions: vec![
1075        TlsExtensionId::SERVER_NAME,
1076        TlsExtensionId::EXTENDED_MASTER_SECRET,
1077        TlsExtensionId::RENEGOTIATION_INFO,
1078        TlsExtensionId::SUPPORTED_GROUPS,
1079        TlsExtensionId::EC_POINT_FORMATS,
1080        TlsExtensionId::SESSION_TICKET,
1081        TlsExtensionId::ALPN,
1082        TlsExtensionId::STATUS_REQUEST,
1083        TlsExtensionId::SIGNATURE_ALGORITHMS,
1084        TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1085        TlsExtensionId::KEY_SHARE,
1086        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1087        TlsExtensionId::SUPPORTED_VERSIONS,
1088        TlsExtensionId::COMPRESS_CERTIFICATE,
1089        TlsExtensionId::PADDING,
1090    ],
1091    supported_groups: vec![
1092        SupportedGroup::X25519Kyber768,
1093        SupportedGroup::X25519,
1094        SupportedGroup::SecP256r1,
1095        SupportedGroup::SecP384r1,
1096    ],
1097    signature_algorithms: vec![
1098        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1099        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1100        SignatureAlgorithm::RSA_PKCS1_SHA256,
1101        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1102        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1103        SignatureAlgorithm::RSA_PKCS1_SHA384,
1104        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1105        SignatureAlgorithm::RSA_PKCS1_SHA512,
1106    ],
1107    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1108});
1109
1110// ── Chrome launch flags ──────────────────────────────────────────────────────
1111
1112/// Return Chrome launch flags that constrain TLS behaviour to approximate this
1113/// profile's protocol-version range.
1114///
1115/// # What flags control
1116///
1117/// | Flag | Effect |
1118/// |---|---|
1119/// | `--ssl-version-max` | Cap the highest advertised TLS version |
1120/// | `--ssl-version-min` | Raise the lowest advertised TLS version |
1121///
1122/// # What flags **cannot** control
1123///
1124/// Chrome's TLS stack (`BoringSSL`) hard-codes the following in its compiled binary:
1125///
1126/// - **Cipher-suite ordering** — set by `ssl_cipher_apply_rule` at build time.
1127/// - **Extension ordering** — emitted in a fixed order by `BoringSSL`.
1128/// - **Supported-group ordering** — set at build time.
1129///
1130/// For precise JA3/JA4 matching, a patched Chromium build or an external TLS
1131/// proxy (see [`to_rustls_config`](TlsProfile::to_rustls_config)) is required.
1132///
1133/// # When to use each approach
1134///
1135/// | Detection layer | Handled by |
1136/// |---|---|
1137/// | JavaScript leaks | CDP stealth scripts (see [`stealth`](super::stealth)) |
1138/// | CDP signals | [`CdpFixMode`](super::cdp_protection::CdpFixMode) |
1139/// | TLS fingerprint | **Flags (this fn)** — version only; full control needs rustls or patched Chrome |
1140pub fn chrome_tls_args(profile: &TlsProfile) -> Vec<String> {
1141    let has_12 = profile.tls_versions.contains(&TlsVersion::Tls12);
1142    let has_13 = profile.tls_versions.contains(&TlsVersion::Tls13);
1143
1144    let mut args = Vec::new();
1145
1146    match (has_12, has_13) {
1147        // TLS 1.2 only — cap to prevent Chrome advertising 1.3.
1148        (true, false) => {
1149            args.push("--ssl-version-max=tls1.2".to_string());
1150        }
1151        // TLS 1.3 only — raise floor so Chrome skips 1.2.
1152        (false, true) => {
1153            args.push("--ssl-version-min=tls1.3".to_string());
1154        }
1155        // Both supported or empty — Chrome's defaults are fine.
1156        _ => {}
1157    }
1158
1159    args
1160}
1161
1162// ── rustls integration ───────────────────────────────────────────────────────
1163//
1164// Feature-gated behind `tls-config`. Builds a rustls `ClientConfig` from a
1165// `TlsProfile` to produce network connections whose TLS `ClientHello` matches
1166// the profile's cipher-suite, key-exchange-group, ALPN, and version ordering.
1167
1168#[cfg(feature = "tls-config")]
1169mod rustls_config {
1170    #[allow(clippy::wildcard_imports)]
1171    use super::*;
1172    use std::sync::Arc;
1173
1174    /// Error building a rustls [`ClientConfig`](rustls::ClientConfig) from a
1175    /// [`TlsProfile`].
1176    #[derive(Debug, thiserror::Error)]
1177    #[non_exhaustive]
1178    pub enum TlsConfigError {
1179        /// None of the profile's cipher suites are supported by the rustls
1180        /// crypto backend.
1181        #[error("no supported cipher suites in profile '{0}'")]
1182        NoCipherSuites(String),
1183
1184        /// rustls rejected the protocol version or configuration.
1185        #[error("rustls configuration: {0}")]
1186        Rustls(#[from] rustls::Error),
1187    }
1188
1189    /// Wrapper around `Arc<rustls::ClientConfig>` built from a [`TlsProfile`].
1190    ///
1191    /// Pass the inner config to
1192    /// `reqwest::ClientBuilder::use_preconfigured_tls` (T14) or use it
1193    /// directly with `tokio-rustls`.
1194    #[derive(Debug, Clone)]
1195    pub struct TlsClientConfig(Arc<rustls::ClientConfig>);
1196
1197    impl TlsClientConfig {
1198        /// Borrow the inner `ClientConfig`.
1199        pub fn inner(&self) -> &rustls::ClientConfig {
1200            &self.0
1201        }
1202
1203        /// Unwrap into the shared `Arc<ClientConfig>`.
1204        pub fn into_inner(self) -> Arc<rustls::ClientConfig> {
1205            self.0
1206        }
1207    }
1208
1209    impl From<TlsClientConfig> for Arc<rustls::ClientConfig> {
1210        fn from(cfg: TlsClientConfig) -> Self {
1211            cfg.0
1212        }
1213    }
1214
1215    impl TlsProfile {
1216        /// Build a rustls `ClientConfig` matching this profile.
1217        ///
1218        /// Cipher suites and key-exchange groups are reordered to match the
1219        /// profile. Entries not supported by the `aws-lc-rs` crypto backend
1220        /// are silently skipped (a `tracing::warn` is emitted for each).
1221        ///
1222        /// # Errors
1223        ///
1224        /// Returns [`TlsConfigError::NoCipherSuites`] when *none* of the
1225        /// profile's cipher suites are available in the backend.
1226        ///
1227        /// # rustls extension control
1228        ///
1229        /// rustls emits most TLS extensions automatically:
1230        ///
1231        /// - `supported_versions`, `key_share`, `signature_algorithms`,
1232        ///   `supported_groups`, `server_name`, `psk_key_exchange_modes`, and
1233        ///   `ec_point_formats` are managed internally.
1234        /// - **ALPN** — set from [`alpn_protocols`](TlsProfile::alpn_protocols)
1235        ///   (order-sensitive for fingerprinting).
1236        /// - **Cipher suite order** — set via custom `CryptoProvider`.
1237        /// - **Key-exchange group order** — set via custom `CryptoProvider`.
1238        /// - **TLS version** — constrained to the profile's `tls_versions`.
1239        ///
1240        /// Extensions like `compress_certificate`, `application_settings`,
1241        /// `delegated_credentials`, and `signed_certificate_timestamp` are
1242        /// not configurable in rustls and are emitted (or not) based on the
1243        /// library version.
1244        pub fn to_rustls_config(&self) -> Result<TlsClientConfig, TlsConfigError> {
1245            let default = rustls::crypto::aws_lc_rs::default_provider();
1246
1247            // ── cipher suites ──
1248            let suite_map: std::collections::HashMap<u16, rustls::SupportedCipherSuite> = default
1249                .cipher_suites
1250                .iter()
1251                .map(|cs| (u16::from(cs.suite()), *cs))
1252                .collect();
1253
1254            let ordered_suites: Vec<rustls::SupportedCipherSuite> = self
1255                .cipher_suites
1256                .iter()
1257                .filter_map(|id| {
1258                    suite_map.get(&id.0).copied().or_else(|| {
1259                        tracing::warn!(
1260                            cipher_suite_id = id.0,
1261                            profile = %self.name,
1262                            "cipher suite not supported by rustls aws-lc-rs backend, skipping"
1263                        );
1264                        None
1265                    })
1266                })
1267                .collect();
1268
1269            if ordered_suites.is_empty() {
1270                return Err(TlsConfigError::NoCipherSuites(self.name.clone()));
1271            }
1272
1273            // ── key-exchange groups ──
1274            let group_map: std::collections::HashMap<
1275                u16,
1276                &'static dyn rustls::crypto::SupportedKxGroup,
1277            > = default
1278                .kx_groups
1279                .iter()
1280                .map(|g| (u16::from(g.name()), *g))
1281                .collect();
1282
1283            let ordered_groups: Vec<&'static dyn rustls::crypto::SupportedKxGroup> = self
1284                .supported_groups
1285                .iter()
1286                .filter_map(|sg| {
1287                    group_map.get(&sg.iana_value()).copied().or_else(|| {
1288                        tracing::warn!(
1289                            group_id = sg.iana_value(),
1290                            profile = %self.name,
1291                            "key-exchange group not supported by rustls, skipping"
1292                        );
1293                        None
1294                    })
1295                })
1296                .collect();
1297
1298            // Fall back to provider defaults when no profile groups matched.
1299            let kx_groups = if ordered_groups.is_empty() {
1300                default.kx_groups.clone()
1301            } else {
1302                ordered_groups
1303            };
1304
1305            // ── custom CryptoProvider ──
1306            let provider = rustls::crypto::CryptoProvider {
1307                cipher_suites: ordered_suites,
1308                kx_groups,
1309                ..default
1310            };
1311
1312            // ── TLS versions ──
1313            let versions: Vec<&'static rustls::SupportedProtocolVersion> = self
1314                .tls_versions
1315                .iter()
1316                .map(|v| match v {
1317                    TlsVersion::Tls12 => &rustls::version::TLS12,
1318                    TlsVersion::Tls13 => &rustls::version::TLS13,
1319                })
1320                .collect();
1321
1322            // ── root certificate store ──
1323            let mut root_store = rustls::RootCertStore::empty();
1324            root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
1325
1326            // ── build ClientConfig ──
1327            let mut config = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
1328                .with_protocol_versions(&versions)?
1329                .with_root_certificates(root_store)
1330                .with_no_client_auth();
1331
1332            // ── ALPN ──
1333            config.alpn_protocols = self
1334                .alpn_protocols
1335                .iter()
1336                .map(|p| p.as_str().as_bytes().to_vec())
1337                .collect();
1338
1339            Ok(TlsClientConfig(Arc::new(config)))
1340        }
1341    }
1342}
1343
1344#[cfg(feature = "tls-config")]
1345pub use rustls_config::{TlsClientConfig, TlsConfigError};
1346
1347// ── reqwest integration ──────────────────────────────────────────────────────
1348//
1349// Feature-gated behind `tls-config`. Builds a `reqwest::Client` that uses a
1350// TLS-profiled `ClientConfig` so that HTTP-only scraping paths present a
1351// browser-consistent TLS fingerprint.
1352
1353#[cfg(feature = "tls-config")]
1354mod reqwest_client {
1355    #[allow(clippy::wildcard_imports)]
1356    use super::*;
1357    use std::sync::Arc;
1358
1359    /// Error building a TLS-profiled reqwest client.
1360    #[derive(Debug, thiserror::Error)]
1361    #[non_exhaustive]
1362    pub enum TlsClientError {
1363        /// Failed to build the underlying rustls `ClientConfig`.
1364        #[error(transparent)]
1365        TlsConfig(#[from] super::rustls_config::TlsConfigError),
1366
1367        /// reqwest rejected the builder configuration.
1368        #[error("reqwest client: {0}")]
1369        Reqwest(#[from] reqwest::Error),
1370    }
1371
1372    /// Return a User-Agent string that matches the given TLS profile's browser.
1373    ///
1374    /// Anti-bot systems cross-reference the `User-Agent` header against the
1375    /// TLS fingerprint. Sending a Chrome TLS profile with a Firefox `User-Agent`
1376    /// is a strong detection signal.
1377    ///
1378    /// # Matching logic
1379    ///
1380    /// | Profile name contains | User-Agent |
1381    /// |---|---|
1382    /// | `"Chrome"` | Chrome 131 on Windows 10 |
1383    /// | `"Firefox"` | Firefox 133 on Windows 10 |
1384    /// | `"Safari"` | Safari 18 on macOS 14.7 |
1385    /// | `"Edge"` | Edge 131 on Windows 10 |
1386    /// | *(other)* | Chrome 131 on Windows 10 (safe fallback) |
1387    pub fn default_user_agent(profile: &TlsProfile) -> &'static str {
1388        let name = profile.name.to_ascii_lowercase();
1389        if name.contains("firefox") {
1390            "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0"
1391        } else if name.contains("safari") && !name.contains("chrome") {
1392            "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"
1393        } else if name.contains("edge") {
1394            "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"
1395        } else {
1396            // Chrome is the default / fallback.
1397            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
1398        }
1399    }
1400
1401    /// Select the built-in [`TlsProfile`] that best matches a
1402    /// [`DeviceProfile`](crate::fingerprint::DeviceProfile).
1403    ///
1404    /// | Device | Selected Profile |
1405    /// |---|---|
1406    /// | `DesktopWindows` | [`CHROME_131`] |
1407    /// | `DesktopMac` | [`SAFARI_18`] |
1408    /// | `DesktopLinux` | [`FIREFOX_133`] |
1409    /// | `MobileAndroid` | [`CHROME_131`] |
1410    /// | `MobileIOS` | [`SAFARI_18`] |
1411    pub fn profile_for_device(device: &crate::fingerprint::DeviceProfile) -> &'static TlsProfile {
1412        use crate::fingerprint::DeviceProfile;
1413        match device {
1414            DeviceProfile::DesktopWindows | DeviceProfile::MobileAndroid => &CHROME_131,
1415            DeviceProfile::DesktopMac | DeviceProfile::MobileIOS => &SAFARI_18,
1416            DeviceProfile::DesktopLinux => &FIREFOX_133,
1417        }
1418    }
1419
1420    /// Build a [`reqwest::Client`] whose TLS `ClientHello` matches
1421    /// `profile`.
1422    ///
1423    /// The returned client:
1424    /// - Uses [`TlsProfile::to_rustls_config`] for cipher-suite ordering,
1425    ///   key-exchange groups, ALPN, and protocol versions.
1426    /// - Sets the `User-Agent` header to match the profile's browser
1427    ///   (via [`default_user_agent`]).
1428    /// - Enables cookie storage, gzip, and brotli decompression.
1429    /// - Routes through `proxy_url` when provided.
1430    ///
1431    /// # Errors
1432    ///
1433    /// Returns [`TlsClientError`] if the TLS profile cannot be converted
1434    /// to a rustls config or if reqwest rejects the builder configuration.
1435    ///
1436    /// # Example
1437    ///
1438    /// ```no_run
1439    /// use stygian_browser::tls::{build_profiled_client, CHROME_131};
1440    ///
1441    /// let client = build_profiled_client(&CHROME_131, None).unwrap();
1442    /// ```
1443    pub fn build_profiled_client(
1444        profile: &TlsProfile,
1445        proxy_url: Option<&str>,
1446    ) -> Result<reqwest::Client, TlsClientError> {
1447        let tls_config = profile.to_rustls_config()?;
1448
1449        // Unwrap the Arc — we're the sole owner after `to_rustls_config`.
1450        let rustls_cfg =
1451            Arc::try_unwrap(tls_config.into_inner()).unwrap_or_else(|arc| (*arc).clone());
1452
1453        let mut builder = reqwest::Client::builder()
1454            .use_preconfigured_tls(rustls_cfg)
1455            .user_agent(default_user_agent(profile))
1456            .cookie_store(true)
1457            .gzip(true)
1458            .brotli(true);
1459
1460        if let Some(url) = proxy_url {
1461            builder = builder.proxy(reqwest::Proxy::all(url)?);
1462        }
1463
1464        Ok(builder.build()?)
1465    }
1466}
1467
1468#[cfg(feature = "tls-config")]
1469pub use reqwest_client::{
1470    TlsClientError, build_profiled_client, default_user_agent, profile_for_device,
1471};
1472
1473// ── tests ────────────────────────────────────────────────────────────────────
1474
1475#[cfg(test)]
1476#[allow(clippy::panic, clippy::unwrap_used)]
1477mod tests {
1478    use super::*;
1479
1480    #[test]
1481    fn md5_known_vectors() {
1482        // RFC 1321 test vectors.
1483        assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
1484        assert_eq!(md5_hex(b"a"), "0cc175b9c0f1b6a831c399e269772661");
1485        assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
1486        assert_eq!(
1487            md5_hex(b"message digest"),
1488            "f96b697d7cb7938d525a2f31aaf161d0"
1489        );
1490    }
1491
1492    #[test]
1493    fn chrome_131_ja3_structure() {
1494        let ja3 = CHROME_131.ja3();
1495        // Must start with 771 (TLS 1.2 = 0x0303 = 771 is the *highest* in
1496        // the supported list, but TLS 1.3 = 0x0304 = 772 is also present;
1497        // ja3 picks max → 772).
1498        assert!(
1499            ja3.raw.starts_with("772,"),
1500            "JA3 raw should start with '772,' but was: {}",
1501            ja3.raw
1502        );
1503        // Has five comma-separated sections.
1504        assert_eq!(ja3.raw.matches(',').count(), 4);
1505        // Hash is 32 hex chars.
1506        assert_eq!(ja3.hash.len(), 32);
1507        assert!(ja3.hash.chars().all(|c| c.is_ascii_hexdigit()));
1508    }
1509
1510    #[test]
1511    fn firefox_133_ja3_differs_from_chrome() {
1512        let chrome_ja3 = CHROME_131.ja3();
1513        let firefox_ja3 = FIREFOX_133.ja3();
1514        assert_ne!(chrome_ja3.hash, firefox_ja3.hash);
1515        assert_ne!(chrome_ja3.raw, firefox_ja3.raw);
1516    }
1517
1518    #[test]
1519    fn safari_18_ja3_is_valid() {
1520        let ja3 = SAFARI_18.ja3();
1521        assert!(ja3.raw.starts_with("772,"));
1522        assert_eq!(ja3.hash.len(), 32);
1523    }
1524
1525    #[test]
1526    fn edge_131_ja3_differs_from_chrome() {
1527        // Edge omits `APPLICATION_SETTINGS` extension compared to Chrome.
1528        let chrome_ja3 = CHROME_131.ja3();
1529        let edge_ja3 = EDGE_131.ja3();
1530        assert_ne!(chrome_ja3.hash, edge_ja3.hash);
1531    }
1532
1533    #[test]
1534    fn chrome_131_ja4_format() {
1535        let ja4 = CHROME_131.ja4();
1536        // Starts with 't13d' (TCP, TLS 1.3, domain SNI).
1537        assert!(
1538            ja4.fingerprint.starts_with("t13d"),
1539            "JA4 should start with 't13d' but was: {}",
1540            ja4.fingerprint
1541        );
1542        // Three underscore-separated sections.
1543        assert_eq!(
1544            ja4.fingerprint.matches('_').count(),
1545            3,
1546            "JA4 should have three underscores: {}",
1547            ja4.fingerprint
1548        );
1549    }
1550
1551    #[test]
1552    fn ja4_firefox_differs_from_chrome() {
1553        let chrome_ja4 = CHROME_131.ja4();
1554        let firefox_ja4 = FIREFOX_133.ja4();
1555        assert_ne!(chrome_ja4.fingerprint, firefox_ja4.fingerprint);
1556    }
1557
1558    #[test]
1559    fn random_weighted_distribution() {
1560        let mut chrome_count = 0u32;
1561        let mut firefox_count = 0u32;
1562        let mut edge_count = 0u32;
1563        let mut safari_count = 0u32;
1564
1565        let total = 10_000u32;
1566        for i in 0..total {
1567            let profile = TlsProfile::random_weighted(u64::from(i));
1568            match profile.name.as_str() {
1569                "Chrome 131" => chrome_count += 1,
1570                "Firefox 133" => firefox_count += 1,
1571                "Edge 131" => edge_count += 1,
1572                "Safari 18" => safari_count += 1,
1573                other => unreachable!("unexpected profile: {other}"),
1574            }
1575        }
1576
1577        // Chrome should be the most common (>40%).
1578        assert!(
1579            chrome_count > total * 40 / 100,
1580            "Chrome share too low: {chrome_count}/{total}"
1581        );
1582        // Firefox should appear (>5%).
1583        assert!(
1584            firefox_count > total * 5 / 100,
1585            "Firefox share too low: {firefox_count}/{total}"
1586        );
1587        // Edge should appear (>5%).
1588        assert!(
1589            edge_count > total * 5 / 100,
1590            "Edge share too low: {edge_count}/{total}"
1591        );
1592        // Safari should appear (>3%).
1593        assert!(
1594            safari_count > total * 3 / 100,
1595            "Safari share too low: {safari_count}/{total}"
1596        );
1597    }
1598
1599    #[test]
1600    fn serde_roundtrip() {
1601        let profile: &TlsProfile = &CHROME_131;
1602        let json = serde_json::to_string(profile).unwrap();
1603        let deserialized: TlsProfile = serde_json::from_str(&json).unwrap();
1604        assert_eq!(profile, &deserialized);
1605    }
1606
1607    #[test]
1608    fn ja3hash_display() {
1609        let ja3 = CHROME_131.ja3();
1610        assert_eq!(format!("{ja3}"), ja3.hash);
1611    }
1612
1613    #[test]
1614    fn ja4_display() {
1615        let ja4 = CHROME_131.ja4();
1616        assert_eq!(format!("{ja4}"), ja4.fingerprint);
1617    }
1618
1619    #[test]
1620    fn cipher_suite_display() {
1621        let cs = CipherSuiteId::TLS_AES_128_GCM_SHA256;
1622        assert_eq!(format!("{cs}"), "4865"); // 0x1301 = 4865
1623    }
1624
1625    #[test]
1626    fn tls_version_display() {
1627        assert_eq!(format!("{}", TlsVersion::Tls13), "772");
1628    }
1629
1630    #[test]
1631    fn alpn_protocol_as_str() {
1632        assert_eq!(AlpnProtocol::H2.as_str(), "h2");
1633        assert_eq!(AlpnProtocol::Http11.as_str(), "http/1.1");
1634    }
1635
1636    #[test]
1637    fn supported_group_values() {
1638        assert_eq!(SupportedGroup::X25519.iana_value(), 0x001d);
1639        assert_eq!(SupportedGroup::SecP256r1.iana_value(), 0x0017);
1640        assert_eq!(SupportedGroup::X25519Kyber768.iana_value(), 0x6399);
1641    }
1642
1643    // ── Chrome TLS flags tests ─────────────────────────────────────────
1644
1645    #[test]
1646    fn chrome_131_tls_args_empty() {
1647        // Chrome 131 supports both TLS 1.2 and 1.3 — no extra flags needed.
1648        let args = chrome_tls_args(&CHROME_131);
1649        assert!(args.is_empty(), "expected no flags, got: {args:?}");
1650    }
1651
1652    #[test]
1653    fn tls12_only_profile_caps_version() {
1654        let profile = TlsProfile {
1655            name: "TLS12-only".to_string(),
1656            cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
1657            tls_versions: vec![TlsVersion::Tls12],
1658            extensions: vec![],
1659            supported_groups: vec![],
1660            signature_algorithms: vec![],
1661            alpn_protocols: vec![],
1662        };
1663        let args = chrome_tls_args(&profile);
1664        assert_eq!(args, vec!["--ssl-version-max=tls1.2"]);
1665    }
1666
1667    #[test]
1668    fn tls13_only_profile_raises_floor() {
1669        let profile = TlsProfile {
1670            name: "TLS13-only".to_string(),
1671            cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
1672            tls_versions: vec![TlsVersion::Tls13],
1673            extensions: vec![],
1674            supported_groups: vec![],
1675            signature_algorithms: vec![],
1676            alpn_protocols: vec![],
1677        };
1678        let args = chrome_tls_args(&profile);
1679        assert_eq!(args, vec!["--ssl-version-min=tls1.3"]);
1680    }
1681
1682    #[test]
1683    fn builder_tls_profile_integration() {
1684        let cfg = crate::BrowserConfig::builder()
1685            .tls_profile(&CHROME_131)
1686            .build();
1687        // Chrome 131 has both versions — no TLS flags added.
1688        let tls_flags: Vec<_> = cfg
1689            .effective_args()
1690            .into_iter()
1691            .filter(|a| a.starts_with("--ssl-version"))
1692            .collect();
1693        assert!(tls_flags.is_empty(), "unexpected TLS flags: {tls_flags:?}");
1694    }
1695
1696    // ── rustls integration tests ─────────────────────────────────────────
1697
1698    #[cfg(feature = "tls-config")]
1699    mod rustls_tests {
1700        use super::super::*;
1701
1702        #[test]
1703        fn chrome_131_config_builds_successfully() {
1704            let config = CHROME_131.to_rustls_config().unwrap();
1705            // The inner ClientConfig should be accessible.
1706            let inner = config.inner();
1707            // ALPN must be set.
1708            assert!(
1709                !inner.alpn_protocols.is_empty(),
1710                "ALPN protocols should be set"
1711            );
1712        }
1713
1714        #[test]
1715        #[allow(clippy::indexing_slicing)]
1716        fn alpn_order_matches_profile() {
1717            let config = CHROME_131.to_rustls_config().unwrap();
1718            let alpn = &config.inner().alpn_protocols;
1719            assert_eq!(alpn.len(), 2);
1720            assert_eq!(alpn[0], b"h2");
1721            assert_eq!(alpn[1], b"http/1.1");
1722        }
1723
1724        #[test]
1725        fn all_builtin_profiles_produce_valid_configs() {
1726            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
1727                let result = profile.to_rustls_config();
1728                assert!(
1729                    result.is_ok(),
1730                    "profile '{}' should produce a valid config: {:?}",
1731                    profile.name,
1732                    result.err()
1733                );
1734            }
1735        }
1736
1737        #[test]
1738        fn unsupported_only_suites_returns_error() {
1739            let profile = TlsProfile {
1740                name: "Bogus".to_string(),
1741                cipher_suites: vec![CipherSuiteId(0xFFFF)],
1742                tls_versions: vec![TlsVersion::Tls13],
1743                extensions: vec![],
1744                supported_groups: vec![],
1745                signature_algorithms: vec![],
1746                alpn_protocols: vec![],
1747            };
1748            let err = profile.to_rustls_config().unwrap_err();
1749            assert!(
1750                err.to_string().contains("no supported cipher suites"),
1751                "expected NoCipherSuites, got: {err}"
1752            );
1753        }
1754
1755        #[test]
1756        fn into_arc_conversion() {
1757            let config = CHROME_131.to_rustls_config().unwrap();
1758            let arc: std::sync::Arc<rustls::ClientConfig> = config.into();
1759            // Should be valid — just verify it doesn't panic.
1760            assert!(!arc.alpn_protocols.is_empty());
1761        }
1762    }
1763
1764    // ── reqwest client tests ─────────────────────────────────────────
1765
1766    #[cfg(feature = "tls-config")]
1767    mod reqwest_tests {
1768        use super::super::*;
1769
1770        #[test]
1771        fn build_profiled_client_no_proxy() {
1772            let client = build_profiled_client(&CHROME_131, None);
1773            assert!(
1774                client.is_ok(),
1775                "should build a client without error: {:?}",
1776                client.err()
1777            );
1778        }
1779
1780        #[test]
1781        fn build_profiled_client_all_profiles() {
1782            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
1783                let result = build_profiled_client(profile, None);
1784                assert!(
1785                    result.is_ok(),
1786                    "profile '{}' should produce a valid client: {:?}",
1787                    profile.name,
1788                    result.err()
1789                );
1790            }
1791        }
1792
1793        #[test]
1794        fn default_user_agent_matches_browser() {
1795            assert!(default_user_agent(&CHROME_131).contains("Chrome/131"));
1796            assert!(default_user_agent(&FIREFOX_133).contains("Firefox/133"));
1797            assert!(default_user_agent(&SAFARI_18).contains("Safari/605"));
1798            assert!(default_user_agent(&EDGE_131).contains("Edg/131"));
1799        }
1800
1801        #[test]
1802        fn profile_for_device_mapping() {
1803            use crate::fingerprint::DeviceProfile;
1804
1805            assert_eq!(
1806                profile_for_device(&DeviceProfile::DesktopWindows).name,
1807                "Chrome 131"
1808            );
1809            assert_eq!(
1810                profile_for_device(&DeviceProfile::DesktopMac).name,
1811                "Safari 18"
1812            );
1813            assert_eq!(
1814                profile_for_device(&DeviceProfile::DesktopLinux).name,
1815                "Firefox 133"
1816            );
1817            assert_eq!(
1818                profile_for_device(&DeviceProfile::MobileAndroid).name,
1819                "Chrome 131"
1820            );
1821            assert_eq!(
1822                profile_for_device(&DeviceProfile::MobileIOS).name,
1823                "Safari 18"
1824            );
1825        }
1826    }
1827}