#[derive(Debug, Clone)]
pub struct TlsFingerprint {
pub cipher_suites: Vec<u16>,
pub extensions: Vec<u16>,
pub signature_algorithms: Vec<u16>,
pub supported_groups: Vec<u16>,
pub alpn_protocols: Vec<Vec<u8>>,
pub ja3_hash: &'static str,
pub tls_version: &'static str,
pub record_size_limit: Option<u16>,
pub compress_certificate_algos: Vec<u16>,
pub application_settings_protocol: Option<&'static str>,
}
impl TlsFingerprint {
pub fn firefox() -> Self {
TlsFingerprint {
cipher_suites: vec![
0x1301, 0x1303, 0x1302, 0xC02B, 0xC02F, 0xC02C, 0xC030,
0x009E, 0x009C, 0xCCA9, 0xCCA8, 0xC013, 0xC009, 0x0033, 0x0067,
],
extensions: vec![
0x0000, 0x0005, 0x000A, 0x000B, 0x000D, 0x0012, 0x0015,
0x0016, 0x0017, 0x001B, 0x0023, 0x002B, 0x002D, 0x0033,
0xFE0D, 0x0010, 0x0000,
],
signature_algorithms: vec![
0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806,
0x0601, 0x0203, 0x0201,
],
supported_groups: vec![0x001D, 0x0017, 0x0018, 0x0019, 0x0100, 0x0101],
alpn_protocols: vec![b"h2".to_vec(), b"http/1.1".to_vec()],
ja3_hash: "771,4865-4867-4866-49195-49199-49196-49200-158-156-52393-52392-49171-49161-51-103,0-5-10-11-13-18-21-22-23-27-35-43-45-51-65037-16-0,29-23-24-25-256-257,1027-2052-1025-1283-2053-1281-2054-1537-515-513",
tls_version: "771",
record_size_limit: None,
compress_certificate_algos: vec![],
application_settings_protocol: None,
}
}
pub fn chrome() -> Self {
Self::chrome_120()
}
pub fn chrome_120() -> Self {
TlsFingerprint {
cipher_suites: vec![
0x1301, 0x1302, 0x1303, 0xC02B, 0xC02F, 0xC02C, 0xC030,
0xCCA9, 0xCCA8, 0xC013, 0xC009, 0x0033, 0x0067,
],
extensions: vec![
0x0000, 0x0005, 0x000A, 0x000B, 0x000D, 0x0012, 0x0015,
0x0016, 0x0017, 0x001B, 0x0023, 0x002B, 0x002D, 0x0033,
0xFE0D, 0x0010, 0x0000,
],
signature_algorithms: vec![
0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806,
0x0601, 0x0203, 0x0201,
],
supported_groups: vec![0x001D, 0x0017, 0x0018],
alpn_protocols: vec![b"h2".to_vec(), b"http/1.1".to_vec()],
ja3_hash: "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49161-51-103,0-5-10-11-13-18-21-22-23-27-35-43-45-51-65037-16-0,29-23-24,1027-2052-1025-1283-2053-1281-2054-1537-515-513",
tls_version: "771",
record_size_limit: None,
compress_certificate_algos: vec![],
application_settings_protocol: None,
}
}
pub fn chrome_latest() -> Self {
TlsFingerprint {
cipher_suites: vec![
0x1301, 0x1302, 0x1303, 0xC02B, 0xC02F, 0xC02C, 0xC030,
0xCCA9, 0xCCA8, 0xC013, 0xC009, 0x0033, 0x0067,
],
extensions: vec![
0x0000, 0x0005, 0x000A, 0x000B, 0x000D, 0x0012, 0x0015,
0x0016, 0x0017, 0x001B, 0x0023, 0x002B, 0x002D, 0x0033,
0xFE0D, 0x0010, 0x0000, 0x001C, 0x0039,
],
signature_algorithms: vec![
0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806,
0x0601, 0x0203, 0x0201,
],
supported_groups: vec![0x001D, 0x0017, 0x0018],
alpn_protocols: vec![b"h2".to_vec(), b"http/1.1".to_vec()],
ja3_hash: "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49161-51-103,0-5-10-11-13-18-21-22-23-27-35-43-45-51-65037-16-0-28-57,29-23-24,1027-2052-1025-1283-2053-1281-2054-1537-515-513",
tls_version: "771",
record_size_limit: Some(0x4001),
compress_certificate_algos: vec![0x0002, 0x0001],
application_settings_protocol: Some("h2"),
}
}
pub fn compute_ja3(&self) -> String {
let ciphers: Vec<String> = self.cipher_suites.iter().map(|c| format!("{c}")).collect();
let exts: Vec<String> = self.extensions.iter().map(|e| format!("{e}")).collect();
let curves: Vec<String> = self
.supported_groups
.iter()
.map(|g| format!("{g}"))
.collect();
let sigs: Vec<String> = self
.signature_algorithms
.iter()
.map(|s| format!("{s}"))
.collect();
format!(
"771,{},{},{},{}",
ciphers.join("-"),
exts.join("-"),
curves.join("-"),
sigs.join("-"),
)
}
pub fn compute_ja4(&self) -> String {
use bun_sha_hmac::SHA256;
let num_suites = self.cipher_suites.len();
let num_exts = self.extensions.len();
let tls13_count = self
.cipher_suites
.iter()
.filter(|&&c| (0x1301..=0x1303).contains(&c))
.count();
let tls12_count = num_suites - tls13_count;
let mut alpn_sorted: Vec<String> = self
.alpn_protocols
.iter()
.filter_map(|p| std::str::from_utf8(p).ok().map(|s| s.to_string()))
.collect();
alpn_sorted.sort();
let alpn_joined = alpn_sorted.join(",");
let alpn_hash = {
let mut hasher = SHA256::init();
hasher.update(alpn_joined.as_bytes());
let mut out = [0u8; SHA256::DIGEST];
hasher.r#final(&mut out);
out.iter().map(|b| format!("{:02x}", b)).collect::<String>()
};
format!(
"t13d{:02x}{:02x}{:02x}_{}",
tls13_count.min(99),
tls12_count.min(99),
num_exts.min(99),
&alpn_hash[..12.min(alpn_hash.len())]
)
}
pub fn alpn_strings(&self) -> Vec<&str> {
self.alpn_protocols
.iter()
.filter_map(|p| std::str::from_utf8(p).ok())
.collect()
}
pub fn is_tls13_suite(&self, suite: u16) -> bool {
(0x1301..=0x1303).contains(&suite)
}
pub fn tls13_suites(&self) -> Vec<u16> {
self.cipher_suites
.iter()
.copied()
.filter(|s| self.is_tls13_suite(*s))
.collect()
}
pub fn tls12_suites(&self) -> Vec<u16> {
self.cipher_suites
.iter()
.copied()
.filter(|s| !self.is_tls13_suite(*s))
.collect()
}
pub fn tls12_cipher_list_string(&self) -> String {
boringssl_cipher_list_string(&self.tls12_suites())
}
pub fn tls13_cipher_suites_string(&self) -> String {
self.tls13_suites()
.iter()
.filter_map(|&id| cipher_suite_openssl_name(id))
.collect::<Vec<_>>()
.join(":")
}
pub fn curves_list_string(&self) -> String {
boringssl_curves_list_string(&self.supported_groups)
}
pub fn sigalgs_list_string(&self) -> String {
boringssl_sigalgs_list_string(&self.signature_algorithms)
}
}
pub fn cipher_suite_openssl_name(id: u16) -> Option<&'static str> {
match id {
0x1301 => Some("TLS_AES_128_GCM_SHA256"),
0x1302 => Some("TLS_AES_256_GCM_SHA384"),
0x1303 => Some("TLS_CHACHA20_POLY1305_SHA256"),
0xC02B => Some("ECDHE-ECDSA-AES128-GCM-SHA256"),
0xC02F => Some("ECDHE-RSA-AES128-GCM-SHA256"),
0xC02C => Some("ECDHE-ECDSA-AES256-GCM-SHA384"),
0xC030 => Some("ECDHE-RSA-AES256-GCM-SHA384"),
0xCCA9 => Some("ECDHE-ECDSA-CHACHA20-POLY1305"),
0xCCA8 => Some("ECDHE-RSA-CHACHA20-POLY1305"),
0x009E => Some("DHE-RSA-AES128-GCM-SHA256"),
0x009C => Some("DHE-RSA-AES256-GCM-SHA384"),
0x0033 => Some("DHE-RSA-AES128-SHA"),
0x0067 => Some("DHE-RSA-AES256-SHA256"),
0xC013 => Some("ECDHE-RSA-AES128-SHA"),
0xC009 => Some("ECDHE-ECDSA-AES128-SHA"),
0xC027 => Some("ECDHE-RSA-AES128-SHA256"),
0xC028 => Some("ECDHE-RSA-AES256-SHA384"),
0x002F => Some("AES128-SHA"),
0x0035 => Some("AES256-SHA"),
_ => None,
}
}
pub fn cipher_suite_boringssl_supported(id: u16) -> bool {
!matches!(id, 0x009E | 0x009C | 0x0033 | 0x0067)
}
pub fn group_openssl_name(id: u16) -> Option<&'static str> {
match id {
0x001D => Some("X25519"),
0x0017 => Some("P-256"),
0x0018 => Some("P-384"),
0x0019 => Some("P-521"),
0x0100 => Some("ffdhe2048"),
0x0101 => Some("ffdhe3072"),
_ => None,
}
}
pub fn group_boringssl_supported(id: u16) -> bool {
!matches!(id, 0x0100 | 0x0101)
}
pub fn sigalg_openssl_name(id: u16) -> Option<&'static str> {
match id {
0x0403 => Some("ecdsa_secp256r1_sha256"),
0x0503 => Some("ecdsa_secp384r1_sha384"),
0x0603 => Some("ecdsa_secp521r1_sha512"),
0x0804 => Some("rsa_pss_rsae_sha256"),
0x0805 => Some("rsa_pss_rsae_sha384"),
0x0806 => Some("rsa_pss_rsae_sha512"),
0x0401 => Some("rsa_pkcs1_sha256"),
0x0501 => Some("rsa_pkcs1_sha384"),
0x0601 => Some("rsa_pkcs1_sha512"),
0x0203 => Some("ecdsa_sha1"),
0x0201 => Some("rsa_pkcs1_sha1"),
_ => None,
}
}
pub fn boringssl_cipher_list_string(suites: &[u16]) -> String {
suites
.iter()
.copied()
.filter(|&id| cipher_suite_boringssl_supported(id) && !(0x1301..=0x1303).contains(&id))
.filter_map(cipher_suite_openssl_name)
.collect::<Vec<_>>()
.join(":")
}
pub fn boringssl_curves_list_string(groups: &[u16]) -> String {
groups
.iter()
.copied()
.filter(|&id| group_boringssl_supported(id))
.filter_map(group_openssl_name)
.collect::<Vec<_>>()
.join(":")
}
pub fn boringssl_sigalgs_list_string(sigalgs: &[u16]) -> String {
sigalgs
.iter()
.copied()
.filter_map(sigalg_openssl_name)
.collect::<Vec<_>>()
.join(":")
}
#[derive(Debug, Clone)]
pub struct TlsFingerprintConfig {
pub tls12_cipher_list: String,
pub tls13_cipher_suites: String,
pub curves_list: String,
pub sigalgs_list: String,
}
impl TlsFingerprintConfig {
pub fn from_fingerprint(fp: &TlsFingerprint) -> Self {
TlsFingerprintConfig {
tls12_cipher_list: fp.tls12_cipher_list_string(),
tls13_cipher_suites: fp.tls13_cipher_suites_string(),
curves_list: fp.curves_list_string(),
sigalgs_list: fp.sigalgs_list_string(),
}
}
pub fn has_fingerprint(&self) -> bool {
!self.tls12_cipher_list.is_empty()
|| !self.tls13_cipher_suites.is_empty()
|| !self.curves_list.is_empty()
|| !self.sigalgs_list.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_firefox_fingerprint_nonempty() {
let fp = TlsFingerprint::firefox();
assert!(!fp.cipher_suites.is_empty());
assert!(!fp.extensions.is_empty());
assert!(!fp.signature_algorithms.is_empty());
assert!(!fp.supported_groups.is_empty());
assert!(!fp.alpn_protocols.is_empty());
}
#[test]
fn test_chrome_fingerprint_nonempty() {
let fp = TlsFingerprint::chrome();
assert!(!fp.cipher_suites.is_empty());
assert!(!fp.extensions.is_empty());
assert!(!fp.signature_algorithms.is_empty());
assert!(!fp.supported_groups.is_empty());
assert!(!fp.alpn_protocols.is_empty());
}
#[test]
fn test_chrome_latest_has_record_size_limit() {
let fp = TlsFingerprint::chrome_latest();
assert!(fp.record_size_limit.is_some());
}
#[test]
fn test_chrome_latest_has_compress_certificate() {
let fp = TlsFingerprint::chrome_latest();
assert!(!fp.compress_certificate_algos.is_empty());
}
#[test]
fn test_firefox_no_record_size_limit() {
let fp = TlsFingerprint::firefox();
assert!(fp.record_size_limit.is_none());
}
#[test]
fn test_chrome_120_no_record_size_limit() {
let fp = TlsFingerprint::chrome_120();
assert!(fp.record_size_limit.is_none());
}
#[test]
fn test_tls_version_is_771() {
assert_eq!(TlsFingerprint::firefox().tls_version, "771");
assert_eq!(TlsFingerprint::chrome().tls_version, "771");
assert_eq!(TlsFingerprint::chrome_latest().tls_version, "771");
}
#[test]
fn test_alpn_protocols_contain_h2() {
let fp = TlsFingerprint::firefox();
assert!(fp.alpn_protocols.iter().any(|p| p == b"h2"));
let fp = TlsFingerprint::chrome();
assert!(fp.alpn_protocols.iter().any(|p| p == b"h2"));
}
#[test]
fn test_compute_ja3_starts_with_771() {
let fp = TlsFingerprint::firefox();
let ja3 = fp.compute_ja3();
assert!(ja3.starts_with("771,"));
}
#[test]
fn test_compute_ja3_matches_stored_hash_firefox() {
let fp = TlsFingerprint::firefox();
let computed = fp.compute_ja3();
assert_eq!(
computed, fp.ja3_hash,
"compute_ja3() must equal stored ja3_hash for Firefox — computed: {}",
computed
);
}
#[test]
fn test_compute_ja3_matches_stored_hash_chrome() {
let fp = TlsFingerprint::chrome();
let computed = fp.compute_ja3();
assert_eq!(
computed, fp.ja3_hash,
"compute_ja3() must equal stored ja3_hash for Chrome — computed: {}",
computed
);
}
#[test]
fn test_compute_ja3_matches_stored_hash_chrome_latest() {
let fp = TlsFingerprint::chrome_latest();
let computed = fp.compute_ja3();
assert_eq!(
computed, fp.ja3_hash,
"compute_ja3() must equal stored ja3_hash for Chrome latest — computed: {}",
computed
);
}
#[test]
fn test_compute_ja3_firefox_consistent() {
let fp = TlsFingerprint::firefox();
let ja3 = fp.compute_ja3();
assert!(ja3.starts_with("771,"));
let parts: Vec<&str> = ja3.split(',').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[1].split('-').count(), fp.cipher_suites.len());
}
#[test]
fn test_compute_ja3_chrome_consistent() {
let fp = TlsFingerprint::chrome();
let ja3 = fp.compute_ja3();
assert!(ja3.starts_with("771,"));
let parts: Vec<&str> = ja3.split(',').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[1].split('-').count(), fp.cipher_suites.len());
}
#[test]
fn test_compute_ja3_chrome_latest_consistent() {
let fp = TlsFingerprint::chrome_latest();
let ja3 = fp.compute_ja3();
assert!(ja3.starts_with("771,"));
let parts: Vec<&str> = ja3.split(',').collect();
assert_eq!(parts.len(), 5);
}
#[test]
fn test_compute_ja3_format_four_csv_fields() {
let fp = TlsFingerprint::firefox();
let ja3 = fp.compute_ja3();
let parts: Vec<&str> = ja3.split(',').collect();
assert_eq!(parts.len(), 5); }
#[test]
fn test_compute_ja4_starts_with_t13d() {
let fp = TlsFingerprint::firefox();
let ja4 = fp.compute_ja4();
assert!(ja4.starts_with("t13d"), "JA4: {}", ja4);
}
#[test]
fn test_compute_ja4_deterministic() {
let fp = TlsFingerprint::firefox();
let ja4_a = fp.compute_ja4();
let ja4_b = fp.compute_ja4();
assert_eq!(ja4_a, ja4_b, "JA4 must be deterministic");
}
#[test]
fn test_compute_ja4_alpn_hash_is_hex() {
let fp = TlsFingerprint::firefox();
let ja4 = fp.compute_ja4();
let parts: Vec<&str> = ja4.split('_').collect();
assert!(
parts.len() >= 2,
"JA4 should have underscore separator: {}",
ja4
);
let hash_part = parts.last().unwrap();
assert!(
hash_part.len() >= 12,
"ALPN hash part should be at least 12 chars: {}",
hash_part
);
assert!(
hash_part.chars().all(|c| c.is_ascii_hexdigit()),
"ALPN hash must be valid hex: {}",
hash_part
);
}
#[test]
fn test_compute_ja4_differs_between_profiles() {
let ff = TlsFingerprint::firefox();
let ch = TlsFingerprint::chrome();
assert_ne!(
ff.compute_ja4(),
ch.compute_ja4(),
"Firefox and Chrome must produce different JA4 fingerprints"
);
}
#[test]
fn test_compute_ja4_firefox_format() {
let fp = TlsFingerprint::firefox();
let ja4 = fp.compute_ja4();
assert!(ja4.starts_with("t13d"));
let parts: Vec<&str> = ja4.split('_').collect();
assert!(parts.len() >= 2, "JA4 should contain underscore: {}", ja4);
}
#[test]
fn test_compute_ja4_chrome_format() {
let fp = TlsFingerprint::chrome();
let ja4 = fp.compute_ja4();
assert!(ja4.starts_with("t13d"));
let parts: Vec<&str> = ja4.split('_').collect();
assert!(parts.len() >= 2, "JA4 should contain underscore: {}", ja4);
}
#[test]
fn test_compute_ja4_contains_underscore_separator() {
let fp = TlsFingerprint::firefox();
let ja4 = fp.compute_ja4();
assert!(ja4.contains("_"), "JA4 should contain underscore: {}", ja4);
}
#[test]
fn test_is_tls13_suite() {
let fp = TlsFingerprint::firefox();
assert!(fp.is_tls13_suite(0x1301));
assert!(fp.is_tls13_suite(0x1302));
assert!(fp.is_tls13_suite(0x1303));
assert!(!fp.is_tls13_suite(0xC02B));
assert!(!fp.is_tls13_suite(0x009E));
}
#[test]
fn test_tls13_suites_count() {
let fp = TlsFingerprint::firefox();
let tls13 = fp.tls13_suites();
assert_eq!(tls13.len(), 3); assert!(tls13.contains(&0x1301));
}
#[test]
fn test_tls12_suites_count() {
let fp = TlsFingerprint::firefox();
let tls12 = fp.tls12_suites();
assert_eq!(
tls12.len(),
fp.cipher_suites.len() - fp.tls13_suites().len()
);
}
#[test]
fn test_suite_partition_completeness() {
let fp = TlsFingerprint::chrome();
let total = fp.cipher_suites.len();
let tls13 = fp.tls13_suites().len();
let tls12 = fp.tls12_suites().len();
assert_eq!(total, tls13 + tls12);
}
#[test]
fn test_alpn_strings_firefox() {
let fp = TlsFingerprint::firefox();
let strings = fp.alpn_strings();
assert!(strings.contains(&"h2"));
assert!(strings.contains(&"http/1.1"));
}
#[test]
fn test_alpn_strings_chrome() {
let fp = TlsFingerprint::chrome();
let strings = fp.alpn_strings();
assert!(strings.contains(&"h2"));
assert!(strings.contains(&"http/1.1"));
}
#[test]
fn test_fingerprint_clone() {
let fp = TlsFingerprint::firefox();
let cloned = fp.clone();
assert_eq!(fp.cipher_suites, cloned.cipher_suites);
assert_eq!(fp.ja3_hash, cloned.ja3_hash);
}
#[test]
fn test_fingerprint_debug_format() {
let fp = TlsFingerprint::chrome();
let debug_str = format!("{:?}", fp);
assert!(debug_str.contains("TlsFingerprint"));
assert!(debug_str.contains("cipher_suites"));
}
#[test]
fn test_firefox_chrome_different_ciphers() {
let ff = TlsFingerprint::firefox();
let ch = TlsFingerprint::chrome();
assert_ne!(ff.cipher_suites.len(), ch.cipher_suites.len());
}
#[test]
fn test_chrome_and_chrome_120_are_same() {
let ch = TlsFingerprint::chrome();
let ch120 = TlsFingerprint::chrome_120();
assert_eq!(ch.cipher_suites, ch120.cipher_suites);
assert_eq!(ch.extensions, ch120.extensions);
}
#[test]
fn test_chrome_latest_more_extensions_than_120() {
let ch120 = TlsFingerprint::chrome_120();
let ch_latest = TlsFingerprint::chrome_latest();
assert!(ch_latest.extensions.len() > ch120.extensions.len());
}
#[test]
fn test_tls12_cipher_list_firefox_nonempty() {
let fp = TlsFingerprint::firefox();
let list = fp.tls12_cipher_list_string();
assert!(!list.is_empty(), "TLS 1.2 cipher list should not be empty");
assert!(list.contains("ECDHE"), "Should contain ECDHE suites");
}
#[test]
fn test_tls12_cipher_list_chrome_nonempty() {
let fp = TlsFingerprint::chrome();
let list = fp.tls12_cipher_list_string();
assert!(!list.is_empty());
}
#[test]
fn test_tls13_cipher_suites_firefox() {
let fp = TlsFingerprint::firefox();
let list = fp.tls13_cipher_suites_string();
assert!(
list.contains("TLS_AES_128_GCM_SHA256"),
"Should contain TLS 1.3 AES-128"
);
assert!(
list.contains("TLS_AES_256_GCM_SHA384"),
"Should contain TLS 1.3 AES-256"
);
assert!(
list.contains("TLS_CHACHA20_POLY1305_SHA256"),
"Should contain TLS 1.3 ChaCha20"
);
}
#[test]
fn test_tls13_cipher_suites_chrome() {
let fp = TlsFingerprint::chrome();
let list = fp.tls13_cipher_suites_string();
assert!(list.contains("TLS_AES_128_GCM_SHA256"));
}
#[test]
fn test_curves_list_firefox() {
let fp = TlsFingerprint::firefox();
let list = fp.curves_list_string();
assert!(list.contains("X25519"), "Should contain X25519");
assert!(list.contains("P-256"), "Should contain P-256");
}
#[test]
fn test_curves_list_chrome() {
let fp = TlsFingerprint::chrome();
let list = fp.curves_list_string();
assert!(list.contains("X25519"));
assert!(list.contains("P-256"));
}
#[test]
fn test_sigalgs_list_firefox() {
let fp = TlsFingerprint::firefox();
let list = fp.sigalgs_list_string();
assert!(
list.contains("ecdsa_secp256r1_sha256"),
"Should contain ECDSA P-256"
);
assert!(
list.contains("rsa_pss_rsae_sha256"),
"Should contain RSA-PSS"
);
}
#[test]
fn test_sigalgs_list_chrome() {
let fp = TlsFingerprint::chrome();
let list = fp.sigalgs_list_string();
assert!(list.contains("ecdsa_secp256r1_sha256"));
}
#[test]
fn test_tls12_cipher_list_colon_separated() {
let fp = TlsFingerprint::firefox();
let list = fp.tls12_cipher_list_string();
assert!(!list.starts_with(':'), "No leading colon");
assert!(!list.ends_with(':'), "No trailing colon");
assert!(!list.contains("::"), "No double colons");
}
#[test]
fn test_curves_list_colon_separated() {
let fp = TlsFingerprint::firefox();
let list = fp.curves_list_string();
assert!(!list.starts_with(':'));
assert!(!list.ends_with(':'));
}
#[test]
fn test_sigalgs_list_colon_separated() {
let fp = TlsFingerprint::firefox();
let list = fp.sigalgs_list_string();
assert!(!list.starts_with(':'));
assert!(!list.ends_with(':'));
}
#[test]
fn test_firefox_chrome_tls12_lists_converge_on_boringssl() {
let ff = TlsFingerprint::firefox();
let ch = TlsFingerprint::chrome();
assert_eq!(ff.tls12_cipher_list_string(), ch.tls12_cipher_list_string());
assert_ne!(ff.cipher_suites, ch.cipher_suites);
}
#[test]
fn test_cipher_suite_openssl_name_known_ids() {
assert_eq!(
cipher_suite_openssl_name(0x1301),
Some("TLS_AES_128_GCM_SHA256")
);
assert_eq!(
cipher_suite_openssl_name(0xC02B),
Some("ECDHE-ECDSA-AES128-GCM-SHA256")
);
assert_eq!(cipher_suite_openssl_name(0xFFFF), None);
}
#[test]
fn test_group_openssl_name_known_ids() {
assert_eq!(group_openssl_name(0x001D), Some("X25519"));
assert_eq!(group_openssl_name(0x0017), Some("P-256"));
assert_eq!(group_openssl_name(0xFFFF), None);
}
#[test]
fn test_sigalg_openssl_name_known_ids() {
assert_eq!(sigalg_openssl_name(0x0403), Some("ecdsa_secp256r1_sha256"));
assert_eq!(sigalg_openssl_name(0x0804), Some("rsa_pss_rsae_sha256"));
assert_eq!(sigalg_openssl_name(0xFFFF), None);
}
#[test]
fn test_fingerprint_config_from_firefox() {
let fp = TlsFingerprint::firefox();
let config = TlsFingerprintConfig::from_fingerprint(&fp);
assert!(!config.tls12_cipher_list.is_empty());
assert!(!config.tls13_cipher_suites.is_empty());
assert!(!config.curves_list.is_empty());
assert!(!config.sigalgs_list.is_empty());
}
#[test]
fn test_fingerprint_config_from_chrome() {
let fp = TlsFingerprint::chrome();
let config = TlsFingerprintConfig::from_fingerprint(&fp);
assert!(!config.tls12_cipher_list.is_empty());
assert!(!config.tls13_cipher_suites.is_empty());
assert!(!config.curves_list.is_empty());
assert!(!config.sigalgs_list.is_empty());
}
#[test]
fn test_fingerprint_config_from_chrome_latest() {
let fp = TlsFingerprint::chrome_latest();
let config = TlsFingerprintConfig::from_fingerprint(&fp);
assert!(config.has_fingerprint());
}
#[test]
fn test_fingerprint_config_has_fingerprint_true() {
let fp = TlsFingerprint::firefox();
let config = TlsFingerprintConfig::from_fingerprint(&fp);
assert!(config.has_fingerprint());
}
#[test]
fn test_fingerprint_config_has_fingerprint_false() {
let config = TlsFingerprintConfig {
tls12_cipher_list: String::new(),
tls13_cipher_suites: String::new(),
curves_list: String::new(),
sigalgs_list: String::new(),
};
assert!(!config.has_fingerprint());
}
#[test]
fn test_fingerprint_config_firefox_chrome_different() {
let ff_config = TlsFingerprintConfig::from_fingerprint(&TlsFingerprint::firefox());
let ch_config = TlsFingerprintConfig::from_fingerprint(&TlsFingerprint::chrome());
assert_ne!(ff_config.curves_list, ch_config.curves_list);
}
#[test]
fn test_fingerprint_config_clone() {
let fp = TlsFingerprint::firefox();
let config = TlsFingerprintConfig::from_fingerprint(&fp);
let cloned = config.clone();
assert_eq!(config.tls12_cipher_list, cloned.tls12_cipher_list);
assert_eq!(config.tls13_cipher_suites, cloned.tls13_cipher_suites);
assert_eq!(config.curves_list, cloned.curves_list);
assert_eq!(config.sigalgs_list, cloned.sigalgs_list);
}
#[test]
fn test_fingerprint_config_debug() {
let fp = TlsFingerprint::firefox();
let config = TlsFingerprintConfig::from_fingerprint(&fp);
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("TlsFingerprintConfig"));
assert!(debug_str.contains("tls12_cipher_list"));
}
#[test]
fn test_cipher_table_exhaustive_iana_truth() {
assert_eq!(cipher_suite_openssl_name(0x1301), Some("TLS_AES_128_GCM_SHA256"));
assert_eq!(cipher_suite_openssl_name(0x1302), Some("TLS_AES_256_GCM_SHA384"));
assert_eq!(cipher_suite_openssl_name(0x1303), Some("TLS_CHACHA20_POLY1305_SHA256"));
assert_eq!(cipher_suite_openssl_name(0xC02B), Some("ECDHE-ECDSA-AES128-GCM-SHA256"));
assert_eq!(cipher_suite_openssl_name(0xC02F), Some("ECDHE-RSA-AES128-GCM-SHA256"));
assert_eq!(cipher_suite_openssl_name(0xC02C), Some("ECDHE-ECDSA-AES256-GCM-SHA384"));
assert_eq!(cipher_suite_openssl_name(0xC030), Some("ECDHE-RSA-AES256-GCM-SHA384"));
assert_eq!(cipher_suite_openssl_name(0xCCA9), Some("ECDHE-ECDSA-CHACHA20-POLY1305"));
assert_eq!(cipher_suite_openssl_name(0xCCA8), Some("ECDHE-RSA-CHACHA20-POLY1305"));
assert_eq!(cipher_suite_openssl_name(0x009E), Some("DHE-RSA-AES128-GCM-SHA256"));
assert_eq!(cipher_suite_openssl_name(0x009C), Some("DHE-RSA-AES256-GCM-SHA384"));
assert_eq!(cipher_suite_openssl_name(0x0033), Some("DHE-RSA-AES128-SHA"));
assert_eq!(cipher_suite_openssl_name(0x0067), Some("DHE-RSA-AES256-SHA256"));
assert_eq!(cipher_suite_openssl_name(0xC013), Some("ECDHE-RSA-AES128-SHA"));
assert_eq!(cipher_suite_openssl_name(0xC009), Some("ECDHE-ECDSA-AES128-SHA"));
assert_eq!(cipher_suite_openssl_name(0xC027), Some("ECDHE-RSA-AES128-SHA256"));
assert_eq!(cipher_suite_openssl_name(0xC028), Some("ECDHE-RSA-AES256-SHA384"));
assert_eq!(cipher_suite_openssl_name(0x002F), Some("AES128-SHA"));
assert_eq!(cipher_suite_openssl_name(0x0035), Some("AES256-SHA"));
assert_eq!(cipher_suite_openssl_name(0xFFFF), None);
}
#[test]
fn test_group_table_exhaustive_iana_truth() {
assert_eq!(group_openssl_name(0x001D), Some("X25519"));
assert_eq!(group_openssl_name(0x0017), Some("P-256"));
assert_eq!(group_openssl_name(0x0018), Some("P-384"));
assert_eq!(group_openssl_name(0x0019), Some("P-521"));
assert_eq!(group_openssl_name(0x0100), Some("ffdhe2048"));
assert_eq!(group_openssl_name(0x0101), Some("ffdhe3072"));
assert_eq!(group_openssl_name(0xFFFF), None);
}
#[test]
fn test_sigalg_table_exhaustive_iana_truth() {
assert_eq!(sigalg_openssl_name(0x0403), Some("ecdsa_secp256r1_sha256"));
assert_eq!(sigalg_openssl_name(0x0503), Some("ecdsa_secp384r1_sha384"));
assert_eq!(sigalg_openssl_name(0x0603), Some("ecdsa_secp521r1_sha512"));
assert_eq!(sigalg_openssl_name(0x0804), Some("rsa_pss_rsae_sha256"));
assert_eq!(sigalg_openssl_name(0x0805), Some("rsa_pss_rsae_sha384"));
assert_eq!(sigalg_openssl_name(0x0806), Some("rsa_pss_rsae_sha512"));
assert_eq!(sigalg_openssl_name(0x0401), Some("rsa_pkcs1_sha256"));
assert_eq!(sigalg_openssl_name(0x0501), Some("rsa_pkcs1_sha384"));
assert_eq!(sigalg_openssl_name(0x0601), Some("rsa_pkcs1_sha512"));
assert_eq!(sigalg_openssl_name(0x0203), Some("ecdsa_sha1"));
assert_eq!(sigalg_openssl_name(0x0201), Some("rsa_pkcs1_sha1"));
assert_eq!(sigalg_openssl_name(0xFFFF), None);
}
#[test]
fn test_cipher_boringssl_support_dhe_excluded() {
for id in [0x009E, 0x009C, 0x0033, 0x0067] {
assert!(
!cipher_suite_boringssl_supported(id),
"0x{id:04X} has no BoringSSL implementation"
);
assert!(cipher_suite_openssl_name(id).is_some());
}
for id in [0x1301, 0xC02B, 0xC02F, 0xC02C, 0xC030, 0xCCA9, 0xCCA8, 0xC013, 0xC009] {
assert!(cipher_suite_boringssl_supported(id), "0x{id:04X}");
}
}
#[test]
fn test_group_boringssl_support_ffdhe_excluded() {
for id in [0x0100, 0x0101] {
assert!(!group_boringssl_supported(id));
assert!(group_openssl_name(id).is_some());
}
for id in [0x001D, 0x0017, 0x0018, 0x0019] {
assert!(group_boringssl_supported(id));
}
}
#[test]
fn test_boringssl_cipher_list_filters_dhe_and_tls13() {
let ff = TlsFingerprint::firefox();
let list = boringssl_cipher_list_string(&ff.cipher_suites);
assert!(
!list.split(':').any(|n| n.starts_with("DHE-")),
"DHE names must not reach BoringSSL: {list}"
);
assert!(!list.contains("TLS_AES"), "TLS 1.3 is engine-builtin: {list}");
assert_eq!(
list,
"ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:\
ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:\
ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:\
ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA"
);
}
#[test]
fn test_firefox_curves_list_excludes_ffdhe() {
let ff = TlsFingerprint::firefox();
let list = ff.curves_list_string();
assert_eq!(list, "X25519:P-256:P-384:P-521");
assert!(!list.contains("ffdhe"), "FFDHE would poison the whole list: {list}");
}
#[test]
fn test_chrome_curves_list_unchanged_by_filter() {
let ch = TlsFingerprint::chrome();
assert_eq!(ch.curves_list_string(), "X25519:P-256:P-384");
}
#[test]
fn test_boringssl_sigalgs_list_full_profile() {
let ff = TlsFingerprint::firefox();
let list = boringssl_sigalgs_list_string(&ff.signature_algorithms);
assert_eq!(
list,
"ecdsa_secp256r1_sha256:rsa_pss_rsae_sha256:rsa_pkcs1_sha256:\
ecdsa_secp384r1_sha384:rsa_pss_rsae_sha384:rsa_pkcs1_sha384:\
rsa_pss_rsae_sha512:rsa_pkcs1_sha512:ecdsa_sha1:rsa_pkcs1_sha1"
);
}
#[test]
fn test_all_profile_cipher_suites_have_names() {
for (name, fp) in [
("firefox", TlsFingerprint::firefox()),
("chrome", TlsFingerprint::chrome()),
("chrome_120", TlsFingerprint::chrome_120()),
("chrome_latest", TlsFingerprint::chrome_latest()),
] {
for &id in &fp.cipher_suites {
assert!(
cipher_suite_openssl_name(id).is_some(),
"{name} suite 0x{id:04X} has no OpenSSL name — silent fingerprint loss"
);
}
for &g in &fp.supported_groups {
assert!(
group_openssl_name(g).is_some(),
"{name} group 0x{g:04X} has no name"
);
}
for &s in &fp.signature_algorithms {
assert!(
sigalg_openssl_name(s).is_some(),
"{name} sigalg 0x{s:04X} has no name"
);
}
}
}
#[test]
fn test_profile_suite_counts() {
let ff = TlsFingerprint::firefox();
assert_eq!(ff.cipher_suites.len(), 15);
assert_eq!(ff.tls12_cipher_list_string().matches(':').count() + 1, 8);
let ch = TlsFingerprint::chrome();
assert_eq!(ch.cipher_suites.len(), 13);
assert_eq!(ch.tls12_cipher_list_string().matches(':').count() + 1, 8);
}
#[test]
fn test_boringssl_builders_preserve_order_and_dedupe_none() {
let list = boringssl_cipher_list_string(&[0xC030, 0xC02B, 0x009E, 0x1301]);
assert_eq!(list, "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256");
assert_eq!(boringssl_cipher_list_string(&[]), "");
assert_eq!(boringssl_curves_list_string(&[]), "");
assert_eq!(boringssl_sigalgs_list_string(&[]), "");
}
}