#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TlsFingerprint {
pub name: String,
pub ja3: String,
pub ja3_hash: String,
pub alpn: Vec<String>,
pub cipher_suites: Vec<String>,
pub extensions: Vec<String>,
}
impl TlsFingerprint {
#[must_use]
pub fn as_header_hints(&self) -> Vec<(String, String)> {
vec![
("Sec-CH-UA-Platform".to_string(), String::new()),
("X-TLS-Fingerprint".to_string(), self.ja3_hash.clone()),
]
}
fn compute_ja3_hash(ja3_str: &str) -> String {
use md5::{Digest, Md5};
let result = Md5::digest(ja3_str.as_bytes());
format!("{result:032x}")
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TlsRotationPolicy {
pub profiles: Vec<TlsFingerprint>,
pub enable: bool,
pub rotate: bool,
}
impl Default for TlsRotationPolicy {
fn default() -> Self {
Self::with_defaults()
}
}
impl TlsRotationPolicy {
#[must_use]
pub fn from_config(cfg: TlsRotationConfig) -> Self {
let mut policy = Self::with_defaults();
policy.enable = cfg.enabled;
policy.rotate = cfg.enabled;
policy
}
#[must_use]
pub fn with_defaults() -> Self {
Self {
profiles: Self::build_default_profiles(),
enable: true,
rotate: true,
}
}
#[must_use]
pub fn build_default_profiles() -> Vec<TlsFingerprint> {
let profiles = vec![
(
"chrome-desktop-121",
"770,4865-4866-4867,23-24,0-11-10-35-16-22,0-1",
),
(
"chrome-mobile-121",
"771,4865-4867,23-24-25,0-11-10-35-16,0-1",
),
(
"firefox-desktop",
"772,4867-4866-4865,23-24,0-10-11-16-35,0-1",
),
];
profiles
.into_iter()
.map(|(name, ja3)| {
let ja3_hash = TlsFingerprint::compute_ja3_hash(ja3);
let cipher_suites = match name {
"chrome-desktop-121" => {
vec!["TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384"]
}
"chrome-mobile-121" => {
vec!["TLS_CHACHA20_POLY1305_SHA256", "TLS_AES_128_GCM_SHA256"]
}
"firefox-desktop" => {
vec!["TLS_AES_128_GCM_SHA256", "TLS_CHACHA20_POLY1305_SHA256"]
}
_ => vec!["TLS_AES_128_GCM_SHA256"],
};
let extensions = match name {
"chrome-desktop-121" => {
vec!["server_name", "application_layer_protocol_negotiation"]
}
"chrome-mobile-121" => vec!["server_name", "extended_master_secret"],
"firefox-desktop" => vec!["server_name", "key_share"],
_ => vec!["server_name"],
};
TlsFingerprint {
name: name.to_string(),
ja3: ja3.to_string(),
ja3_hash,
alpn: vec!["h2".into(), "http/1.1".into()],
cipher_suites: cipher_suites.into_iter().map(String::from).collect(),
extensions: extensions.into_iter().map(String::from).collect(),
}
})
.collect()
}
#[must_use]
pub fn rotate(&self, rng: &mut impl rand::Rng) -> TlsFingerprint {
if self.profiles.is_empty() {
let ja3 = "771,4865,0,0,0".to_string();
let ja3_hash = TlsFingerprint::compute_ja3_hash(&ja3);
return TlsFingerprint {
name: "fallback".to_string(),
ja3,
ja3_hash,
alpn: vec!["h2".into()],
cipher_suites: vec!["TLS_AES_128_GCM_SHA256".into()],
extensions: vec!["server_name".into()],
};
}
self.profiles[rng.gen_range(0..self.profiles.len())].clone()
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TlsRotationConfig {
pub enabled: bool,
}
impl Default for TlsRotationConfig {
fn default() -> Self {
Self { enabled: true }
}
}
impl From<TlsRotationConfig> for TlsRotationPolicy {
fn from(value: TlsRotationConfig) -> Self {
Self::from_config(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::{rngs::StdRng, SeedableRng};
#[test]
fn default_profiles_are_stable() {
let policy = TlsRotationPolicy::default();
let mut rng = StdRng::seed_from_u64(99);
let a = policy.rotate(&mut rng);
let mut rng = StdRng::seed_from_u64(99);
let b = policy.rotate(&mut rng);
assert_eq!(a.name, b.name);
}
#[test]
fn fallback_has_headers() {
let profile = TlsRotationPolicy {
profiles: vec![],
enable: true,
rotate: false,
};
let fp = profile.rotate(&mut StdRng::seed_from_u64(1));
assert_eq!(fp.name, "fallback");
assert_eq!(fp.as_header_hints()[1].0, "X-TLS-Fingerprint");
assert_eq!(fp.ja3, "771,4865,0,0,0");
assert_eq!(
fp.ja3_hash.len(),
32,
"ja3_hash should be 32 hex characters (MD5)"
);
}
#[test]
fn ja3_hash_is_deterministic() {
let policy = TlsRotationPolicy::default();
assert!(!policy.profiles.is_empty());
for profile in &policy.profiles {
assert!(
profile.ja3.split(',').count() == 5,
"JA3 string should contain 5 comma-separated sections"
);
assert!(!profile.ja3_hash.is_empty(), "JA3 hash should not be empty");
assert_eq!(
profile.ja3_hash.len(),
32,
"JA3 hash should be 32 hex chars (MD5)"
);
assert_eq!(
profile.ja3_hash,
TlsFingerprint::compute_ja3_hash(&profile.ja3),
"JA3 hash should match the raw JA3 string"
);
assert!(
profile.ja3_hash.chars().all(|c| c.is_ascii_hexdigit()),
"JA3 hash should be valid hexadecimal"
);
}
}
}