use std::net::{IpAddr, Ipv4Addr};
use serde_json::Value;
const BROWSER_MIN_CIPHERS: u32 = 10;
const BROWSER_MIN_EXTENSIONS: u32 = 10;
const BROWSER_JA4_SHAPES: &[(&str, &str, &str, &str)] = &[
("t", "13", "d", "h2"),
("q", "13", "d", "h3"),
];
const CONSISTENT_BOOST: f64 = 0.05;
const MISMATCH_PENALTY: f64 = 0.5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IpRisk {
Low,
Medium,
High,
}
impl IpRisk {
pub fn as_str(self) -> &'static str {
match self {
IpRisk::Low => "low",
IpRisk::Medium => "medium",
IpRisk::High => "high",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TlsConsistency {
Consistent,
Mismatch,
Degraded,
}
impl TlsConsistency {
pub fn as_str(self) -> &'static str {
match self {
TlsConsistency::Consistent => "consistent",
TlsConsistency::Mismatch => "mismatch",
TlsConsistency::Degraded => "degraded",
}
}
pub fn ua_tls_consistent(self) -> bool {
!matches!(self, TlsConsistency::Mismatch)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PassiveSignals {
pub ip_risk: IpRisk,
pub tls_consistency: TlsConsistency,
}
impl PassiveSignals {
pub fn confidence_adjustment(self) -> f64 {
match self.tls_consistency {
TlsConsistency::Consistent => CONSISTENT_BOOST,
TlsConsistency::Mismatch => -MISMATCH_PENALTY,
TlsConsistency::Degraded => 0.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClientStack {
Browser,
Automation,
Unknown,
}
pub trait IpIntel: Send + Sync {
fn assess(&self, ip: IpAddr) -> IpRisk;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct StaticIpIntel;
impl StaticIpIntel {
pub fn new() -> Self {
Self
}
}
const DATACENTER_V4: &[(Ipv4Addr, u8)] = &[
(Ipv4Addr::new(34, 0, 0, 0), 8), (Ipv4Addr::new(35, 0, 0, 0), 8), (Ipv4Addr::new(52, 0, 0, 0), 8), (Ipv4Addr::new(13, 64, 0, 0), 11), ];
impl IpIntel for StaticIpIntel {
fn assess(&self, ip: IpAddr) -> IpRisk {
match ip {
IpAddr::V4(v4) => {
if v4.is_loopback() || v4.is_private() || v4.is_link_local() {
return IpRisk::Low;
}
if DATACENTER_V4
.iter()
.any(|&(net, prefix)| ipv4_in_block(v4, net, prefix))
{
IpRisk::High
} else {
IpRisk::Low
}
}
IpAddr::V6(_) => IpRisk::Low,
}
}
}
pub fn compute(
ja4: Option<&str>,
client_ip: Option<&str>,
claimed_ua: Option<&str>,
intel: &dyn IpIntel,
) -> PassiveSignals {
let ip_risk = client_ip
.and_then(|s| s.trim().parse::<IpAddr>().ok())
.map_or(IpRisk::Low, |ip| intel.assess(ip));
let tls_consistency = match ja4 {
Some(ja4) => consistency(claimed_ua.map(classify_ua), classify_ja4(ja4)),
None => TlsConsistency::Degraded, };
PassiveSignals {
ip_risk,
tls_consistency,
}
}
fn consistency(claimed: Option<ClientStack>, observed: ClientStack) -> TlsConsistency {
match observed {
ClientStack::Unknown => TlsConsistency::Degraded,
ClientStack::Browser => match claimed {
Some(ClientStack::Automation) => TlsConsistency::Mismatch,
_ => TlsConsistency::Consistent,
},
ClientStack::Automation => match claimed {
Some(ClientStack::Browser) => TlsConsistency::Mismatch,
_ => TlsConsistency::Consistent,
},
}
}
fn classify_ua(ua: &str) -> ClientStack {
const AUTOMATION: &[&str] = &[
"curl",
"wget",
"python",
"go-http",
"java",
"okhttp",
"libwww",
"node-fetch",
"axios",
];
const BROWSER: &[&str] = &["mozilla", "chrome", "firefox", "safari", "edg", "opera"];
let ua = ua.to_ascii_lowercase();
if AUTOMATION.iter().any(|m| ua.contains(m)) {
ClientStack::Automation
} else if BROWSER.iter().any(|m| ua.contains(m)) {
ClientStack::Browser
} else {
ClientStack::Unknown
}
}
struct Ja4Shape<'a> {
protocol: &'a str,
tls_version: &'a str,
sni: &'a str,
ciphers: u32,
extensions: u32,
alpn: &'a str,
}
fn parse_ja4_shape(a: &str) -> Option<Ja4Shape<'_>> {
Some(Ja4Shape {
protocol: a.get(0..1)?,
tls_version: a.get(1..3)?,
sni: a.get(3..4)?,
ciphers: a.get(4..6)?.parse().ok()?,
extensions: a.get(6..8)?.parse().ok()?,
alpn: a.get(8..10)?,
})
}
fn classify_ja4(ja4: &str) -> ClientStack {
let a = ja4.split('_').next().unwrap_or(ja4);
let Some(shape) = parse_ja4_shape(a) else {
return ClientStack::Unknown;
};
if shape.ciphers < BROWSER_MIN_CIPHERS || shape.extensions < BROWSER_MIN_EXTENSIONS {
return ClientStack::Automation;
}
let is_browser_shape = BROWSER_JA4_SHAPES
.iter()
.any(|&(protocol, tls, sni, alpn)| {
shape.protocol == protocol
&& shape.tls_version == tls
&& shape.sni == sni
&& shape.alpn == alpn
});
if is_browser_shape {
ClientStack::Browser
} else {
ClientStack::Automation
}
}
pub fn ja4_class(ja4: &str) -> Option<String> {
let a = ja4.split('_').next().unwrap_or(ja4);
let shape = parse_ja4_shape(a)?;
Some(format!(
"{}{}{}{}",
shape.protocol, shape.tls_version, shape.sni, shape.alpn
))
}
fn ipv4_in_block(ip: Ipv4Addr, net: Ipv4Addr, prefix: u8) -> bool {
if prefix == 0 {
return true;
}
let mask = u32::MAX << (32 - u32::from(prefix));
(u32::from(ip) & mask) == (u32::from(net) & mask)
}
pub const INTEGRITY_PROBES: [&str; 4] = ["canvas", "webgl", "audio", "rect"];
pub const INTEGRITY_NOISE_PENALTY: f64 = 0.3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientIntegrity {
Ok,
Noise,
Unavailable,
}
impl ClientIntegrity {
pub fn as_str(self) -> &'static str {
match self {
ClientIntegrity::Ok => "ok",
ClientIntegrity::Noise => "noise",
ClientIntegrity::Unavailable => "unavailable",
}
}
pub fn from_verdicts<'a>(verdicts: impl IntoIterator<Item = Option<&'a str>>) -> Self {
let mut saw_ok = false;
for verdict in verdicts {
match verdict {
Some("noise") => return ClientIntegrity::Noise,
Some("ok") => saw_ok = true,
_ => {}
}
}
if saw_ok {
ClientIntegrity::Ok
} else {
ClientIntegrity::Unavailable
}
}
pub fn from_json(integrity: Option<&Value>) -> Self {
let Some(object) = integrity.and_then(Value::as_object) else {
return ClientIntegrity::Unavailable;
};
Self::from_verdicts(
INTEGRITY_PROBES
.iter()
.map(|probe| object.get(*probe).and_then(Value::as_str)),
)
}
pub fn confidence_adjustment(self, penalty_enabled: bool) -> f64 {
if penalty_enabled && self == ClientIntegrity::Noise {
-INTEGRITY_NOISE_PENALTY
} else {
0.0
}
}
}
pub const WINDOWS_CORE_FONTS: [&str; 5] = [
"Calibri",
"Segoe UI Light",
"MS UI Gothic",
"Marlett",
"Microsoft Uighur",
];
pub const MACOS_CORE_FONTS: [&str; 3] = ["Helvetica Neue", "Menlo", "Gill Sans"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PersonaCoherence {
Consistent,
Mismatch,
Unknown,
}
impl PersonaCoherence {
pub fn as_str(self) -> &'static str {
match self {
PersonaCoherence::Consistent => "consistent",
PersonaCoherence::Mismatch => "mismatch",
PersonaCoherence::Unknown => "unknown",
}
}
}
fn core_fonts_for_ua(claimed_ua: &str) -> Option<&'static [&'static str]> {
if claimed_ua.contains("Windows NT") {
Some(&WINDOWS_CORE_FONTS)
} else if claimed_ua.contains("Macintosh") {
Some(&MACOS_CORE_FONTS)
} else {
None
}
}
pub fn persona_coherence(claimed_ua: Option<&str>, fonts: Option<&Value>) -> PersonaCoherence {
let Some(core) = claimed_ua.and_then(core_fonts_for_ua) else {
return PersonaCoherence::Unknown;
};
let Some(fonts) = fonts.and_then(Value::as_array) else {
return PersonaCoherence::Unknown;
};
if fonts
.iter()
.filter_map(Value::as_str)
.any(|font| core.contains(&font))
{
PersonaCoherence::Consistent
} else {
PersonaCoherence::Mismatch
}
}
#[cfg(test)]
mod tests {
use super::{ClientIntegrity, INTEGRITY_NOISE_PENALTY, PersonaCoherence, persona_coherence};
const WINDOWS_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/146.0.0.0 Safari/537.36";
const MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/146.0.0.0 Safari/537.36";
const IOS_UA: &str =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1";
#[test]
fn persona_windows_ua_without_any_windows_core_font_is_a_mismatch() {
let linux_fonts = json!([
"Arial",
"Courier New",
"Times New Roman",
"Noto Color Emoji"
]);
assert_eq!(
persona_coherence(Some(WINDOWS_UA), Some(&linux_fonts)),
PersonaCoherence::Mismatch
);
assert_eq!(
persona_coherence(Some(WINDOWS_UA), Some(&json!([]))),
PersonaCoherence::Mismatch
);
let windows_fonts = json!(["Arial", "Calibri", "Segoe UI Light"]);
assert_eq!(
persona_coherence(Some(WINDOWS_UA), Some(&windows_fonts)),
PersonaCoherence::Consistent
);
}
#[test]
fn persona_macos_checks_its_own_core_fonts_and_ios_is_not_macos() {
let mac_fonts = json!(["Helvetica Neue", "Menlo"]);
assert_eq!(
persona_coherence(Some(MAC_UA), Some(&mac_fonts)),
PersonaCoherence::Consistent
);
assert_eq!(
persona_coherence(Some(MAC_UA), Some(&json!(["Calibri"]))),
PersonaCoherence::Mismatch
);
assert_eq!(
persona_coherence(Some(IOS_UA), Some(&json!([]))),
PersonaCoherence::Unknown
);
}
#[test]
fn persona_unknown_without_ua_family_or_fonts_array() {
assert_eq!(
persona_coherence(None, Some(&json!([]))),
PersonaCoherence::Unknown
);
assert_eq!(
persona_coherence(
Some("Mozilla/5.0 (X11; Linux x86_64) Chrome/149"),
Some(&json!([]))
),
PersonaCoherence::Unknown
);
assert_eq!(
persona_coherence(Some(WINDOWS_UA), None),
PersonaCoherence::Unknown
);
assert_eq!(
persona_coherence(Some(WINDOWS_UA), Some(&json!("Calibri"))),
PersonaCoherence::Unknown
);
}
use super::{
ClientStack, IpIntel, IpRisk, PassiveSignals, StaticIpIntel, TlsConsistency, classify_ja4,
compute, ja4_class,
};
use serde_json::json;
#[test]
fn integrity_any_noise_wins_then_ok_then_unavailable() {
assert_eq!(
ClientIntegrity::from_verdicts([Some("ok"), Some("ok"), Some("noise"), None]),
ClientIntegrity::Noise
);
assert_eq!(
ClientIntegrity::from_verdicts([Some("ok"), Some("unavailable"), None, Some("weird")]),
ClientIntegrity::Ok
);
assert_eq!(
ClientIntegrity::from_verdicts([Some("unavailable"), None, Some("weird")]),
ClientIntegrity::Unavailable
);
assert_eq!(
ClientIntegrity::from_verdicts([]),
ClientIntegrity::Unavailable
);
}
#[test]
fn integrity_from_json_reads_the_four_probes_and_degrades_on_malformed() {
let noisy = json!({ "canvas": "noise", "webgl": "ok", "audio": "ok", "rect": "ok" });
assert_eq!(
ClientIntegrity::from_json(Some(&noisy)),
ClientIntegrity::Noise
);
let clean = json!({ "canvas": "ok", "webgl": "ok", "audio": "ok", "rect": "ok" });
assert_eq!(
ClientIntegrity::from_json(Some(&clean)),
ClientIntegrity::Ok
);
assert_eq!(
ClientIntegrity::from_json(None),
ClientIntegrity::Unavailable
);
assert_eq!(
ClientIntegrity::from_json(Some(&json!("noise"))),
ClientIntegrity::Unavailable
);
assert_eq!(
ClientIntegrity::from_json(Some(&json!({}))),
ClientIntegrity::Unavailable
);
assert_eq!(
ClientIntegrity::from_json(Some(&json!({ "canvas": 1, "other": "noise" }))),
ClientIntegrity::Unavailable
);
}
#[test]
fn integrity_penalty_is_opt_in_and_only_for_noise() {
let approx = |a: f64, b: f64| (a - b).abs() < 1e-12;
assert!(approx(
ClientIntegrity::Noise.confidence_adjustment(false),
0.0
));
assert!(approx(ClientIntegrity::Ok.confidence_adjustment(true), 0.0));
assert!(approx(
ClientIntegrity::Unavailable.confidence_adjustment(true),
0.0
));
assert!(approx(
ClientIntegrity::Noise.confidence_adjustment(true),
-INTEGRITY_NOISE_PENALTY
));
}
const BROWSER_JA4: &str = "t13d1516h2_8daaf6152771_02713d6af862";
const AUTOMATION_JA4: &str = "t13d0304h1_aaaaaaaaaaaa_bbbbbbbbbbbb";
const PADDED_AUTOMATION_JA4: &str = "t13d1516h1_cccccccccccc_dddddddddddd";
const CHROME_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0";
const CLOAK_JA4: &str = "t13d1516h2_8daaf6152771_e5627efa2ab1";
const CLOAK_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
const QUIC_BROWSER_JA4: &str = "q13d1516h3_8daaf6152771_02713d6af862";
const CLOAK_IP: &str = "203.0.113.55";
#[test]
fn static_intel_flags_datacenter_high_and_residential_low() {
let intel = StaticIpIntel::new();
assert_eq!(intel.assess("34.120.5.6".parse().unwrap()), IpRisk::High);
assert_eq!(intel.assess("198.51.100.7".parse().unwrap()), IpRisk::Low);
assert_eq!(intel.assess("10.1.2.3".parse().unwrap()), IpRisk::Low);
assert_eq!(intel.assess("127.0.0.1".parse().unwrap()), IpRisk::Low);
assert_eq!(intel.assess("2001:db8::1".parse().unwrap()), IpRisk::Low);
}
#[test]
fn client_ip_drives_the_ip_band() {
let intel = StaticIpIntel::new();
let sig = compute(
Some(BROWSER_JA4),
Some("34.120.5.6"),
Some(CHROME_UA),
&intel,
);
assert_eq!(sig.ip_risk, IpRisk::High);
}
#[test]
fn missing_client_ip_defaults_low() {
let intel = StaticIpIntel::new();
let sig = compute(Some(BROWSER_JA4), None, Some(CHROME_UA), &intel);
assert_eq!(sig.ip_risk, IpRisk::Low);
}
#[test]
fn absent_ja4_auto_degrades_without_penalty() {
let intel = StaticIpIntel::new();
let sig = compute(None, Some("198.51.100.7"), Some(CHROME_UA), &intel);
assert_eq!(sig.tls_consistency, TlsConsistency::Degraded);
assert!(sig.tls_consistency.ua_tls_consistent());
}
#[test]
fn browser_ua_over_browser_stack_is_consistent() {
let intel = StaticIpIntel::new();
let sig = compute(
Some(BROWSER_JA4),
Some("198.51.100.7"),
Some(CHROME_UA),
&intel,
);
assert_eq!(sig.tls_consistency, TlsConsistency::Consistent);
assert!(sig.tls_consistency.ua_tls_consistent());
}
#[test]
fn spoofed_browser_ua_over_automation_stack_is_mismatch() {
let intel = StaticIpIntel::new();
let sig = compute(
Some(AUTOMATION_JA4),
Some("34.120.5.6"),
Some(CHROME_UA),
&intel,
);
assert_eq!(
sig,
PassiveSignals {
ip_risk: IpRisk::High,
tls_consistency: TlsConsistency::Mismatch,
}
);
assert!(!sig.tls_consistency.ua_tls_consistent());
}
#[test]
fn honest_curl_ua_over_automation_stack_is_consistent() {
let intel = StaticIpIntel::new();
let sig = compute(Some(AUTOMATION_JA4), None, Some("curl/8.4.0"), &intel);
assert_eq!(sig.tls_consistency, TlsConsistency::Consistent);
}
#[test]
fn unparseable_ja4_degrades() {
let intel = StaticIpIntel::new();
let sig = compute(Some("garbage"), None, Some(CHROME_UA), &intel);
assert_eq!(sig.tls_consistency, TlsConsistency::Degraded);
}
#[test]
fn missing_ua_never_manufactures_a_mismatch() {
let intel = StaticIpIntel::new();
let sig = compute(Some(AUTOMATION_JA4), None, None, &intel);
assert_eq!(sig.tls_consistency, TlsConsistency::Consistent);
}
#[test]
fn ja4_structural_classification() {
assert_eq!(classify_ja4(BROWSER_JA4), ClientStack::Browser);
assert_eq!(classify_ja4(AUTOMATION_JA4), ClientStack::Automation);
assert_eq!(classify_ja4("t13d"), ClientStack::Unknown);
}
#[test]
fn quic_h3_browser_ja4_classifies_as_browser() {
assert_eq!(classify_ja4(QUIC_BROWSER_JA4), ClientStack::Browser);
}
#[test]
fn padded_counts_with_non_browser_shape_reads_as_automation() {
assert_eq!(classify_ja4(PADDED_AUTOMATION_JA4), ClientStack::Automation);
}
#[test]
fn ja4_class_is_the_coarse_shape_without_counts() {
assert_eq!(ja4_class(BROWSER_JA4).as_deref(), Some("t13dh2"));
assert_eq!(ja4_class(CLOAK_JA4).as_deref(), Some("t13dh2"));
assert_eq!(ja4_class(BROWSER_JA4), ja4_class(CLOAK_JA4));
assert_eq!(ja4_class(QUIC_BROWSER_JA4).as_deref(), Some("q13dh3"));
assert_eq!(ja4_class("t13d"), None);
}
#[test]
fn confidence_adjustment_boosts_consistent_penalises_mismatch_neutral_degrade() {
let mk = |ip, c| PassiveSignals {
ip_risk: ip,
tls_consistency: c,
};
let boost = mk(IpRisk::Low, TlsConsistency::Consistent).confidence_adjustment();
let penalty = mk(IpRisk::Low, TlsConsistency::Mismatch).confidence_adjustment();
let degrade = mk(IpRisk::Low, TlsConsistency::Degraded).confidence_adjustment();
assert!(boost > 0.0);
assert!(penalty < 0.0);
assert!(degrade.abs() < f64::EPSILON); assert!(penalty.abs() > boost);
let degrade_high_ip = mk(IpRisk::High, TlsConsistency::Degraded).confidence_adjustment();
assert!((degrade - degrade_high_ip).abs() < f64::EPSILON);
}
#[test]
fn cloak_browser_passes_every_single_request_signal() {
let intel = StaticIpIntel::new();
let sig = compute(Some(CLOAK_JA4), Some(CLOAK_IP), Some(CLOAK_UA), &intel);
assert_eq!(sig.tls_consistency, TlsConsistency::Consistent);
assert!(sig.confidence_adjustment() > 0.0);
assert_eq!(sig.ip_risk, IpRisk::Low);
}
#[test]
fn ip_risk_wire_labels() {
assert_eq!(IpRisk::Low.as_str(), "low");
assert_eq!(IpRisk::Medium.as_str(), "medium");
assert_eq!(IpRisk::High.as_str(), "high");
}
}