1use serde::{Deserialize, Serialize};
35use std::fmt;
36use std::sync::LazyLock;
37
38pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
65pub struct CipherSuiteId(pub u16);
66
67impl CipherSuiteId {
68 pub const TLS_AES_128_GCM_SHA256: Self = Self(0x1301);
70 pub const TLS_AES_256_GCM_SHA384: Self = Self(0x1302);
72 pub const TLS_CHACHA20_POLY1305_SHA256: Self = Self(0x1303);
74 pub const TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: Self = Self(0xc02b);
76 pub const TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: Self = Self(0xc02f);
78 pub const TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: Self = Self(0xc02c);
80 pub const TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: Self = Self(0xc030);
82 pub const TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: Self = Self(0xcca9);
84 pub const TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: Self = Self(0xcca8);
86 pub const TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: Self = Self(0xc013);
88 pub const TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: Self = Self(0xc014);
90 pub const TLS_RSA_WITH_AES_128_GCM_SHA256: Self = Self(0x009c);
92 pub const TLS_RSA_WITH_AES_256_GCM_SHA384: Self = Self(0x009d);
94 pub const TLS_RSA_WITH_AES_128_CBC_SHA: Self = Self(0x002f);
96 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
117#[non_exhaustive]
118pub enum TlsVersion {
119 Tls12,
121 Tls13,
123}
124
125impl TlsVersion {
126 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
160pub struct TlsExtensionId(pub u16);
161
162impl TlsExtensionId {
163 pub const SERVER_NAME: Self = Self(0);
165 pub const EXTENDED_MASTER_SECRET: Self = Self(23);
167 pub const ENCRYPT_THEN_MAC: Self = Self(22);
169 pub const SESSION_TICKET: Self = Self(35);
171 pub const SIGNATURE_ALGORITHMS: Self = Self(13);
173 pub const SUPPORTED_VERSIONS: Self = Self(43);
175 pub const PSK_KEY_EXCHANGE_MODES: Self = Self(45);
177 pub const KEY_SHARE: Self = Self(51);
179 pub const SUPPORTED_GROUPS: Self = Self(10);
181 pub const EC_POINT_FORMATS: Self = Self(11);
183 pub const ALPN: Self = Self(16);
185 pub const STATUS_REQUEST: Self = Self(5);
187 pub const SIGNED_CERTIFICATE_TIMESTAMP: Self = Self(18);
189 pub const COMPRESS_CERTIFICATE: Self = Self(27);
191 pub const APPLICATION_SETTINGS: Self = Self(17513);
193 pub const RENEGOTIATION_INFO: Self = Self(0xff01);
195 pub const DELEGATED_CREDENTIALS: Self = Self(34);
197 pub const RECORD_SIZE_LIMIT: Self = Self(28);
199 pub const PADDING: Self = Self(21);
201 pub const PRE_SHARED_KEY: Self = Self(41);
203 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
224#[non_exhaustive]
225pub enum SupportedGroup {
226 X25519,
228 SecP256r1,
230 SecP384r1,
232 SecP521r1,
234 X25519Kyber768,
236 Ffdhe2048,
238 Ffdhe3072,
240}
241
242impl SupportedGroup {
243 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
282pub struct SignatureAlgorithm(pub u16);
283
284impl SignatureAlgorithm {
285 pub const ECDSA_SECP256R1_SHA256: Self = Self(0x0403);
287 pub const RSA_PSS_RSAE_SHA256: Self = Self(0x0804);
289 pub const RSA_PKCS1_SHA256: Self = Self(0x0401);
291 pub const ECDSA_SECP384R1_SHA384: Self = Self(0x0503);
293 pub const RSA_PSS_RSAE_SHA384: Self = Self(0x0805);
295 pub const RSA_PKCS1_SHA384: Self = Self(0x0501);
297 pub const RSA_PSS_RSAE_SHA512: Self = Self(0x0806);
299 pub const RSA_PKCS1_SHA512: Self = Self(0x0601);
301 pub const ECDSA_SECP521R1_SHA512: Self = Self(0x0603);
303 pub const RSA_PKCS1_SHA1: Self = Self(0x0201);
305 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
325#[non_exhaustive]
326pub enum AlpnProtocol {
327 H2,
329 Http11,
331}
332
333impl AlpnProtocol {
334 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[non_exhaustive]
375pub struct TlsProfile {
376 pub name: String,
378 pub cipher_suites: Vec<CipherSuiteId>,
380 pub tls_versions: Vec<TlsVersion>,
382 pub extensions: Vec<TlsExtensionId>,
384 pub supported_groups: Vec<SupportedGroup>,
386 pub signature_algorithms: Vec<SignatureAlgorithm>,
388 pub alpn_protocols: Vec<AlpnProtocol>,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct Ja3Hash {
412 pub raw: String,
414 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#[allow(
429 clippy::many_single_char_names,
430 clippy::too_many_lines,
431 clippy::indexing_slicing
432)]
433fn md5_hex(data: &[u8]) -> String {
434 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
586pub struct Ja4 {
587 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
602pub struct Http3Perk {
603 pub settings: Vec<(u64, u64)>,
605 pub pseudo_headers: String,
607 pub has_grease: bool,
609}
610
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
613pub struct Http3PerkComparison {
614 pub matches: bool,
616 pub mismatches: Vec<String>,
618}
619
620const fn is_quic_grease(value: u64) -> bool {
621 let low = value & 0xffff;
622 let a = (low >> 8) & 0xff;
623 let b = low & 0xff;
624 a == b && (a & 0x0f) == 0x0a
625}
626
627impl Http3Perk {
628 #[must_use]
630 pub fn perk_text(&self) -> String {
631 let mut parts: Vec<String> = self
632 .settings
633 .iter()
634 .filter(|(id, _)| !is_quic_grease(*id))
635 .map(|(id, value)| format!("{id}:{value}"))
636 .collect();
637
638 if self.has_grease || self.settings.iter().any(|(id, _)| is_quic_grease(*id)) {
639 parts.push("GREASE".to_string());
640 }
641
642 format!("{}|{}", parts.join(";"), self.pseudo_headers)
643 }
644
645 #[must_use]
647 pub fn perk_hash(&self) -> String {
648 md5_hex(self.perk_text().as_bytes())
649 }
650
651 #[must_use]
653 pub fn compare(
654 &self,
655 observed_text: Option<&str>,
656 observed_hash: Option<&str>,
657 ) -> Http3PerkComparison {
658 let expected_text = self.perk_text();
659 let expected_hash = self.perk_hash();
660
661 let mut mismatches = Vec::new();
662
663 if let Some(text) = observed_text
664 && text != expected_text
665 {
666 mismatches.push(format!(
667 "perk_text mismatch: expected '{expected_text}', observed '{text}'"
668 ));
669 }
670
671 if let Some(hash) = observed_hash
672 && !hash.eq_ignore_ascii_case(&expected_hash)
673 {
674 mismatches.push(format!(
675 "perk_hash mismatch: expected '{expected_hash}', observed '{hash}'"
676 ));
677 }
678
679 Http3PerkComparison {
680 matches: mismatches.is_empty() && (observed_text.is_some() || observed_hash.is_some()),
681 mismatches,
682 }
683 }
684}
685
686#[must_use]
690pub fn expected_http3_perk_from_user_agent(user_agent: &str) -> Option<Http3Perk> {
691 expected_tls_profile_from_user_agent(user_agent).and_then(TlsProfile::http3_perk)
692}
693
694#[must_use]
698pub fn expected_tls_profile_from_user_agent(user_agent: &str) -> Option<&'static TlsProfile> {
699 let ua = user_agent.to_ascii_lowercase();
700
701 if ua.contains("edg/") {
702 return Some(&EDGE_131);
703 }
704
705 if ua.contains("firefox/") {
706 return Some(&FIREFOX_133);
707 }
708
709 if ua.contains("safari/") && !ua.contains("chrome/") && !ua.contains("edg/") {
711 return Some(&SAFARI_18);
712 }
713
714 if ua.contains("chrome/") {
715 return Some(&CHROME_131);
716 }
717
718 None
719}
720
721#[must_use]
723pub fn expected_ja3_from_user_agent(user_agent: &str) -> Option<Ja3Hash> {
724 expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja3)
725}
726
727#[must_use]
729pub fn expected_ja4_from_user_agent(user_agent: &str) -> Option<Ja4> {
730 expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja4)
731}
732
733fn truncate_hex(s: &str, n: usize) -> &str {
739 let end = s.len().min(n);
742 &s[..end]
743}
744
745const GREASE_VALUES: &[u16] = &[
747 0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba,
748 0xcaca, 0xdada, 0xeaea, 0xfafa,
749];
750
751fn is_grease(v: u16) -> bool {
753 GREASE_VALUES.contains(&v)
754}
755
756impl TlsProfile {
757 pub fn ja3(&self) -> Ja3Hash {
775 let tls_ver = self
777 .tls_versions
778 .iter()
779 .map(|v| v.iana_value())
780 .max()
781 .unwrap_or(TlsVersion::Tls12.iana_value());
782
783 let ciphers: Vec<String> = self
785 .cipher_suites
786 .iter()
787 .filter(|c| !is_grease(c.0))
788 .map(|c| c.0.to_string())
789 .collect();
790
791 let extensions: Vec<String> = self
793 .extensions
794 .iter()
795 .filter(|e| !is_grease(e.0))
796 .map(|e| e.0.to_string())
797 .collect();
798
799 let curves: Vec<String> = self
801 .supported_groups
802 .iter()
803 .filter(|g| !is_grease(g.iana_value()))
804 .map(|g| g.iana_value().to_string())
805 .collect();
806
807 let ec_point_formats = "0";
809
810 let raw = format!(
811 "{tls_ver},{},{},{},{ec_point_formats}",
812 ciphers.join("-"),
813 extensions.join("-"),
814 curves.join("-"),
815 );
816
817 let hash = md5_hex(raw.as_bytes());
818 Ja3Hash { raw, hash }
819 }
820
821 pub fn ja4(&self) -> Ja4 {
841 let proto = 't';
843
844 let version = if self.tls_versions.contains(&TlsVersion::Tls13) {
846 "13"
847 } else {
848 "12"
849 };
850
851 let sni = 'd';
854
855 let cipher_count = self
857 .cipher_suites
858 .iter()
859 .filter(|c| !is_grease(c.0))
860 .count()
861 .min(99);
862 let ext_count = self
863 .extensions
864 .iter()
865 .filter(|e| !is_grease(e.0))
866 .count()
867 .min(99);
868
869 let alpn_tag = match self.alpn_protocols.first() {
872 Some(AlpnProtocol::H2) => "h2",
873 Some(AlpnProtocol::Http11) => "h1",
874 None => "00",
875 };
876
877 let section_a = format!("{proto}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn_tag}",);
879
880 let mut sorted_ciphers: Vec<u16> = self
883 .cipher_suites
884 .iter()
885 .filter(|c| !is_grease(c.0))
886 .map(|c| c.0)
887 .collect();
888 sorted_ciphers.sort_unstable();
889 let cipher_str: String = sorted_ciphers
890 .iter()
891 .map(|c| format!("{c:04x}"))
892 .collect::<Vec<_>>()
893 .join(",");
894 let cipher_hash_full = md5_hex(cipher_str.as_bytes());
895 let cipher_hash = truncate_hex(&cipher_hash_full, 12);
896
897 let mut sorted_exts: Vec<u16> = self
900 .extensions
901 .iter()
902 .filter(|e| {
903 !is_grease(e.0)
904 && e.0 != TlsExtensionId::SERVER_NAME.0
905 && e.0 != TlsExtensionId::ALPN.0
906 })
907 .map(|e| e.0)
908 .collect();
909 sorted_exts.sort_unstable();
910 let ext_str: String = sorted_exts
911 .iter()
912 .map(|e| format!("{e:04x}"))
913 .collect::<Vec<_>>()
914 .join(",");
915 let ext_hash_full = md5_hex(ext_str.as_bytes());
916 let ext_hash = truncate_hex(&ext_hash_full, 12);
917
918 Ja4 {
919 fingerprint: format!("{section_a}_{cipher_hash}_{ext_hash}"),
920 }
921 }
922
923 #[must_use]
927 pub fn http3_perk(&self) -> Option<Http3Perk> {
928 match self.name.as_str() {
929 name if name.starts_with("Chrome ") || name.starts_with("Edge ") => Some(Http3Perk {
930 settings: vec![(1, 65_536), (6, 262_144), (7, 100), (51, 1)],
931 pseudo_headers: "masp".to_string(),
932 has_grease: true,
933 }),
934 name if name.starts_with("Firefox ") => Some(Http3Perk {
935 settings: vec![(1, 65_536), (7, 20), (727_725_890, 0)],
936 pseudo_headers: "mpas".to_string(),
937 has_grease: false,
938 }),
939 name if name.starts_with("Safari ") => None,
940 _ => None,
941 }
942 }
943
944 pub fn random_weighted(seed: u64) -> &'static Self {
965 let os_roll = rng(seed, 97) % 100;
967
968 let browser_roll = rng(seed, 201) % 100;
970
971 match os_roll {
972 0..=69 | 90..=99 => match browser_roll {
974 0..=64 => &CHROME_131,
975 65..=80 => &EDGE_131,
976 _ => &FIREFOX_133,
977 },
978 _ => match browser_roll {
980 0..=55 => &CHROME_131,
981 56..=91 => &SAFARI_18,
982 _ => &FIREFOX_133,
983 },
984 }
985 }
986}
987
988pub static CHROME_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1004 name: "Chrome 131".to_string(),
1005 cipher_suites: vec![
1006 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1007 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1008 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1009 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1010 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1011 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1012 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1013 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1014 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1015 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1016 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1017 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1018 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1019 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1020 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1021 ],
1022 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1023 extensions: vec![
1024 TlsExtensionId::SERVER_NAME,
1025 TlsExtensionId::EXTENDED_MASTER_SECRET,
1026 TlsExtensionId::RENEGOTIATION_INFO,
1027 TlsExtensionId::SUPPORTED_GROUPS,
1028 TlsExtensionId::EC_POINT_FORMATS,
1029 TlsExtensionId::SESSION_TICKET,
1030 TlsExtensionId::ALPN,
1031 TlsExtensionId::STATUS_REQUEST,
1032 TlsExtensionId::SIGNATURE_ALGORITHMS,
1033 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1034 TlsExtensionId::KEY_SHARE,
1035 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1036 TlsExtensionId::SUPPORTED_VERSIONS,
1037 TlsExtensionId::COMPRESS_CERTIFICATE,
1038 TlsExtensionId::APPLICATION_SETTINGS,
1039 TlsExtensionId::PADDING,
1040 ],
1041 supported_groups: vec![
1042 SupportedGroup::X25519Kyber768,
1043 SupportedGroup::X25519,
1044 SupportedGroup::SecP256r1,
1045 SupportedGroup::SecP384r1,
1046 ],
1047 signature_algorithms: vec![
1048 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1049 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1050 SignatureAlgorithm::RSA_PKCS1_SHA256,
1051 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1052 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1053 SignatureAlgorithm::RSA_PKCS1_SHA384,
1054 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1055 SignatureAlgorithm::RSA_PKCS1_SHA512,
1056 ],
1057 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1058});
1059
1060pub static FIREFOX_133: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1074 name: "Firefox 133".to_string(),
1075 cipher_suites: vec![
1076 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1077 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1078 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1079 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1080 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1081 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1082 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1083 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1084 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1085 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1086 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1087 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1088 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1089 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1090 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1091 ],
1092 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1093 extensions: vec![
1094 TlsExtensionId::SERVER_NAME,
1095 TlsExtensionId::EXTENDED_MASTER_SECRET,
1096 TlsExtensionId::RENEGOTIATION_INFO,
1097 TlsExtensionId::SUPPORTED_GROUPS,
1098 TlsExtensionId::EC_POINT_FORMATS,
1099 TlsExtensionId::SESSION_TICKET,
1100 TlsExtensionId::ALPN,
1101 TlsExtensionId::STATUS_REQUEST,
1102 TlsExtensionId::DELEGATED_CREDENTIALS,
1103 TlsExtensionId::KEY_SHARE,
1104 TlsExtensionId::SUPPORTED_VERSIONS,
1105 TlsExtensionId::SIGNATURE_ALGORITHMS,
1106 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1107 TlsExtensionId::RECORD_SIZE_LIMIT,
1108 TlsExtensionId::POST_HANDSHAKE_AUTH,
1109 TlsExtensionId::PADDING,
1110 ],
1111 supported_groups: vec![
1112 SupportedGroup::X25519,
1113 SupportedGroup::SecP256r1,
1114 SupportedGroup::SecP384r1,
1115 SupportedGroup::SecP521r1,
1116 SupportedGroup::Ffdhe2048,
1117 SupportedGroup::Ffdhe3072,
1118 ],
1119 signature_algorithms: vec![
1120 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1121 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1122 SignatureAlgorithm::ECDSA_SECP521R1_SHA512,
1123 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1124 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1125 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1126 SignatureAlgorithm::RSA_PKCS1_SHA256,
1127 SignatureAlgorithm::RSA_PKCS1_SHA384,
1128 SignatureAlgorithm::RSA_PKCS1_SHA512,
1129 SignatureAlgorithm::ECDSA_SHA1,
1130 SignatureAlgorithm::RSA_PKCS1_SHA1,
1131 ],
1132 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1133});
1134
1135pub static SAFARI_18: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1148 name: "Safari 18".to_string(),
1149 cipher_suites: vec![
1150 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1151 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1152 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1153 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1154 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1155 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1156 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1157 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1158 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1159 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1160 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1161 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1162 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1163 ],
1164 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1165 extensions: vec![
1166 TlsExtensionId::SERVER_NAME,
1167 TlsExtensionId::EXTENDED_MASTER_SECRET,
1168 TlsExtensionId::RENEGOTIATION_INFO,
1169 TlsExtensionId::SUPPORTED_GROUPS,
1170 TlsExtensionId::EC_POINT_FORMATS,
1171 TlsExtensionId::ALPN,
1172 TlsExtensionId::STATUS_REQUEST,
1173 TlsExtensionId::SIGNATURE_ALGORITHMS,
1174 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1175 TlsExtensionId::KEY_SHARE,
1176 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1177 TlsExtensionId::SUPPORTED_VERSIONS,
1178 TlsExtensionId::PADDING,
1179 ],
1180 supported_groups: vec![
1181 SupportedGroup::X25519,
1182 SupportedGroup::SecP256r1,
1183 SupportedGroup::SecP384r1,
1184 SupportedGroup::SecP521r1,
1185 ],
1186 signature_algorithms: vec![
1187 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1188 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1189 SignatureAlgorithm::RSA_PKCS1_SHA256,
1190 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1191 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1192 SignatureAlgorithm::RSA_PKCS1_SHA384,
1193 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1194 SignatureAlgorithm::RSA_PKCS1_SHA512,
1195 ],
1196 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1197});
1198
1199pub static EDGE_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1212 name: "Edge 131".to_string(),
1213 cipher_suites: vec![
1214 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1215 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1216 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1217 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1218 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1219 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1220 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1221 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1222 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1223 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1224 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1225 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1226 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1227 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1228 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1229 ],
1230 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1231 extensions: vec![
1232 TlsExtensionId::SERVER_NAME,
1233 TlsExtensionId::EXTENDED_MASTER_SECRET,
1234 TlsExtensionId::RENEGOTIATION_INFO,
1235 TlsExtensionId::SUPPORTED_GROUPS,
1236 TlsExtensionId::EC_POINT_FORMATS,
1237 TlsExtensionId::SESSION_TICKET,
1238 TlsExtensionId::ALPN,
1239 TlsExtensionId::STATUS_REQUEST,
1240 TlsExtensionId::SIGNATURE_ALGORITHMS,
1241 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1242 TlsExtensionId::KEY_SHARE,
1243 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1244 TlsExtensionId::SUPPORTED_VERSIONS,
1245 TlsExtensionId::COMPRESS_CERTIFICATE,
1246 TlsExtensionId::PADDING,
1247 ],
1248 supported_groups: vec![
1249 SupportedGroup::X25519Kyber768,
1250 SupportedGroup::X25519,
1251 SupportedGroup::SecP256r1,
1252 SupportedGroup::SecP384r1,
1253 ],
1254 signature_algorithms: vec![
1255 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1256 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1257 SignatureAlgorithm::RSA_PKCS1_SHA256,
1258 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1259 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1260 SignatureAlgorithm::RSA_PKCS1_SHA384,
1261 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1262 SignatureAlgorithm::RSA_PKCS1_SHA512,
1263 ],
1264 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1265});
1266
1267pub fn chrome_tls_args(profile: &TlsProfile) -> Vec<String> {
1298 let has_12 = profile.tls_versions.contains(&TlsVersion::Tls12);
1299 let has_13 = profile.tls_versions.contains(&TlsVersion::Tls13);
1300
1301 let mut args = Vec::new();
1302
1303 match (has_12, has_13) {
1304 (true, false) => {
1306 args.push("--ssl-version-max=tls1.2".to_string());
1307 }
1308 (false, true) => {
1310 args.push("--ssl-version-min=tls1.3".to_string());
1311 }
1312 _ => {}
1314 }
1315
1316 args
1317}
1318
1319#[cfg(feature = "tls-config")]
1326mod rustls_config {
1327 #[allow(clippy::wildcard_imports)]
1328 use super::*;
1329 use std::sync::Arc;
1330
1331 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1351 pub struct TlsControl {
1352 pub strict_cipher_suites: bool,
1354 pub strict_supported_groups: bool,
1356 pub fallback_to_provider_groups: bool,
1358 pub allow_legacy_compat_suites: bool,
1360 }
1361
1362 impl Default for TlsControl {
1363 fn default() -> Self {
1364 Self::compatible()
1365 }
1366 }
1367
1368 impl TlsControl {
1369 #[must_use]
1371 pub const fn compatible() -> Self {
1372 Self {
1373 strict_cipher_suites: false,
1374 strict_supported_groups: false,
1375 fallback_to_provider_groups: true,
1376 allow_legacy_compat_suites: true,
1377 }
1378 }
1379
1380 #[must_use]
1382 pub const fn strict() -> Self {
1383 Self {
1384 strict_cipher_suites: true,
1385 strict_supported_groups: false,
1386 fallback_to_provider_groups: true,
1387 allow_legacy_compat_suites: true,
1388 }
1389 }
1390
1391 #[must_use]
1393 pub const fn strict_all() -> Self {
1394 Self {
1395 strict_cipher_suites: true,
1396 strict_supported_groups: true,
1397 fallback_to_provider_groups: false,
1398 allow_legacy_compat_suites: true,
1399 }
1400 }
1401
1402 #[must_use]
1408 pub fn for_profile(profile: &TlsProfile) -> Self {
1409 let name = profile.name.to_ascii_lowercase();
1410 if name.contains("chrome")
1411 || name.contains("edge")
1412 || name.contains("firefox")
1413 || name.contains("safari")
1414 {
1415 Self::strict()
1416 } else {
1417 Self::compatible()
1418 }
1419 }
1420 }
1421
1422 const fn is_legacy_compat_suite(id: u16) -> bool {
1423 matches!(id, 0xc013 | 0xc014 | 0x009c | 0x009d | 0x002f | 0x0035)
1424 }
1425
1426 #[derive(Debug, thiserror::Error)]
1429 #[non_exhaustive]
1430 pub enum TlsConfigError {
1431 #[error("no supported cipher suites in profile '{0}'")]
1434 NoCipherSuites(String),
1435
1436 #[error(
1438 "unsupported cipher suite {cipher_suite_id:#06x} in profile '{profile}' under strict mode"
1439 )]
1440 UnsupportedCipherSuite {
1441 profile: String,
1443 cipher_suite_id: u16,
1445 },
1446
1447 #[error(
1449 "unsupported supported_group {group_id:#06x} in profile '{profile}' under strict mode"
1450 )]
1451 UnsupportedSupportedGroup {
1452 profile: String,
1454 group_id: u16,
1456 },
1457
1458 #[error("no supported key-exchange groups in profile '{0}'")]
1460 NoSupportedGroups(String),
1461
1462 #[error("rustls configuration: {0}")]
1464 Rustls(#[from] rustls::Error),
1465 }
1466
1467 #[derive(Debug, Clone)]
1473 pub struct TlsClientConfig(Arc<rustls::ClientConfig>);
1474
1475 impl TlsClientConfig {
1476 pub fn inner(&self) -> &rustls::ClientConfig {
1478 &self.0
1479 }
1480
1481 pub fn into_inner(self) -> Arc<rustls::ClientConfig> {
1483 self.0
1484 }
1485 }
1486
1487 impl From<TlsClientConfig> for Arc<rustls::ClientConfig> {
1488 fn from(cfg: TlsClientConfig) -> Self {
1489 cfg.0
1490 }
1491 }
1492
1493 impl TlsProfile {
1494 pub fn to_rustls_config(&self) -> Result<TlsClientConfig, TlsConfigError> {
1523 self.to_rustls_config_with_control(TlsControl::default())
1524 }
1525
1526 pub fn to_rustls_config_with_control(
1547 &self,
1548 control: TlsControl,
1549 ) -> Result<TlsClientConfig, TlsConfigError> {
1550 let default = rustls::crypto::aws_lc_rs::default_provider();
1551
1552 let suite_map: std::collections::HashMap<u16, rustls::SupportedCipherSuite> = default
1554 .cipher_suites
1555 .iter()
1556 .map(|cs| (u16::from(cs.suite()), *cs))
1557 .collect();
1558
1559 let mut ordered_suites: Vec<rustls::SupportedCipherSuite> = Vec::new();
1560 for id in &self.cipher_suites {
1561 if let Some(cs) = suite_map.get(&id.0).copied() {
1562 ordered_suites.push(cs);
1563 } else if control.allow_legacy_compat_suites && is_legacy_compat_suite(id.0) {
1564 tracing::warn!(
1565 cipher_suite_id = id.0,
1566 profile = %self.name,
1567 "legacy profile suite has no rustls equivalent, skipping"
1568 );
1569 } else if control.strict_cipher_suites {
1570 return Err(TlsConfigError::UnsupportedCipherSuite {
1571 profile: self.name.clone(),
1572 cipher_suite_id: id.0,
1573 });
1574 } else {
1575 tracing::warn!(
1576 cipher_suite_id = id.0,
1577 profile = %self.name,
1578 "cipher suite not supported by rustls aws-lc-rs backend, skipping"
1579 );
1580 }
1581 }
1582
1583 if ordered_suites.is_empty() {
1584 return Err(TlsConfigError::NoCipherSuites(self.name.clone()));
1585 }
1586
1587 let group_map: std::collections::HashMap<
1589 u16,
1590 &'static dyn rustls::crypto::SupportedKxGroup,
1591 > = default
1592 .kx_groups
1593 .iter()
1594 .map(|g| (u16::from(g.name()), *g))
1595 .collect();
1596
1597 let mut ordered_groups: Vec<&'static dyn rustls::crypto::SupportedKxGroup> = Vec::new();
1598 for sg in &self.supported_groups {
1599 if let Some(group) = group_map.get(&sg.iana_value()).copied() {
1600 ordered_groups.push(group);
1601 } else if control.strict_supported_groups {
1602 return Err(TlsConfigError::UnsupportedSupportedGroup {
1603 profile: self.name.clone(),
1604 group_id: sg.iana_value(),
1605 });
1606 } else {
1607 tracing::warn!(
1608 group_id = sg.iana_value(),
1609 profile = %self.name,
1610 "key-exchange group not supported by rustls, skipping"
1611 );
1612 }
1613 }
1614
1615 let kx_groups = if ordered_groups.is_empty() && control.fallback_to_provider_groups {
1617 default.kx_groups.clone()
1618 } else if ordered_groups.is_empty() {
1619 return Err(TlsConfigError::NoSupportedGroups(self.name.clone()));
1620 } else {
1621 ordered_groups
1622 };
1623
1624 let provider = rustls::crypto::CryptoProvider {
1626 cipher_suites: ordered_suites,
1627 kx_groups,
1628 ..default
1629 };
1630
1631 let versions: Vec<&'static rustls::SupportedProtocolVersion> = self
1633 .tls_versions
1634 .iter()
1635 .map(|v| match v {
1636 TlsVersion::Tls12 => &rustls::version::TLS12,
1637 TlsVersion::Tls13 => &rustls::version::TLS13,
1638 })
1639 .collect();
1640
1641 let mut root_store = rustls::RootCertStore::empty();
1643 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
1644
1645 let mut config = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
1647 .with_protocol_versions(&versions)?
1648 .with_root_certificates(root_store)
1649 .with_no_client_auth();
1650
1651 config.alpn_protocols = self
1653 .alpn_protocols
1654 .iter()
1655 .map(|p| p.as_str().as_bytes().to_vec())
1656 .collect();
1657
1658 Ok(TlsClientConfig(Arc::new(config)))
1659 }
1660 }
1661}
1662
1663#[cfg(feature = "tls-config")]
1664pub use rustls_config::{TlsClientConfig, TlsConfigError};
1665
1666#[cfg(feature = "tls-config")]
1667pub use rustls_config::TlsControl;
1668
1669#[cfg(feature = "tls-config")]
1676mod reqwest_client {
1677 #[allow(clippy::wildcard_imports)]
1678 use super::*;
1679 use std::sync::Arc;
1680
1681 #[derive(Debug, thiserror::Error)]
1683 #[non_exhaustive]
1684 pub enum TlsClientError {
1685 #[error(transparent)]
1687 TlsConfig(#[from] super::rustls_config::TlsConfigError),
1688
1689 #[error("reqwest client: {0}")]
1691 Reqwest(#[from] reqwest::Error),
1692 }
1693
1694 pub fn default_user_agent(profile: &TlsProfile) -> &'static str {
1710 let name = profile.name.to_ascii_lowercase();
1711 if name.contains("firefox") {
1712 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0"
1713 } else if name.contains("safari") && !name.contains("chrome") {
1714 "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15"
1715 } else if name.contains("edge") {
1716 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
1717 } else {
1718 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
1720 }
1721 }
1722
1723 pub fn profile_for_device(device: &crate::fingerprint::DeviceProfile) -> &'static TlsProfile {
1734 use crate::fingerprint::DeviceProfile;
1735 match device {
1736 DeviceProfile::DesktopWindows | DeviceProfile::MobileAndroid => &CHROME_131,
1737 DeviceProfile::DesktopMac | DeviceProfile::MobileIOS => &SAFARI_18,
1738 DeviceProfile::DesktopLinux => &FIREFOX_133,
1739 }
1740 }
1741
1742 pub fn browser_headers(profile: &TlsProfile) -> reqwest::header::HeaderMap {
1761 use reqwest::header::{
1762 ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, HeaderMap, HeaderValue,
1763 UPGRADE_INSECURE_REQUESTS,
1764 };
1765
1766 let mut map = HeaderMap::new();
1767 let name = profile.name.to_ascii_lowercase();
1768
1769 let is_firefox = name.contains("firefox");
1770 let is_safari = name.contains("safari") && !name.contains("chrome");
1771 let is_chromium = !(is_firefox || is_safari);
1772
1773 let accept = if is_chromium {
1775 "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
1777 } else {
1778 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1779 };
1780
1781 let accept_encoding = "gzip, deflate, br";
1783
1784 let accept_language = "en-US,en;q=0.9";
1788
1789 if is_chromium {
1791 let (brand, version) = if name.contains("edge") {
1792 ("\"Microsoft Edge\";v=\"131\"", "131")
1793 } else {
1794 ("\"Google Chrome\";v=\"131\"", "131")
1795 };
1796
1797 let sec_ch_ua =
1798 format!("{brand}, \"Chromium\";v=\"{version}\", \"Not_A Brand\";v=\"24\"");
1799
1800 if let Ok(v) = HeaderValue::from_str(&sec_ch_ua) {
1803 map.insert("sec-ch-ua", v);
1804 }
1805 map.insert("sec-ch-ua-mobile", HeaderValue::from_static("?0"));
1806 map.insert(
1807 "sec-ch-ua-platform",
1808 HeaderValue::from_static("\"Windows\""),
1809 );
1810 map.insert("sec-fetch-dest", HeaderValue::from_static("document"));
1811 map.insert("sec-fetch-mode", HeaderValue::from_static("navigate"));
1812 map.insert("sec-fetch-site", HeaderValue::from_static("none"));
1813 map.insert("sec-fetch-user", HeaderValue::from_static("?1"));
1814 map.insert(UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1"));
1815 }
1816
1817 if let Ok(v) = HeaderValue::from_str(accept) {
1818 map.insert(ACCEPT, v);
1819 }
1820 map.insert(ACCEPT_ENCODING, HeaderValue::from_static(accept_encoding));
1821 map.insert(ACCEPT_LANGUAGE, HeaderValue::from_static(accept_language));
1822 map.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
1823
1824 map
1825 }
1826
1827 pub fn build_profiled_client(
1853 profile: &TlsProfile,
1854 proxy_url: Option<&str>,
1855 ) -> Result<reqwest::Client, TlsClientError> {
1856 build_profiled_client_with_control(profile, proxy_url, TlsControl::default())
1857 }
1858
1859 pub fn build_profiled_client_preset(
1873 profile: &TlsProfile,
1874 proxy_url: Option<&str>,
1875 ) -> Result<reqwest::Client, TlsClientError> {
1876 build_profiled_client_with_control(profile, proxy_url, TlsControl::for_profile(profile))
1877 }
1878
1879 pub fn build_profiled_client_with_control(
1897 profile: &TlsProfile,
1898 proxy_url: Option<&str>,
1899 control: TlsControl,
1900 ) -> Result<reqwest::Client, TlsClientError> {
1901 let tls_config = profile.to_rustls_config_with_control(control)?;
1902
1903 let rustls_cfg =
1905 Arc::try_unwrap(tls_config.into_inner()).unwrap_or_else(|arc| (*arc).clone());
1906
1907 let mut builder = reqwest::Client::builder()
1908 .use_preconfigured_tls(rustls_cfg)
1909 .user_agent(default_user_agent(profile))
1910 .default_headers(browser_headers(profile))
1911 .cookie_store(true)
1912 .gzip(true)
1913 .brotli(true);
1914
1915 if let Some(url) = proxy_url {
1916 builder = builder.proxy(reqwest::Proxy::all(url)?);
1917 }
1918
1919 Ok(builder.build()?)
1920 }
1921
1922 pub fn build_profiled_client_strict(
1936 profile: &TlsProfile,
1937 proxy_url: Option<&str>,
1938 ) -> Result<reqwest::Client, TlsClientError> {
1939 build_profiled_client_with_control(profile, proxy_url, TlsControl::strict())
1940 }
1941}
1942
1943#[cfg(feature = "tls-config")]
1944pub use reqwest_client::{
1945 TlsClientError, browser_headers, build_profiled_client, build_profiled_client_preset,
1946 build_profiled_client_strict, build_profiled_client_with_control, default_user_agent,
1947 profile_for_device,
1948};
1949
1950#[cfg(test)]
1953#[allow(clippy::panic, clippy::unwrap_used)]
1954mod tests {
1955 use super::*;
1956
1957 #[test]
1958 fn md5_known_vectors() {
1959 assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
1961 assert_eq!(md5_hex(b"a"), "0cc175b9c0f1b6a831c399e269772661");
1962 assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
1963 assert_eq!(
1964 md5_hex(b"message digest"),
1965 "f96b697d7cb7938d525a2f31aaf161d0"
1966 );
1967 }
1968
1969 #[test]
1970 fn chrome_131_ja3_structure() {
1971 let ja3 = CHROME_131.ja3();
1972 assert!(
1976 ja3.raw.starts_with("772,"),
1977 "JA3 raw should start with '772,' but was: {}",
1978 ja3.raw
1979 );
1980 assert_eq!(ja3.raw.matches(',').count(), 4);
1982 assert_eq!(ja3.hash.len(), 32);
1984 assert!(ja3.hash.chars().all(|c| c.is_ascii_hexdigit()));
1985 }
1986
1987 #[test]
1988 fn firefox_133_ja3_differs_from_chrome() {
1989 let chrome_ja3 = CHROME_131.ja3();
1990 let firefox_ja3 = FIREFOX_133.ja3();
1991 assert_ne!(chrome_ja3.hash, firefox_ja3.hash);
1992 assert_ne!(chrome_ja3.raw, firefox_ja3.raw);
1993 }
1994
1995 #[test]
1996 fn safari_18_ja3_is_valid() {
1997 let ja3 = SAFARI_18.ja3();
1998 assert!(ja3.raw.starts_with("772,"));
1999 assert_eq!(ja3.hash.len(), 32);
2000 }
2001
2002 #[test]
2003 fn edge_131_ja3_differs_from_chrome() {
2004 let chrome_ja3 = CHROME_131.ja3();
2006 let edge_ja3 = EDGE_131.ja3();
2007 assert_ne!(chrome_ja3.hash, edge_ja3.hash);
2008 }
2009
2010 #[test]
2011 fn chrome_131_ja4_format() {
2012 let ja4 = CHROME_131.ja4();
2013 assert!(
2015 ja4.fingerprint.starts_with("t13d"),
2016 "JA4 should start with 't13d' but was: {}",
2017 ja4.fingerprint
2018 );
2019 assert_eq!(
2021 ja4.fingerprint.matches('_').count(),
2022 3,
2023 "JA4 should have three underscores: {}",
2024 ja4.fingerprint
2025 );
2026 }
2027
2028 #[test]
2029 fn ja4_firefox_differs_from_chrome() {
2030 let chrome_ja4 = CHROME_131.ja4();
2031 let firefox_ja4 = FIREFOX_133.ja4();
2032 assert_ne!(chrome_ja4.fingerprint, firefox_ja4.fingerprint);
2033 }
2034
2035 #[test]
2036 fn random_weighted_distribution() {
2037 let mut chrome_count = 0u32;
2038 let mut firefox_count = 0u32;
2039 let mut edge_count = 0u32;
2040 let mut safari_count = 0u32;
2041
2042 let total = 10_000u32;
2043 for i in 0..total {
2044 let profile = TlsProfile::random_weighted(u64::from(i));
2045 match profile.name.as_str() {
2046 "Chrome 131" => chrome_count += 1,
2047 "Firefox 133" => firefox_count += 1,
2048 "Edge 131" => edge_count += 1,
2049 "Safari 18" => safari_count += 1,
2050 other => unreachable!("unexpected profile: {other}"),
2051 }
2052 }
2053
2054 assert!(
2056 chrome_count > total * 40 / 100,
2057 "Chrome share too low: {chrome_count}/{total}"
2058 );
2059 assert!(
2061 firefox_count > total * 5 / 100,
2062 "Firefox share too low: {firefox_count}/{total}"
2063 );
2064 assert!(
2066 edge_count > total * 5 / 100,
2067 "Edge share too low: {edge_count}/{total}"
2068 );
2069 assert!(
2071 safari_count > total * 3 / 100,
2072 "Safari share too low: {safari_count}/{total}"
2073 );
2074 }
2075
2076 #[test]
2077 fn serde_roundtrip() {
2078 let profile: &TlsProfile = &CHROME_131;
2079 let json = serde_json::to_string(profile).unwrap();
2080 let deserialized: TlsProfile = serde_json::from_str(&json).unwrap();
2081 assert_eq!(profile, &deserialized);
2082 }
2083
2084 #[test]
2085 fn ja3hash_display() {
2086 let ja3 = CHROME_131.ja3();
2087 assert_eq!(format!("{ja3}"), ja3.hash);
2088 }
2089
2090 #[test]
2091 fn ja4_display() {
2092 let ja4 = CHROME_131.ja4();
2093 assert_eq!(format!("{ja4}"), ja4.fingerprint);
2094 }
2095
2096 #[test]
2097 fn http3_perk_chrome_text_and_hash_are_stable() {
2098 let Some(perk) = CHROME_131.http3_perk() else {
2099 panic!("chrome should have perk");
2100 };
2101 let text = perk.perk_text();
2102 assert_eq!(text, "1:65536;6:262144;7:100;51:1;GREASE|masp");
2103 assert_eq!(perk.perk_hash().len(), 32);
2104 }
2105
2106 #[test]
2107 fn expected_perk_from_user_agent_detects_firefox() {
2108 let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0";
2109 let Some(perk) = expected_http3_perk_from_user_agent(ua) else {
2110 panic!("firefox should resolve");
2111 };
2112 assert_eq!(perk.perk_text(), "1:65536;7:20;727725890:0|mpas");
2113 }
2114
2115 #[test]
2116 fn http3_perk_compare_detects_text_mismatch() {
2117 let Some(perk) = CHROME_131.http3_perk() else {
2118 panic!("chrome should have perk");
2119 };
2120 let cmp = perk.compare(Some("1:65536|masp"), None);
2121 assert!(!cmp.matches);
2122 assert_eq!(cmp.mismatches.len(), 1);
2123 assert!(
2124 cmp.mismatches
2125 .first()
2126 .is_some_and(|mismatch| mismatch.contains("perk_text mismatch"))
2127 );
2128 }
2129
2130 #[test]
2131 fn cipher_suite_display() {
2132 let cs = CipherSuiteId::TLS_AES_128_GCM_SHA256;
2133 assert_eq!(format!("{cs}"), "4865"); }
2135
2136 #[test]
2137 fn tls_version_display() {
2138 assert_eq!(format!("{}", TlsVersion::Tls13), "772");
2139 }
2140
2141 #[test]
2142 fn alpn_protocol_as_str() {
2143 assert_eq!(AlpnProtocol::H2.as_str(), "h2");
2144 assert_eq!(AlpnProtocol::Http11.as_str(), "http/1.1");
2145 }
2146
2147 #[test]
2148 fn supported_group_values() {
2149 assert_eq!(SupportedGroup::X25519.iana_value(), 0x001d);
2150 assert_eq!(SupportedGroup::SecP256r1.iana_value(), 0x0017);
2151 assert_eq!(SupportedGroup::X25519Kyber768.iana_value(), 0x6399);
2152 }
2153
2154 #[test]
2157 fn chrome_131_tls_args_empty() {
2158 let args = chrome_tls_args(&CHROME_131);
2160 assert!(args.is_empty(), "expected no flags, got: {args:?}");
2161 }
2162
2163 #[test]
2164 fn tls12_only_profile_caps_version() {
2165 let profile = TlsProfile {
2166 name: "TLS12-only".to_string(),
2167 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2168 tls_versions: vec![TlsVersion::Tls12],
2169 extensions: vec![],
2170 supported_groups: vec![],
2171 signature_algorithms: vec![],
2172 alpn_protocols: vec![],
2173 };
2174 let args = chrome_tls_args(&profile);
2175 assert_eq!(args, vec!["--ssl-version-max=tls1.2"]);
2176 }
2177
2178 #[test]
2179 fn tls13_only_profile_raises_floor() {
2180 let profile = TlsProfile {
2181 name: "TLS13-only".to_string(),
2182 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2183 tls_versions: vec![TlsVersion::Tls13],
2184 extensions: vec![],
2185 supported_groups: vec![],
2186 signature_algorithms: vec![],
2187 alpn_protocols: vec![],
2188 };
2189 let args = chrome_tls_args(&profile);
2190 assert_eq!(args, vec!["--ssl-version-min=tls1.3"]);
2191 }
2192
2193 #[test]
2194 fn builder_tls_profile_integration() {
2195 let cfg = crate::BrowserConfig::builder()
2196 .tls_profile(&CHROME_131)
2197 .build();
2198 let tls_flags: Vec<_> = cfg
2200 .effective_args()
2201 .into_iter()
2202 .filter(|a| a.starts_with("--ssl-version"))
2203 .collect();
2204 assert!(tls_flags.is_empty(), "unexpected TLS flags: {tls_flags:?}");
2205 }
2206
2207 #[cfg(feature = "tls-config")]
2210 mod rustls_tests {
2211 use super::super::*;
2212
2213 #[test]
2214 fn chrome_131_config_builds_successfully() {
2215 let config = CHROME_131.to_rustls_config().unwrap();
2216 let inner = config.inner();
2218 assert!(
2220 !inner.alpn_protocols.is_empty(),
2221 "ALPN protocols should be set"
2222 );
2223 }
2224
2225 #[test]
2226 #[allow(clippy::indexing_slicing)]
2227 fn alpn_order_matches_profile() {
2228 let config = CHROME_131.to_rustls_config().unwrap();
2229 let alpn = &config.inner().alpn_protocols;
2230 assert_eq!(alpn.len(), 2);
2231 assert_eq!(alpn[0], b"h2");
2232 assert_eq!(alpn[1], b"http/1.1");
2233 }
2234
2235 #[test]
2236 fn all_builtin_profiles_produce_valid_configs() {
2237 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2238 let result = profile.to_rustls_config();
2239 assert!(
2240 result.is_ok(),
2241 "profile '{}' should produce a valid config: {:?}",
2242 profile.name,
2243 result.err()
2244 );
2245 }
2246 }
2247
2248 #[test]
2249 fn unsupported_only_suites_returns_error() {
2250 let profile = TlsProfile {
2251 name: "Bogus".to_string(),
2252 cipher_suites: vec![CipherSuiteId(0xFFFF)],
2253 tls_versions: vec![TlsVersion::Tls13],
2254 extensions: vec![],
2255 supported_groups: vec![],
2256 signature_algorithms: vec![],
2257 alpn_protocols: vec![],
2258 };
2259 let err = profile.to_rustls_config().unwrap_err();
2260 assert!(
2261 err.to_string().contains("no supported cipher suites"),
2262 "expected NoCipherSuites, got: {err}"
2263 );
2264 }
2265
2266 #[test]
2267 fn strict_mode_rejects_unknown_cipher_suite() {
2268 let profile = TlsProfile {
2269 name: "StrictCipherTest".to_string(),
2270 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256, CipherSuiteId(0xFFFF)],
2271 tls_versions: vec![TlsVersion::Tls13],
2272 extensions: vec![],
2273 supported_groups: vec![SupportedGroup::X25519],
2274 signature_algorithms: vec![],
2275 alpn_protocols: vec![],
2276 };
2277
2278 let err = profile
2279 .to_rustls_config_with_control(TlsControl::strict())
2280 .unwrap_err();
2281
2282 match err {
2283 TlsConfigError::UnsupportedCipherSuite {
2284 cipher_suite_id, ..
2285 } => {
2286 assert_eq!(cipher_suite_id, 0xFFFF);
2287 }
2288 other => panic!("expected UnsupportedCipherSuite, got: {other}"),
2289 }
2290 }
2291
2292 #[test]
2293 fn compatible_mode_skips_unknown_cipher_suite() {
2294 let mut profile = (*CHROME_131).clone();
2295 profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2296
2297 let cfg = profile.to_rustls_config_with_control(TlsControl::compatible());
2298 assert!(cfg.is_ok(), "compatible mode should skip unknown suite");
2299 }
2300
2301 #[test]
2302 fn control_for_builtin_profiles_is_strict() {
2303 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2304 let control = TlsControl::for_profile(profile);
2305 assert!(
2306 control.strict_cipher_suites,
2307 "builtin profile '{}' should use strict cipher checking",
2308 profile.name
2309 );
2310 }
2311 }
2312
2313 #[test]
2314 fn control_for_custom_profile_is_compatible() {
2315 let profile = TlsProfile {
2316 name: "Custom Backend".to_string(),
2317 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2318 tls_versions: vec![TlsVersion::Tls13],
2319 extensions: vec![],
2320 supported_groups: vec![SupportedGroup::X25519],
2321 signature_algorithms: vec![],
2322 alpn_protocols: vec![],
2323 };
2324
2325 let control = TlsControl::for_profile(&profile);
2326 assert!(!control.strict_cipher_suites);
2327 assert!(!control.strict_supported_groups);
2328 assert!(control.fallback_to_provider_groups);
2329 }
2330
2331 #[test]
2332 fn strict_all_without_groups_returns_error() {
2333 let profile = TlsProfile {
2334 name: "StrictGroupTest".to_string(),
2335 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2336 tls_versions: vec![TlsVersion::Tls13],
2337 extensions: vec![],
2338 supported_groups: vec![],
2339 signature_algorithms: vec![],
2340 alpn_protocols: vec![],
2341 };
2342
2343 let err = profile
2344 .to_rustls_config_with_control(TlsControl::strict_all())
2345 .unwrap_err();
2346
2347 match err {
2348 TlsConfigError::NoSupportedGroups(name) => {
2349 assert_eq!(name, "StrictGroupTest");
2350 }
2351 other => panic!("expected NoSupportedGroups, got: {other}"),
2352 }
2353 }
2354
2355 #[test]
2356 fn into_arc_conversion() {
2357 let config = CHROME_131.to_rustls_config().unwrap();
2358 let arc: std::sync::Arc<rustls::ClientConfig> = config.into();
2359 assert!(!arc.alpn_protocols.is_empty());
2361 }
2362 }
2363
2364 #[cfg(feature = "tls-config")]
2367 mod reqwest_tests {
2368 use super::super::*;
2369
2370 #[test]
2371 fn build_profiled_client_no_proxy() {
2372 let client = build_profiled_client(&CHROME_131, None);
2373 assert!(
2374 client.is_ok(),
2375 "should build a client without error: {:?}",
2376 client.err()
2377 );
2378 }
2379
2380 #[test]
2381 fn build_profiled_client_all_profiles() {
2382 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2383 let result = build_profiled_client(profile, None);
2384 assert!(
2385 result.is_ok(),
2386 "profile '{}' should produce a valid client: {:?}",
2387 profile.name,
2388 result.err()
2389 );
2390 }
2391 }
2392
2393 #[test]
2394 fn build_profiled_client_strict_no_proxy() {
2395 let client = build_profiled_client_strict(&CHROME_131, None);
2396 assert!(
2397 client.is_ok(),
2398 "strict mode should build for built-in profile: {:?}",
2399 client.err()
2400 );
2401 }
2402
2403 #[test]
2404 fn build_profiled_client_preset_all_profiles() {
2405 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2406 let result = build_profiled_client_preset(profile, None);
2407 assert!(
2408 result.is_ok(),
2409 "preset builder should work for profile '{}': {:?}",
2410 profile.name,
2411 result.err()
2412 );
2413 }
2414 }
2415
2416 #[test]
2417 fn build_profiled_client_with_control_rejects_unknown_cipher_suite() {
2418 let mut profile = (*CHROME_131).clone();
2419 profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2420
2421 let client = build_profiled_client_with_control(&profile, None, TlsControl::strict());
2422
2423 assert!(
2424 client.is_err(),
2425 "strict mode should reject unsupported cipher suite"
2426 );
2427 }
2428
2429 #[test]
2430 fn default_user_agent_matches_browser() {
2431 assert!(default_user_agent(&CHROME_131).contains("Chrome/131"));
2432 assert!(default_user_agent(&FIREFOX_133).contains("Firefox/133"));
2433 assert!(default_user_agent(&SAFARI_18).contains("Safari/605"));
2434 assert!(default_user_agent(&EDGE_131).contains("Edg/131"));
2435 }
2436
2437 #[test]
2438 fn profile_for_device_mapping() {
2439 use crate::fingerprint::DeviceProfile;
2440
2441 assert_eq!(
2442 profile_for_device(&DeviceProfile::DesktopWindows).name,
2443 "Chrome 131"
2444 );
2445 assert_eq!(
2446 profile_for_device(&DeviceProfile::DesktopMac).name,
2447 "Safari 18"
2448 );
2449 assert_eq!(
2450 profile_for_device(&DeviceProfile::DesktopLinux).name,
2451 "Firefox 133"
2452 );
2453 assert_eq!(
2454 profile_for_device(&DeviceProfile::MobileAndroid).name,
2455 "Chrome 131"
2456 );
2457 assert_eq!(
2458 profile_for_device(&DeviceProfile::MobileIOS).name,
2459 "Safari 18"
2460 );
2461 }
2462
2463 #[test]
2464 fn browser_headers_chrome_has_sec_ch_ua() {
2465 let headers = browser_headers(&CHROME_131);
2466 assert!(
2467 headers.contains_key("sec-ch-ua"),
2468 "Chrome profile should have sec-ch-ua"
2469 );
2470 assert!(
2471 headers.contains_key("sec-fetch-dest"),
2472 "Chrome profile should have sec-fetch-dest"
2473 );
2474 let accept = headers.get("accept").unwrap().to_str().unwrap();
2475 assert!(
2476 accept.contains("image/avif"),
2477 "Chrome accept should include avif"
2478 );
2479 }
2480
2481 #[test]
2482 fn browser_headers_firefox_no_sec_ch_ua() {
2483 let headers = browser_headers(&FIREFOX_133);
2484 assert!(
2485 !headers.contains_key("sec-ch-ua"),
2486 "Firefox profile should not have sec-ch-ua"
2487 );
2488 let accept = headers.get("accept").unwrap().to_str().unwrap();
2489 assert!(
2490 accept.contains("text/html"),
2491 "Firefox accept should include text/html"
2492 );
2493 }
2494
2495 #[test]
2496 fn browser_headers_all_profiles_have_accept() {
2497 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2498 let headers = browser_headers(profile);
2499 assert!(
2500 headers.contains_key("accept"),
2501 "profile '{}' must have accept header",
2502 profile.name
2503 );
2504 assert!(
2505 headers.contains_key("accept-encoding"),
2506 "profile '{}' must have accept-encoding",
2507 profile.name
2508 );
2509 assert!(
2510 headers.contains_key("accept-language"),
2511 "profile '{}' must have accept-language",
2512 profile.name
2513 );
2514 }
2515 }
2516
2517 #[test]
2518 fn browser_headers_edge_uses_edge_brand() {
2519 let headers = browser_headers(&EDGE_131);
2520 let sec_ch_ua = headers.get("sec-ch-ua").unwrap().to_str().unwrap();
2521 assert!(
2522 sec_ch_ua.contains("Microsoft Edge"),
2523 "Edge sec-ch-ua should identify Edge: {sec_ch_ua}"
2524 );
2525 }
2526 }
2527}