use serde::{Deserialize, Serialize};
use crate::tls::TlsProfile;
pub const CHROME_131_JA3: &str = "cd08e31494f9531f560d64c695473da9";
pub const CHROME_136_JA3: &str = "b32309a26951912be7dba376398abc3b";
pub const CHROME_136_JA4: &str = "t13d1516h2_8daaf6152771_b1ff8ab37d37";
pub const CHROME_136_HTTP2_SETTINGS: &[(u32, u32)] = &[
(1, 65_536), (2, 0), (3, 1_000), (4, 6_291_456), (6, 262_144), ];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TlsValidationReport {
pub ja3_expected: String,
pub ja3_actual: String,
pub ja3_match: bool,
pub ja4_expected: String,
pub ja4_actual: String,
pub ja4_match: bool,
pub http2_settings_match: bool,
pub alpn_match: bool,
pub issues: Vec<String>,
}
impl TlsValidationReport {
#[must_use]
pub const fn is_ok(&self) -> bool {
self.issues.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsValidationConfig {
pub echo_service_url: String,
pub timeout_secs: u64,
}
impl Default for TlsValidationConfig {
fn default() -> Self {
Self {
echo_service_url: "https://tls.peet.ws/api/all".into(),
timeout_secs: 10,
}
}
}
#[must_use]
pub fn compare_http2_settings(
expected: &[(u32, u32)],
observed: &[(u32, u32)],
) -> (bool, Vec<String>) {
let mut issues = Vec::new();
if expected.len() != observed.len() {
issues.push(format!(
"HTTP/2 SETTINGS count mismatch: expected {}, got {}",
expected.len(),
observed.len()
));
}
for &(exp_id, exp_val) in expected {
match observed.iter().find(|&&(id, _)| id == exp_id) {
None => issues.push(format!(
"HTTP/2 SETTINGS missing id={exp_id} (expected value {exp_val})"
)),
Some(&(_, obs_val)) if obs_val != exp_val => issues.push(format!(
"HTTP/2 SETTINGS id={exp_id}: expected {exp_val}, got {obs_val}"
)),
_ => {}
}
}
for &(obs_id, _) in observed {
if !expected.iter().any(|&(id, _)| id == obs_id) {
issues.push(format!("HTTP/2 SETTINGS unexpected id={obs_id}"));
}
}
(issues.is_empty(), issues)
}
#[must_use]
pub fn validate_profile_static(
profile: &TlsProfile,
expected_ja3: &str,
expected_ja4: &str,
expected_alpn: &[(&str, &str)],
) -> TlsValidationReport {
let ja3 = profile.ja3();
let ja4 = profile.ja4();
let ja3_match = expected_ja3.is_empty() || ja3.hash == expected_ja3;
let ja4_match = expected_ja4.is_empty() || ja4.fingerprint == expected_ja4;
let profile_alpn: Vec<String> = profile
.alpn_protocols
.iter()
.map(|a| format!("{a:?}").to_lowercase())
.collect();
let expected_alpn_strs: Vec<String> = expected_alpn
.iter()
.map(|(a, _)| (*a).to_string())
.collect();
let alpn_match = expected_alpn.is_empty()
|| profile_alpn
.iter()
.zip(expected_alpn_strs.iter())
.all(|(a, b)| a == b);
let mut issues = Vec::new();
if !ja3_match {
issues.push(format!(
"JA3 mismatch: expected `{expected_ja3}`, computed `{}`",
ja3.hash
));
}
if !ja4_match {
issues.push(format!(
"JA4 mismatch: expected `{expected_ja4}`, computed `{}`",
ja4.fingerprint
));
}
if !alpn_match {
issues.push(format!(
"ALPN mismatch: expected {expected_alpn_strs:?}, profile has {profile_alpn:?}"
));
}
TlsValidationReport {
ja3_expected: expected_ja3.to_string(),
ja3_actual: ja3.hash,
ja3_match,
ja4_expected: expected_ja4.to_string(),
ja4_actual: ja4.fingerprint,
ja4_match,
http2_settings_match: true, alpn_match,
issues,
}
}
pub trait TlsProfileValidate {
fn validate_static(&self, expected_ja3: &str, expected_ja4: &str) -> TlsValidationReport;
}
impl TlsProfileValidate for TlsProfile {
fn validate_static(&self, expected_ja3: &str, expected_ja4: &str) -> TlsValidationReport {
validate_profile_static(self, expected_ja3, expected_ja4, &[])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chrome_131_ja3_is_valid_md5_hex() {
assert_eq!(CHROME_131_JA3.len(), 32, "JA3 must be 32-char MD5 hex");
assert!(
CHROME_131_JA3.chars().all(|c| c.is_ascii_hexdigit()),
"JA3 must be hex"
);
}
#[test]
fn chrome_136_ja3_is_valid_md5_hex() {
assert_eq!(CHROME_136_JA3.len(), 32, "JA3 must be 32-char MD5 hex");
assert!(
CHROME_136_JA3.chars().all(|c| c.is_ascii_hexdigit()),
"JA3 must be hex"
);
}
#[test]
fn chrome_136_ja4_format() {
assert!(
CHROME_136_JA4.starts_with('t'),
"JA4 must start with 't' for TLS"
);
assert_eq!(
CHROME_136_JA4.matches('_').count(),
2,
"JA4 must have 2 underscore separators"
);
}
#[test]
fn http2_settings_identical_match() {
let (ok, issues) =
compare_http2_settings(CHROME_136_HTTP2_SETTINGS, CHROME_136_HTTP2_SETTINGS);
assert!(ok);
assert!(issues.is_empty());
}
#[test]
fn http2_settings_missing_key_is_reported() {
let observed: Vec<(u32, u32)> = CHROME_136_HTTP2_SETTINGS.iter().copied().take(2).collect();
let (ok, issues) = compare_http2_settings(CHROME_136_HTTP2_SETTINGS, &observed);
assert!(!ok);
assert!(!issues.is_empty());
assert!(
issues
.iter()
.any(|i| i.contains("count mismatch") || i.contains("missing"))
);
}
#[test]
fn http2_settings_wrong_value_is_reported() {
let mut bad = CHROME_136_HTTP2_SETTINGS.to_vec();
if let Some(slot) = bad.get_mut(3) {
*slot = (4, 65535);
}
let (ok, issues) = compare_http2_settings(CHROME_136_HTTP2_SETTINGS, &bad);
assert!(!ok);
assert!(issues.iter().any(|i| i.contains("id=4")));
}
#[test]
fn http2_settings_extra_key_is_reported() {
let mut extra = CHROME_136_HTTP2_SETTINGS.to_vec();
extra.push((99, 0));
let (ok, issues) = compare_http2_settings(CHROME_136_HTTP2_SETTINGS, &extra);
assert!(!ok);
assert!(issues.iter().any(|i| i.contains("unexpected id=99")));
}
#[test]
fn report_is_ok_when_no_issues() {
let report = TlsValidationReport {
ja3_expected: "abc".into(),
ja3_actual: "abc".into(),
ja3_match: true,
ja4_expected: String::new(),
ja4_actual: String::new(),
ja4_match: true,
http2_settings_match: true,
alpn_match: true,
issues: vec![],
};
assert!(report.is_ok());
}
#[test]
fn report_not_ok_when_has_issues() {
let report = TlsValidationReport {
ja3_match: false,
issues: vec!["JA3 mismatch".into()],
..Default::default()
};
assert!(!report.is_ok());
}
#[test]
fn report_serde_round_trip() {
let report = TlsValidationReport {
ja3_expected: CHROME_131_JA3.into(),
ja3_actual: CHROME_136_JA3.into(),
ja3_match: false,
ja4_expected: CHROME_136_JA4.into(),
ja4_actual: CHROME_136_JA4.into(),
ja4_match: true,
http2_settings_match: false,
alpn_match: true,
issues: vec!["JA3 mismatch".into()],
};
let json_result = serde_json::to_string(&report);
assert!(json_result.is_ok(), "serialize failed: {json_result:?}");
let Ok(json) = json_result else {
return;
};
let report_result: Result<TlsValidationReport, _> = serde_json::from_str(&json);
assert!(
report_result.is_ok(),
"deserialize failed: {report_result:?}"
);
let Ok(r2) = report_result else {
return;
};
assert_eq!(report, r2);
}
#[test]
fn static_validation_empty_expected_always_passes() {
use crate::tls::CHROME_131;
let report = validate_profile_static(&CHROME_131, "", "", &[]);
assert!(
report.is_ok(),
"empty expected hashes should always pass; issues: {:?}",
report.issues
);
}
#[test]
fn static_validation_mismatch_populates_issues() {
use crate::tls::CHROME_131;
let report =
validate_profile_static(&CHROME_131, "0000000000000000000000000000000f", "", &[]);
assert!(!report.is_ok());
assert!(report.issues.iter().any(|i| i.contains("JA3 mismatch")));
}
#[test]
#[ignore = "requires network access and real TLS client"]
fn live_tls_echo_chrome_131() {
}
#[test]
#[ignore = "requires network access and HTTP/2 capture capability"]
fn live_http2_settings_chrome_136() {
}
}