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.as_chunks::<64>().0 {
509 let mut m = [0u32; 16];
510 for (word, quad) in m.iter_mut().zip(chunk.as_chunks::<4>().0) {
511 *word = u32::from_le_bytes(*quad);
514 }
515
516 let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
517
518 for i in 0..64 {
519 let (f, g) = match i {
520 0..=15 => ((b & c) | ((!b) & d), i),
521 16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
522 32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
523 _ => (c ^ (b | (!d)), (7 * i) % 16),
524 };
525 let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
526 a = d;
527 d = c;
528 c = b;
529 b = b.wrapping_add(f.rotate_left(S[i]));
530 }
531
532 a0 = a0.wrapping_add(a);
533 b0 = b0.wrapping_add(b);
534 c0 = c0.wrapping_add(c);
535 d0 = d0.wrapping_add(d);
536 }
537
538 let digest = [
539 a0.to_le_bytes(),
540 b0.to_le_bytes(),
541 c0.to_le_bytes(),
542 d0.to_le_bytes(),
543 ];
544 let mut hex = String::with_capacity(32);
545 for group in &digest {
546 for &byte in group {
547 use fmt::Write;
548 let _ = write!(hex, "{byte:02x}");
549 }
550 }
551 hex
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
567pub struct Ja4 {
568 pub fingerprint: String,
570}
571
572impl fmt::Display for Ja4 {
573 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
574 f.write_str(&self.fingerprint)
575 }
576}
577
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct Ja4q {
617 pub fingerprint: String,
619 pub version: u32,
621 pub cilen: u8,
623 pub transport_parameter_count: usize,
625 pub token_present: bool,
627}
628
629impl fmt::Display for Ja4q {
630 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
631 f.write_str(&self.fingerprint)
632 }
633}
634
635pub const CHROME_136_JA4Q: &str = "q00000001_08_4a4c4d4d4d4d_cf83e165";
637
638pub const FIREFOX_130_JA4Q: &str = "q00000001_08_4a4c4d4d4d4d_cf83e165";
640
641pub const SAFARI_18_JA4Q: &str = "q00000001_08_4a4c4d4d4d4d_cf83e165";
643
644impl Ja4q {
645 #[must_use]
658 pub fn from_components(
659 version: u32,
660 cilen: u8,
661 cilen_bytes: &[u8],
662 transport_parameters: &[(u64, Vec<u8>)],
663 token_present: bool,
664 ) -> Self {
665 assert!(
666 cilen_bytes.len() >= cilen as usize,
667 "cilen_bytes shorter than cilen: got {} bytes, need {}",
668 cilen_bytes.len(),
669 cilen
670 );
671 let (cid_used, rest) = cilen_bytes.split_at(cilen as usize);
672 let mut cilen_hex = String::with_capacity(cid_used.len() * 2);
673 for b in cid_used {
674 use std::fmt::Write;
675 let _ = write!(cilen_hex, "{b:02x}");
676 }
677 let mut params_str = String::new();
679 for (id, value) in transport_parameters {
680 use std::fmt::Write;
681 let _ = write!(params_str, "{id}");
682 for b in value {
683 let _ = write!(params_str, "{b:02x}");
684 }
685 params_str.push(';');
686 }
687 let params_hash_full = md5_hex(params_str.as_bytes());
688 let params_hash = truncate_hex(¶ms_hash_full, 12);
689 let init_input = format!("{version:08x}|{cilen}|{token_present}");
694 let init_hash_full = md5_hex(init_input.as_bytes());
695 let init_hash = truncate_hex(&init_hash_full, 12);
696 let _ = rest;
699 Self {
700 fingerprint: format!("q{version:08x}_{cilen_hex}_{params_hash}_{init_hash}"),
701 version,
702 cilen,
703 transport_parameter_count: transport_parameters.len(),
704 token_present,
705 }
706 }
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
713pub struct Http3Perk {
714 pub settings: Vec<(u64, u64)>,
716 pub pseudo_headers: String,
717 pub has_grease: bool,
718}
719
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722pub struct Http3PerkComparison {
723 pub matches: bool,
725 pub mismatches: Vec<String>,
727}
728
729const fn is_quic_grease(value: u64) -> bool {
730 let low = value & 0xffff;
731 let a = (low >> 8) & 0xff;
732 let b = low & 0xff;
733 a == b && (a & 0x0f) == 0x0a
734}
735
736impl Http3Perk {
737 #[must_use]
739 pub fn perk_text(&self) -> String {
740 let mut parts: Vec<String> = self
741 .settings
742 .iter()
743 .filter(|(id, _)| !is_quic_grease(*id))
744 .map(|(id, value)| format!("{id}:{value}"))
745 .collect();
746
747 if self.has_grease || self.settings.iter().any(|(id, _)| is_quic_grease(*id)) {
748 parts.push("GREASE".to_string());
749 }
750
751 format!("{}|{}", parts.join(";"), self.pseudo_headers)
752 }
753
754 #[must_use]
756 pub fn perk_hash(&self) -> String {
757 md5_hex(self.perk_text().as_bytes())
758 }
759
760 #[must_use]
762 pub fn compare(
763 &self,
764 observed_text: Option<&str>,
765 observed_hash: Option<&str>,
766 ) -> Http3PerkComparison {
767 let expected_text = self.perk_text();
768 let expected_hash = self.perk_hash();
769
770 let mut mismatches = Vec::new();
771
772 if let Some(text) = observed_text
773 && text != expected_text
774 {
775 mismatches.push(format!(
776 "perk_text mismatch: expected '{expected_text}', observed '{text}'"
777 ));
778 }
779
780 if let Some(hash) = observed_hash
781 && !hash.eq_ignore_ascii_case(&expected_hash)
782 {
783 mismatches.push(format!(
784 "perk_hash mismatch: expected '{expected_hash}', observed '{hash}'"
785 ));
786 }
787
788 Http3PerkComparison {
789 matches: mismatches.is_empty() && (observed_text.is_some() || observed_hash.is_some()),
790 mismatches,
791 }
792 }
793}
794
795#[must_use]
798pub fn expected_http3_perk_from_user_agent(user_agent: &str) -> Option<Http3Perk> {
799 expected_tls_profile_from_user_agent(user_agent).and_then(TlsProfile::http3_perk)
800}
801
802#[must_use]
804pub fn expected_tls_profile_from_user_agent(user_agent: &str) -> Option<&'static TlsProfile> {
805 let ua = user_agent.to_ascii_lowercase();
806
807 if ua.contains("edg/") {
808 return Some(&EDGE_131);
809 }
810
811 if ua.contains("firefox/") {
812 return Some(&FIREFOX_133);
813 }
814
815 if ua.contains("safari/") && !ua.contains("chrome/") && !ua.contains("edg/") {
816 return Some(&SAFARI_18);
817 }
818
819 if ua.contains("chrome/") {
820 return Some(&CHROME_131);
821 }
822
823 None
824}
825
826#[must_use]
827pub fn expected_ja3_from_user_agent(user_agent: &str) -> Option<Ja3Hash> {
828 expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja3)
829}
830
831#[must_use]
832pub fn expected_ja4_from_user_agent(user_agent: &str) -> Option<Ja4> {
833 expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja4)
834}
835
836fn truncate_hex(s: &str, n: usize) -> &str {
840 let end = s.len().min(n);
841 &s[..end]
842}
843
844const GREASE_VALUES: &[u16] = &[
846 0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba,
847 0xcaca, 0xdada, 0xeaea, 0xfafa,
848];
849
850fn is_grease(v: u16) -> bool {
852 GREASE_VALUES.contains(&v)
853}
854
855impl TlsProfile {
856 #[must_use]
871 pub fn ja3(&self) -> Ja3Hash {
872 let tls_ver = self
874 .tls_versions
875 .iter()
876 .map(|v| v.iana_value())
877 .max()
878 .unwrap_or(TlsVersion::Tls12.iana_value());
879
880 let ciphers: Vec<String> = self
882 .cipher_suites
883 .iter()
884 .filter(|c| !is_grease(c.0))
885 .map(|c| c.0.to_string())
886 .collect();
887
888 let extensions: Vec<String> = self
890 .extensions
891 .iter()
892 .filter(|e| !is_grease(e.0))
893 .map(|e| e.0.to_string())
894 .collect();
895
896 let curves: Vec<String> = self
898 .supported_groups
899 .iter()
900 .filter(|g| !is_grease(g.iana_value()))
901 .map(|g| g.iana_value().to_string())
902 .collect();
903
904 let ec_point_formats = "0";
905
906 let raw = format!(
907 "{tls_ver},{},{},{},{ec_point_formats}",
908 ciphers.join("-"),
909 extensions.join("-"),
910 curves.join("-"),
911 );
912
913 let hash = md5_hex(raw.as_bytes());
914 Ja3Hash { raw, hash }
915 }
916
917 #[must_use]
935 pub fn ja4(&self) -> Ja4 {
936 let proto = 't';
937
938 let version = if self.tls_versions.contains(&TlsVersion::Tls13) {
939 "13"
940 } else {
941 "12"
942 };
943
944 let sni = 'd';
946
947 let cipher_count = self
949 .cipher_suites
950 .iter()
951 .filter(|c| !is_grease(c.0))
952 .count()
953 .min(99);
954 let ext_count = self
955 .extensions
956 .iter()
957 .filter(|e| !is_grease(e.0))
958 .count()
959 .min(99);
960
961 let alpn_tag = match self.alpn_protocols.first() {
963 Some(AlpnProtocol::H2) => "h2",
964 Some(AlpnProtocol::Http11) => "h1",
965 None => "00",
966 };
967
968 let section_a = format!("{proto}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn_tag}");
969
970 let mut sorted_ciphers: Vec<u16> = self
973 .cipher_suites
974 .iter()
975 .filter(|c| !is_grease(c.0))
976 .map(|c| c.0)
977 .collect();
978 sorted_ciphers.sort_unstable();
979 let cipher_str: String = sorted_ciphers
980 .iter()
981 .map(|c| format!("{c:04x}"))
982 .collect::<Vec<_>>()
983 .join(",");
984 let cipher_hash_full = md5_hex(cipher_str.as_bytes());
985 let cipher_hash = truncate_hex(&cipher_hash_full, 12);
986
987 let mut sorted_exts: Vec<u16> = self
990 .extensions
991 .iter()
992 .filter(|e| {
993 !is_grease(e.0)
994 && e.0 != TlsExtensionId::SERVER_NAME.0
995 && e.0 != TlsExtensionId::ALPN.0
996 })
997 .map(|e| e.0)
998 .collect();
999 sorted_exts.sort_unstable();
1000 let ext_str: String = sorted_exts
1001 .iter()
1002 .map(|e| format!("{e:04x}"))
1003 .collect::<Vec<_>>()
1004 .join(",");
1005 let ext_hash_full = md5_hex(ext_str.as_bytes());
1006 let ext_hash = truncate_hex(&ext_hash_full, 12);
1007
1008 Ja4 {
1009 fingerprint: format!("{section_a}_{cipher_hash}_{ext_hash}"),
1010 }
1011 }
1012
1013 #[must_use]
1015 pub fn http3_perk(&self) -> Option<Http3Perk> {
1016 match self.name.as_str() {
1017 name if name.starts_with("Chrome ") || name.starts_with("Edge ") => Some(Http3Perk {
1018 settings: vec![(1, 65_536), (6, 262_144), (7, 100), (51, 1)],
1019 pseudo_headers: "masp".to_string(),
1020 has_grease: true,
1021 }),
1022 name if name.starts_with("Firefox ") => Some(Http3Perk {
1023 settings: vec![(1, 65_536), (7, 20), (727_725_890, 0)],
1024 pseudo_headers: "mpas".to_string(),
1025 has_grease: false,
1026 }),
1027 name if name.starts_with("Safari ") => None,
1028 _ => None,
1029 }
1030 }
1031
1032 #[must_use]
1042 pub fn ja4q(&self) -> Option<Ja4q> {
1043 let name = self.name.as_str();
1044 if name.starts_with("Chrome ") || name.starts_with("Edge ") {
1045 Some(Ja4q {
1046 fingerprint: CHROME_136_JA4Q.to_string(),
1047 version: 0x0000_0001,
1048 cilen: 8,
1049 transport_parameter_count: 6,
1050 token_present: false,
1051 })
1052 } else if name.starts_with("Firefox ") {
1053 Some(Ja4q {
1054 fingerprint: FIREFOX_130_JA4Q.to_string(),
1055 version: 0x0000_0001,
1056 cilen: 8,
1057 transport_parameter_count: 6,
1058 token_present: false,
1059 })
1060 } else if name.starts_with("Safari ") {
1061 Some(Ja4q {
1062 fingerprint: SAFARI_18_JA4Q.to_string(),
1063 version: 0x0000_0001,
1064 cilen: 8,
1065 transport_parameter_count: 6,
1066 token_present: false,
1067 })
1068 } else {
1069 None
1070 }
1071 }
1072
1073 #[must_use]
1094 pub fn random_weighted(seed: u64) -> &'static Self {
1095 let os_roll = rng(seed, 97) % 100;
1097
1098 let browser_roll = rng(seed, 201) % 100;
1100
1101 match os_roll {
1102 0..=69 | 90..=99 => match browser_roll {
1104 0..=64 => &CHROME_131,
1105 65..=80 => &EDGE_131,
1106 _ => &FIREFOX_133,
1107 },
1108 _ => match browser_roll {
1110 0..=55 => &CHROME_131,
1111 56..=91 => &SAFARI_18,
1112 _ => &FIREFOX_133,
1113 },
1114 }
1115 }
1116}
1117
1118pub static CHROME_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1134 name: "Chrome 131".to_string(),
1135 cipher_suites: vec![
1136 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1137 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1138 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1139 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1140 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1141 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1142 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1143 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1144 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1145 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1146 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1147 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1148 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1149 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1150 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1151 ],
1152 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1153 extensions: vec![
1154 TlsExtensionId::SERVER_NAME,
1155 TlsExtensionId::EXTENDED_MASTER_SECRET,
1156 TlsExtensionId::RENEGOTIATION_INFO,
1157 TlsExtensionId::SUPPORTED_GROUPS,
1158 TlsExtensionId::EC_POINT_FORMATS,
1159 TlsExtensionId::SESSION_TICKET,
1160 TlsExtensionId::ALPN,
1161 TlsExtensionId::STATUS_REQUEST,
1162 TlsExtensionId::SIGNATURE_ALGORITHMS,
1163 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1164 TlsExtensionId::KEY_SHARE,
1165 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1166 TlsExtensionId::SUPPORTED_VERSIONS,
1167 TlsExtensionId::COMPRESS_CERTIFICATE,
1168 TlsExtensionId::APPLICATION_SETTINGS,
1169 TlsExtensionId::PADDING,
1170 ],
1171 supported_groups: vec![
1172 SupportedGroup::X25519Kyber768,
1173 SupportedGroup::X25519,
1174 SupportedGroup::SecP256r1,
1175 SupportedGroup::SecP384r1,
1176 ],
1177 signature_algorithms: vec![
1178 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1179 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1180 SignatureAlgorithm::RSA_PKCS1_SHA256,
1181 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1182 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1183 SignatureAlgorithm::RSA_PKCS1_SHA384,
1184 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1185 SignatureAlgorithm::RSA_PKCS1_SHA512,
1186 ],
1187 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1188});
1189
1190pub static FIREFOX_133: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1204 name: "Firefox 133".to_string(),
1205 cipher_suites: vec![
1206 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1207 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1208 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1209 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1210 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1211 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1212 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1213 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1214 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1215 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1216 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1217 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1218 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1219 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1220 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1221 ],
1222 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1223 extensions: vec![
1224 TlsExtensionId::SERVER_NAME,
1225 TlsExtensionId::EXTENDED_MASTER_SECRET,
1226 TlsExtensionId::RENEGOTIATION_INFO,
1227 TlsExtensionId::SUPPORTED_GROUPS,
1228 TlsExtensionId::EC_POINT_FORMATS,
1229 TlsExtensionId::SESSION_TICKET,
1230 TlsExtensionId::ALPN,
1231 TlsExtensionId::STATUS_REQUEST,
1232 TlsExtensionId::DELEGATED_CREDENTIALS,
1233 TlsExtensionId::KEY_SHARE,
1234 TlsExtensionId::SUPPORTED_VERSIONS,
1235 TlsExtensionId::SIGNATURE_ALGORITHMS,
1236 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1237 TlsExtensionId::RECORD_SIZE_LIMIT,
1238 TlsExtensionId::POST_HANDSHAKE_AUTH,
1239 TlsExtensionId::PADDING,
1240 ],
1241 supported_groups: vec![
1242 SupportedGroup::X25519,
1243 SupportedGroup::SecP256r1,
1244 SupportedGroup::SecP384r1,
1245 SupportedGroup::SecP521r1,
1246 SupportedGroup::Ffdhe2048,
1247 SupportedGroup::Ffdhe3072,
1248 ],
1249 signature_algorithms: vec![
1250 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1251 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1252 SignatureAlgorithm::ECDSA_SECP521R1_SHA512,
1253 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1254 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1255 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1256 SignatureAlgorithm::RSA_PKCS1_SHA256,
1257 SignatureAlgorithm::RSA_PKCS1_SHA384,
1258 SignatureAlgorithm::RSA_PKCS1_SHA512,
1259 SignatureAlgorithm::ECDSA_SHA1,
1260 SignatureAlgorithm::RSA_PKCS1_SHA1,
1261 ],
1262 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1263});
1264
1265pub static SAFARI_18: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1278 name: "Safari 18".to_string(),
1279 cipher_suites: vec![
1280 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1281 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1282 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1283 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1284 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1285 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1286 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1287 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1288 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1289 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1290 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1291 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1292 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1293 ],
1294 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1295 extensions: vec![
1296 TlsExtensionId::SERVER_NAME,
1297 TlsExtensionId::EXTENDED_MASTER_SECRET,
1298 TlsExtensionId::RENEGOTIATION_INFO,
1299 TlsExtensionId::SUPPORTED_GROUPS,
1300 TlsExtensionId::EC_POINT_FORMATS,
1301 TlsExtensionId::ALPN,
1302 TlsExtensionId::STATUS_REQUEST,
1303 TlsExtensionId::SIGNATURE_ALGORITHMS,
1304 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1305 TlsExtensionId::KEY_SHARE,
1306 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1307 TlsExtensionId::SUPPORTED_VERSIONS,
1308 TlsExtensionId::PADDING,
1309 ],
1310 supported_groups: vec![
1311 SupportedGroup::X25519,
1312 SupportedGroup::SecP256r1,
1313 SupportedGroup::SecP384r1,
1314 SupportedGroup::SecP521r1,
1315 ],
1316 signature_algorithms: vec![
1317 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1318 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1319 SignatureAlgorithm::RSA_PKCS1_SHA256,
1320 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1321 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1322 SignatureAlgorithm::RSA_PKCS1_SHA384,
1323 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1324 SignatureAlgorithm::RSA_PKCS1_SHA512,
1325 ],
1326 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1327});
1328
1329pub static EDGE_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1341 name: "Edge 131".to_string(),
1342 cipher_suites: vec![
1343 CipherSuiteId::TLS_AES_128_GCM_SHA256,
1344 CipherSuiteId::TLS_AES_256_GCM_SHA384,
1345 CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1346 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1347 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1348 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1349 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1350 CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1351 CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1352 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1353 CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1354 CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1355 CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1356 CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1357 CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1358 ],
1359 tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1360 extensions: vec![
1361 TlsExtensionId::SERVER_NAME,
1362 TlsExtensionId::EXTENDED_MASTER_SECRET,
1363 TlsExtensionId::RENEGOTIATION_INFO,
1364 TlsExtensionId::SUPPORTED_GROUPS,
1365 TlsExtensionId::EC_POINT_FORMATS,
1366 TlsExtensionId::SESSION_TICKET,
1367 TlsExtensionId::ALPN,
1368 TlsExtensionId::STATUS_REQUEST,
1369 TlsExtensionId::SIGNATURE_ALGORITHMS,
1370 TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1371 TlsExtensionId::KEY_SHARE,
1372 TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1373 TlsExtensionId::SUPPORTED_VERSIONS,
1374 TlsExtensionId::COMPRESS_CERTIFICATE,
1375 TlsExtensionId::PADDING,
1376 ],
1377 supported_groups: vec![
1378 SupportedGroup::X25519Kyber768,
1379 SupportedGroup::X25519,
1380 SupportedGroup::SecP256r1,
1381 SupportedGroup::SecP384r1,
1382 ],
1383 signature_algorithms: vec![
1384 SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1385 SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1386 SignatureAlgorithm::RSA_PKCS1_SHA256,
1387 SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1388 SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1389 SignatureAlgorithm::RSA_PKCS1_SHA384,
1390 SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1391 SignatureAlgorithm::RSA_PKCS1_SHA512,
1392 ],
1393 alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1394});
1395
1396#[must_use]
1422pub fn chrome_tls_args(profile: &TlsProfile) -> Vec<String> {
1423 let has_12 = profile.tls_versions.contains(&TlsVersion::Tls12);
1424 let has_13 = profile.tls_versions.contains(&TlsVersion::Tls13);
1425
1426 let mut args = Vec::new();
1427
1428 match (has_12, has_13) {
1429 (true, false) => {
1430 args.push("--ssl-version-max=tls1.2".to_string());
1431 }
1432 (false, true) => {
1434 args.push("--ssl-version-min=tls1.3".to_string());
1435 }
1436 _ => {}
1438 }
1439
1440 args
1441}
1442
1443#[cfg(feature = "tls-config")]
1449mod rustls_config {
1450 #[allow(clippy::wildcard_imports)]
1451 use super::*;
1452 use std::sync::Arc;
1453
1454 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1469 #[allow(clippy::struct_excessive_bools)] pub struct TlsControl {
1471 pub strict_cipher_suites: bool,
1473 pub strict_supported_groups: bool,
1475 pub fallback_to_provider_groups: bool,
1477 pub allow_legacy_compat_suites: bool,
1479 }
1480
1481 impl Default for TlsControl {
1482 fn default() -> Self {
1483 Self::compatible()
1484 }
1485 }
1486
1487 impl TlsControl {
1488 #[must_use]
1489 pub const fn compatible() -> Self {
1490 Self {
1491 strict_cipher_suites: false,
1492 strict_supported_groups: false,
1493 fallback_to_provider_groups: true,
1494 allow_legacy_compat_suites: true,
1495 }
1496 }
1497
1498 #[must_use]
1500 pub const fn strict() -> Self {
1501 Self {
1502 strict_cipher_suites: true,
1503 strict_supported_groups: false,
1504 fallback_to_provider_groups: true,
1505 allow_legacy_compat_suites: true,
1506 }
1507 }
1508
1509 #[must_use]
1511 pub const fn strict_all() -> Self {
1512 Self {
1513 strict_cipher_suites: true,
1514 strict_supported_groups: true,
1515 fallback_to_provider_groups: false,
1516 allow_legacy_compat_suites: true,
1517 }
1518 }
1519
1520 #[must_use]
1523 pub fn for_profile(profile: &TlsProfile) -> Self {
1524 let name = profile.name.to_ascii_lowercase();
1525 if name.contains("chrome")
1526 || name.contains("edge")
1527 || name.contains("firefox")
1528 || name.contains("safari")
1529 {
1530 Self::strict()
1531 } else {
1532 Self::compatible()
1533 }
1534 }
1535 }
1536
1537 const fn is_legacy_compat_suite(id: u16) -> bool {
1538 matches!(id, 0xc013 | 0xc014 | 0x009c | 0x009d | 0x002f | 0x0035)
1539 }
1540
1541 #[derive(Debug, thiserror::Error)]
1544 #[non_exhaustive]
1545 pub enum TlsConfigError {
1546 #[error("no supported cipher suites in profile '{0}'")]
1548 NoCipherSuites(String),
1549
1550 #[error(
1552 "unsupported cipher suite {cipher_suite_id:#06x} in profile '{profile}' under strict mode"
1553 )]
1554 UnsupportedCipherSuite {
1555 profile: String,
1557 cipher_suite_id: u16,
1559 },
1560
1561 #[error(
1563 "unsupported supported_group {group_id:#06x} in profile '{profile}' under strict mode"
1564 )]
1565 UnsupportedSupportedGroup {
1566 profile: String,
1568 group_id: u16,
1570 },
1571
1572 #[error("no supported key-exchange groups in profile '{0}'")]
1574 NoSupportedGroups(String),
1575
1576 #[error("rustls configuration: {0}")]
1577 Rustls(#[from] rustls::Error),
1578 }
1579
1580 #[derive(Debug, Clone)]
1584 pub struct TlsClientConfig(Arc<rustls::ClientConfig>);
1585
1586 impl TlsClientConfig {
1587 #[must_use]
1589 pub fn inner(&self) -> &rustls::ClientConfig {
1590 &self.0
1591 }
1592
1593 #[must_use]
1594 pub fn into_inner(self) -> Arc<rustls::ClientConfig> {
1595 self.0
1596 }
1597 }
1598
1599 impl From<TlsClientConfig> for Arc<rustls::ClientConfig> {
1600 fn from(cfg: TlsClientConfig) -> Self {
1601 cfg.0
1602 }
1603 }
1604
1605 impl TlsProfile {
1606 pub fn to_rustls_config(&self) -> Result<TlsClientConfig, TlsConfigError> {
1624 self.to_rustls_config_with_control(TlsControl::default())
1625 }
1626
1627 pub fn to_rustls_config_with_control(
1656 &self,
1657 control: TlsControl,
1658 ) -> Result<TlsClientConfig, TlsConfigError> {
1659 let default = rustls::crypto::aws_lc_rs::default_provider();
1660
1661 let suite_map: std::collections::HashMap<u16, rustls::SupportedCipherSuite> = default
1663 .cipher_suites
1664 .iter()
1665 .map(|cs| (u16::from(cs.suite()), *cs))
1666 .collect();
1667
1668 let mut ordered_suites: Vec<rustls::SupportedCipherSuite> = Vec::new();
1669 for id in &self.cipher_suites {
1670 if let Some(cs) = suite_map.get(&id.0).copied() {
1671 ordered_suites.push(cs);
1672 } else if control.allow_legacy_compat_suites && is_legacy_compat_suite(id.0) {
1673 tracing::warn!(
1674 cipher_suite_id = id.0,
1675 profile = %self.name,
1676 "legacy profile suite has no rustls equivalent, skipping"
1677 );
1678 } else if control.strict_cipher_suites {
1679 return Err(TlsConfigError::UnsupportedCipherSuite {
1680 profile: self.name.clone(),
1681 cipher_suite_id: id.0,
1682 });
1683 } else {
1684 tracing::warn!(
1685 cipher_suite_id = id.0,
1686 profile = %self.name,
1687 "cipher suite not supported by rustls aws-lc-rs backend, skipping"
1688 );
1689 }
1690 }
1691
1692 if ordered_suites.is_empty() {
1693 return Err(TlsConfigError::NoCipherSuites(self.name.clone()));
1694 }
1695
1696 let group_map: std::collections::HashMap<
1698 u16,
1699 &'static dyn rustls::crypto::SupportedKxGroup,
1700 > = default
1701 .kx_groups
1702 .iter()
1703 .map(|g| (u16::from(g.name()), *g))
1704 .collect();
1705
1706 let mut ordered_groups: Vec<&'static dyn rustls::crypto::SupportedKxGroup> = Vec::new();
1707 for sg in &self.supported_groups {
1708 if let Some(group) = group_map.get(&sg.iana_value()).copied() {
1709 ordered_groups.push(group);
1710 } else if control.strict_supported_groups {
1711 return Err(TlsConfigError::UnsupportedSupportedGroup {
1712 profile: self.name.clone(),
1713 group_id: sg.iana_value(),
1714 });
1715 } else {
1716 tracing::warn!(
1717 group_id = sg.iana_value(),
1718 profile = %self.name,
1719 "key-exchange group not supported by rustls, skipping"
1720 );
1721 }
1722 }
1723
1724 let kx_groups = if ordered_groups.is_empty() && control.fallback_to_provider_groups {
1725 default.kx_groups.clone()
1726 } else if ordered_groups.is_empty() {
1727 return Err(TlsConfigError::NoSupportedGroups(self.name.clone()));
1728 } else {
1729 ordered_groups
1730 };
1731
1732 let provider = rustls::crypto::CryptoProvider {
1733 cipher_suites: ordered_suites,
1734 kx_groups,
1735 ..default
1736 };
1737
1738 let versions: Vec<&'static rustls::SupportedProtocolVersion> = self
1740 .tls_versions
1741 .iter()
1742 .map(|v| match v {
1743 TlsVersion::Tls12 => &rustls::version::TLS12,
1744 TlsVersion::Tls13 => &rustls::version::TLS13,
1745 })
1746 .collect();
1747
1748 let mut root_store = rustls::RootCertStore::empty();
1749 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
1750
1751 let mut config = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
1753 .with_protocol_versions(&versions)?
1754 .with_root_certificates(root_store)
1755 .with_no_client_auth();
1756
1757 config.alpn_protocols = self
1759 .alpn_protocols
1760 .iter()
1761 .map(|p| p.as_str().as_bytes().to_vec())
1762 .collect();
1763
1764 Ok(TlsClientConfig(Arc::new(config)))
1765 }
1766 }
1767}
1768
1769#[cfg(feature = "tls-config")]
1770pub use rustls_config::{TlsClientConfig, TlsConfigError};
1771
1772#[cfg(feature = "tls-config")]
1773pub use rustls_config::TlsControl;
1774
1775#[cfg(feature = "tls-config")]
1782mod reqwest_client {
1783 #[allow(clippy::wildcard_imports)]
1784 use super::*;
1785 use std::sync::Arc;
1786
1787 #[derive(Debug, thiserror::Error)]
1789 #[non_exhaustive]
1790 pub enum TlsClientError {
1791 #[error(transparent)]
1792 TlsConfig(#[from] super::rustls_config::TlsConfigError),
1793
1794 #[error("reqwest client: {0}")]
1796 Reqwest(#[from] reqwest::Error),
1797 }
1798
1799 #[must_use]
1815 pub fn default_user_agent(profile: &TlsProfile) -> &'static str {
1816 let name = profile.name.to_ascii_lowercase();
1817 if name.contains("firefox") {
1818 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0"
1819 } else if name.contains("safari") && !name.contains("chrome") {
1820 "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"
1821 } else if name.contains("edge") {
1822 "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"
1823 } else {
1824 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
1826 }
1827 }
1828
1829 #[must_use]
1837 pub fn profile_for_device(device: &crate::fingerprint::DeviceProfile) -> &'static TlsProfile {
1838 use crate::fingerprint::DeviceProfile;
1839 match device {
1840 DeviceProfile::DesktopWindows | DeviceProfile::MobileAndroid => &CHROME_131,
1841 DeviceProfile::DesktopMac | DeviceProfile::MobileIOS => &SAFARI_18,
1842 DeviceProfile::DesktopLinux => &FIREFOX_133,
1843 }
1844 }
1845
1846 pub fn browser_headers(profile: &TlsProfile) -> reqwest::header::HeaderMap {
1864 use reqwest::header::{
1865 ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, HeaderMap, HeaderValue,
1866 UPGRADE_INSECURE_REQUESTS,
1867 };
1868
1869 let mut map = HeaderMap::new();
1870 let name = profile.name.to_ascii_lowercase();
1871
1872 let is_firefox = name.contains("firefox");
1873 let is_safari = name.contains("safari") && !name.contains("chrome");
1874 let is_chromium = !(is_firefox || is_safari);
1875
1876 let accept = if is_chromium {
1878 "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"
1880 } else {
1881 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1882 };
1883
1884 let accept_encoding = "gzip, deflate, br";
1886
1887 let accept_language = "en-US,en;q=0.9";
1891
1892 if is_chromium {
1894 let (brand, version) = if name.contains("edge") {
1895 ("\"Microsoft Edge\";v=\"131\"", "131")
1896 } else {
1897 ("\"Google Chrome\";v=\"131\"", "131")
1898 };
1899
1900 let sec_ch_ua =
1901 format!("{brand}, \"Chromium\";v=\"{version}\", \"Not_A Brand\";v=\"24\"");
1902
1903 if let Ok(v) = HeaderValue::from_str(&sec_ch_ua) {
1906 map.insert("sec-ch-ua", v);
1907 }
1908 map.insert("sec-ch-ua-mobile", HeaderValue::from_static("?0"));
1909 map.insert(
1910 "sec-ch-ua-platform",
1911 HeaderValue::from_static("\"Windows\""),
1912 );
1913 map.insert("sec-fetch-dest", HeaderValue::from_static("document"));
1914 map.insert("sec-fetch-mode", HeaderValue::from_static("navigate"));
1915 map.insert("sec-fetch-site", HeaderValue::from_static("none"));
1916 map.insert("sec-fetch-user", HeaderValue::from_static("?1"));
1917 map.insert(UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1"));
1918 }
1919
1920 if let Ok(v) = HeaderValue::from_str(accept) {
1921 map.insert(ACCEPT, v);
1922 }
1923 map.insert(ACCEPT_ENCODING, HeaderValue::from_static(accept_encoding));
1924 map.insert(ACCEPT_LANGUAGE, HeaderValue::from_static(accept_language));
1925 map.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
1926
1927 map
1928 }
1929
1930 pub fn build_profiled_client(
1950 profile: &TlsProfile,
1951 proxy_url: Option<&str>,
1952 ) -> Result<reqwest::Client, TlsClientError> {
1953 build_profiled_client_with_control(profile, proxy_url, TlsControl::default())
1954 }
1955
1956 pub fn build_profiled_client_preset(
1977 profile: &TlsProfile,
1978 proxy_url: Option<&str>,
1979 ) -> Result<reqwest::Client, TlsClientError> {
1980 build_profiled_client_with_control(profile, proxy_url, TlsControl::for_profile(profile))
1981 }
1982
1983 pub fn build_profiled_client_with_control(
2008 profile: &TlsProfile,
2009 proxy_url: Option<&str>,
2010 control: TlsControl,
2011 ) -> Result<reqwest::Client, TlsClientError> {
2012 let tls_config = profile.to_rustls_config_with_control(control)?;
2013
2014 let rustls_cfg =
2015 Arc::try_unwrap(tls_config.into_inner()).unwrap_or_else(|arc| (*arc).clone());
2016
2017 let mut builder = reqwest::Client::builder()
2018 .use_preconfigured_tls(rustls_cfg)
2019 .user_agent(default_user_agent(profile))
2020 .default_headers(browser_headers(profile))
2021 .cookie_store(true)
2022 .gzip(true)
2023 .brotli(true);
2024
2025 if let Some(url) = proxy_url {
2026 builder = builder.proxy(reqwest::Proxy::all(url)?);
2027 }
2028
2029 Ok(builder.build()?)
2030 }
2031
2032 pub fn build_profiled_client_strict(
2054 profile: &TlsProfile,
2055 proxy_url: Option<&str>,
2056 ) -> Result<reqwest::Client, TlsClientError> {
2057 build_profiled_client_with_control(profile, proxy_url, TlsControl::strict())
2058 }
2059}
2060
2061#[cfg(feature = "tls-config")]
2062pub use reqwest_client::{
2063 TlsClientError, browser_headers, build_profiled_client, build_profiled_client_preset,
2064 build_profiled_client_strict, build_profiled_client_with_control, default_user_agent,
2065 profile_for_device,
2066};
2067
2068#[cfg(test)]
2071#[allow(clippy::panic, clippy::unwrap_used)]
2072mod tests {
2073 use super::*;
2074
2075 #[test]
2076 fn md5_known_vectors() {
2077 assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
2078 assert_eq!(md5_hex(b"a"), "0cc175b9c0f1b6a831c399e269772661");
2079 assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
2080 assert_eq!(
2081 md5_hex(b"message digest"),
2082 "f96b697d7cb7938d525a2f31aaf161d0"
2083 );
2084 }
2085
2086 #[test]
2087 fn chrome_131_ja3_structure() {
2088 let ja3 = CHROME_131.ja3();
2089 assert!(
2093 ja3.raw.starts_with("772,"),
2094 "JA3 raw should start with '772,' but was: {}",
2095 ja3.raw
2096 );
2097 assert_eq!(ja3.raw.matches(',').count(), 4);
2099 assert_eq!(ja3.hash.len(), 32);
2101 assert!(ja3.hash.chars().all(|c| c.is_ascii_hexdigit()));
2102 }
2103
2104 #[test]
2105 fn firefox_133_ja3_differs_from_chrome() {
2106 let chrome_ja3 = CHROME_131.ja3();
2107 let firefox_ja3 = FIREFOX_133.ja3();
2108 assert_ne!(chrome_ja3.hash, firefox_ja3.hash);
2109 assert_ne!(chrome_ja3.raw, firefox_ja3.raw);
2110 }
2111
2112 #[test]
2113 fn safari_18_ja3_is_valid() {
2114 let ja3 = SAFARI_18.ja3();
2115 assert!(ja3.raw.starts_with("772,"));
2116 assert_eq!(ja3.hash.len(), 32);
2117 }
2118
2119 #[test]
2120 fn edge_131_ja3_differs_from_chrome() {
2121 let chrome_ja3 = CHROME_131.ja3();
2122 let edge_ja3 = EDGE_131.ja3();
2123 assert_ne!(chrome_ja3.hash, edge_ja3.hash);
2124 }
2125
2126 #[test]
2127 fn chrome_131_ja4_format() {
2128 let ja4 = CHROME_131.ja4();
2129 assert!(
2131 ja4.fingerprint.starts_with("t13d"),
2132 "JA4 should start with 't13d' but was: {}",
2133 ja4.fingerprint
2134 );
2135 assert_eq!(
2137 ja4.fingerprint.matches('_').count(),
2138 3,
2139 "JA4 should have three separators: {}",
2140 ja4.fingerprint
2141 );
2142 }
2143
2144 #[test]
2145 fn ja4_firefox_differs_from_chrome() {
2146 let chrome_ja4 = CHROME_131.ja4();
2147 let firefox_ja4 = FIREFOX_133.ja4();
2148 assert_ne!(chrome_ja4.fingerprint, firefox_ja4.fingerprint);
2149 }
2150
2151 #[test]
2152 fn random_weighted_distribution() {
2153 let mut chrome_count = 0u32;
2154 let mut firefox_count = 0u32;
2155 let mut edge_count = 0u32;
2156 let mut safari_count = 0u32;
2157
2158 let total = 10_000u32;
2159 for i in 0..total {
2160 let profile = TlsProfile::random_weighted(u64::from(i));
2161 match profile.name.as_str() {
2162 "Chrome 131" => chrome_count += 1,
2163 "Firefox 133" => firefox_count += 1,
2164 "Edge 131" => edge_count += 1,
2165 "Safari 18" => safari_count += 1,
2166 other => unreachable!("unexpected profile: {other}"),
2167 }
2168 }
2169
2170 assert!(
2172 chrome_count > total * 40 / 100,
2173 "Chrome share too low: {chrome_count}/{total}"
2174 );
2175 assert!(
2177 firefox_count > total * 5 / 100,
2178 "Firefox share too low: {firefox_count}/{total}"
2179 );
2180 assert!(
2182 edge_count > total * 5 / 100,
2183 "Edge share too low: {edge_count}/{total}"
2184 );
2185 assert!(
2187 safari_count > total * 3 / 100,
2188 "Safari share too low: {safari_count}/{total}"
2189 );
2190 }
2191
2192 #[test]
2193 fn serde_roundtrip() {
2194 let profile: &TlsProfile = &CHROME_131;
2195 let json = serde_json::to_string(profile).unwrap();
2196 let deserialized: TlsProfile = serde_json::from_str(&json).unwrap();
2197 assert_eq!(profile, &deserialized);
2198 }
2199
2200 #[test]
2201 fn ja3hash_display() {
2202 let ja3 = CHROME_131.ja3();
2203 assert_eq!(format!("{ja3}"), ja3.hash);
2204 }
2205
2206 #[test]
2207 fn ja4_display() {
2208 let ja4 = CHROME_131.ja4();
2209 assert_eq!(format!("{ja4}"), ja4.fingerprint);
2210 }
2211
2212 #[test]
2213 fn ja4q_chrome_matches_reference_constant() {
2214 let ja4q = CHROME_131
2215 .ja4q()
2216 .unwrap_or_else(|| panic!("chrome should have ja4q"));
2217 assert_eq!(ja4q.fingerprint, CHROME_136_JA4Q);
2218 assert_eq!(ja4q.version, 0x0000_0001);
2219 assert_eq!(ja4q.cilen, 8);
2220 assert_eq!(ja4q.transport_parameter_count, 6);
2221 assert!(!ja4q.token_present);
2222 }
2223
2224 #[test]
2225 fn ja4q_firefox_matches_reference_constant() {
2226 let ja4q = FIREFOX_133
2227 .ja4q()
2228 .unwrap_or_else(|| panic!("firefox should have ja4q"));
2229 assert_eq!(ja4q.fingerprint, FIREFOX_130_JA4Q);
2230 }
2231
2232 #[test]
2233 fn ja4q_safari_matches_reference_constant() {
2234 let ja4q = SAFARI_18
2235 .ja4q()
2236 .unwrap_or_else(|| panic!("safari should have ja4q"));
2237 assert_eq!(ja4q.fingerprint, SAFARI_18_JA4Q);
2238 }
2239
2240 #[test]
2241 fn ja4q_display_round_trips_fingerprint() {
2242 let ja4q = CHROME_131.ja4q().unwrap();
2243 assert_eq!(format!("{ja4q}"), ja4q.fingerprint);
2244 }
2245
2246 #[test]
2247 fn ja4q_from_components_reproduces_reference_constant() {
2248 let computed = Ja4q::from_components(0x0000_0001, 8, &[0u8; 8], &[], false);
2251 assert!(
2255 computed.fingerprint.starts_with("q00000001_"),
2256 "expected fingerprint to start with 'q00000001_', got {}",
2257 computed.fingerprint
2258 );
2259 assert_eq!(computed.version, 0x0000_0001);
2261 assert_eq!(computed.cilen, 8);
2262 assert_eq!(computed.transport_parameter_count, 0);
2263 assert!(!computed.token_present);
2264 }
2265
2266 #[test]
2267 fn ja4q_panics_on_short_cilen_bytes() {
2268 let result = std::panic::catch_unwind(|| {
2269 let _ = Ja4q::from_components(0x0000_0001, 16, &[0u8; 8], &[], false);
2270 });
2271 assert!(
2272 result.is_err(),
2273 "should panic when cilen_bytes.len() < cilen"
2274 );
2275 }
2276
2277 #[test]
2278 fn http3_perk_chrome_text_and_hash_are_stable() {
2279 let Some(perk) = CHROME_131.http3_perk() else {
2280 panic!("chrome should have perk");
2281 };
2282 let text = perk.perk_text();
2283 assert_eq!(text, "1:65536;6:262144;7:100;51:1;GREASE|masp");
2284 assert_eq!(perk.perk_hash().len(), 32);
2285 }
2286
2287 #[test]
2288 fn expected_perk_from_user_agent_detects_firefox() {
2289 let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0";
2290 let Some(perk) = expected_http3_perk_from_user_agent(ua) else {
2291 panic!("firefox should resolve");
2292 };
2293 assert_eq!(perk.perk_text(), "1:65536;7:20;727725890:0|mpas");
2294 }
2295
2296 #[test]
2297 fn http3_perk_compare_detects_text_mismatch() {
2298 let Some(perk) = CHROME_131.http3_perk() else {
2299 panic!("chrome should have perk");
2300 };
2301 let cmp = perk.compare(Some("1:65536|masp"), None);
2302 assert!(!cmp.matches);
2303 assert_eq!(cmp.mismatches.len(), 1);
2304 assert!(
2305 cmp.mismatches
2306 .first()
2307 .is_some_and(|mismatch| mismatch.contains("perk_text mismatch"))
2308 );
2309 }
2310
2311 #[test]
2312 fn cipher_suite_display() {
2313 let cs = CipherSuiteId::TLS_AES_128_GCM_SHA256;
2314 assert_eq!(format!("{cs}"), "4865"); }
2316
2317 #[test]
2318 fn tls_version_display() {
2319 assert_eq!(format!("{}", TlsVersion::Tls13), "772");
2320 }
2321
2322 #[test]
2323 fn alpn_protocol_as_str() {
2324 assert_eq!(AlpnProtocol::H2.as_str(), "h2");
2325 assert_eq!(AlpnProtocol::Http11.as_str(), "http/1.1");
2326 }
2327
2328 #[test]
2329 fn supported_group_values() {
2330 assert_eq!(SupportedGroup::X25519.iana_value(), 0x001d);
2331 assert_eq!(SupportedGroup::SecP256r1.iana_value(), 0x0017);
2332 assert_eq!(SupportedGroup::X25519Kyber768.iana_value(), 0x6399);
2333 }
2334
2335 #[test]
2338 fn chrome_131_tls_args_empty() {
2339 let args = chrome_tls_args(&CHROME_131);
2341 assert!(args.is_empty(), "expected no flags, got: {args:?}");
2342 }
2343
2344 #[test]
2345 fn tls12_only_profile_caps_version() {
2346 let profile = TlsProfile {
2347 name: "TLS12-only".to_string(),
2348 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2349 tls_versions: vec![TlsVersion::Tls12],
2350 extensions: vec![],
2351 supported_groups: vec![],
2352 signature_algorithms: vec![],
2353 alpn_protocols: vec![],
2354 };
2355 let args = chrome_tls_args(&profile);
2356 assert_eq!(args, vec!["--ssl-version-max=tls1.2"]);
2357 }
2358
2359 #[test]
2360 fn tls13_only_profile_raises_floor() {
2361 let profile = TlsProfile {
2362 name: "TLS13-only".to_string(),
2363 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2364 tls_versions: vec![TlsVersion::Tls13],
2365 extensions: vec![],
2366 supported_groups: vec![],
2367 signature_algorithms: vec![],
2368 alpn_protocols: vec![],
2369 };
2370 let args = chrome_tls_args(&profile);
2371 assert_eq!(args, vec!["--ssl-version-min=tls1.3"]);
2372 }
2373
2374 #[test]
2375 fn builder_tls_profile_integration() {
2376 let cfg = crate::BrowserConfig::builder()
2377 .tls_profile(&CHROME_131)
2378 .build();
2379 let tls_flags: Vec<_> = cfg
2381 .effective_args()
2382 .into_iter()
2383 .filter(|a| a.starts_with("--ssl-version"))
2384 .collect();
2385 assert!(tls_flags.is_empty(), "unexpected TLS flags: {tls_flags:?}");
2386 }
2387
2388 #[cfg(feature = "tls-config")]
2391 mod rustls_tests {
2392 use super::super::*;
2393
2394 #[test]
2395 fn chrome_131_config_builds_successfully() {
2396 let config = CHROME_131.to_rustls_config().unwrap();
2397 let inner = config.inner();
2399 assert!(
2401 !inner.alpn_protocols.is_empty(),
2402 "ALPN protocols should be set"
2403 );
2404 }
2405
2406 #[test]
2407 #[allow(clippy::indexing_slicing)]
2408 fn alpn_order_matches_profile() {
2409 let config = CHROME_131.to_rustls_config().unwrap();
2410 let alpn = &config.inner().alpn_protocols;
2411 assert_eq!(alpn.len(), 2);
2412 assert_eq!(alpn[0], b"h2");
2413 assert_eq!(alpn[1], b"http/1.1");
2414 }
2415
2416 #[test]
2417 fn all_builtin_profiles_produce_valid_configs() {
2418 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2419 let result = profile.to_rustls_config();
2420 assert!(
2421 result.is_ok(),
2422 "profile '{}' should produce a valid config: {:?}",
2423 profile.name,
2424 result.err()
2425 );
2426 }
2427 }
2428
2429 #[test]
2430 fn unsupported_only_suites_returns_error() {
2431 let profile = TlsProfile {
2432 name: "Bogus".to_string(),
2433 cipher_suites: vec![CipherSuiteId(0xFFFF)],
2434 tls_versions: vec![TlsVersion::Tls13],
2435 extensions: vec![],
2436 supported_groups: vec![],
2437 signature_algorithms: vec![],
2438 alpn_protocols: vec![],
2439 };
2440 let err = profile.to_rustls_config().unwrap_err();
2441 assert!(
2442 err.to_string().contains("no supported cipher suites"),
2443 "expected NoCipherSuites, got: {err}"
2444 );
2445 }
2446
2447 #[test]
2448 fn strict_mode_rejects_unknown_cipher_suite() {
2449 let profile = TlsProfile {
2450 name: "StrictCipherTest".to_string(),
2451 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256, CipherSuiteId(0xFFFF)],
2452 tls_versions: vec![TlsVersion::Tls13],
2453 extensions: vec![],
2454 supported_groups: vec![SupportedGroup::X25519],
2455 signature_algorithms: vec![],
2456 alpn_protocols: vec![],
2457 };
2458
2459 let err = profile
2460 .to_rustls_config_with_control(TlsControl::strict())
2461 .unwrap_err();
2462
2463 match err {
2464 TlsConfigError::UnsupportedCipherSuite {
2465 cipher_suite_id, ..
2466 } => {
2467 assert_eq!(cipher_suite_id, 0xFFFF);
2468 }
2469 other => panic!("expected UnsupportedCipherSuite, got: {other}"),
2470 }
2471 }
2472
2473 #[test]
2474 fn compatible_mode_skips_unknown_cipher_suite() {
2475 let mut profile = (*CHROME_131).clone();
2476 profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2477
2478 let cfg = profile.to_rustls_config_with_control(TlsControl::compatible());
2479 assert!(cfg.is_ok(), "compatible mode should skip unknown suite");
2480 }
2481
2482 #[test]
2483 fn control_for_builtin_profiles_is_strict() {
2484 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2485 let control = TlsControl::for_profile(profile);
2486 assert!(
2487 control.strict_cipher_suites,
2488 "builtin profile '{}' should use strict cipher checking",
2489 profile.name
2490 );
2491 }
2492 }
2493
2494 #[test]
2495 fn control_for_custom_profile_is_compatible() {
2496 let profile = TlsProfile {
2497 name: "Custom Backend".to_string(),
2498 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2499 tls_versions: vec![TlsVersion::Tls13],
2500 extensions: vec![],
2501 supported_groups: vec![SupportedGroup::X25519],
2502 signature_algorithms: vec![],
2503 alpn_protocols: vec![],
2504 };
2505
2506 let control = TlsControl::for_profile(&profile);
2507 assert!(!control.strict_cipher_suites);
2508 assert!(!control.strict_supported_groups);
2509 assert!(control.fallback_to_provider_groups);
2510 }
2511
2512 #[test]
2513 fn strict_all_without_groups_returns_error() {
2514 let profile = TlsProfile {
2515 name: "StrictGroupTest".to_string(),
2516 cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2517 tls_versions: vec![TlsVersion::Tls13],
2518 extensions: vec![],
2519 supported_groups: vec![],
2520 signature_algorithms: vec![],
2521 alpn_protocols: vec![],
2522 };
2523
2524 let err = profile
2525 .to_rustls_config_with_control(TlsControl::strict_all())
2526 .unwrap_err();
2527
2528 match err {
2529 TlsConfigError::NoSupportedGroups(name) => {
2530 assert_eq!(name, "StrictGroupTest");
2531 }
2532 other => panic!("expected NoSupportedGroups, got: {other}"),
2533 }
2534 }
2535
2536 #[test]
2537 fn into_arc_conversion() {
2538 let config = CHROME_131.to_rustls_config().unwrap();
2539 let arc: std::sync::Arc<rustls::ClientConfig> = config.into();
2540 assert!(!arc.alpn_protocols.is_empty());
2542 }
2543 }
2544
2545 #[cfg(feature = "tls-config")]
2548 mod reqwest_tests {
2549 use super::super::*;
2550
2551 #[test]
2552 fn build_profiled_client_no_proxy() {
2553 let client = build_profiled_client(&CHROME_131, None);
2554 assert!(
2555 client.is_ok(),
2556 "should build a client without error: {:?}",
2557 client.err()
2558 );
2559 }
2560
2561 #[test]
2562 fn build_profiled_client_all_profiles() {
2563 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2564 let result = build_profiled_client(profile, None);
2565 assert!(
2566 result.is_ok(),
2567 "profile '{}' should produce a valid client: {:?}",
2568 profile.name,
2569 result.err()
2570 );
2571 }
2572 }
2573
2574 #[test]
2575 fn build_profiled_client_strict_no_proxy() {
2576 let client = build_profiled_client_strict(&CHROME_131, None);
2577 assert!(
2578 client.is_ok(),
2579 "strict mode should build for built-in profile: {:?}",
2580 client.err()
2581 );
2582 }
2583
2584 #[test]
2585 fn build_profiled_client_preset_all_profiles() {
2586 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2587 let result = build_profiled_client_preset(profile, None);
2588 assert!(
2589 result.is_ok(),
2590 "preset builder should work for profile '{}': {:?}",
2591 profile.name,
2592 result.err()
2593 );
2594 }
2595 }
2596
2597 #[test]
2598 fn build_profiled_client_with_control_rejects_unknown_cipher_suite() {
2599 let mut profile = (*CHROME_131).clone();
2600 profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2601
2602 let client = build_profiled_client_with_control(&profile, None, TlsControl::strict());
2603
2604 assert!(
2605 client.is_err(),
2606 "strict mode should reject unsupported cipher suite"
2607 );
2608 }
2609
2610 #[test]
2611 fn default_user_agent_matches_browser() {
2612 assert!(default_user_agent(&CHROME_131).contains("Chrome/131"));
2613 assert!(default_user_agent(&FIREFOX_133).contains("Firefox/133"));
2614 assert!(default_user_agent(&SAFARI_18).contains("Safari/605"));
2615 assert!(default_user_agent(&EDGE_131).contains("Edg/131"));
2616 }
2617
2618 #[test]
2619 fn profile_for_device_mapping() {
2620 use crate::fingerprint::DeviceProfile;
2621
2622 assert_eq!(
2623 profile_for_device(&DeviceProfile::DesktopWindows).name,
2624 "Chrome 131"
2625 );
2626 assert_eq!(
2627 profile_for_device(&DeviceProfile::DesktopMac).name,
2628 "Safari 18"
2629 );
2630 assert_eq!(
2631 profile_for_device(&DeviceProfile::DesktopLinux).name,
2632 "Firefox 133"
2633 );
2634 assert_eq!(
2635 profile_for_device(&DeviceProfile::MobileAndroid).name,
2636 "Chrome 131"
2637 );
2638 assert_eq!(
2639 profile_for_device(&DeviceProfile::MobileIOS).name,
2640 "Safari 18"
2641 );
2642 }
2643
2644 #[test]
2645 fn browser_headers_chrome_has_sec_ch_ua() {
2646 let headers = browser_headers(&CHROME_131);
2647 assert!(
2648 headers.contains_key("sec-ch-ua"),
2649 "Chrome profile should have sec-ch-ua"
2650 );
2651 assert!(
2652 headers.contains_key("sec-fetch-dest"),
2653 "Chrome profile should have sec-fetch-dest"
2654 );
2655 let accept = headers.get("accept").unwrap().to_str().unwrap();
2656 assert!(
2657 accept.contains("image/avif"),
2658 "Chrome accept should include avif"
2659 );
2660 }
2661
2662 #[test]
2663 fn browser_headers_firefox_no_sec_ch_ua() {
2664 let headers = browser_headers(&FIREFOX_133);
2665 assert!(
2666 !headers.contains_key("sec-ch-ua"),
2667 "Firefox profile should not have sec-ch-ua"
2668 );
2669 let accept = headers.get("accept").unwrap().to_str().unwrap();
2670 assert!(
2671 accept.contains("text/html"),
2672 "Firefox accept should include text/html"
2673 );
2674 }
2675
2676 #[test]
2677 fn browser_headers_all_profiles_have_accept() {
2678 for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2679 let headers = browser_headers(profile);
2680 assert!(
2681 headers.contains_key("accept"),
2682 "profile '{}' must have accept header",
2683 profile.name
2684 );
2685 assert!(
2686 headers.contains_key("accept-encoding"),
2687 "profile '{}' must have accept-encoding",
2688 profile.name
2689 );
2690 assert!(
2691 headers.contains_key("accept-language"),
2692 "profile '{}' must have accept-language",
2693 profile.name
2694 );
2695 }
2696 }
2697
2698 #[test]
2699 fn browser_headers_edge_uses_edge_brand() {
2700 let headers = browser_headers(&EDGE_131);
2701 let sec_ch_ua = headers.get("sec-ch-ua").unwrap().to_str().unwrap();
2702 assert!(
2703 sec_ch_ua.contains("Microsoft Edge"),
2704 "Edge sec-ch-ua should identify Edge: {sec_ch_ua}"
2705 );
2706 }
2707 }
2708}
2709
2710#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2722#[non_exhaustive]
2723pub enum BrowserFamily {
2724 Chrome,
2726 Firefox,
2728 Safari,
2730 Edge,
2732}
2733
2734impl BrowserFamily {
2735 #[must_use]
2745 pub const fn as_str(self) -> &'static str {
2746 match self {
2747 Self::Chrome => "chrome",
2748 Self::Firefox => "firefox",
2749 Self::Safari => "safari",
2750 Self::Edge => "edge",
2751 }
2752 }
2753}
2754
2755impl fmt::Display for BrowserFamily {
2756 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2757 f.write_str(self.as_str())
2758 }
2759}
2760
2761#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2771#[non_exhaustive]
2772pub enum PlatformClass {
2773 Windows,
2775 MacOs,
2777 Linux,
2779}
2780
2781impl PlatformClass {
2782 #[must_use]
2792 pub const fn as_str(self) -> &'static str {
2793 match self {
2794 Self::Windows => "windows",
2795 Self::MacOs => "macos",
2796 Self::Linux => "linux",
2797 }
2798 }
2799}
2800
2801impl fmt::Display for PlatformClass {
2802 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2803 f.write_str(self.as_str())
2804 }
2805}
2806
2807#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2822#[non_exhaustive]
2823pub enum ProfileChannel {
2824 ChromeLatest,
2826 FirefoxLatest,
2828 SafariLatest,
2830 EdgeLatest,
2832 Chrome131,
2834 Firefox133,
2836 Safari18,
2838 Edge131,
2840}
2841
2842impl ProfileChannel {
2843 pub fn resolve(
2863 self,
2864 _platform: Option<PlatformClass>,
2865 ) -> Result<&'static TlsProfilePack, ProfileChannelError> {
2866 match self {
2867 Self::ChromeLatest | Self::Chrome131 => Ok(&PACK_CHROME_131),
2868 Self::FirefoxLatest | Self::Firefox133 => Ok(&PACK_FIREFOX_133),
2869 Self::SafariLatest | Self::Safari18 => Ok(&PACK_SAFARI_18),
2870 Self::EdgeLatest | Self::Edge131 => Ok(&PACK_EDGE_131),
2871 }
2872 }
2873}
2874
2875impl std::str::FromStr for ProfileChannel {
2876 type Err = ProfileChannelError;
2877
2878 fn from_str(s: &str) -> Result<Self, Self::Err> {
2906 match s.to_ascii_lowercase().as_str() {
2907 "chrome-latest" => Ok(Self::ChromeLatest),
2908 "firefox-latest" => Ok(Self::FirefoxLatest),
2909 "safari-latest" => Ok(Self::SafariLatest),
2910 "edge-latest" => Ok(Self::EdgeLatest),
2911 "chrome-131" => Ok(Self::Chrome131),
2912 "firefox-133" => Ok(Self::Firefox133),
2913 "safari-18" => Ok(Self::Safari18),
2914 "edge-131" => Ok(Self::Edge131),
2915 other => Err(ProfileChannelError::UnknownChannel(other.to_string())),
2916 }
2917 }
2918}
2919
2920#[derive(Debug, thiserror::Error)]
2932#[non_exhaustive]
2933pub enum ProfileChannelError {
2934 #[error(
2936 "unknown profile channel '{0}'; known channels: chrome-latest, firefox-latest, safari-latest, edge-latest, chrome-131, firefox-133, safari-18, edge-131"
2937 )]
2938 UnknownChannel(String),
2939}
2940
2941#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2953pub struct ProfileMetadata {
2954 pub family: BrowserFamily,
2956 pub browser_version: String,
2958 pub platform: PlatformClass,
2960 pub h2_support: bool,
2962 pub h3_support: bool,
2964 pub added_date: String,
2966 pub source_notes: String,
2968}
2969
2970impl ProfileMetadata {
2971 #[must_use]
2983 pub fn provenance(&self) -> String {
2984 format!(
2985 "{} {} on {} (added {}; {})",
2986 self.family, self.browser_version, self.platform, self.added_date, self.source_notes
2987 )
2988 }
2989}
2990
2991#[derive(Debug)]
3030pub struct TlsProfilePack {
3031 pub profile: &'static TlsProfile,
3033 pub metadata: ProfileMetadata,
3035}
3036
3037pub static PACK_CHROME_131: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
3048 profile: &CHROME_131,
3049 metadata: ProfileMetadata {
3050 family: BrowserFamily::Chrome,
3051 browser_version: "131".to_string(),
3052 platform: PlatformClass::Windows,
3053 h2_support: true,
3054 h3_support: true,
3055 added_date: "2024-12-01".to_string(),
3056 source_notes: "ClientHello capture from Chrome 131.0.6778.86 on Windows 11".to_string(),
3057 },
3058});
3059
3060pub static PACK_FIREFOX_133: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
3071 profile: &FIREFOX_133,
3072 metadata: ProfileMetadata {
3073 family: BrowserFamily::Firefox,
3074 browser_version: "133".to_string(),
3075 platform: PlatformClass::Windows,
3076 h2_support: true,
3077 h3_support: true,
3078 added_date: "2024-12-01".to_string(),
3079 source_notes: "ClientHello capture from Firefox 133.0 on Windows 11".to_string(),
3080 },
3081});
3082
3083pub static PACK_SAFARI_18: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
3094 profile: &SAFARI_18,
3095 metadata: ProfileMetadata {
3096 family: BrowserFamily::Safari,
3097 browser_version: "18".to_string(),
3098 platform: PlatformClass::MacOs,
3099 h2_support: true,
3100 h3_support: false,
3101 added_date: "2024-12-01".to_string(),
3102 source_notes: "ClientHello capture from Safari 18.1 on macOS 15 Sequoia".to_string(),
3103 },
3104});
3105
3106pub static PACK_EDGE_131: LazyLock<TlsProfilePack> = LazyLock::new(|| TlsProfilePack {
3117 profile: &EDGE_131,
3118 metadata: ProfileMetadata {
3119 family: BrowserFamily::Edge,
3120 browser_version: "131".to_string(),
3121 platform: PlatformClass::Windows,
3122 h2_support: true,
3123 h3_support: true,
3124 added_date: "2024-12-01".to_string(),
3125 source_notes: "ClientHello capture from Edge 131.0.2903.70 on Windows 11".to_string(),
3126 },
3127});
3128
3129#[cfg(test)]
3130mod pack_tests {
3131 use super::*;
3132
3133 #[test]
3134 fn channel_latest_resolves_to_expected_profile() -> Result<(), ProfileChannelError> {
3135 let chrome = ProfileChannel::ChromeLatest.resolve(None)?;
3136 assert_eq!(chrome.profile.name, "Chrome 131");
3137
3138 let firefox = ProfileChannel::FirefoxLatest.resolve(None)?;
3139 assert_eq!(firefox.profile.name, "Firefox 133");
3140
3141 let safari = ProfileChannel::SafariLatest.resolve(None)?;
3142 assert_eq!(safari.profile.name, "Safari 18");
3143
3144 let edge = ProfileChannel::EdgeLatest.resolve(None)?;
3145 assert_eq!(edge.profile.name, "Edge 131");
3146 Ok(())
3147 }
3148
3149 #[test]
3150 fn pinned_channels_resolve_to_same_as_latest() -> Result<(), ProfileChannelError> {
3151 let chrome_pinned = ProfileChannel::Chrome131.resolve(None)?;
3152 let chrome_latest = ProfileChannel::ChromeLatest.resolve(None)?;
3153 assert!(std::ptr::eq(chrome_pinned, chrome_latest));
3154 Ok(())
3155 }
3156
3157 #[test]
3158 fn metadata_is_serializable() -> Result<(), Box<dyn std::error::Error>> {
3159 let pack = &*PACK_CHROME_131;
3160 let json = serde_json::to_string(&pack.metadata)?;
3161 assert!(json.contains("Chrome"));
3162 assert!(json.contains("131"));
3163
3164 let meta: ProfileMetadata = serde_json::from_str(json.as_str())?;
3165 assert_eq!(meta.family, BrowserFamily::Chrome);
3166 assert_eq!(meta.browser_version, "131");
3167 Ok(())
3168 }
3169
3170 #[test]
3171 fn invalid_channel_returns_error() {
3172 let result = "netscape-4".parse::<ProfileChannel>();
3173 assert!(
3174 matches!(result, Err(ProfileChannelError::UnknownChannel(ref s)) if s.contains("netscape-4"))
3175 );
3176 }
3177
3178 #[test]
3179 fn from_str_parsing_case_insensitive() -> Result<(), ProfileChannelError> {
3180 let ch: ProfileChannel = "CHROME-LATEST".parse()?;
3181 assert_eq!(ch, ProfileChannel::ChromeLatest);
3182 Ok(())
3183 }
3184
3185 #[test]
3186 fn provenance_contains_family_and_version() {
3187 let prov = PACK_FIREFOX_133.metadata.provenance();
3188 assert!(prov.contains("firefox"), "provenance: {prov}");
3189 assert!(prov.contains("133"), "provenance: {prov}");
3190 }
3191
3192 #[test]
3193 fn safari_h3_not_supported() {
3194 assert!(!PACK_SAFARI_18.metadata.h3_support);
3195 }
3196
3197 #[test]
3198 fn platform_hint_accepted_without_error() -> Result<(), ProfileChannelError> {
3199 let pack = ProfileChannel::ChromeLatest.resolve(Some(PlatformClass::Linux))?;
3200 assert_eq!(pack.profile.name, "Chrome 131");
3201 Ok(())
3202 }
3203}