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 #[must_use]
132 pub const fn iana_value(self) -> u16 {
133 match self {
134 Self::Tls12 => 0x0303,
135 Self::Tls13 => 0x0304,
136 }
137 }
138}
139
140impl fmt::Display for TlsVersion {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 write!(f, "{}", self.iana_value())
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
157pub struct TlsExtensionId(pub u16);
158
159impl TlsExtensionId {
160 pub const SERVER_NAME: Self = Self(0);
162 pub const EXTENDED_MASTER_SECRET: Self = Self(23);
164 pub const ENCRYPT_THEN_MAC: Self = Self(22);
166 pub const SESSION_TICKET: Self = Self(35);
168 pub const SIGNATURE_ALGORITHMS: Self = Self(13);
170 pub const SUPPORTED_VERSIONS: Self = Self(43);
172 pub const PSK_KEY_EXCHANGE_MODES: Self = Self(45);
174 pub const KEY_SHARE: Self = Self(51);
176 pub const SUPPORTED_GROUPS: Self = Self(10);
178 pub const EC_POINT_FORMATS: Self = Self(11);
179 pub const ALPN: Self = Self(16);
180 pub const STATUS_REQUEST: Self = Self(5);
182 pub const SIGNED_CERTIFICATE_TIMESTAMP: Self = Self(18);
184 pub const COMPRESS_CERTIFICATE: Self = Self(27);
186 pub const APPLICATION_SETTINGS: Self = Self(17513);
188 pub const RENEGOTIATION_INFO: Self = Self(0xff01);
190 pub const DELEGATED_CREDENTIALS: Self = Self(34);
192 pub const RECORD_SIZE_LIMIT: Self = Self(28);
194 pub const PADDING: Self = Self(21);
196 pub const PRE_SHARED_KEY: Self = Self(41);
198 pub const POST_HANDSHAKE_AUTH: Self = Self(49);
200}
201
202impl fmt::Display for TlsExtensionId {
203 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204 write!(f, "{}", self.0)
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
219#[non_exhaustive]
220pub enum SupportedGroup {
221 X25519,
223 SecP256r1,
225 SecP384r1,
227 SecP521r1,
229 X25519Kyber768,
231 Ffdhe2048,
233 Ffdhe3072,
235}
236
237impl SupportedGroup {
238 #[must_use]
248 pub const fn iana_value(self) -> u16 {
249 match self {
250 Self::X25519 => 0x001d,
251 Self::SecP256r1 => 0x0017,
252 Self::SecP384r1 => 0x0018,
253 Self::SecP521r1 => 0x0019,
254 Self::X25519Kyber768 => 0x6399,
255 Self::Ffdhe2048 => 0x0100,
256 Self::Ffdhe3072 => 0x0101,
257 }
258 }
259}
260
261impl fmt::Display for SupportedGroup {
262 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263 write!(f, "{}", self.iana_value())
264 }
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
278pub struct SignatureAlgorithm(pub u16);
279
280impl SignatureAlgorithm {
281 pub const ECDSA_SECP256R1_SHA256: Self = Self(0x0403);
283 pub const RSA_PSS_RSAE_SHA256: Self = Self(0x0804);
285 pub const RSA_PKCS1_SHA256: Self = Self(0x0401);
287 pub const ECDSA_SECP384R1_SHA384: Self = Self(0x0503);
289 pub const RSA_PSS_RSAE_SHA384: Self = Self(0x0805);
291 pub const RSA_PKCS1_SHA384: Self = Self(0x0501);
293 pub const RSA_PSS_RSAE_SHA512: Self = Self(0x0806);
295 pub const RSA_PKCS1_SHA512: Self = Self(0x0601);
297 pub const ECDSA_SECP521R1_SHA512: Self = Self(0x0603);
299 pub const RSA_PKCS1_SHA1: Self = Self(0x0201);
301 pub const ECDSA_SHA1: Self = Self(0x0203);
303}
304
305impl fmt::Display for SignatureAlgorithm {
306 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307 write!(f, "{}", self.0)
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
319#[non_exhaustive]
320pub enum AlpnProtocol {
321 H2,
323 Http11,
325}
326
327impl AlpnProtocol {
328 #[must_use]
337 pub const fn as_str(self) -> &'static str {
338 match self {
339 Self::H2 => "h2",
340 Self::Http11 => "http/1.1",
341 }
342 }
343}
344
345impl fmt::Display for AlpnProtocol {
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 f.write_str(self.as_str())
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[non_exhaustive]
369pub struct TlsProfile {
370 pub name: String,
372 pub cipher_suites: Vec<CipherSuiteId>,
374 pub tls_versions: Vec<TlsVersion>,
375 pub extensions: Vec<TlsExtensionId>,
377 pub supported_groups: Vec<SupportedGroup>,
379 pub signature_algorithms: Vec<SignatureAlgorithm>,
381 pub alpn_protocols: Vec<AlpnProtocol>,
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct Ja3Hash {
401 pub raw: String,
402 pub hash: String,
404}
405
406impl fmt::Display for Ja3Hash {
407 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408 f.write_str(&self.hash)
409 }
410}
411
412#[allow(
414 clippy::many_single_char_names,
415 clippy::too_many_lines,
416 clippy::indexing_slicing
417)]
418fn md5_hex(data: &[u8]) -> String {
419 const S: [u32; 64] = [
421 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5,
422 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10,
423 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
424 ];
425
426 const K: [u32; 64] = [
428 0xd76a_a478,
429 0xe8c7_b756,
430 0x2420_70db,
431 0xc1bd_ceee,
432 0xf57c_0faf,
433 0x4787_c62a,
434 0xa830_4613,
435 0xfd46_9501,
436 0x6980_98d8,
437 0x8b44_f7af,
438 0xffff_5bb1,
439 0x895c_d7be,
440 0x6b90_1122,
441 0xfd98_7193,
442 0xa679_438e,
443 0x49b4_0821,
444 0xf61e_2562,
445 0xc040_b340,
446 0x265e_5a51,
447 0xe9b6_c7aa,
448 0xd62f_105d,
449 0x0244_1453,
450 0xd8a1_e681,
451 0xe7d3_fbc8,
452 0x21e1_cde6,
453 0xc337_07d6,
454 0xf4d5_0d87,
455 0x455a_14ed,
456 0xa9e3_e905,
457 0xfcef_a3f8,
458 0x676f_02d9,
459 0x8d2a_4c8a,
460 0xfffa_3942,
461 0x8771_f681,
462 0x6d9d_6122,
463 0xfde5_380c,
464 0xa4be_ea44,
465 0x4bde_cfa9,
466 0xf6bb_4b60,
467 0xbebf_bc70,
468 0x289b_7ec6,
469 0xeaa1_27fa,
470 0xd4ef_3085,
471 0x0488_1d05,
472 0xd9d4_d039,
473 0xe6db_99e5,
474 0x1fa2_7cf8,
475 0xc4ac_5665,
476 0xf429_2244,
477 0x432a_ff97,
478 0xab94_23a7,
479 0xfc93_a039,
480 0x655b_59c3,
481 0x8f0c_cc92,
482 0xffef_f47d,
483 0x8584_5dd1,
484 0x6fa8_7e4f,
485 0xfe2c_e6e0,
486 0xa301_4314,
487 0x4e08_11a1,
488 0xf753_7e82,
489 0xbd3a_f235,
490 0x2ad7_d2bb,
491 0xeb86_d391,
492 ];
493
494 let orig_len_bits = (data.len() as u64).wrapping_mul(8);
496 let mut msg = data.to_vec();
497 msg.push(0x80);
498 while msg.len() % 64 != 56 {
499 msg.push(0);
500 }
501 msg.extend_from_slice(&orig_len_bits.to_le_bytes());
502
503 let mut a0: u32 = 0x6745_2301;
504 let mut b0: u32 = 0xefcd_ab89;
505 let mut c0: u32 = 0x98ba_dcfe;
506 let mut d0: u32 = 0x1032_5476;
507
508 for chunk in msg.chunks_exact(64) {
509 let mut m = [0u32; 16];
510 for (word, quad) in m.iter_mut().zip(chunk.chunks_exact(4)) {
511 if let Ok(bytes) = <[u8; 4]>::try_from(quad) {
513 *word = u32::from_le_bytes(bytes);
514 }
515 }
516
517 let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
518
519 for i in 0..64 {
520 let (f, g) = match i {
521 0..=15 => ((b & c) | ((!b) & d), i),
522 16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
523 32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
524 _ => (c ^ (b | (!d)), (7 * i) % 16),
525 };
526 let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
527 a = d;
528 d = c;
529 c = b;
530 b = b.wrapping_add(f.rotate_left(S[i]));
531 }
532
533 a0 = a0.wrapping_add(a);
534 b0 = b0.wrapping_add(b);
535 c0 = c0.wrapping_add(c);
536 d0 = d0.wrapping_add(d);
537 }
538
539 let digest = [
540 a0.to_le_bytes(),
541 b0.to_le_bytes(),
542 c0.to_le_bytes(),
543 d0.to_le_bytes(),
544 ];
545 let mut hex = String::with_capacity(32);
546 for group in &digest {
547 for &byte in group {
548 use fmt::Write;
549 let _ = write!(hex, "{byte:02x}");
550 }
551 }
552 hex
553}
554
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct Ja4 {
569 pub fingerprint: String,
571}
572
573impl fmt::Display for Ja4 {
574 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
575 f.write_str(&self.fingerprint)
576 }
577}
578
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct Http3Perk {
584 pub settings: Vec<(u64, u64)>,
586 pub pseudo_headers: String,
587 pub has_grease: bool,
588}
589
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
592pub struct Http3PerkComparison {
593 pub matches: bool,
595 pub mismatches: Vec<String>,
597}
598
599const fn is_quic_grease(value: u64) -> bool {
600 let low = value & 0xffff;
601 let a = (low >> 8) & 0xff;
602 let b = low & 0xff;
603 a == b && (a & 0x0f) == 0x0a
604}
605
606impl Http3Perk {
607 #[must_use]
609 pub fn perk_text(&self) -> String {
610 let mut parts: Vec<String> = self
611 .settings
612 .iter()
613 .filter(|(id, _)| !is_quic_grease(*id))
614 .map(|(id, value)| format!("{id}:{value}"))
615 .collect();
616
617 if self.has_grease || self.settings.iter().any(|(id, _)| is_quic_grease(*id)) {
618 parts.push("GREASE".to_string());
619 }
620
621 format!("{}|{}", parts.join(";"), self.pseudo_headers)
622 }
623
624 #[must_use]
626 pub fn perk_hash(&self) -> String {
627 md5_hex(self.perk_text().as_bytes())
628 }
629
630 #[must_use]
632 pub fn compare(
633 &self,
634 observed_text: Option<&str>,
635 observed_hash: Option<&str>,
636 ) -> Http3PerkComparison {
637 let expected_text = self.perk_text();
638 let expected_hash = self.perk_hash();
639
640 let mut mismatches = Vec::new();
641
642 if let Some(text) = observed_text
643 && text != expected_text
644 {
645 mismatches.push(format!(
646 "perk_text mismatch: expected '{expected_text}', observed '{text}'"
647 ));
648 }
649
650 if let Some(hash) = observed_hash
651 && !hash.eq_ignore_ascii_case(&expected_hash)
652 {
653 mismatches.push(format!(
654 "perk_hash mismatch: expected '{expected_hash}', observed '{hash}'"
655 ));
656 }
657
658 Http3PerkComparison {
659 matches: mismatches.is_empty() && (observed_text.is_some() || observed_hash.is_some()),
660 mismatches,
661 }
662 }
663}
664
665#[must_use]
668pub fn expected_http3_perk_from_user_agent(user_agent: &str) -> Option<Http3Perk> {
669 expected_tls_profile_from_user_agent(user_agent).and_then(TlsProfile::http3_perk)
670}
671
672#[must_use]
674pub fn expected_tls_profile_from_user_agent(user_agent: &str) -> Option<&'static TlsProfile> {
675 let ua = user_agent.to_ascii_lowercase();
676
677 if ua.contains("edg/") {
678 return Some(&EDGE_131);
679 }
680
681 if ua.contains("firefox/") {
682 return Some(&FIREFOX_133);
683 }
684
685 if ua.contains("safari/") && !ua.contains("chrome/") && !ua.contains("edg/") {
686 return Some(&SAFARI_18);
687 }
688
689 if ua.contains("chrome/") {
690 return Some(&CHROME_131);
691 }
692
693 None
694}
695
696#[must_use]
697pub fn expected_ja3_from_user_agent(user_agent: &str) -> Option<Ja3Hash> {
698 expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja3)
699}
700
701#[must_use]
702pub fn expected_ja4_from_user_agent(user_agent: &str) -> Option<Ja4> {
703 expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja4)
704}
705
706fn truncate_hex(s: &str, n: usize) -> &str {
710 let end = s.len().min(n);
711 &s[..end]
712}
713
714const GREASE_VALUES: &[u16] = &[
716 0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba,
717 0xcaca, 0xdada, 0xeaea, 0xfafa,
718];
719
720fn is_grease(v: u16) -> bool {
722 GREASE_VALUES.contains(&v)
723}
724
725impl TlsProfile {
726 #[must_use]
741 pub fn ja3(&self) -> Ja3Hash {
742 let tls_ver = self
744 .tls_versions
745 .iter()
746 .map(|v| v.iana_value())
747 .max()
748 .unwrap_or(TlsVersion::Tls12.iana_value());
749
750 let ciphers: Vec<String> = self
752 .cipher_suites
753 .iter()
754 .filter(|c| !is_grease(c.0))
755 .map(|c| c.0.to_string())
756 .collect();
757
758 let extensions: Vec<String> = self
760 .extensions
761 .iter()
762 .filter(|e| !is_grease(e.0))
763 .map(|e| e.0.to_string())
764 .collect();
765
766 let curves: Vec<String> = self
768 .supported_groups
769 .iter()
770 .filter(|g| !is_grease(g.iana_value()))
771 .map(|g| g.iana_value().to_string())
772 .collect();
773
774 let ec_point_formats = "0";
775
776 let raw = format!(
777 "{tls_ver},{},{},{},{ec_point_formats}",
778 ciphers.join("-"),
779 extensions.join("-"),
780 curves.join("-"),
781 );
782
783 let hash = md5_hex(raw.as_bytes());
784 Ja3Hash { raw, hash }
785 }
786
787 #[must_use]
805 pub fn ja4(&self) -> Ja4 {
806 let proto = 't';
807
808 let version = if self.tls_versions.contains(&TlsVersion::Tls13) {
809 "13"
810 } else {
811 "12"
812 };
813
814 let sni = 'd';
816
817 let cipher_count = self
819 .cipher_suites
820 .iter()
821 .filter(|c| !is_grease(c.0))
822 .count()
823 .min(99);
824 let ext_count = self
825 .extensions
826 .iter()
827 .filter(|e| !is_grease(e.0))
828 .count()
829 .min(99);
830
831 let alpn_tag = match self.alpn_protocols.first() {
833 Some(AlpnProtocol::H2) => "h2",
834 Some(AlpnProtocol::Http11) => "h1",
835 None => "00",
836 };
837
838 let section_a = format!("{proto}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn_tag}");
839
840 let mut sorted_ciphers: Vec<u16> = self
843 .cipher_suites
844 .iter()
845 .filter(|c| !is_grease(c.0))
846 .map(|c| c.0)
847 .collect();
848 sorted_ciphers.sort_unstable();
849 let cipher_str: String = sorted_ciphers
850 .iter()
851 .map(|c| format!("{c:04x}"))
852 .collect::<Vec<_>>()
853 .join(",");
854 let cipher_hash_full = md5_hex(cipher_str.as_bytes());
855 let cipher_hash = truncate_hex(&cipher_hash_full, 12);
856
857 let mut sorted_exts: Vec<u16> = self
860 .extensions
861 .iter()
862 .filter(|e| {
863 !is_grease(e.0)
864 && e.0 != TlsExtensionId::SERVER_NAME.0
865 && e.0 != TlsExtensionId::ALPN.0
866 })
867 .map(|e| e.0)
868 .collect();
869 sorted_exts.sort_unstable();
870 let ext_str: String = sorted_exts
871 .iter()
872 .map(|e| format!("{e:04x}"))
873 .collect::<Vec<_>>()
874 .join(",");
875 let ext_hash_full = md5_hex(ext_str.as_bytes());
876 let ext_hash = truncate_hex(&ext_hash_full, 12);
877
878 Ja4 {
879 fingerprint: format!("{section_a}_{cipher_hash}_{ext_hash}"),
880 }
881 }
882
883 #[must_use]
885 pub fn http3_perk(&self) -> Option<Http3Perk> {
886 match self.name.as_str() {
887 name if name.starts_with("Chrome ") || name.starts_with("Edge ") => Some(Http3Perk {
888 settings: vec![(1, 65_536), (6, 262_144), (7, 100), (51, 1)],
889 pseudo_headers: "masp".to_string(),
890 has_grease: true,
891 }),
892 name if name.starts_with("Firefox ") => Some(Http3Perk {
893 settings: vec![(1, 65_536), (7, 20), (727_725_890, 0)],
894 pseudo_headers: "mpas".to_string(),
895 has_grease: false,
896 }),
897 name if name.starts_with("Safari ") => None,
898 _ => None,
899 }
900 }
901
902 #[must_use]
923 pub fn random_weighted(seed: u64) -> &'static Self {
924 let os_roll = rng(seed, 97) % 100;
926
927 let browser_roll = rng(seed, 201) % 100;
929
930 match os_roll {
931 0..=69 | 90..=99 => match browser_roll {
933 0..=64 => &CHROME_131,
934 65..=80 => &EDGE_131,
935 _ => &FIREFOX_133,
936 },
937 _ => match browser_roll {
939 0..=55 => &CHROME_131,
940 56..=91 => &SAFARI_18,
941 _ => &FIREFOX_133,
942 },
943 }
944 }
945}
946
947pub static CHROME_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
963 name: "Chrome 131".to_string(),
964 cipher_suites: vec![
965 CipherSuiteId::TLS_AES_128_GCM_SHA256,
966 CipherSuiteId::TLS_AES_256_GCM_SHA384,
967 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
968 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
969 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
970 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
971 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
972 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
973 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
974 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
975 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
976 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
977 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
978 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
979 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
980 ],
981 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
982 extensions: vec![
983 TlsExtensionId::SERVER_NAME,
984 TlsExtensionId::EXTENDED_MASTER_SECRET,
985 TlsExtensionId::RENEGOTIATION_INFO,
986 TlsExtensionId::SUPPORTED_GROUPS,
987 TlsExtensionId::EC_POINT_FORMATS,
988 TlsExtensionId::SESSION_TICKET,
989 TlsExtensionId::ALPN,
990 TlsExtensionId::STATUS_REQUEST,
991 TlsExtensionId::SIGNATURE_ALGORITHMS,
992 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
993 TlsExtensionId::KEY_SHARE,
994 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
995 TlsExtensionId::SUPPORTED_VERSIONS,
996 TlsExtensionId::COMPRESS_CERTIFICATE,
997 TlsExtensionId::APPLICATION_SETTINGS,
998 TlsExtensionId::PADDING,
999 ],
1000 supported_groups: vec![
1001 SupportedGroup::X25519Kyber768,
1002 SupportedGroup::X25519,
1003 SupportedGroup::SecP256r1,
1004 SupportedGroup::SecP384r1,
1005 ],
1006 signature_algorithms: vec![
1007 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1008 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1009 SignatureAlgorithm::RSA_PKCS1_SHA256,
1010 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1011 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1012 SignatureAlgorithm::RSA_PKCS1_SHA384,
1013 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1014 SignatureAlgorithm::RSA_PKCS1_SHA512,
1015 ],
1016 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1017});
1018
1019pub static FIREFOX_133: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1033 name: "Firefox 133".to_string(),
1034 cipher_suites: vec![
1035 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1036 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1037 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1038 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1039 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1040 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1041 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1042 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1043 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1044 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1045 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1046 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1047 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1048 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1049 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1050 ],
1051 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1052 extensions: vec![
1053 TlsExtensionId::SERVER_NAME,
1054 TlsExtensionId::EXTENDED_MASTER_SECRET,
1055 TlsExtensionId::RENEGOTIATION_INFO,
1056 TlsExtensionId::SUPPORTED_GROUPS,
1057 TlsExtensionId::EC_POINT_FORMATS,
1058 TlsExtensionId::SESSION_TICKET,
1059 TlsExtensionId::ALPN,
1060 TlsExtensionId::STATUS_REQUEST,
1061 TlsExtensionId::DELEGATED_CREDENTIALS,
1062 TlsExtensionId::KEY_SHARE,
1063 TlsExtensionId::SUPPORTED_VERSIONS,
1064 TlsExtensionId::SIGNATURE_ALGORITHMS,
1065 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1066 TlsExtensionId::RECORD_SIZE_LIMIT,
1067 TlsExtensionId::POST_HANDSHAKE_AUTH,
1068 TlsExtensionId::PADDING,
1069 ],
1070 supported_groups: vec![
1071 SupportedGroup::X25519,
1072 SupportedGroup::SecP256r1,
1073 SupportedGroup::SecP384r1,
1074 SupportedGroup::SecP521r1,
1075 SupportedGroup::Ffdhe2048,
1076 SupportedGroup::Ffdhe3072,
1077 ],
1078 signature_algorithms: vec![
1079 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1080 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1081 SignatureAlgorithm::ECDSA_SECP521R1_SHA512,
1082 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1083 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1084 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1085 SignatureAlgorithm::RSA_PKCS1_SHA256,
1086 SignatureAlgorithm::RSA_PKCS1_SHA384,
1087 SignatureAlgorithm::RSA_PKCS1_SHA512,
1088 SignatureAlgorithm::ECDSA_SHA1,
1089 SignatureAlgorithm::RSA_PKCS1_SHA1,
1090 ],
1091 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1092});
1093
1094pub static SAFARI_18: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1107 name: "Safari 18".to_string(),
1108 cipher_suites: vec![
1109 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1110 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1111 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1112 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1113 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1114 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1115 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1116 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1117 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1118 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1119 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1120 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1121 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1122 ],
1123 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1124 extensions: vec![
1125 TlsExtensionId::SERVER_NAME,
1126 TlsExtensionId::EXTENDED_MASTER_SECRET,
1127 TlsExtensionId::RENEGOTIATION_INFO,
1128 TlsExtensionId::SUPPORTED_GROUPS,
1129 TlsExtensionId::EC_POINT_FORMATS,
1130 TlsExtensionId::ALPN,
1131 TlsExtensionId::STATUS_REQUEST,
1132 TlsExtensionId::SIGNATURE_ALGORITHMS,
1133 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1134 TlsExtensionId::KEY_SHARE,
1135 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1136 TlsExtensionId::SUPPORTED_VERSIONS,
1137 TlsExtensionId::PADDING,
1138 ],
1139 supported_groups: vec![
1140 SupportedGroup::X25519,
1141 SupportedGroup::SecP256r1,
1142 SupportedGroup::SecP384r1,
1143 SupportedGroup::SecP521r1,
1144 ],
1145 signature_algorithms: vec![
1146 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1147 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1148 SignatureAlgorithm::RSA_PKCS1_SHA256,
1149 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1150 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1151 SignatureAlgorithm::RSA_PKCS1_SHA384,
1152 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1153 SignatureAlgorithm::RSA_PKCS1_SHA512,
1154 ],
1155 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1156});
1157
1158pub static EDGE_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1170 name: "Edge 131".to_string(),
1171 cipher_suites: vec![
1172 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1173 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1174 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1175 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1176 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1177 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1178 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1179 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1180 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1181 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1182 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1183 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1184 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1185 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1186 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1187 ],
1188 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1189 extensions: vec![
1190 TlsExtensionId::SERVER_NAME,
1191 TlsExtensionId::EXTENDED_MASTER_SECRET,
1192 TlsExtensionId::RENEGOTIATION_INFO,
1193 TlsExtensionId::SUPPORTED_GROUPS,
1194 TlsExtensionId::EC_POINT_FORMATS,
1195 TlsExtensionId::SESSION_TICKET,
1196 TlsExtensionId::ALPN,
1197 TlsExtensionId::STATUS_REQUEST,
1198 TlsExtensionId::SIGNATURE_ALGORITHMS,
1199 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1200 TlsExtensionId::KEY_SHARE,
1201 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1202 TlsExtensionId::SUPPORTED_VERSIONS,
1203 TlsExtensionId::COMPRESS_CERTIFICATE,
1204 TlsExtensionId::PADDING,
1205 ],
1206 supported_groups: vec![
1207 SupportedGroup::X25519Kyber768,
1208 SupportedGroup::X25519,
1209 SupportedGroup::SecP256r1,
1210 SupportedGroup::SecP384r1,
1211 ],
1212 signature_algorithms: vec![
1213 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1214 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1215 SignatureAlgorithm::RSA_PKCS1_SHA256,
1216 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1217 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1218 SignatureAlgorithm::RSA_PKCS1_SHA384,
1219 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1220 SignatureAlgorithm::RSA_PKCS1_SHA512,
1221 ],
1222 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1223});
1224
1225#[must_use]
1251pub fn chrome_tls_args(profile: &TlsProfile) -> Vec<String> {
1252 let has_12 = profile.tls_versions.contains(&TlsVersion::Tls12);
1253 let has_13 = profile.tls_versions.contains(&TlsVersion::Tls13);
1254
1255 let mut args = Vec::new();
1256
1257 match (has_12, has_13) {
1258 (true, false) => {
1259 args.push("--ssl-version-max=tls1.2".to_string());
1260 }
1261 (false, true) => {
1263 args.push("--ssl-version-min=tls1.3".to_string());
1264 }
1265 _ => {}
1267 }
1268
1269 args
1270}
1271
1272#[cfg(feature = "tls-config")]
1278mod rustls_config {
1279 #[allow(clippy::wildcard_imports)]
1280 use super::*;
1281 use std::sync::Arc;
1282
1283 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1298 #[allow(clippy::struct_excessive_bools)] pub struct TlsControl {
1300 pub strict_cipher_suites: bool,
1302 pub strict_supported_groups: bool,
1304 pub fallback_to_provider_groups: bool,
1306 pub allow_legacy_compat_suites: bool,
1308 }
1309
1310 impl Default for TlsControl {
1311 fn default() -> Self {
1312 Self::compatible()
1313 }
1314 }
1315
1316 impl TlsControl {
1317 #[must_use]
1318 pub const fn compatible() -> Self {
1319 Self {
1320 strict_cipher_suites: false,
1321 strict_supported_groups: false,
1322 fallback_to_provider_groups: true,
1323 allow_legacy_compat_suites: true,
1324 }
1325 }
1326
1327 #[must_use]
1329 pub const fn strict() -> Self {
1330 Self {
1331 strict_cipher_suites: true,
1332 strict_supported_groups: false,
1333 fallback_to_provider_groups: true,
1334 allow_legacy_compat_suites: true,
1335 }
1336 }
1337
1338 #[must_use]
1340 pub const fn strict_all() -> Self {
1341 Self {
1342 strict_cipher_suites: true,
1343 strict_supported_groups: true,
1344 fallback_to_provider_groups: false,
1345 allow_legacy_compat_suites: true,
1346 }
1347 }
1348
1349 #[must_use]
1352 pub fn for_profile(profile: &TlsProfile) -> Self {
1353 let name = profile.name.to_ascii_lowercase();
1354 if name.contains("chrome")
1355 || name.contains("edge")
1356 || name.contains("firefox")
1357 || name.contains("safari")
1358 {
1359 Self::strict()
1360 } else {
1361 Self::compatible()
1362 }
1363 }
1364 }
1365
1366 const fn is_legacy_compat_suite(id: u16) -> bool {
1367 matches!(id, 0xc013 | 0xc014 | 0x009c | 0x009d | 0x002f | 0x0035)
1368 }
1369
1370 #[derive(Debug, thiserror::Error)]
1373 #[non_exhaustive]
1374 pub enum TlsConfigError {
1375 #[error("no supported cipher suites in profile '{0}'")]
1377 NoCipherSuites(String),
1378
1379 #[error(
1381 "unsupported cipher suite {cipher_suite_id:#06x} in profile '{profile}' under strict mode"
1382 )]
1383 UnsupportedCipherSuite {
1384 profile: String,
1386 cipher_suite_id: u16,
1388 },
1389
1390 #[error(
1392 "unsupported supported_group {group_id:#06x} in profile '{profile}' under strict mode"
1393 )]
1394 UnsupportedSupportedGroup {
1395 profile: String,
1397 group_id: u16,
1399 },
1400
1401 #[error("no supported key-exchange groups in profile '{0}'")]
1403 NoSupportedGroups(String),
1404
1405 #[error("rustls configuration: {0}")]
1406 Rustls(#[from] rustls::Error),
1407 }
1408
1409 #[derive(Debug, Clone)]
1413 pub struct TlsClientConfig(Arc<rustls::ClientConfig>);
1414
1415 impl TlsClientConfig {
1416 #[must_use]
1418 pub fn inner(&self) -> &rustls::ClientConfig {
1419 &self.0
1420 }
1421
1422 #[must_use]
1423 pub fn into_inner(self) -> Arc<rustls::ClientConfig> {
1424 self.0
1425 }
1426 }
1427
1428 impl From<TlsClientConfig> for Arc<rustls::ClientConfig> {
1429 fn from(cfg: TlsClientConfig) -> Self {
1430 cfg.0
1431 }
1432 }
1433
1434 impl TlsProfile {
1435 pub fn to_rustls_config(&self) -> Result<TlsClientConfig, TlsConfigError> {
1453 self.to_rustls_config_with_control(TlsControl::default())
1454 }
1455
1456 pub fn to_rustls_config_with_control(
1485 &self,
1486 control: TlsControl,
1487 ) -> Result<TlsClientConfig, TlsConfigError> {
1488 let default = rustls::crypto::aws_lc_rs::default_provider();
1489
1490 let suite_map: std::collections::HashMap<u16, rustls::SupportedCipherSuite> = default
1492 .cipher_suites
1493 .iter()
1494 .map(|cs| (u16::from(cs.suite()), *cs))
1495 .collect();
1496
1497 let mut ordered_suites: Vec<rustls::SupportedCipherSuite> = Vec::new();
1498 for id in &self.cipher_suites {
1499 if let Some(cs) = suite_map.get(&id.0).copied() {
1500 ordered_suites.push(cs);
1501 } else if control.allow_legacy_compat_suites && is_legacy_compat_suite(id.0) {
1502 tracing::warn!(
1503 cipher_suite_id = id.0,
1504 profile = %self.name,
1505 "legacy profile suite has no rustls equivalent, skipping"
1506 );
1507 } else if control.strict_cipher_suites {
1508 return Err(TlsConfigError::UnsupportedCipherSuite {
1509 profile: self.name.clone(),
1510 cipher_suite_id: id.0,
1511 });
1512 } else {
1513 tracing::warn!(
1514 cipher_suite_id = id.0,
1515 profile = %self.name,
1516 "cipher suite not supported by rustls aws-lc-rs backend, skipping"
1517 );
1518 }
1519 }
1520
1521 if ordered_suites.is_empty() {
1522 return Err(TlsConfigError::NoCipherSuites(self.name.clone()));
1523 }
1524
1525 let group_map: std::collections::HashMap<
1527 u16,
1528 &'static dyn rustls::crypto::SupportedKxGroup,
1529 > = default
1530 .kx_groups
1531 .iter()
1532 .map(|g| (u16::from(g.name()), *g))
1533 .collect();
1534
1535 let mut ordered_groups: Vec<&'static dyn rustls::crypto::SupportedKxGroup> = Vec::new();
1536 for sg in &self.supported_groups {
1537 if let Some(group) = group_map.get(&sg.iana_value()).copied() {
1538 ordered_groups.push(group);
1539 } else if control.strict_supported_groups {
1540 return Err(TlsConfigError::UnsupportedSupportedGroup {
1541 profile: self.name.clone(),
1542 group_id: sg.iana_value(),
1543 });
1544 } else {
1545 tracing::warn!(
1546 group_id = sg.iana_value(),
1547 profile = %self.name,
1548 "key-exchange group not supported by rustls, skipping"
1549 );
1550 }
1551 }
1552
1553 let kx_groups = if ordered_groups.is_empty() && control.fallback_to_provider_groups {
1554 default.kx_groups.clone()
1555 } else if ordered_groups.is_empty() {
1556 return Err(TlsConfigError::NoSupportedGroups(self.name.clone()));
1557 } else {
1558 ordered_groups
1559 };
1560
1561 let provider = rustls::crypto::CryptoProvider {
1562 cipher_suites: ordered_suites,
1563 kx_groups,
1564 ..default
1565 };
1566
1567 let versions: Vec<&'static rustls::SupportedProtocolVersion> = self
1569 .tls_versions
1570 .iter()
1571 .map(|v| match v {
1572 TlsVersion::Tls12 => &rustls::version::TLS12,
1573 TlsVersion::Tls13 => &rustls::version::TLS13,
1574 })
1575 .collect();
1576
1577 let mut root_store = rustls::RootCertStore::empty();
1578 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
1579
1580 let mut config = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
1582 .with_protocol_versions(&versions)?
1583 .with_root_certificates(root_store)
1584 .with_no_client_auth();
1585
1586 config.alpn_protocols = self
1588 .alpn_protocols
1589 .iter()
1590 .map(|p| p.as_str().as_bytes().to_vec())
1591 .collect();
1592
1593 Ok(TlsClientConfig(Arc::new(config)))
1594 }
1595 }
1596}
1597
1598#[cfg(feature = "tls-config")]
1599pub use rustls_config::{TlsClientConfig, TlsConfigError};
1600
1601#[cfg(feature = "tls-config")]
1602pub use rustls_config::TlsControl;
1603
1604#[cfg(feature = "tls-config")]
1611mod reqwest_client {
1612 #[allow(clippy::wildcard_imports)]
1613 use super::*;
1614 use std::sync::Arc;
1615
1616 #[derive(Debug, thiserror::Error)]
1618 #[non_exhaustive]
1619 pub enum TlsClientError {
1620 #[error(transparent)]
1621 TlsConfig(#[from] super::rustls_config::TlsConfigError),
1622
1623 #[error("reqwest client: {0}")]
1625 Reqwest(#[from] reqwest::Error),
1626 }
1627
1628 #[must_use]
1644 pub fn default_user_agent(profile: &TlsProfile) -> &'static str {
1645 let name = profile.name.to_ascii_lowercase();
1646 if name.contains("firefox") {
1647 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0"
1648 } else if name.contains("safari") && !name.contains("chrome") {
1649 "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15"
1650 } else if name.contains("edge") {
1651 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
1652 } else {
1653 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
1655 }
1656 }
1657
1658 #[must_use]
1666 pub fn profile_for_device(device: &crate::fingerprint::DeviceProfile) -> &'static TlsProfile {
1667 use crate::fingerprint::DeviceProfile;
1668 match device {
1669 DeviceProfile::DesktopWindows | DeviceProfile::MobileAndroid => &CHROME_131,
1670 DeviceProfile::DesktopMac | DeviceProfile::MobileIOS => &SAFARI_18,
1671 DeviceProfile::DesktopLinux => &FIREFOX_133,
1672 }
1673 }
1674
1675 pub fn browser_headers(profile: &TlsProfile) -> reqwest::header::HeaderMap {
1693 use reqwest::header::{
1694 ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, HeaderMap, HeaderValue,
1695 UPGRADE_INSECURE_REQUESTS,
1696 };
1697
1698 let mut map = HeaderMap::new();
1699 let name = profile.name.to_ascii_lowercase();
1700
1701 let is_firefox = name.contains("firefox");
1702 let is_safari = name.contains("safari") && !name.contains("chrome");
1703 let is_chromium = !(is_firefox || is_safari);
1704
1705 let accept = if is_chromium {
1707 "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
1709 } else {
1710 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1711 };
1712
1713 let accept_encoding = "gzip, deflate, br";
1715
1716 let accept_language = "en-US,en;q=0.9";
1720
1721 if is_chromium {
1723 let (brand, version) = if name.contains("edge") {
1724 ("\"Microsoft Edge\";v=\"131\"", "131")
1725 } else {
1726 ("\"Google Chrome\";v=\"131\"", "131")
1727 };
1728
1729 let sec_ch_ua =
1730 format!("{brand}, \"Chromium\";v=\"{version}\", \"Not_A Brand\";v=\"24\"");
1731
1732 if let Ok(v) = HeaderValue::from_str(&sec_ch_ua) {
1735 map.insert("sec-ch-ua", v);
1736 }
1737 map.insert("sec-ch-ua-mobile", HeaderValue::from_static("?0"));
1738 map.insert(
1739 "sec-ch-ua-platform",
1740 HeaderValue::from_static("\"Windows\""),
1741 );
1742 map.insert("sec-fetch-dest", HeaderValue::from_static("document"));
1743 map.insert("sec-fetch-mode", HeaderValue::from_static("navigate"));
1744 map.insert("sec-fetch-site", HeaderValue::from_static("none"));
1745 map.insert("sec-fetch-user", HeaderValue::from_static("?1"));
1746 map.insert(UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1"));
1747 }
1748
1749 if let Ok(v) = HeaderValue::from_str(accept) {
1750 map.insert(ACCEPT, v);
1751 }
1752 map.insert(ACCEPT_ENCODING, HeaderValue::from_static(accept_encoding));
1753 map.insert(ACCEPT_LANGUAGE, HeaderValue::from_static(accept_language));
1754 map.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
1755
1756 map
1757 }
1758
1759 pub fn build_profiled_client(
1779 profile: &TlsProfile,
1780 proxy_url: Option<&str>,
1781 ) -> Result<reqwest::Client, TlsClientError> {
1782 build_profiled_client_with_control(profile, proxy_url, TlsControl::default())
1783 }
1784
1785 pub fn build_profiled_client_preset(
1806 profile: &TlsProfile,
1807 proxy_url: Option<&str>,
1808 ) -> Result<reqwest::Client, TlsClientError> {
1809 build_profiled_client_with_control(profile, proxy_url, TlsControl::for_profile(profile))
1810 }
1811
1812 pub fn build_profiled_client_with_control(
1837 profile: &TlsProfile,
1838 proxy_url: Option<&str>,
1839 control: TlsControl,
1840 ) -> Result<reqwest::Client, TlsClientError> {
1841 let tls_config = profile.to_rustls_config_with_control(control)?;
1842
1843 let rustls_cfg =
1844 Arc::try_unwrap(tls_config.into_inner()).unwrap_or_else(|arc| (*arc).clone());
1845
1846 let mut builder = reqwest::Client::builder()
1847 .use_preconfigured_tls(rustls_cfg)
1848 .user_agent(default_user_agent(profile))
1849 .default_headers(browser_headers(profile))
1850 .cookie_store(true)
1851 .gzip(true)
1852 .brotli(true);
1853
1854 if let Some(url) = proxy_url {
1855 builder = builder.proxy(reqwest::Proxy::all(url)?);
1856 }
1857
1858 Ok(builder.build()?)
1859 }
1860
1861 pub fn build_profiled_client_strict(
1883 profile: &TlsProfile,
1884 proxy_url: Option<&str>,
1885 ) -> Result<reqwest::Client, TlsClientError> {
1886 build_profiled_client_with_control(profile, proxy_url, TlsControl::strict())
1887 }
1888}
1889
1890#[cfg(feature = "tls-config")]
1891pub use reqwest_client::{
1892 TlsClientError, browser_headers, build_profiled_client, build_profiled_client_preset,
1893 build_profiled_client_strict, build_profiled_client_with_control, default_user_agent,
1894 profile_for_device,
1895};
1896
1897#[cfg(test)]
1900#[allow(clippy::panic, clippy::unwrap_used)]
1901mod tests {
1902 use super::*;
1903
1904 #[test]
1905 fn md5_known_vectors() {
1906 assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
1907 assert_eq!(md5_hex(b"a"), "0cc175b9c0f1b6a831c399e269772661");
1908 assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
1909 assert_eq!(
1910 md5_hex(b"message digest"),
1911 "f96b697d7cb7938d525a2f31aaf161d0"
1912 );
1913 }
1914
1915 #[test]
1916 fn chrome_131_ja3_structure() {
1917 let ja3 = CHROME_131.ja3();
1918 assert!(
1922 ja3.raw.starts_with("772,"),
1923 "JA3 raw should start with '772,' but was: {}",
1924 ja3.raw
1925 );
1926 assert_eq!(ja3.raw.matches(',').count(), 4);
1928 assert_eq!(ja3.hash.len(), 32);
1930 assert!(ja3.hash.chars().all(|c| c.is_ascii_hexdigit()));
1931 }
1932
1933 #[test]
1934 fn firefox_133_ja3_differs_from_chrome() {
1935 let chrome_ja3 = CHROME_131.ja3();
1936 let firefox_ja3 = FIREFOX_133.ja3();
1937 assert_ne!(chrome_ja3.hash, firefox_ja3.hash);
1938 assert_ne!(chrome_ja3.raw, firefox_ja3.raw);
1939 }
1940
1941 #[test]
1942 fn safari_18_ja3_is_valid() {
1943 let ja3 = SAFARI_18.ja3();
1944 assert!(ja3.raw.starts_with("772,"));
1945 assert_eq!(ja3.hash.len(), 32);
1946 }
1947
1948 #[test]
1949 fn edge_131_ja3_differs_from_chrome() {
1950 let chrome_ja3 = CHROME_131.ja3();
1951 let edge_ja3 = EDGE_131.ja3();
1952 assert_ne!(chrome_ja3.hash, edge_ja3.hash);
1953 }
1954
1955 #[test]
1956 fn chrome_131_ja4_format() {
1957 let ja4 = CHROME_131.ja4();
1958 assert!(
1960 ja4.fingerprint.starts_with("t13d"),
1961 "JA4 should start with 't13d' but was: {}",
1962 ja4.fingerprint
1963 );
1964 assert_eq!(
1966 ja4.fingerprint.matches('_').count(),
1967 3,
1968 "JA4 should have three separators: {}",
1969 ja4.fingerprint
1970 );
1971 }
1972
1973 #[test]
1974 fn ja4_firefox_differs_from_chrome() {
1975 let chrome_ja4 = CHROME_131.ja4();
1976 let firefox_ja4 = FIREFOX_133.ja4();
1977 assert_ne!(chrome_ja4.fingerprint, firefox_ja4.fingerprint);
1978 }
1979
1980 #[test]
1981 fn random_weighted_distribution() {
1982 let mut chrome_count = 0u32;
1983 let mut firefox_count = 0u32;
1984 let mut edge_count = 0u32;
1985 let mut safari_count = 0u32;
1986
1987 let total = 10_000u32;
1988 for i in 0..total {
1989 let profile = TlsProfile::random_weighted(u64::from(i));
1990 match profile.name.as_str() {
1991 "Chrome 131" => chrome_count += 1,
1992 "Firefox 133" => firefox_count += 1,
1993 "Edge 131" => edge_count += 1,
1994 "Safari 18" => safari_count += 1,
1995 other => unreachable!("unexpected profile: {other}"),
1996 }
1997 }
1998
1999 assert!(
2001 chrome_count > total * 40 / 100,
2002 "Chrome share too low: {chrome_count}/{total}"
2003 );
2004 assert!(
2006 firefox_count > total * 5 / 100,
2007 "Firefox share too low: {firefox_count}/{total}"
2008 );
2009 assert!(
2011 edge_count > total * 5 / 100,
2012 "Edge share too low: {edge_count}/{total}"
2013 );
2014 assert!(
2016 safari_count > total * 3 / 100,
2017 "Safari share too low: {safari_count}/{total}"
2018 );
2019 }
2020
2021 #[test]
2022 fn serde_roundtrip() {
2023 let profile: &TlsProfile = &CHROME_131;
2024 let json = serde_json::to_string(profile).unwrap();
2025 let deserialized: TlsProfile = serde_json::from_str(&json).unwrap();
2026 assert_eq!(profile, &deserialized);
2027 }
2028
2029 #[test]
2030 fn ja3hash_display() {
2031 let ja3 = CHROME_131.ja3();
2032 assert_eq!(format!("{ja3}"), ja3.hash);
2033 }
2034
2035 #[test]
2036 fn ja4_display() {
2037 let ja4 = CHROME_131.ja4();
2038 assert_eq!(format!("{ja4}"), ja4.fingerprint);
2039 }
2040
2041 #[test]
2042 fn http3_perk_chrome_text_and_hash_are_stable() {
2043 let Some(perk) = CHROME_131.http3_perk() else {
2044 panic!("chrome should have perk");
2045 };
2046 let text = perk.perk_text();
2047 assert_eq!(text, "1:65536;6:262144;7:100;51:1;GREASE|masp");
2048 assert_eq!(perk.perk_hash().len(), 32);
2049 }
2050
2051 #[test]
2052 fn expected_perk_from_user_agent_detects_firefox() {
2053 let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0";
2054 let Some(perk) = expected_http3_perk_from_user_agent(ua) else {
2055 panic!("firefox should resolve");
2056 };
2057 assert_eq!(perk.perk_text(), "1:65536;7:20;727725890:0|mpas");
2058 }
2059
2060 #[test]
2061 fn http3_perk_compare_detects_text_mismatch() {
2062 let Some(perk) = CHROME_131.http3_perk() else {
2063 panic!("chrome should have perk");
2064 };
2065 let cmp = perk.compare(Some("1:65536|masp"), None);
2066 assert!(!cmp.matches);
2067 assert_eq!(cmp.mismatches.len(), 1);
2068 assert!(
2069 cmp.mismatches
2070 .first()
2071 .is_some_and(|mismatch| mismatch.contains("perk_text mismatch"))
2072 );
2073 }
2074
2075 #[test]
2076 fn cipher_suite_display() {
2077 let cs = CipherSuiteId::TLS_AES_128_GCM_SHA256;
2078 assert_eq!(format!("{cs}"), "4865"); }
2080
2081 #[test]
2082 fn tls_version_display() {
2083 assert_eq!(format!("{}", TlsVersion::Tls13), "772");
2084 }
2085
2086 #[test]
2087 fn alpn_protocol_as_str() {
2088 assert_eq!(AlpnProtocol::H2.as_str(), "h2");
2089 assert_eq!(AlpnProtocol::Http11.as_str(), "http/1.1");
2090 }
2091
2092 #[test]
2093 fn supported_group_values() {
2094 assert_eq!(SupportedGroup::X25519.iana_value(), 0x001d);
2095 assert_eq!(SupportedGroup::SecP256r1.iana_value(), 0x0017);
2096 assert_eq!(SupportedGroup::X25519Kyber768.iana_value(), 0x6399);
2097 }
2098
2099 #[test]
2102 fn chrome_131_tls_args_empty() {
2103 let args = chrome_tls_args(&CHROME_131);
2105 assert!(args.is_empty(), "expected no flags, got: {args:?}");
2106 }
2107
2108 #[test]
2109 fn tls12_only_profile_caps_version() {
2110 let profile = TlsProfile {
2111 name: "TLS12-only".to_string(),
2112 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2113 tls_versions: vec![TlsVersion::Tls12],
2114 extensions: vec![],
2115 supported_groups: vec![],
2116 signature_algorithms: vec![],
2117 alpn_protocols: vec![],
2118 };
2119 let args = chrome_tls_args(&profile);
2120 assert_eq!(args, vec!["--ssl-version-max=tls1.2"]);
2121 }
2122
2123 #[test]
2124 fn tls13_only_profile_raises_floor() {
2125 let profile = TlsProfile {
2126 name: "TLS13-only".to_string(),
2127 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2128 tls_versions: vec![TlsVersion::Tls13],
2129 extensions: vec![],
2130 supported_groups: vec![],
2131 signature_algorithms: vec![],
2132 alpn_protocols: vec![],
2133 };
2134 let args = chrome_tls_args(&profile);
2135 assert_eq!(args, vec!["--ssl-version-min=tls1.3"]);
2136 }
2137
2138 #[test]
2139 fn builder_tls_profile_integration() {
2140 let cfg = crate::BrowserConfig::builder()
2141 .tls_profile(&CHROME_131)
2142 .build();
2143 let tls_flags: Vec<_> = cfg
2145 .effective_args()
2146 .into_iter()
2147 .filter(|a| a.starts_with("--ssl-version"))
2148 .collect();
2149 assert!(tls_flags.is_empty(), "unexpected TLS flags: {tls_flags:?}");
2150 }
2151
2152 #[cfg(feature = "tls-config")]
2155 mod rustls_tests {
2156 use super::super::*;
2157
2158 #[test]
2159 fn chrome_131_config_builds_successfully() {
2160 let config = CHROME_131.to_rustls_config().unwrap();
2161 let inner = config.inner();
2163 assert!(
2165 !inner.alpn_protocols.is_empty(),
2166 "ALPN protocols should be set"
2167 );
2168 }
2169
2170 #[test]
2171 #[allow(clippy::indexing_slicing)]
2172 fn alpn_order_matches_profile() {
2173 let config = CHROME_131.to_rustls_config().unwrap();
2174 let alpn = &config.inner().alpn_protocols;
2175 assert_eq!(alpn.len(), 2);
2176 assert_eq!(alpn[0], b"h2");
2177 assert_eq!(alpn[1], b"http/1.1");
2178 }
2179
2180 #[test]
2181 fn all_builtin_profiles_produce_valid_configs() {
2182 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2183 let result = profile.to_rustls_config();
2184 assert!(
2185 result.is_ok(),
2186 "profile '{}' should produce a valid config: {:?}",
2187 profile.name,
2188 result.err()
2189 );
2190 }
2191 }
2192
2193 #[test]
2194 fn unsupported_only_suites_returns_error() {
2195 let profile = TlsProfile {
2196 name: "Bogus".to_string(),
2197 cipher_suites: vec![CipherSuiteId(0xFFFF)],
2198 tls_versions: vec![TlsVersion::Tls13],
2199 extensions: vec![],
2200 supported_groups: vec![],
2201 signature_algorithms: vec![],
2202 alpn_protocols: vec![],
2203 };
2204 let err = profile.to_rustls_config().unwrap_err();
2205 assert!(
2206 err.to_string().contains("no supported cipher suites"),
2207 "expected NoCipherSuites, got: {err}"
2208 );
2209 }
2210
2211 #[test]
2212 fn strict_mode_rejects_unknown_cipher_suite() {
2213 let profile = TlsProfile {
2214 name: "StrictCipherTest".to_string(),
2215 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256, CipherSuiteId(0xFFFF)],
2216 tls_versions: vec![TlsVersion::Tls13],
2217 extensions: vec![],
2218 supported_groups: vec![SupportedGroup::X25519],
2219 signature_algorithms: vec![],
2220 alpn_protocols: vec![],
2221 };
2222
2223 let err = profile
2224 .to_rustls_config_with_control(TlsControl::strict())
2225 .unwrap_err();
2226
2227 match err {
2228 TlsConfigError::UnsupportedCipherSuite {
2229 cipher_suite_id, ..
2230 } => {
2231 assert_eq!(cipher_suite_id, 0xFFFF);
2232 }
2233 other => panic!("expected UnsupportedCipherSuite, got: {other}"),
2234 }
2235 }
2236
2237 #[test]
2238 fn compatible_mode_skips_unknown_cipher_suite() {
2239 let mut profile = (*CHROME_131).clone();
2240 profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2241
2242 let cfg = profile.to_rustls_config_with_control(TlsControl::compatible());
2243 assert!(cfg.is_ok(), "compatible mode should skip unknown suite");
2244 }
2245
2246 #[test]
2247 fn control_for_builtin_profiles_is_strict() {
2248 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2249 let control = TlsControl::for_profile(profile);
2250 assert!(
2251 control.strict_cipher_suites,
2252 "builtin profile '{}' should use strict cipher checking",
2253 profile.name
2254 );
2255 }
2256 }
2257
2258 #[test]
2259 fn control_for_custom_profile_is_compatible() {
2260 let profile = TlsProfile {
2261 name: "Custom Backend".to_string(),
2262 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2263 tls_versions: vec![TlsVersion::Tls13],
2264 extensions: vec![],
2265 supported_groups: vec![SupportedGroup::X25519],
2266 signature_algorithms: vec![],
2267 alpn_protocols: vec![],
2268 };
2269
2270 let control = TlsControl::for_profile(&profile);
2271 assert!(!control.strict_cipher_suites);
2272 assert!(!control.strict_supported_groups);
2273 assert!(control.fallback_to_provider_groups);
2274 }
2275
2276 #[test]
2277 fn strict_all_without_groups_returns_error() {
2278 let profile = TlsProfile {
2279 name: "StrictGroupTest".to_string(),
2280 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2281 tls_versions: vec![TlsVersion::Tls13],
2282 extensions: vec![],
2283 supported_groups: vec![],
2284 signature_algorithms: vec![],
2285 alpn_protocols: vec![],
2286 };
2287
2288 let err = profile
2289 .to_rustls_config_with_control(TlsControl::strict_all())
2290 .unwrap_err();
2291
2292 match err {
2293 TlsConfigError::NoSupportedGroups(name) => {
2294 assert_eq!(name, "StrictGroupTest");
2295 }
2296 other => panic!("expected NoSupportedGroups, got: {other}"),
2297 }
2298 }
2299
2300 #[test]
2301 fn into_arc_conversion() {
2302 let config = CHROME_131.to_rustls_config().unwrap();
2303 let arc: std::sync::Arc<rustls::ClientConfig> = config.into();
2304 assert!(!arc.alpn_protocols.is_empty());
2306 }
2307 }
2308
2309 #[cfg(feature = "tls-config")]
2312 mod reqwest_tests {
2313 use super::super::*;
2314
2315 #[test]
2316 fn build_profiled_client_no_proxy() {
2317 let client = build_profiled_client(&CHROME_131, None);
2318 assert!(
2319 client.is_ok(),
2320 "should build a client without error: {:?}",
2321 client.err()
2322 );
2323 }
2324
2325 #[test]
2326 fn build_profiled_client_all_profiles() {
2327 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2328 let result = build_profiled_client(profile, None);
2329 assert!(
2330 result.is_ok(),
2331 "profile '{}' should produce a valid client: {:?}",
2332 profile.name,
2333 result.err()
2334 );
2335 }
2336 }
2337
2338 #[test]
2339 fn build_profiled_client_strict_no_proxy() {
2340 let client = build_profiled_client_strict(&CHROME_131, None);
2341 assert!(
2342 client.is_ok(),
2343 "strict mode should build for built-in profile: {:?}",
2344 client.err()
2345 );
2346 }
2347
2348 #[test]
2349 fn build_profiled_client_preset_all_profiles() {
2350 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2351 let result = build_profiled_client_preset(profile, None);
2352 assert!(
2353 result.is_ok(),
2354 "preset builder should work for profile '{}': {:?}",
2355 profile.name,
2356 result.err()
2357 );
2358 }
2359 }
2360
2361 #[test]
2362 fn build_profiled_client_with_control_rejects_unknown_cipher_suite() {
2363 let mut profile = (*CHROME_131).clone();
2364 profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2365
2366 let client = build_profiled_client_with_control(&profile, None, TlsControl::strict());
2367
2368 assert!(
2369 client.is_err(),
2370 "strict mode should reject unsupported cipher suite"
2371 );
2372 }
2373
2374 #[test]
2375 fn default_user_agent_matches_browser() {
2376 assert!(default_user_agent(&CHROME_131).contains("Chrome/131"));
2377 assert!(default_user_agent(&FIREFOX_133).contains("Firefox/133"));
2378 assert!(default_user_agent(&SAFARI_18).contains("Safari/605"));
2379 assert!(default_user_agent(&EDGE_131).contains("Edg/131"));
2380 }
2381
2382 #[test]
2383 fn profile_for_device_mapping() {
2384 use crate::fingerprint::DeviceProfile;
2385
2386 assert_eq!(
2387 profile_for_device(&DeviceProfile::DesktopWindows).name,
2388 "Chrome 131"
2389 );
2390 assert_eq!(
2391 profile_for_device(&DeviceProfile::DesktopMac).name,
2392 "Safari 18"
2393 );
2394 assert_eq!(
2395 profile_for_device(&DeviceProfile::DesktopLinux).name,
2396 "Firefox 133"
2397 );
2398 assert_eq!(
2399 profile_for_device(&DeviceProfile::MobileAndroid).name,
2400 "Chrome 131"
2401 );
2402 assert_eq!(
2403 profile_for_device(&DeviceProfile::MobileIOS).name,
2404 "Safari 18"
2405 );
2406 }
2407
2408 #[test]
2409 fn browser_headers_chrome_has_sec_ch_ua() {
2410 let headers = browser_headers(&CHROME_131);
2411 assert!(
2412 headers.contains_key("sec-ch-ua"),
2413 "Chrome profile should have sec-ch-ua"
2414 );
2415 assert!(
2416 headers.contains_key("sec-fetch-dest"),
2417 "Chrome profile should have sec-fetch-dest"
2418 );
2419 let accept = headers.get("accept").unwrap().to_str().unwrap();
2420 assert!(
2421 accept.contains("image/avif"),
2422 "Chrome accept should include avif"
2423 );
2424 }
2425
2426 #[test]
2427 fn browser_headers_firefox_no_sec_ch_ua() {
2428 let headers = browser_headers(&FIREFOX_133);
2429 assert!(
2430 !headers.contains_key("sec-ch-ua"),
2431 "Firefox profile should not have sec-ch-ua"
2432 );
2433 let accept = headers.get("accept").unwrap().to_str().unwrap();
2434 assert!(
2435 accept.contains("text/html"),
2436 "Firefox accept should include text/html"
2437 );
2438 }
2439
2440 #[test]
2441 fn browser_headers_all_profiles_have_accept() {
2442 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2443 let headers = browser_headers(profile);
2444 assert!(
2445 headers.contains_key("accept"),
2446 "profile '{}' must have accept header",
2447 profile.name
2448 );
2449 assert!(
2450 headers.contains_key("accept-encoding"),
2451 "profile '{}' must have accept-encoding",
2452 profile.name
2453 );
2454 assert!(
2455 headers.contains_key("accept-language"),
2456 "profile '{}' must have accept-language",
2457 profile.name
2458 );
2459 }
2460 }
2461
2462 #[test]
2463 fn browser_headers_edge_uses_edge_brand() {
2464 let headers = browser_headers(&EDGE_131);
2465 let sec_ch_ua = headers.get("sec-ch-ua").unwrap().to_str().unwrap();
2466 assert!(
2467 sec_ch_ua.contains("Microsoft Edge"),
2468 "Edge sec-ch-ua should identify Edge: {sec_ch_ua}"
2469 );
2470 }
2471 }
2472}
2473
2474#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2486#[non_exhaustive]
2487pub enum BrowserFamily {
2488 Chrome,
2490 Firefox,
2492 Safari,
2494 Edge,
2496}
2497
2498impl BrowserFamily {
2499 #[must_use]
2509 pub const fn as_str(self) -> &'static str {
2510 match self {
2511 Self::Chrome => "chrome",
2512 Self::Firefox => "firefox",
2513 Self::Safari => "safari",
2514 Self::Edge => "edge",
2515 }
2516 }
2517}
2518
2519impl fmt::Display for BrowserFamily {
2520 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2521 f.write_str(self.as_str())
2522 }
2523}
2524
2525#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2535#[non_exhaustive]
2536pub enum PlatformClass {
2537 Windows,
2539 MacOs,
2541 Linux,
2543}
2544
2545impl PlatformClass {
2546 #[must_use]
2556 pub const fn as_str(self) -> &'static str {
2557 match self {
2558 Self::Windows => "windows",
2559 Self::MacOs => "macos",
2560 Self::Linux => "linux",
2561 }
2562 }
2563}
2564
2565impl fmt::Display for PlatformClass {
2566 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2567 f.write_str(self.as_str())
2568 }
2569}
2570
2571#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2586#[non_exhaustive]
2587pub enum ProfileChannel {
2588 ChromeLatest,
2590 FirefoxLatest,
2592 SafariLatest,
2594 EdgeLatest,
2596 Chrome131,
2598 Firefox133,
2600 Safari18,
2602 Edge131,
2604}
2605
2606impl ProfileChannel {
2607 pub fn resolve(
2627 self,
2628 _platform: Option<PlatformClass>,
2629 ) -> Result<&'static TlsProfilePack, ProfileChannelError> {
2630 match self {
2631 Self::ChromeLatest | Self::Chrome131 => Ok(&PACK_CHROME_131),
2632 Self::FirefoxLatest | Self::Firefox133 => Ok(&PACK_FIREFOX_133),
2633 Self::SafariLatest | Self::Safari18 => Ok(&PACK_SAFARI_18),
2634 Self::EdgeLatest | Self::Edge131 => Ok(&PACK_EDGE_131),
2635 }
2636 }
2637}
2638
2639impl std::str::FromStr for ProfileChannel {
2640 type Err = ProfileChannelError;
2641
2642 fn from_str(s: &str) -> Result<Self, Self::Err> {
2670 match s.to_ascii_lowercase().as_str() {
2671 "chrome-latest" => Ok(Self::ChromeLatest),
2672 "firefox-latest" => Ok(Self::FirefoxLatest),
2673 "safari-latest" => Ok(Self::SafariLatest),
2674 "edge-latest" => Ok(Self::EdgeLatest),
2675 "chrome-131" => Ok(Self::Chrome131),
2676 "firefox-133" => Ok(Self::Firefox133),
2677 "safari-18" => Ok(Self::Safari18),
2678 "edge-131" => Ok(Self::Edge131),
2679 other => Err(ProfileChannelError::UnknownChannel(other.to_string())),
2680 }
2681 }
2682}
2683
2684#[derive(Debug, thiserror::Error)]
2696#[non_exhaustive]
2697pub enum ProfileChannelError {
2698 #[error(
2700 "unknown profile channel '{0}'; known channels: chrome-latest, firefox-latest, safari-latest, edge-latest, chrome-131, firefox-133, safari-18, edge-131"
2701 )]
2702 UnknownChannel(String),
2703}
2704
2705#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2717pub struct ProfileMetadata {
2718 pub family: BrowserFamily,
2720 pub browser_version: String,
2722 pub platform: PlatformClass,
2724 pub h2_support: bool,
2726 pub h3_support: bool,
2728 pub added_date: String,
2730 pub source_notes: String,
2732}
2733
2734impl ProfileMetadata {
2735 #[must_use]
2747 pub fn provenance(&self) -> String {
2748 format!(
2749 "{} {} on {} (added {}; {})",
2750 self.family, self.browser_version, self.platform, self.added_date, self.source_notes
2751 )
2752 }
2753}
2754
2755#[derive(Debug)]
2769pub struct TlsProfilePack {
2770 pub profile: &'static TlsProfile,
2772 pub metadata: ProfileMetadata,
2774}
2775
2776pub static PACK_CHROME_131: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
2787 profile: &CHROME_131,
2788 metadata: ProfileMetadata {
2789 family: BrowserFamily::Chrome,
2790 browser_version: "131".to_string(),
2791 platform: PlatformClass::Windows,
2792 h2_support: true,
2793 h3_support: true,
2794 added_date: "2024-12-01".to_string(),
2795 source_notes: "ClientHello capture from Chrome 131.0.6778.86 on Windows 11".to_string(),
2796 },
2797});
2798
2799pub static PACK_FIREFOX_133: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
2810 profile: &FIREFOX_133,
2811 metadata: ProfileMetadata {
2812 family: BrowserFamily::Firefox,
2813 browser_version: "133".to_string(),
2814 platform: PlatformClass::Windows,
2815 h2_support: true,
2816 h3_support: true,
2817 added_date: "2024-12-01".to_string(),
2818 source_notes: "ClientHello capture from Firefox 133.0 on Windows 11".to_string(),
2819 },
2820});
2821
2822pub static PACK_SAFARI_18: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
2833 profile: &SAFARI_18,
2834 metadata: ProfileMetadata {
2835 family: BrowserFamily::Safari,
2836 browser_version: "18".to_string(),
2837 platform: PlatformClass::MacOs,
2838 h2_support: true,
2839 h3_support: false,
2840 added_date: "2024-12-01".to_string(),
2841 source_notes: "ClientHello capture from Safari 18.1 on macOS 15 Sequoia".to_string(),
2842 },
2843});
2844
2845pub static PACK_EDGE_131: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
2856 profile: &EDGE_131,
2857 metadata: ProfileMetadata {
2858 family: BrowserFamily::Edge,
2859 browser_version: "131".to_string(),
2860 platform: PlatformClass::Windows,
2861 h2_support: true,
2862 h3_support: true,
2863 added_date: "2024-12-01".to_string(),
2864 source_notes: "ClientHello capture from Edge 131.0.2903.70 on Windows 11".to_string(),
2865 },
2866});
2867
2868#[cfg(test)]
2869mod pack_tests {
2870 use super::*;
2871
2872 #[test]
2873 fn channel_latest_resolves_to_expected_profile() -> Result<(), ProfileChannelError> {
2874 let chrome = ProfileChannel::ChromeLatest.resolve(None)?;
2875 assert_eq!(chrome.profile.name, "Chrome 131");
2876
2877 let firefox = ProfileChannel::FirefoxLatest.resolve(None)?;
2878 assert_eq!(firefox.profile.name, "Firefox 133");
2879
2880 let safari = ProfileChannel::SafariLatest.resolve(None)?;
2881 assert_eq!(safari.profile.name, "Safari 18");
2882
2883 let edge = ProfileChannel::EdgeLatest.resolve(None)?;
2884 assert_eq!(edge.profile.name, "Edge 131");
2885 Ok(())
2886 }
2887
2888 #[test]
2889 fn pinned_channels_resolve_to_same_as_latest() -> Result<(), ProfileChannelError> {
2890 let chrome_pinned = ProfileChannel::Chrome131.resolve(None)?;
2891 let chrome_latest = ProfileChannel::ChromeLatest.resolve(None)?;
2892 assert!(std::ptr::eq(chrome_pinned, chrome_latest));
2893 Ok(())
2894 }
2895
2896 #[test]
2897 fn metadata_is_serializable() -> Result<(), Box<dyn std::error::Error>> {
2898 let pack = &*PACK_CHROME_131;
2899 let json = serde_json::to_string(&pack.metadata)?;
2900 assert!(json.contains("Chrome"));
2901 assert!(json.contains("131"));
2902
2903 let meta: ProfileMetadata = serde_json::from_str(json.as_str())?;
2904 assert_eq!(meta.family, BrowserFamily::Chrome);
2905 assert_eq!(meta.browser_version, "131");
2906 Ok(())
2907 }
2908
2909 #[test]
2910 fn invalid_channel_returns_error() {
2911 let result = "netscape-4".parse::<ProfileChannel>();
2912 assert!(
2913 matches!(result, Err(ProfileChannelError::UnknownChannel(ref s)) if s.contains("netscape-4"))
2914 );
2915 }
2916
2917 #[test]
2918 fn from_str_parsing_case_insensitive() -> Result<(), ProfileChannelError> {
2919 let ch: ProfileChannel = "CHROME-LATEST".parse()?;
2920 assert_eq!(ch, ProfileChannel::ChromeLatest);
2921 Ok(())
2922 }
2923
2924 #[test]
2925 fn provenance_contains_family_and_version() {
2926 let prov = PACK_FIREFOX_133.metadata.provenance();
2927 assert!(prov.contains("firefox"), "provenance: {prov}");
2928 assert!(prov.contains("133"), "provenance: {prov}");
2929 }
2930
2931 #[test]
2932 fn safari_h3_not_supported() {
2933 assert!(!PACK_SAFARI_18.metadata.h3_support);
2934 }
2935
2936 #[test]
2937 fn platform_hint_accepted_without_error() -> Result<(), ProfileChannelError> {
2938 let pack = ProfileChannel::ChromeLatest.resolve(Some(PlatformClass::Linux))?;
2939 assert_eq!(pack.profile.name, "Chrome 131");
2940 Ok(())
2941 }
2942}