1use serde::{Deserialize, Serialize};
33use std::fmt;
34use std::sync::LazyLock;
35
36pub(crate) const fn rng(seed: u64, step: u64) -> u64 {
41 let x = seed.wrapping_add(step.wrapping_mul(0x9e37_79b9_7f4a_7c15));
42 let x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
43 let x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
44 x ^ (x >> 31)
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub struct CipherSuiteId(pub u16);
64
65impl CipherSuiteId {
66 pub const TLS_AES_128_GCM_SHA256: Self = Self(0x1301);
68 pub const TLS_AES_256_GCM_SHA384: Self = Self(0x1302);
70 pub const TLS_CHACHA20_POLY1305_SHA256: Self = Self(0x1303);
72 pub const TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: Self = Self(0xc02b);
74 pub const TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: Self = Self(0xc02f);
76 pub const TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: Self = Self(0xc02c);
78 pub const TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: Self = Self(0xc030);
80 pub const TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: Self = Self(0xcca9);
82 pub const TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: Self = Self(0xcca8);
84 pub const TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: Self = Self(0xc013);
86 pub const TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: Self = Self(0xc014);
88 pub const TLS_RSA_WITH_AES_128_GCM_SHA256: Self = Self(0x009c);
90 pub const TLS_RSA_WITH_AES_256_GCM_SHA384: Self = Self(0x009d);
92 pub const TLS_RSA_WITH_AES_128_CBC_SHA: Self = Self(0x002f);
94 pub const TLS_RSA_WITH_AES_256_CBC_SHA: Self = Self(0x0035);
96}
97
98impl fmt::Display for CipherSuiteId {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 write!(f, "{}", self.0)
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[non_exhaustive]
115pub enum TlsVersion {
116 Tls12,
118 Tls13,
120}
121
122impl TlsVersion {
123 pub const fn iana_value(self) -> u16 {
132 match self {
133 Self::Tls12 => 0x0303,
134 Self::Tls13 => 0x0304,
135 }
136 }
137}
138
139impl fmt::Display for TlsVersion {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 write!(f, "{}", self.iana_value())
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
156pub struct TlsExtensionId(pub u16);
157
158impl TlsExtensionId {
159 pub const SERVER_NAME: Self = Self(0);
161 pub const EXTENDED_MASTER_SECRET: Self = Self(23);
163 pub const ENCRYPT_THEN_MAC: Self = Self(22);
165 pub const SESSION_TICKET: Self = Self(35);
167 pub const SIGNATURE_ALGORITHMS: Self = Self(13);
169 pub const SUPPORTED_VERSIONS: Self = Self(43);
171 pub const PSK_KEY_EXCHANGE_MODES: Self = Self(45);
173 pub const KEY_SHARE: Self = Self(51);
175 pub const SUPPORTED_GROUPS: Self = Self(10);
177 pub const EC_POINT_FORMATS: Self = Self(11);
178 pub const ALPN: Self = Self(16);
179 pub const STATUS_REQUEST: Self = Self(5);
181 pub const SIGNED_CERTIFICATE_TIMESTAMP: Self = Self(18);
183 pub const COMPRESS_CERTIFICATE: Self = Self(27);
185 pub const APPLICATION_SETTINGS: Self = Self(17513);
187 pub const RENEGOTIATION_INFO: Self = Self(0xff01);
189 pub const DELEGATED_CREDENTIALS: Self = Self(34);
191 pub const RECORD_SIZE_LIMIT: Self = Self(28);
193 pub const PADDING: Self = Self(21);
195 pub const PRE_SHARED_KEY: Self = Self(41);
197 pub const POST_HANDSHAKE_AUTH: Self = Self(49);
199}
200
201impl fmt::Display for TlsExtensionId {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 write!(f, "{}", self.0)
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
218#[non_exhaustive]
219pub enum SupportedGroup {
220 X25519,
222 SecP256r1,
224 SecP384r1,
226 SecP521r1,
228 X25519Kyber768,
230 Ffdhe2048,
232 Ffdhe3072,
234}
235
236impl SupportedGroup {
237 pub const fn iana_value(self) -> u16 {
247 match self {
248 Self::X25519 => 0x001d,
249 Self::SecP256r1 => 0x0017,
250 Self::SecP384r1 => 0x0018,
251 Self::SecP521r1 => 0x0019,
252 Self::X25519Kyber768 => 0x6399,
253 Self::Ffdhe2048 => 0x0100,
254 Self::Ffdhe3072 => 0x0101,
255 }
256 }
257}
258
259impl fmt::Display for SupportedGroup {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 write!(f, "{}", self.iana_value())
262 }
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
276pub struct SignatureAlgorithm(pub u16);
277
278impl SignatureAlgorithm {
279 pub const ECDSA_SECP256R1_SHA256: Self = Self(0x0403);
281 pub const RSA_PSS_RSAE_SHA256: Self = Self(0x0804);
283 pub const RSA_PKCS1_SHA256: Self = Self(0x0401);
285 pub const ECDSA_SECP384R1_SHA384: Self = Self(0x0503);
287 pub const RSA_PSS_RSAE_SHA384: Self = Self(0x0805);
289 pub const RSA_PKCS1_SHA384: Self = Self(0x0501);
291 pub const RSA_PSS_RSAE_SHA512: Self = Self(0x0806);
293 pub const RSA_PKCS1_SHA512: Self = Self(0x0601);
295 pub const ECDSA_SECP521R1_SHA512: Self = Self(0x0603);
297 pub const RSA_PKCS1_SHA1: Self = Self(0x0201);
299 pub const ECDSA_SHA1: Self = Self(0x0203);
301}
302
303impl fmt::Display for SignatureAlgorithm {
304 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305 write!(f, "{}", self.0)
306 }
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
317#[non_exhaustive]
318pub enum AlpnProtocol {
319 H2,
321 Http11,
323}
324
325impl AlpnProtocol {
326 pub const fn as_str(self) -> &'static str {
335 match self {
336 Self::H2 => "h2",
337 Self::Http11 => "http/1.1",
338 }
339 }
340}
341
342impl fmt::Display for AlpnProtocol {
343 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344 f.write_str(self.as_str())
345 }
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365#[non_exhaustive]
366pub struct TlsProfile {
367 pub name: String,
369 pub cipher_suites: Vec<CipherSuiteId>,
371 pub tls_versions: Vec<TlsVersion>,
372 pub extensions: Vec<TlsExtensionId>,
374 pub supported_groups: Vec<SupportedGroup>,
376 pub signature_algorithms: Vec<SignatureAlgorithm>,
378 pub alpn_protocols: Vec<AlpnProtocol>,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397pub struct Ja3Hash {
398 pub raw: String,
399 pub hash: String,
401}
402
403impl fmt::Display for Ja3Hash {
404 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405 f.write_str(&self.hash)
406 }
407}
408
409#[allow(
411 clippy::many_single_char_names,
412 clippy::too_many_lines,
413 clippy::indexing_slicing
414)]
415fn md5_hex(data: &[u8]) -> String {
416 const S: [u32; 64] = [
418 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,
419 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,
420 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
421 ];
422
423 const K: [u32; 64] = [
425 0xd76a_a478,
426 0xe8c7_b756,
427 0x2420_70db,
428 0xc1bd_ceee,
429 0xf57c_0faf,
430 0x4787_c62a,
431 0xa830_4613,
432 0xfd46_9501,
433 0x6980_98d8,
434 0x8b44_f7af,
435 0xffff_5bb1,
436 0x895c_d7be,
437 0x6b90_1122,
438 0xfd98_7193,
439 0xa679_438e,
440 0x49b4_0821,
441 0xf61e_2562,
442 0xc040_b340,
443 0x265e_5a51,
444 0xe9b6_c7aa,
445 0xd62f_105d,
446 0x0244_1453,
447 0xd8a1_e681,
448 0xe7d3_fbc8,
449 0x21e1_cde6,
450 0xc337_07d6,
451 0xf4d5_0d87,
452 0x455a_14ed,
453 0xa9e3_e905,
454 0xfcef_a3f8,
455 0x676f_02d9,
456 0x8d2a_4c8a,
457 0xfffa_3942,
458 0x8771_f681,
459 0x6d9d_6122,
460 0xfde5_380c,
461 0xa4be_ea44,
462 0x4bde_cfa9,
463 0xf6bb_4b60,
464 0xbebf_bc70,
465 0x289b_7ec6,
466 0xeaa1_27fa,
467 0xd4ef_3085,
468 0x0488_1d05,
469 0xd9d4_d039,
470 0xe6db_99e5,
471 0x1fa2_7cf8,
472 0xc4ac_5665,
473 0xf429_2244,
474 0x432a_ff97,
475 0xab94_23a7,
476 0xfc93_a039,
477 0x655b_59c3,
478 0x8f0c_cc92,
479 0xffef_f47d,
480 0x8584_5dd1,
481 0x6fa8_7e4f,
482 0xfe2c_e6e0,
483 0xa301_4314,
484 0x4e08_11a1,
485 0xf753_7e82,
486 0xbd3a_f235,
487 0x2ad7_d2bb,
488 0xeb86_d391,
489 ];
490
491 let orig_len_bits = (data.len() as u64).wrapping_mul(8);
493 let mut msg = data.to_vec();
494 msg.push(0x80);
495 while msg.len() % 64 != 56 {
496 msg.push(0);
497 }
498 msg.extend_from_slice(&orig_len_bits.to_le_bytes());
499
500 let mut a0: u32 = 0x6745_2301;
501 let mut b0: u32 = 0xefcd_ab89;
502 let mut c0: u32 = 0x98ba_dcfe;
503 let mut d0: u32 = 0x1032_5476;
504
505 for chunk in msg.chunks_exact(64) {
506 let mut m = [0u32; 16];
507 for (word, quad) in m.iter_mut().zip(chunk.chunks_exact(4)) {
508 if let Ok(bytes) = <[u8; 4]>::try_from(quad) {
510 *word = u32::from_le_bytes(bytes);
511 }
512 }
513
514 let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
515
516 for i in 0..64 {
517 let (f, g) = match i {
518 0..=15 => ((b & c) | ((!b) & d), i),
519 16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
520 32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
521 _ => (c ^ (b | (!d)), (7 * i) % 16),
522 };
523 let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
524 a = d;
525 d = c;
526 c = b;
527 b = b.wrapping_add(f.rotate_left(S[i]));
528 }
529
530 a0 = a0.wrapping_add(a);
531 b0 = b0.wrapping_add(b);
532 c0 = c0.wrapping_add(c);
533 d0 = d0.wrapping_add(d);
534 }
535
536 let digest = [
537 a0.to_le_bytes(),
538 b0.to_le_bytes(),
539 c0.to_le_bytes(),
540 d0.to_le_bytes(),
541 ];
542 let mut hex = String::with_capacity(32);
543 for group in &digest {
544 for &byte in group {
545 use fmt::Write;
546 let _ = write!(hex, "{byte:02x}");
547 }
548 }
549 hex
550}
551
552#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565pub struct Ja4 {
566 pub fingerprint: String,
568}
569
570impl fmt::Display for Ja4 {
571 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
572 f.write_str(&self.fingerprint)
573 }
574}
575
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580pub struct Http3Perk {
581 pub settings: Vec<(u64, u64)>,
583 pub pseudo_headers: String,
584 pub has_grease: bool,
585}
586
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
589pub struct Http3PerkComparison {
590 pub matches: bool,
592 pub mismatches: Vec<String>,
594}
595
596const fn is_quic_grease(value: u64) -> bool {
597 let low = value & 0xffff;
598 let a = (low >> 8) & 0xff;
599 let b = low & 0xff;
600 a == b && (a & 0x0f) == 0x0a
601}
602
603impl Http3Perk {
604 #[must_use]
606 pub fn perk_text(&self) -> String {
607 let mut parts: Vec<String> = self
608 .settings
609 .iter()
610 .filter(|(id, _)| !is_quic_grease(*id))
611 .map(|(id, value)| format!("{id}:{value}"))
612 .collect();
613
614 if self.has_grease || self.settings.iter().any(|(id, _)| is_quic_grease(*id)) {
615 parts.push("GREASE".to_string());
616 }
617
618 format!("{}|{}", parts.join(";"), self.pseudo_headers)
619 }
620
621 #[must_use]
623 pub fn perk_hash(&self) -> String {
624 md5_hex(self.perk_text().as_bytes())
625 }
626
627 #[must_use]
629 pub fn compare(
630 &self,
631 observed_text: Option<&str>,
632 observed_hash: Option<&str>,
633 ) -> Http3PerkComparison {
634 let expected_text = self.perk_text();
635 let expected_hash = self.perk_hash();
636
637 let mut mismatches = Vec::new();
638
639 if let Some(text) = observed_text
640 && text != expected_text
641 {
642 mismatches.push(format!(
643 "perk_text mismatch: expected '{expected_text}', observed '{text}'"
644 ));
645 }
646
647 if let Some(hash) = observed_hash
648 && !hash.eq_ignore_ascii_case(&expected_hash)
649 {
650 mismatches.push(format!(
651 "perk_hash mismatch: expected '{expected_hash}', observed '{hash}'"
652 ));
653 }
654
655 Http3PerkComparison {
656 matches: mismatches.is_empty() && (observed_text.is_some() || observed_hash.is_some()),
657 mismatches,
658 }
659 }
660}
661
662#[must_use]
665pub fn expected_http3_perk_from_user_agent(user_agent: &str) -> Option<Http3Perk> {
666 expected_tls_profile_from_user_agent(user_agent).and_then(TlsProfile::http3_perk)
667}
668
669#[must_use]
671pub fn expected_tls_profile_from_user_agent(user_agent: &str) -> Option<&'static TlsProfile> {
672 let ua = user_agent.to_ascii_lowercase();
673
674 if ua.contains("edg/") {
675 return Some(&EDGE_131);
676 }
677
678 if ua.contains("firefox/") {
679 return Some(&FIREFOX_133);
680 }
681
682 if ua.contains("safari/") && !ua.contains("chrome/") && !ua.contains("edg/") {
683 return Some(&SAFARI_18);
684 }
685
686 if ua.contains("chrome/") {
687 return Some(&CHROME_131);
688 }
689
690 None
691}
692
693#[must_use]
694pub fn expected_ja3_from_user_agent(user_agent: &str) -> Option<Ja3Hash> {
695 expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja3)
696}
697
698#[must_use]
699pub fn expected_ja4_from_user_agent(user_agent: &str) -> Option<Ja4> {
700 expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja4)
701}
702
703fn truncate_hex(s: &str, n: usize) -> &str {
707 let end = s.len().min(n);
708 &s[..end]
709}
710
711const GREASE_VALUES: &[u16] = &[
713 0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba,
714 0xcaca, 0xdada, 0xeaea, 0xfafa,
715];
716
717fn is_grease(v: u16) -> bool {
719 GREASE_VALUES.contains(&v)
720}
721
722impl TlsProfile {
723 pub fn ja3(&self) -> Ja3Hash {
738 let tls_ver = self
740 .tls_versions
741 .iter()
742 .map(|v| v.iana_value())
743 .max()
744 .unwrap_or(TlsVersion::Tls12.iana_value());
745
746 let ciphers: Vec<String> = self
748 .cipher_suites
749 .iter()
750 .filter(|c| !is_grease(c.0))
751 .map(|c| c.0.to_string())
752 .collect();
753
754 let extensions: Vec<String> = self
756 .extensions
757 .iter()
758 .filter(|e| !is_grease(e.0))
759 .map(|e| e.0.to_string())
760 .collect();
761
762 let curves: Vec<String> = self
764 .supported_groups
765 .iter()
766 .filter(|g| !is_grease(g.iana_value()))
767 .map(|g| g.iana_value().to_string())
768 .collect();
769
770 let ec_point_formats = "0";
771
772 let raw = format!(
773 "{tls_ver},{},{},{},{ec_point_formats}",
774 ciphers.join("-"),
775 extensions.join("-"),
776 curves.join("-"),
777 );
778
779 let hash = md5_hex(raw.as_bytes());
780 Ja3Hash { raw, hash }
781 }
782
783 pub fn ja4(&self) -> Ja4 {
801 let proto = 't';
802
803 let version = if self.tls_versions.contains(&TlsVersion::Tls13) {
804 "13"
805 } else {
806 "12"
807 };
808
809 let sni = 'd';
811
812 let cipher_count = self
814 .cipher_suites
815 .iter()
816 .filter(|c| !is_grease(c.0))
817 .count()
818 .min(99);
819 let ext_count = self
820 .extensions
821 .iter()
822 .filter(|e| !is_grease(e.0))
823 .count()
824 .min(99);
825
826 let alpn_tag = match self.alpn_protocols.first() {
828 Some(AlpnProtocol::H2) => "h2",
829 Some(AlpnProtocol::Http11) => "h1",
830 None => "00",
831 };
832
833 let section_a = format!("{proto}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn_tag}",);
834
835 let mut sorted_ciphers: Vec<u16> = self
838 .cipher_suites
839 .iter()
840 .filter(|c| !is_grease(c.0))
841 .map(|c| c.0)
842 .collect();
843 sorted_ciphers.sort_unstable();
844 let cipher_str: String = sorted_ciphers
845 .iter()
846 .map(|c| format!("{c:04x}"))
847 .collect::<Vec<_>>()
848 .join(",");
849 let cipher_hash_full = md5_hex(cipher_str.as_bytes());
850 let cipher_hash = truncate_hex(&cipher_hash_full, 12);
851
852 let mut sorted_exts: Vec<u16> = self
855 .extensions
856 .iter()
857 .filter(|e| {
858 !is_grease(e.0)
859 && e.0 != TlsExtensionId::SERVER_NAME.0
860 && e.0 != TlsExtensionId::ALPN.0
861 })
862 .map(|e| e.0)
863 .collect();
864 sorted_exts.sort_unstable();
865 let ext_str: String = sorted_exts
866 .iter()
867 .map(|e| format!("{e:04x}"))
868 .collect::<Vec<_>>()
869 .join(",");
870 let ext_hash_full = md5_hex(ext_str.as_bytes());
871 let ext_hash = truncate_hex(&ext_hash_full, 12);
872
873 Ja4 {
874 fingerprint: format!("{section_a}_{cipher_hash}_{ext_hash}"),
875 }
876 }
877
878 #[must_use]
880 pub fn http3_perk(&self) -> Option<Http3Perk> {
881 match self.name.as_str() {
882 name if name.starts_with("Chrome ") || name.starts_with("Edge ") => Some(Http3Perk {
883 settings: vec![(1, 65_536), (6, 262_144), (7, 100), (51, 1)],
884 pseudo_headers: "masp".to_string(),
885 has_grease: true,
886 }),
887 name if name.starts_with("Firefox ") => Some(Http3Perk {
888 settings: vec![(1, 65_536), (7, 20), (727_725_890, 0)],
889 pseudo_headers: "mpas".to_string(),
890 has_grease: false,
891 }),
892 name if name.starts_with("Safari ") => None,
893 _ => None,
894 }
895 }
896
897 pub fn random_weighted(seed: u64) -> &'static Self {
918 let os_roll = rng(seed, 97) % 100;
920
921 let browser_roll = rng(seed, 201) % 100;
923
924 match os_roll {
925 0..=69 | 90..=99 => match browser_roll {
927 0..=64 => &CHROME_131,
928 65..=80 => &EDGE_131,
929 _ => &FIREFOX_133,
930 },
931 _ => match browser_roll {
933 0..=55 => &CHROME_131,
934 56..=91 => &SAFARI_18,
935 _ => &FIREFOX_133,
936 },
937 }
938 }
939}
940
941pub static CHROME_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
957 name: "Chrome 131".to_string(),
958 cipher_suites: vec![
959 CipherSuiteId::TLS_AES_128_GCM_SHA256,
960 CipherSuiteId::TLS_AES_256_GCM_SHA384,
961 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
962 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
963 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
964 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
965 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
966 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
967 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
968 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
969 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
970 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
971 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
972 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
973 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
974 ],
975 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
976 extensions: vec![
977 TlsExtensionId::SERVER_NAME,
978 TlsExtensionId::EXTENDED_MASTER_SECRET,
979 TlsExtensionId::RENEGOTIATION_INFO,
980 TlsExtensionId::SUPPORTED_GROUPS,
981 TlsExtensionId::EC_POINT_FORMATS,
982 TlsExtensionId::SESSION_TICKET,
983 TlsExtensionId::ALPN,
984 TlsExtensionId::STATUS_REQUEST,
985 TlsExtensionId::SIGNATURE_ALGORITHMS,
986 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
987 TlsExtensionId::KEY_SHARE,
988 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
989 TlsExtensionId::SUPPORTED_VERSIONS,
990 TlsExtensionId::COMPRESS_CERTIFICATE,
991 TlsExtensionId::APPLICATION_SETTINGS,
992 TlsExtensionId::PADDING,
993 ],
994 supported_groups: vec![
995 SupportedGroup::X25519Kyber768,
996 SupportedGroup::X25519,
997 SupportedGroup::SecP256r1,
998 SupportedGroup::SecP384r1,
999 ],
1000 signature_algorithms: vec![
1001 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1002 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1003 SignatureAlgorithm::RSA_PKCS1_SHA256,
1004 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1005 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1006 SignatureAlgorithm::RSA_PKCS1_SHA384,
1007 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1008 SignatureAlgorithm::RSA_PKCS1_SHA512,
1009 ],
1010 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1011});
1012
1013pub static FIREFOX_133: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1027 name: "Firefox 133".to_string(),
1028 cipher_suites: vec![
1029 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1030 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1031 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1032 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1033 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1034 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1035 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1036 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1037 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1038 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1039 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1040 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1041 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1042 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1043 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1044 ],
1045 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1046 extensions: vec![
1047 TlsExtensionId::SERVER_NAME,
1048 TlsExtensionId::EXTENDED_MASTER_SECRET,
1049 TlsExtensionId::RENEGOTIATION_INFO,
1050 TlsExtensionId::SUPPORTED_GROUPS,
1051 TlsExtensionId::EC_POINT_FORMATS,
1052 TlsExtensionId::SESSION_TICKET,
1053 TlsExtensionId::ALPN,
1054 TlsExtensionId::STATUS_REQUEST,
1055 TlsExtensionId::DELEGATED_CREDENTIALS,
1056 TlsExtensionId::KEY_SHARE,
1057 TlsExtensionId::SUPPORTED_VERSIONS,
1058 TlsExtensionId::SIGNATURE_ALGORITHMS,
1059 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1060 TlsExtensionId::RECORD_SIZE_LIMIT,
1061 TlsExtensionId::POST_HANDSHAKE_AUTH,
1062 TlsExtensionId::PADDING,
1063 ],
1064 supported_groups: vec![
1065 SupportedGroup::X25519,
1066 SupportedGroup::SecP256r1,
1067 SupportedGroup::SecP384r1,
1068 SupportedGroup::SecP521r1,
1069 SupportedGroup::Ffdhe2048,
1070 SupportedGroup::Ffdhe3072,
1071 ],
1072 signature_algorithms: vec![
1073 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1074 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1075 SignatureAlgorithm::ECDSA_SECP521R1_SHA512,
1076 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1077 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1078 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1079 SignatureAlgorithm::RSA_PKCS1_SHA256,
1080 SignatureAlgorithm::RSA_PKCS1_SHA384,
1081 SignatureAlgorithm::RSA_PKCS1_SHA512,
1082 SignatureAlgorithm::ECDSA_SHA1,
1083 SignatureAlgorithm::RSA_PKCS1_SHA1,
1084 ],
1085 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1086});
1087
1088pub static SAFARI_18: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1101 name: "Safari 18".to_string(),
1102 cipher_suites: vec![
1103 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1104 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1105 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1106 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1107 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1108 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1109 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1110 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1111 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1112 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1113 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1114 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1115 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1116 ],
1117 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1118 extensions: vec![
1119 TlsExtensionId::SERVER_NAME,
1120 TlsExtensionId::EXTENDED_MASTER_SECRET,
1121 TlsExtensionId::RENEGOTIATION_INFO,
1122 TlsExtensionId::SUPPORTED_GROUPS,
1123 TlsExtensionId::EC_POINT_FORMATS,
1124 TlsExtensionId::ALPN,
1125 TlsExtensionId::STATUS_REQUEST,
1126 TlsExtensionId::SIGNATURE_ALGORITHMS,
1127 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1128 TlsExtensionId::KEY_SHARE,
1129 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1130 TlsExtensionId::SUPPORTED_VERSIONS,
1131 TlsExtensionId::PADDING,
1132 ],
1133 supported_groups: vec![
1134 SupportedGroup::X25519,
1135 SupportedGroup::SecP256r1,
1136 SupportedGroup::SecP384r1,
1137 SupportedGroup::SecP521r1,
1138 ],
1139 signature_algorithms: vec![
1140 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1141 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1142 SignatureAlgorithm::RSA_PKCS1_SHA256,
1143 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1144 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1145 SignatureAlgorithm::RSA_PKCS1_SHA384,
1146 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1147 SignatureAlgorithm::RSA_PKCS1_SHA512,
1148 ],
1149 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1150});
1151
1152pub static EDGE_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1164 name: "Edge 131".to_string(),
1165 cipher_suites: vec![
1166 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1167 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1168 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1169 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1170 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1171 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1172 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1173 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1174 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1175 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1176 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1177 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1178 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1179 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1180 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1181 ],
1182 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1183 extensions: vec![
1184 TlsExtensionId::SERVER_NAME,
1185 TlsExtensionId::EXTENDED_MASTER_SECRET,
1186 TlsExtensionId::RENEGOTIATION_INFO,
1187 TlsExtensionId::SUPPORTED_GROUPS,
1188 TlsExtensionId::EC_POINT_FORMATS,
1189 TlsExtensionId::SESSION_TICKET,
1190 TlsExtensionId::ALPN,
1191 TlsExtensionId::STATUS_REQUEST,
1192 TlsExtensionId::SIGNATURE_ALGORITHMS,
1193 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1194 TlsExtensionId::KEY_SHARE,
1195 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1196 TlsExtensionId::SUPPORTED_VERSIONS,
1197 TlsExtensionId::COMPRESS_CERTIFICATE,
1198 TlsExtensionId::PADDING,
1199 ],
1200 supported_groups: vec![
1201 SupportedGroup::X25519Kyber768,
1202 SupportedGroup::X25519,
1203 SupportedGroup::SecP256r1,
1204 SupportedGroup::SecP384r1,
1205 ],
1206 signature_algorithms: vec![
1207 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1208 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1209 SignatureAlgorithm::RSA_PKCS1_SHA256,
1210 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1211 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1212 SignatureAlgorithm::RSA_PKCS1_SHA384,
1213 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1214 SignatureAlgorithm::RSA_PKCS1_SHA512,
1215 ],
1216 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1217});
1218
1219pub fn chrome_tls_args(profile: &TlsProfile) -> Vec<String> {
1245 let has_12 = profile.tls_versions.contains(&TlsVersion::Tls12);
1246 let has_13 = profile.tls_versions.contains(&TlsVersion::Tls13);
1247
1248 let mut args = Vec::new();
1249
1250 match (has_12, has_13) {
1251 (true, false) => {
1252 args.push("--ssl-version-max=tls1.2".to_string());
1253 }
1254 (false, true) => {
1256 args.push("--ssl-version-min=tls1.3".to_string());
1257 }
1258 _ => {}
1260 }
1261
1262 args
1263}
1264
1265#[cfg(feature = "tls-config")]
1271mod rustls_config {
1272 #[allow(clippy::wildcard_imports)]
1273 use super::*;
1274 use std::sync::Arc;
1275
1276 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1291 pub struct TlsControl {
1292 pub strict_cipher_suites: bool,
1294 pub strict_supported_groups: bool,
1296 pub fallback_to_provider_groups: bool,
1298 pub allow_legacy_compat_suites: bool,
1300 }
1301
1302 impl Default for TlsControl {
1303 fn default() -> Self {
1304 Self::compatible()
1305 }
1306 }
1307
1308 impl TlsControl {
1309 #[must_use]
1310 pub const fn compatible() -> Self {
1311 Self {
1312 strict_cipher_suites: false,
1313 strict_supported_groups: false,
1314 fallback_to_provider_groups: true,
1315 allow_legacy_compat_suites: true,
1316 }
1317 }
1318
1319 #[must_use]
1321 pub const fn strict() -> Self {
1322 Self {
1323 strict_cipher_suites: true,
1324 strict_supported_groups: false,
1325 fallback_to_provider_groups: true,
1326 allow_legacy_compat_suites: true,
1327 }
1328 }
1329
1330 #[must_use]
1332 pub const fn strict_all() -> Self {
1333 Self {
1334 strict_cipher_suites: true,
1335 strict_supported_groups: true,
1336 fallback_to_provider_groups: false,
1337 allow_legacy_compat_suites: true,
1338 }
1339 }
1340
1341 #[must_use]
1344 pub fn for_profile(profile: &TlsProfile) -> Self {
1345 let name = profile.name.to_ascii_lowercase();
1346 if name.contains("chrome")
1347 || name.contains("edge")
1348 || name.contains("firefox")
1349 || name.contains("safari")
1350 {
1351 Self::strict()
1352 } else {
1353 Self::compatible()
1354 }
1355 }
1356 }
1357
1358 const fn is_legacy_compat_suite(id: u16) -> bool {
1359 matches!(id, 0xc013 | 0xc014 | 0x009c | 0x009d | 0x002f | 0x0035)
1360 }
1361
1362 #[derive(Debug, thiserror::Error)]
1365 #[non_exhaustive]
1366 pub enum TlsConfigError {
1367 #[error("no supported cipher suites in profile '{0}'")]
1369 NoCipherSuites(String),
1370
1371 #[error(
1373 "unsupported cipher suite {cipher_suite_id:#06x} in profile '{profile}' under strict mode"
1374 )]
1375 UnsupportedCipherSuite {
1376 profile: String,
1378 cipher_suite_id: u16,
1380 },
1381
1382 #[error(
1384 "unsupported supported_group {group_id:#06x} in profile '{profile}' under strict mode"
1385 )]
1386 UnsupportedSupportedGroup {
1387 profile: String,
1389 group_id: u16,
1391 },
1392
1393 #[error("no supported key-exchange groups in profile '{0}'")]
1395 NoSupportedGroups(String),
1396
1397 #[error("rustls configuration: {0}")]
1398 Rustls(#[from] rustls::Error),
1399 }
1400
1401 #[derive(Debug, Clone)]
1405 pub struct TlsClientConfig(Arc<rustls::ClientConfig>);
1406
1407 impl TlsClientConfig {
1408 pub fn inner(&self) -> &rustls::ClientConfig {
1410 &self.0
1411 }
1412
1413 pub fn into_inner(self) -> Arc<rustls::ClientConfig> {
1414 self.0
1415 }
1416 }
1417
1418 impl From<TlsClientConfig> for Arc<rustls::ClientConfig> {
1419 fn from(cfg: TlsClientConfig) -> Self {
1420 cfg.0
1421 }
1422 }
1423
1424 impl TlsProfile {
1425 pub fn to_rustls_config(&self) -> Result<TlsClientConfig, TlsConfigError> {
1443 self.to_rustls_config_with_control(TlsControl::default())
1444 }
1445
1446 pub fn to_rustls_config_with_control(
1464 &self,
1465 control: TlsControl,
1466 ) -> Result<TlsClientConfig, TlsConfigError> {
1467 let default = rustls::crypto::aws_lc_rs::default_provider();
1468
1469 let suite_map: std::collections::HashMap<u16, rustls::SupportedCipherSuite> = default
1471 .cipher_suites
1472 .iter()
1473 .map(|cs| (u16::from(cs.suite()), *cs))
1474 .collect();
1475
1476 let mut ordered_suites: Vec<rustls::SupportedCipherSuite> = Vec::new();
1477 for id in &self.cipher_suites {
1478 if let Some(cs) = suite_map.get(&id.0).copied() {
1479 ordered_suites.push(cs);
1480 } else if control.allow_legacy_compat_suites && is_legacy_compat_suite(id.0) {
1481 tracing::warn!(
1482 cipher_suite_id = id.0,
1483 profile = %self.name,
1484 "legacy profile suite has no rustls equivalent, skipping"
1485 );
1486 } else if control.strict_cipher_suites {
1487 return Err(TlsConfigError::UnsupportedCipherSuite {
1488 profile: self.name.clone(),
1489 cipher_suite_id: id.0,
1490 });
1491 } else {
1492 tracing::warn!(
1493 cipher_suite_id = id.0,
1494 profile = %self.name,
1495 "cipher suite not supported by rustls aws-lc-rs backend, skipping"
1496 );
1497 }
1498 }
1499
1500 if ordered_suites.is_empty() {
1501 return Err(TlsConfigError::NoCipherSuites(self.name.clone()));
1502 }
1503
1504 let group_map: std::collections::HashMap<
1506 u16,
1507 &'static dyn rustls::crypto::SupportedKxGroup,
1508 > = default
1509 .kx_groups
1510 .iter()
1511 .map(|g| (u16::from(g.name()), *g))
1512 .collect();
1513
1514 let mut ordered_groups: Vec<&'static dyn rustls::crypto::SupportedKxGroup> = Vec::new();
1515 for sg in &self.supported_groups {
1516 if let Some(group) = group_map.get(&sg.iana_value()).copied() {
1517 ordered_groups.push(group);
1518 } else if control.strict_supported_groups {
1519 return Err(TlsConfigError::UnsupportedSupportedGroup {
1520 profile: self.name.clone(),
1521 group_id: sg.iana_value(),
1522 });
1523 } else {
1524 tracing::warn!(
1525 group_id = sg.iana_value(),
1526 profile = %self.name,
1527 "key-exchange group not supported by rustls, skipping"
1528 );
1529 }
1530 }
1531
1532 let kx_groups = if ordered_groups.is_empty() && control.fallback_to_provider_groups {
1533 default.kx_groups.clone()
1534 } else if ordered_groups.is_empty() {
1535 return Err(TlsConfigError::NoSupportedGroups(self.name.clone()));
1536 } else {
1537 ordered_groups
1538 };
1539
1540 let provider = rustls::crypto::CryptoProvider {
1541 cipher_suites: ordered_suites,
1542 kx_groups,
1543 ..default
1544 };
1545
1546 let versions: Vec<&'static rustls::SupportedProtocolVersion> = self
1548 .tls_versions
1549 .iter()
1550 .map(|v| match v {
1551 TlsVersion::Tls12 => &rustls::version::TLS12,
1552 TlsVersion::Tls13 => &rustls::version::TLS13,
1553 })
1554 .collect();
1555
1556 let mut root_store = rustls::RootCertStore::empty();
1557 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
1558
1559 let mut config = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
1561 .with_protocol_versions(&versions)?
1562 .with_root_certificates(root_store)
1563 .with_no_client_auth();
1564
1565 config.alpn_protocols = self
1567 .alpn_protocols
1568 .iter()
1569 .map(|p| p.as_str().as_bytes().to_vec())
1570 .collect();
1571
1572 Ok(TlsClientConfig(Arc::new(config)))
1573 }
1574 }
1575}
1576
1577#[cfg(feature = "tls-config")]
1578pub use rustls_config::{TlsClientConfig, TlsConfigError};
1579
1580#[cfg(feature = "tls-config")]
1581pub use rustls_config::TlsControl;
1582
1583#[cfg(feature = "tls-config")]
1590mod reqwest_client {
1591 #[allow(clippy::wildcard_imports)]
1592 use super::*;
1593 use std::sync::Arc;
1594
1595 #[derive(Debug, thiserror::Error)]
1597 #[non_exhaustive]
1598 pub enum TlsClientError {
1599 #[error(transparent)]
1600 TlsConfig(#[from] super::rustls_config::TlsConfigError),
1601
1602 #[error("reqwest client: {0}")]
1604 Reqwest(#[from] reqwest::Error),
1605 }
1606
1607 pub fn default_user_agent(profile: &TlsProfile) -> &'static str {
1623 let name = profile.name.to_ascii_lowercase();
1624 if name.contains("firefox") {
1625 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0"
1626 } else if name.contains("safari") && !name.contains("chrome") {
1627 "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"
1628 } else if name.contains("edge") {
1629 "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"
1630 } else {
1631 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
1633 }
1634 }
1635
1636 pub fn profile_for_device(device: &crate::fingerprint::DeviceProfile) -> &'static TlsProfile {
1644 use crate::fingerprint::DeviceProfile;
1645 match device {
1646 DeviceProfile::DesktopWindows | DeviceProfile::MobileAndroid => &CHROME_131,
1647 DeviceProfile::DesktopMac | DeviceProfile::MobileIOS => &SAFARI_18,
1648 DeviceProfile::DesktopLinux => &FIREFOX_133,
1649 }
1650 }
1651
1652 pub fn browser_headers(profile: &TlsProfile) -> reqwest::header::HeaderMap {
1670 use reqwest::header::{
1671 ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, HeaderMap, HeaderValue,
1672 UPGRADE_INSECURE_REQUESTS,
1673 };
1674
1675 let mut map = HeaderMap::new();
1676 let name = profile.name.to_ascii_lowercase();
1677
1678 let is_firefox = name.contains("firefox");
1679 let is_safari = name.contains("safari") && !name.contains("chrome");
1680 let is_chromium = !(is_firefox || is_safari);
1681
1682 let accept = if is_chromium {
1684 "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"
1686 } else {
1687 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1688 };
1689
1690 let accept_encoding = "gzip, deflate, br";
1692
1693 let accept_language = "en-US,en;q=0.9";
1697
1698 if is_chromium {
1700 let (brand, version) = if name.contains("edge") {
1701 ("\"Microsoft Edge\";v=\"131\"", "131")
1702 } else {
1703 ("\"Google Chrome\";v=\"131\"", "131")
1704 };
1705
1706 let sec_ch_ua =
1707 format!("{brand}, \"Chromium\";v=\"{version}\", \"Not_A Brand\";v=\"24\"");
1708
1709 if let Ok(v) = HeaderValue::from_str(&sec_ch_ua) {
1712 map.insert("sec-ch-ua", v);
1713 }
1714 map.insert("sec-ch-ua-mobile", HeaderValue::from_static("?0"));
1715 map.insert(
1716 "sec-ch-ua-platform",
1717 HeaderValue::from_static("\"Windows\""),
1718 );
1719 map.insert("sec-fetch-dest", HeaderValue::from_static("document"));
1720 map.insert("sec-fetch-mode", HeaderValue::from_static("navigate"));
1721 map.insert("sec-fetch-site", HeaderValue::from_static("none"));
1722 map.insert("sec-fetch-user", HeaderValue::from_static("?1"));
1723 map.insert(UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1"));
1724 }
1725
1726 if let Ok(v) = HeaderValue::from_str(accept) {
1727 map.insert(ACCEPT, v);
1728 }
1729 map.insert(ACCEPT_ENCODING, HeaderValue::from_static(accept_encoding));
1730 map.insert(ACCEPT_LANGUAGE, HeaderValue::from_static(accept_language));
1731 map.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
1732
1733 map
1734 }
1735
1736 pub fn build_profiled_client(
1756 profile: &TlsProfile,
1757 proxy_url: Option<&str>,
1758 ) -> Result<reqwest::Client, TlsClientError> {
1759 build_profiled_client_with_control(profile, proxy_url, TlsControl::default())
1760 }
1761
1762 pub fn build_profiled_client_preset(
1775 profile: &TlsProfile,
1776 proxy_url: Option<&str>,
1777 ) -> Result<reqwest::Client, TlsClientError> {
1778 build_profiled_client_with_control(profile, proxy_url, TlsControl::for_profile(profile))
1779 }
1780
1781 pub fn build_profiled_client_with_control(
1798 profile: &TlsProfile,
1799 proxy_url: Option<&str>,
1800 control: TlsControl,
1801 ) -> Result<reqwest::Client, TlsClientError> {
1802 let tls_config = profile.to_rustls_config_with_control(control)?;
1803
1804 let rustls_cfg =
1805 Arc::try_unwrap(tls_config.into_inner()).unwrap_or_else(|arc| (*arc).clone());
1806
1807 let mut builder = reqwest::Client::builder()
1808 .use_preconfigured_tls(rustls_cfg)
1809 .user_agent(default_user_agent(profile))
1810 .default_headers(browser_headers(profile))
1811 .cookie_store(true)
1812 .gzip(true)
1813 .brotli(true);
1814
1815 if let Some(url) = proxy_url {
1816 builder = builder.proxy(reqwest::Proxy::all(url)?);
1817 }
1818
1819 Ok(builder.build()?)
1820 }
1821
1822 pub fn build_profiled_client_strict(
1836 profile: &TlsProfile,
1837 proxy_url: Option<&str>,
1838 ) -> Result<reqwest::Client, TlsClientError> {
1839 build_profiled_client_with_control(profile, proxy_url, TlsControl::strict())
1840 }
1841}
1842
1843#[cfg(feature = "tls-config")]
1844pub use reqwest_client::{
1845 TlsClientError, browser_headers, build_profiled_client, build_profiled_client_preset,
1846 build_profiled_client_strict, build_profiled_client_with_control, default_user_agent,
1847 profile_for_device,
1848};
1849
1850#[cfg(test)]
1853#[allow(clippy::panic, clippy::unwrap_used)]
1854mod tests {
1855 use super::*;
1856
1857 #[test]
1858 fn md5_known_vectors() {
1859 assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
1860 assert_eq!(md5_hex(b"a"), "0cc175b9c0f1b6a831c399e269772661");
1861 assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
1862 assert_eq!(
1863 md5_hex(b"message digest"),
1864 "f96b697d7cb7938d525a2f31aaf161d0"
1865 );
1866 }
1867
1868 #[test]
1869 fn chrome_131_ja3_structure() {
1870 let ja3 = CHROME_131.ja3();
1871 assert!(
1875 ja3.raw.starts_with("772,"),
1876 "JA3 raw should start with '772,' but was: {}",
1877 ja3.raw
1878 );
1879 assert_eq!(ja3.raw.matches(',').count(), 4);
1881 assert_eq!(ja3.hash.len(), 32);
1883 assert!(ja3.hash.chars().all(|c| c.is_ascii_hexdigit()));
1884 }
1885
1886 #[test]
1887 fn firefox_133_ja3_differs_from_chrome() {
1888 let chrome_ja3 = CHROME_131.ja3();
1889 let firefox_ja3 = FIREFOX_133.ja3();
1890 assert_ne!(chrome_ja3.hash, firefox_ja3.hash);
1891 assert_ne!(chrome_ja3.raw, firefox_ja3.raw);
1892 }
1893
1894 #[test]
1895 fn safari_18_ja3_is_valid() {
1896 let ja3 = SAFARI_18.ja3();
1897 assert!(ja3.raw.starts_with("772,"));
1898 assert_eq!(ja3.hash.len(), 32);
1899 }
1900
1901 #[test]
1902 fn edge_131_ja3_differs_from_chrome() {
1903 let chrome_ja3 = CHROME_131.ja3();
1904 let edge_ja3 = EDGE_131.ja3();
1905 assert_ne!(chrome_ja3.hash, edge_ja3.hash);
1906 }
1907
1908 #[test]
1909 fn chrome_131_ja4_format() {
1910 let ja4 = CHROME_131.ja4();
1911 assert!(
1913 ja4.fingerprint.starts_with("t13d"),
1914 "JA4 should start with 't13d' but was: {}",
1915 ja4.fingerprint
1916 );
1917 assert_eq!(
1919 ja4.fingerprint.matches('_').count(),
1920 3,
1921 "JA4 should have three separators: {}",
1922 ja4.fingerprint
1923 );
1924 }
1925
1926 #[test]
1927 fn ja4_firefox_differs_from_chrome() {
1928 let chrome_ja4 = CHROME_131.ja4();
1929 let firefox_ja4 = FIREFOX_133.ja4();
1930 assert_ne!(chrome_ja4.fingerprint, firefox_ja4.fingerprint);
1931 }
1932
1933 #[test]
1934 fn random_weighted_distribution() {
1935 let mut chrome_count = 0u32;
1936 let mut firefox_count = 0u32;
1937 let mut edge_count = 0u32;
1938 let mut safari_count = 0u32;
1939
1940 let total = 10_000u32;
1941 for i in 0..total {
1942 let profile = TlsProfile::random_weighted(u64::from(i));
1943 match profile.name.as_str() {
1944 "Chrome 131" => chrome_count += 1,
1945 "Firefox 133" => firefox_count += 1,
1946 "Edge 131" => edge_count += 1,
1947 "Safari 18" => safari_count += 1,
1948 other => unreachable!("unexpected profile: {other}"),
1949 }
1950 }
1951
1952 assert!(
1954 chrome_count > total * 40 / 100,
1955 "Chrome share too low: {chrome_count}/{total}"
1956 );
1957 assert!(
1959 firefox_count > total * 5 / 100,
1960 "Firefox share too low: {firefox_count}/{total}"
1961 );
1962 assert!(
1964 edge_count > total * 5 / 100,
1965 "Edge share too low: {edge_count}/{total}"
1966 );
1967 assert!(
1969 safari_count > total * 3 / 100,
1970 "Safari share too low: {safari_count}/{total}"
1971 );
1972 }
1973
1974 #[test]
1975 fn serde_roundtrip() {
1976 let profile: &TlsProfile = &CHROME_131;
1977 let json = serde_json::to_string(profile).unwrap();
1978 let deserialized: TlsProfile = serde_json::from_str(&json).unwrap();
1979 assert_eq!(profile, &deserialized);
1980 }
1981
1982 #[test]
1983 fn ja3hash_display() {
1984 let ja3 = CHROME_131.ja3();
1985 assert_eq!(format!("{ja3}"), ja3.hash);
1986 }
1987
1988 #[test]
1989 fn ja4_display() {
1990 let ja4 = CHROME_131.ja4();
1991 assert_eq!(format!("{ja4}"), ja4.fingerprint);
1992 }
1993
1994 #[test]
1995 fn http3_perk_chrome_text_and_hash_are_stable() {
1996 let Some(perk) = CHROME_131.http3_perk() else {
1997 panic!("chrome should have perk");
1998 };
1999 let text = perk.perk_text();
2000 assert_eq!(text, "1:65536;6:262144;7:100;51:1;GREASE|masp");
2001 assert_eq!(perk.perk_hash().len(), 32);
2002 }
2003
2004 #[test]
2005 fn expected_perk_from_user_agent_detects_firefox() {
2006 let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0";
2007 let Some(perk) = expected_http3_perk_from_user_agent(ua) else {
2008 panic!("firefox should resolve");
2009 };
2010 assert_eq!(perk.perk_text(), "1:65536;7:20;727725890:0|mpas");
2011 }
2012
2013 #[test]
2014 fn http3_perk_compare_detects_text_mismatch() {
2015 let Some(perk) = CHROME_131.http3_perk() else {
2016 panic!("chrome should have perk");
2017 };
2018 let cmp = perk.compare(Some("1:65536|masp"), None);
2019 assert!(!cmp.matches);
2020 assert_eq!(cmp.mismatches.len(), 1);
2021 assert!(
2022 cmp.mismatches
2023 .first()
2024 .is_some_and(|mismatch| mismatch.contains("perk_text mismatch"))
2025 );
2026 }
2027
2028 #[test]
2029 fn cipher_suite_display() {
2030 let cs = CipherSuiteId::TLS_AES_128_GCM_SHA256;
2031 assert_eq!(format!("{cs}"), "4865"); }
2033
2034 #[test]
2035 fn tls_version_display() {
2036 assert_eq!(format!("{}", TlsVersion::Tls13), "772");
2037 }
2038
2039 #[test]
2040 fn alpn_protocol_as_str() {
2041 assert_eq!(AlpnProtocol::H2.as_str(), "h2");
2042 assert_eq!(AlpnProtocol::Http11.as_str(), "http/1.1");
2043 }
2044
2045 #[test]
2046 fn supported_group_values() {
2047 assert_eq!(SupportedGroup::X25519.iana_value(), 0x001d);
2048 assert_eq!(SupportedGroup::SecP256r1.iana_value(), 0x0017);
2049 assert_eq!(SupportedGroup::X25519Kyber768.iana_value(), 0x6399);
2050 }
2051
2052 #[test]
2055 fn chrome_131_tls_args_empty() {
2056 let args = chrome_tls_args(&CHROME_131);
2058 assert!(args.is_empty(), "expected no flags, got: {args:?}");
2059 }
2060
2061 #[test]
2062 fn tls12_only_profile_caps_version() {
2063 let profile = TlsProfile {
2064 name: "TLS12-only".to_string(),
2065 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2066 tls_versions: vec![TlsVersion::Tls12],
2067 extensions: vec![],
2068 supported_groups: vec![],
2069 signature_algorithms: vec![],
2070 alpn_protocols: vec![],
2071 };
2072 let args = chrome_tls_args(&profile);
2073 assert_eq!(args, vec!["--ssl-version-max=tls1.2"]);
2074 }
2075
2076 #[test]
2077 fn tls13_only_profile_raises_floor() {
2078 let profile = TlsProfile {
2079 name: "TLS13-only".to_string(),
2080 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2081 tls_versions: vec![TlsVersion::Tls13],
2082 extensions: vec![],
2083 supported_groups: vec![],
2084 signature_algorithms: vec![],
2085 alpn_protocols: vec![],
2086 };
2087 let args = chrome_tls_args(&profile);
2088 assert_eq!(args, vec!["--ssl-version-min=tls1.3"]);
2089 }
2090
2091 #[test]
2092 fn builder_tls_profile_integration() {
2093 let cfg = crate::BrowserConfig::builder()
2094 .tls_profile(&CHROME_131)
2095 .build();
2096 let tls_flags: Vec<_> = cfg
2098 .effective_args()
2099 .into_iter()
2100 .filter(|a| a.starts_with("--ssl-version"))
2101 .collect();
2102 assert!(tls_flags.is_empty(), "unexpected TLS flags: {tls_flags:?}");
2103 }
2104
2105 #[cfg(feature = "tls-config")]
2108 mod rustls_tests {
2109 use super::super::*;
2110
2111 #[test]
2112 fn chrome_131_config_builds_successfully() {
2113 let config = CHROME_131.to_rustls_config().unwrap();
2114 let inner = config.inner();
2116 assert!(
2118 !inner.alpn_protocols.is_empty(),
2119 "ALPN protocols should be set"
2120 );
2121 }
2122
2123 #[test]
2124 #[allow(clippy::indexing_slicing)]
2125 fn alpn_order_matches_profile() {
2126 let config = CHROME_131.to_rustls_config().unwrap();
2127 let alpn = &config.inner().alpn_protocols;
2128 assert_eq!(alpn.len(), 2);
2129 assert_eq!(alpn[0], b"h2");
2130 assert_eq!(alpn[1], b"http/1.1");
2131 }
2132
2133 #[test]
2134 fn all_builtin_profiles_produce_valid_configs() {
2135 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2136 let result = profile.to_rustls_config();
2137 assert!(
2138 result.is_ok(),
2139 "profile '{}' should produce a valid config: {:?}",
2140 profile.name,
2141 result.err()
2142 );
2143 }
2144 }
2145
2146 #[test]
2147 fn unsupported_only_suites_returns_error() {
2148 let profile = TlsProfile {
2149 name: "Bogus".to_string(),
2150 cipher_suites: vec![CipherSuiteId(0xFFFF)],
2151 tls_versions: vec![TlsVersion::Tls13],
2152 extensions: vec![],
2153 supported_groups: vec![],
2154 signature_algorithms: vec![],
2155 alpn_protocols: vec![],
2156 };
2157 let err = profile.to_rustls_config().unwrap_err();
2158 assert!(
2159 err.to_string().contains("no supported cipher suites"),
2160 "expected NoCipherSuites, got: {err}"
2161 );
2162 }
2163
2164 #[test]
2165 fn strict_mode_rejects_unknown_cipher_suite() {
2166 let profile = TlsProfile {
2167 name: "StrictCipherTest".to_string(),
2168 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256, CipherSuiteId(0xFFFF)],
2169 tls_versions: vec![TlsVersion::Tls13],
2170 extensions: vec![],
2171 supported_groups: vec![SupportedGroup::X25519],
2172 signature_algorithms: vec![],
2173 alpn_protocols: vec![],
2174 };
2175
2176 let err = profile
2177 .to_rustls_config_with_control(TlsControl::strict())
2178 .unwrap_err();
2179
2180 match err {
2181 TlsConfigError::UnsupportedCipherSuite {
2182 cipher_suite_id, ..
2183 } => {
2184 assert_eq!(cipher_suite_id, 0xFFFF);
2185 }
2186 other => panic!("expected UnsupportedCipherSuite, got: {other}"),
2187 }
2188 }
2189
2190 #[test]
2191 fn compatible_mode_skips_unknown_cipher_suite() {
2192 let mut profile = (*CHROME_131).clone();
2193 profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2194
2195 let cfg = profile.to_rustls_config_with_control(TlsControl::compatible());
2196 assert!(cfg.is_ok(), "compatible mode should skip unknown suite");
2197 }
2198
2199 #[test]
2200 fn control_for_builtin_profiles_is_strict() {
2201 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2202 let control = TlsControl::for_profile(profile);
2203 assert!(
2204 control.strict_cipher_suites,
2205 "builtin profile '{}' should use strict cipher checking",
2206 profile.name
2207 );
2208 }
2209 }
2210
2211 #[test]
2212 fn control_for_custom_profile_is_compatible() {
2213 let profile = TlsProfile {
2214 name: "Custom Backend".to_string(),
2215 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2216 tls_versions: vec![TlsVersion::Tls13],
2217 extensions: vec![],
2218 supported_groups: vec![SupportedGroup::X25519],
2219 signature_algorithms: vec![],
2220 alpn_protocols: vec![],
2221 };
2222
2223 let control = TlsControl::for_profile(&profile);
2224 assert!(!control.strict_cipher_suites);
2225 assert!(!control.strict_supported_groups);
2226 assert!(control.fallback_to_provider_groups);
2227 }
2228
2229 #[test]
2230 fn strict_all_without_groups_returns_error() {
2231 let profile = TlsProfile {
2232 name: "StrictGroupTest".to_string(),
2233 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2234 tls_versions: vec![TlsVersion::Tls13],
2235 extensions: vec![],
2236 supported_groups: vec![],
2237 signature_algorithms: vec![],
2238 alpn_protocols: vec![],
2239 };
2240
2241 let err = profile
2242 .to_rustls_config_with_control(TlsControl::strict_all())
2243 .unwrap_err();
2244
2245 match err {
2246 TlsConfigError::NoSupportedGroups(name) => {
2247 assert_eq!(name, "StrictGroupTest");
2248 }
2249 other => panic!("expected NoSupportedGroups, got: {other}"),
2250 }
2251 }
2252
2253 #[test]
2254 fn into_arc_conversion() {
2255 let config = CHROME_131.to_rustls_config().unwrap();
2256 let arc: std::sync::Arc<rustls::ClientConfig> = config.into();
2257 assert!(!arc.alpn_protocols.is_empty());
2259 }
2260 }
2261
2262 #[cfg(feature = "tls-config")]
2265 mod reqwest_tests {
2266 use super::super::*;
2267
2268 #[test]
2269 fn build_profiled_client_no_proxy() {
2270 let client = build_profiled_client(&CHROME_131, None);
2271 assert!(
2272 client.is_ok(),
2273 "should build a client without error: {:?}",
2274 client.err()
2275 );
2276 }
2277
2278 #[test]
2279 fn build_profiled_client_all_profiles() {
2280 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2281 let result = build_profiled_client(profile, None);
2282 assert!(
2283 result.is_ok(),
2284 "profile '{}' should produce a valid client: {:?}",
2285 profile.name,
2286 result.err()
2287 );
2288 }
2289 }
2290
2291 #[test]
2292 fn build_profiled_client_strict_no_proxy() {
2293 let client = build_profiled_client_strict(&CHROME_131, None);
2294 assert!(
2295 client.is_ok(),
2296 "strict mode should build for built-in profile: {:?}",
2297 client.err()
2298 );
2299 }
2300
2301 #[test]
2302 fn build_profiled_client_preset_all_profiles() {
2303 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2304 let result = build_profiled_client_preset(profile, None);
2305 assert!(
2306 result.is_ok(),
2307 "preset builder should work for profile '{}': {:?}",
2308 profile.name,
2309 result.err()
2310 );
2311 }
2312 }
2313
2314 #[test]
2315 fn build_profiled_client_with_control_rejects_unknown_cipher_suite() {
2316 let mut profile = (*CHROME_131).clone();
2317 profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2318
2319 let client = build_profiled_client_with_control(&profile, None, TlsControl::strict());
2320
2321 assert!(
2322 client.is_err(),
2323 "strict mode should reject unsupported cipher suite"
2324 );
2325 }
2326
2327 #[test]
2328 fn default_user_agent_matches_browser() {
2329 assert!(default_user_agent(&CHROME_131).contains("Chrome/131"));
2330 assert!(default_user_agent(&FIREFOX_133).contains("Firefox/133"));
2331 assert!(default_user_agent(&SAFARI_18).contains("Safari/605"));
2332 assert!(default_user_agent(&EDGE_131).contains("Edg/131"));
2333 }
2334
2335 #[test]
2336 fn profile_for_device_mapping() {
2337 use crate::fingerprint::DeviceProfile;
2338
2339 assert_eq!(
2340 profile_for_device(&DeviceProfile::DesktopWindows).name,
2341 "Chrome 131"
2342 );
2343 assert_eq!(
2344 profile_for_device(&DeviceProfile::DesktopMac).name,
2345 "Safari 18"
2346 );
2347 assert_eq!(
2348 profile_for_device(&DeviceProfile::DesktopLinux).name,
2349 "Firefox 133"
2350 );
2351 assert_eq!(
2352 profile_for_device(&DeviceProfile::MobileAndroid).name,
2353 "Chrome 131"
2354 );
2355 assert_eq!(
2356 profile_for_device(&DeviceProfile::MobileIOS).name,
2357 "Safari 18"
2358 );
2359 }
2360
2361 #[test]
2362 fn browser_headers_chrome_has_sec_ch_ua() {
2363 let headers = browser_headers(&CHROME_131);
2364 assert!(
2365 headers.contains_key("sec-ch-ua"),
2366 "Chrome profile should have sec-ch-ua"
2367 );
2368 assert!(
2369 headers.contains_key("sec-fetch-dest"),
2370 "Chrome profile should have sec-fetch-dest"
2371 );
2372 let accept = headers.get("accept").unwrap().to_str().unwrap();
2373 assert!(
2374 accept.contains("image/avif"),
2375 "Chrome accept should include avif"
2376 );
2377 }
2378
2379 #[test]
2380 fn browser_headers_firefox_no_sec_ch_ua() {
2381 let headers = browser_headers(&FIREFOX_133);
2382 assert!(
2383 !headers.contains_key("sec-ch-ua"),
2384 "Firefox profile should not have sec-ch-ua"
2385 );
2386 let accept = headers.get("accept").unwrap().to_str().unwrap();
2387 assert!(
2388 accept.contains("text/html"),
2389 "Firefox accept should include text/html"
2390 );
2391 }
2392
2393 #[test]
2394 fn browser_headers_all_profiles_have_accept() {
2395 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2396 let headers = browser_headers(profile);
2397 assert!(
2398 headers.contains_key("accept"),
2399 "profile '{}' must have accept header",
2400 profile.name
2401 );
2402 assert!(
2403 headers.contains_key("accept-encoding"),
2404 "profile '{}' must have accept-encoding",
2405 profile.name
2406 );
2407 assert!(
2408 headers.contains_key("accept-language"),
2409 "profile '{}' must have accept-language",
2410 profile.name
2411 );
2412 }
2413 }
2414
2415 #[test]
2416 fn browser_headers_edge_uses_edge_brand() {
2417 let headers = browser_headers(&EDGE_131);
2418 let sec_ch_ua = headers.get("sec-ch-ua").unwrap().to_str().unwrap();
2419 assert!(
2420 sec_ch_ua.contains("Microsoft Edge"),
2421 "Edge sec-ch-ua should identify Edge: {sec_ch_ua}"
2422 );
2423 }
2424 }
2425}