use crate::chromiumoxide::Page;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use guise::fingerprint::user_agent_facts;
pub use guise::fingerprint::chrome_tls::{expected_fingerprint, ChromeFingerprint, CHROME_FINGERPRINTS};
pub const DEFAULT_DIAGNOSTIC_URL: &str = "https://tls.peet.ws/api/all";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Diagnosis {
Match(ChromeFingerprint),
Drift {
expected: String,
actual: String,
axes: Vec<DriftAxis>,
},
Unreachable(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DriftAxis {
CipherOrder,
ExtensionOrder,
GreasePosition,
AlpnOrder,
GroupOrder,
SignatureAlgOrder,
H2SettingsOrder,
H2PriorityShape,
H2HeaderOrder,
}
impl DriftAxis {
pub fn as_str(self) -> &'static str {
match self {
DriftAxis::CipherOrder => "cipher_order",
DriftAxis::ExtensionOrder => "extension_order",
DriftAxis::GreasePosition => "grease_position",
DriftAxis::AlpnOrder => "alpn_order",
DriftAxis::GroupOrder => "group_order",
DriftAxis::SignatureAlgOrder => "signature_alg_order",
DriftAxis::H2SettingsOrder => "h2_settings_order",
DriftAxis::H2PriorityShape => "h2_priority_shape",
DriftAxis::H2HeaderOrder => "h2_header_order",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportedHandshake {
#[serde(default)]
pub ja3: String,
#[serde(default, alias = "ja3_hash")]
pub ja3_hash: String,
#[serde(default)]
pub ja4: String,
#[serde(default, alias = "akamai_fingerprint", alias = "h2")]
pub h2: String,
#[serde(default, alias = "user_agent")]
pub user_agent: String,
}
pub async fn fetch_reported_handshake(page: &Page) -> Result<ReportedHandshake> {
let url = std::env::var("CAPTCHAFORGE_TLS_DIAGNOSTIC_URL")
.unwrap_or_else(|_| DEFAULT_DIAGNOSTIC_URL.to_string());
page.goto(&url)
.await
.map_err(|e| anyhow!("goto({url}) failed: {e}"))?;
let body_js = r#"document.body && document.body.innerText
? document.body.innerText : document.documentElement.outerHTML"#;
let body_value = page
.evaluate(body_js)
.await
.map_err(|e| anyhow!("body fetch failed: {e}"))?;
let body = body_value
.into_value::<String>()
.context("body was not a JSON string")?;
let json = body
.trim()
.trim_start_matches("<pre>")
.trim_end_matches("</pre>")
.trim();
serde_json::from_str::<ReportedHandshake>(json).map_err(|e| {
anyhow!(
"JSON parse failed: {e}; body prefix: {}",
&json[..json.len().min(400)]
)
})
}
pub fn compute_drift_axes(
expected: &ChromeFingerprint,
reported: &ReportedHandshake,
) -> Vec<DriftAxis> {
let mut axes = Vec::new();
if expected.ja3_hash != reported.ja3_hash {
let exp_parts: Vec<&str> = expected.ja3.split(',').collect();
let act_parts: Vec<&str> = reported.ja3.split(',').collect();
if exp_parts.len() == 5 && act_parts.len() == 5 {
if exp_parts[1] != act_parts[1] {
axes.push(DriftAxis::CipherOrder);
}
if exp_parts[2] != act_parts[2] {
axes.push(DriftAxis::ExtensionOrder);
let exp_ext: Vec<i32> = exp_parts[2]
.split('-')
.filter_map(|s| s.parse().ok())
.collect();
let act_ext: Vec<i32> = act_parts[2]
.split('-')
.filter_map(|s| s.parse().ok())
.collect();
let exp_grease: Vec<usize> = exp_ext
.iter()
.enumerate()
.filter(|(_, &v)| (v & 0x0F0F) == 0x0A0A)
.map(|(i, _)| i)
.collect();
let act_grease: Vec<usize> = act_ext
.iter()
.enumerate()
.filter(|(_, &v)| (v & 0x0F0F) == 0x0A0A)
.map(|(i, _)| i)
.collect();
if exp_grease != act_grease {
axes.push(DriftAxis::GreasePosition);
}
}
if exp_parts[3] != act_parts[3] {
axes.push(DriftAxis::GroupOrder);
}
}
}
if expected.ja4 != reported.ja4 && !reported.ja4.is_empty() {
let exp_p2 = expected.ja4.split('_').nth(1).unwrap_or("");
let act_p2 = reported.ja4.split('_').nth(1).unwrap_or("");
if exp_p2 != act_p2 {
axes.push(DriftAxis::AlpnOrder);
}
}
if !expected.h2_fingerprint.is_empty()
&& !reported.h2.is_empty()
&& expected.h2_fingerprint != reported.h2
{
let exp_p: Vec<&str> = expected.h2_fingerprint.split('|').collect();
let act_p: Vec<&str> = reported.h2.split('|').collect();
if exp_p.len() == 4 && act_p.len() == 4 {
if exp_p[0] != act_p[0] {
axes.push(DriftAxis::H2SettingsOrder);
}
if exp_p[2] != act_p[2] {
axes.push(DriftAxis::H2PriorityShape);
}
if exp_p[3] != act_p[3] {
axes.push(DriftAxis::H2HeaderOrder);
}
}
}
axes
}
pub async fn diagnose_page(page: &Page) -> Result<Diagnosis> {
let ua = page
.evaluate("navigator.userAgent")
.await
.map_err(|e| anyhow!("navigator.userAgent eval failed: {e}"))?
.into_value::<String>()
.unwrap_or_default();
let facts = user_agent_facts(&ua);
let major = facts.chromium_major_version.unwrap_or(134);
let platform = facts.platform.chrome_tls_label().unwrap_or("Linux");
let expected = expected_fingerprint(major, platform)
.ok_or_else(|| anyhow!("no Chrome fingerprint entry for {major}/{platform}"))?;
let reported = match fetch_reported_handshake(page).await {
Ok(r) => r,
Err(e) => return Ok(Diagnosis::Unreachable(e.to_string())),
};
let axes = compute_drift_axes(expected, &reported);
if axes.is_empty() {
Ok(Diagnosis::Match(expected.clone()))
} else {
Ok(Diagnosis::Drift {
expected: expected.name.to_string(),
actual: if reported.ja4.is_empty() {
reported.ja3_hash.clone()
} else {
reported.ja4.clone()
},
axes,
})
}
}
pub struct ForwardingProxy {
_bind: std::net::SocketAddr,
_target_fp: ChromeFingerprint,
}
impl ForwardingProxy {
pub fn new(bind: std::net::SocketAddr, target_fp: ChromeFingerprint) -> Result<Self> {
Err(anyhow!(
"ForwardingProxy::new is unfinished: JA3-forging requires the boring \
backend to control TLS extension ordering. Track this gap in \
captchaforge::tls_proxy::BORING_BACKEND_TODO. \
For diagnostic-only use, run `diagnose_page(page).await` against the \
current chromium build to see whether TLS drift is your blocker. \
Args: bind={bind} fp={}",
target_fp.name
))
}
pub fn transparent(bind: std::net::SocketAddr) -> Result<Self> {
Ok(Self {
_bind: bind,
_target_fp: CHROME_FINGERPRINTS[0].clone(),
})
}
}
pub const BORING_BACKEND_TODO: &[&str] = &[
"Add `boring = { version = \"4\", optional = true }` to Cargo.toml under \
feature `tls-impersonation`.",
"Wire `boring::ssl::SslContextBuilder` with manual cipher-suite list \
order (set_cipher_list_str) matching CHROME_FINGERPRINTS[0].ja3 column 2.",
"Insert GREASE bytes via custom extension ordering, boring exposes \
`set_grease_enabled(true)` plus the order callback.",
"Build the H2 SETTINGS frame manually before sending the first request, \
matching CHROME_FINGERPRINTS[0].h2_fingerprint column 0.",
"Wire `tokio::net::TcpListener` accepting plaintext from Chromium → \
parse HTTP/1 CONNECT → terminate Chromium's TLS with a per-host MITM \
cert signed by a generated local CA (Chromium passed \
`--ignore-certificate-errors-spki-list=<sha256>` to whitelist the CA).",
"Pump bytes bidirectionally between Chromium-side TLS and upstream \
boring TLS, with backpressure via mpsc::channel.",
"Test corpus: 12 hand-built fixtures asserting forged JA3 == real \
Chrome's JA3 against tls.peet.ws across all CHROME_FINGERPRINTS entries.",
"Property test: random byte injection on the upstream connection MUST \
NOT crash the forwarder.",
"Differential test: side-by-side run vs `curl-impersonate-chrome134` on \
the same upstream URL; identical JA3 + JA4 + H2 outputs.",
"Scale test: 1000 concurrent forwarded connections to a synthetic \
upstream; no memory leaks, no FD exhaustion.",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fingerprint_table_covers_last_six_chrome_majors() {
let mut majors: Vec<u32> = CHROME_FINGERPRINTS
.iter()
.map(|f| f.major_version)
.collect();
majors.sort_unstable();
majors.dedup();
assert!(
majors.len() >= 5,
"fingerprint table covers {} majors, expected >=5",
majors.len()
);
}
#[test]
fn fingerprint_table_covers_all_four_platforms() {
let mut platforms: Vec<&str> = CHROME_FINGERPRINTS.iter().map(|f| f.platform).collect();
platforms.sort_unstable();
platforms.dedup();
for p in &["Linux", "macOS", "Windows", "Android"] {
assert!(platforms.contains(p), "missing platform: {p}");
}
}
#[test]
fn fingerprint_ja3_hashes_are_md5_length() {
for f in CHROME_FINGERPRINTS {
assert_eq!(
f.ja3_hash.len(),
32,
"{}: ja3_hash must be 32 chars",
f.name
);
}
}
#[test]
fn expected_fingerprint_finds_exact_match() {
let f = expected_fingerprint(134, "Linux").expect("must find Chrome 134/Linux");
assert_eq!(f.major_version, 134);
assert_eq!(f.platform, "Linux");
}
#[test]
fn expected_fingerprint_falls_back_to_latest_same_platform() {
let f = expected_fingerprint(9999, "Linux").expect("must fall back to a Linux entry");
assert_eq!(f.platform, "Linux");
assert!(f.major_version >= 130);
}
#[test]
fn expected_fingerprint_falls_back_when_platform_unknown() {
let f = expected_fingerprint(134, "FreeBSD");
assert!(f.is_some());
}
#[test]
fn shared_user_agent_facts_extract_chromium_major() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/134.0.6998.45 Safari/537.36";
assert_eq!(user_agent_facts(ua).chromium_major_version, Some(134));
}
#[test]
fn shared_user_agent_facts_returns_none_when_no_chromium_token() {
let ua = "Mozilla/5.0 (Macintosh) AppleWebKit/605 Safari/605 Version/17.5";
assert_eq!(user_agent_facts(ua).chromium_major_version, None);
}
#[test]
fn shared_user_agent_facts_identifies_tls_platform_labels() {
let cases = [
("Mozilla/5.0 (Linux x86_64)", "Linux"),
("Mozilla/5.0 (Windows NT 10.0)", "Windows"),
("Mozilla/5.0 (Macintosh; Intel Mac OS X)", "macOS"),
("Mozilla/5.0 (Linux; Android 14)", "Android"),
];
for (ua, expected) in cases {
assert_eq!(
user_agent_facts(ua).platform.chrome_tls_label(),
Some(expected)
);
}
}
#[test]
fn compute_drift_axes_returns_empty_when_match() {
let expected = CHROME_FINGERPRINTS[0].clone();
let reported = ReportedHandshake {
ja3: expected.ja3.to_string(),
ja3_hash: expected.ja3_hash.to_string(),
ja4: expected.ja4.to_string(),
h2: expected.h2_fingerprint.to_string(),
user_agent: String::new(),
};
let axes = compute_drift_axes(&expected, &reported);
assert!(axes.is_empty(), "match should yield no axes: {axes:?}");
}
#[test]
fn compute_drift_axes_flags_cipher_order_drift() {
let expected = CHROME_FINGERPRINTS[0].clone();
let drifted = ReportedHandshake {
ja3: "771,4865-4866,0-23,29-23-24,0".to_string(),
ja3_hash: "00000000000000000000000000000000".to_string(),
ja4: expected.ja4.to_string(),
h2: expected.h2_fingerprint.to_string(),
user_agent: String::new(),
};
let axes = compute_drift_axes(&expected, &drifted);
assert!(axes.contains(&DriftAxis::CipherOrder));
}
#[test]
fn compute_drift_axes_flags_h2_settings_drift() {
let expected = CHROME_FINGERPRINTS[0].clone();
let drifted = ReportedHandshake {
ja3: expected.ja3.to_string(),
ja3_hash: expected.ja3_hash.to_string(),
ja4: expected.ja4.to_string(),
h2: "9:262144;7:99|15663105|0|m,a,s,p".to_string(),
user_agent: String::new(),
};
let axes = compute_drift_axes(&expected, &drifted);
assert!(axes.contains(&DriftAxis::H2SettingsOrder));
}
#[test]
fn drift_axis_str_round_trip() {
for axis in &[
DriftAxis::CipherOrder,
DriftAxis::ExtensionOrder,
DriftAxis::GreasePosition,
DriftAxis::AlpnOrder,
DriftAxis::GroupOrder,
DriftAxis::SignatureAlgOrder,
DriftAxis::H2SettingsOrder,
DriftAxis::H2PriorityShape,
DriftAxis::H2HeaderOrder,
] {
let s = axis.as_str();
assert!(!s.is_empty());
assert!(!s.contains(' '));
}
}
#[test]
fn forwarding_proxy_new_errors_until_boring_wired() {
let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
let fp = CHROME_FINGERPRINTS[0].clone();
let r = ForwardingProxy::new(bind, fp);
assert!(
r.is_err(),
"ForwardingProxy::new must error until JA3-forging \
backend is wired; this test is the canary"
);
}
#[test]
fn forwarding_proxy_transparent_constructs() {
let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
assert!(ForwardingProxy::transparent(bind).is_ok());
}
#[test]
fn boring_backend_todo_lists_concrete_items() {
assert!(
BORING_BACKEND_TODO.len() >= 8,
"BORING_BACKEND_TODO has {} items, expected >=8",
BORING_BACKEND_TODO.len()
);
for item in BORING_BACKEND_TODO {
assert!(
!item.to_lowercase().contains("consider")
&& !item.to_lowercase().contains("investigate")
&& !item.to_lowercase().contains("maybe"),
"TODO must be concrete, not exploratory: {item}"
);
}
}
#[test]
fn fingerprint_table_sample_ja3_string_is_well_formed() {
for f in CHROME_FINGERPRINTS {
let segments: Vec<&str> = f.ja3.split(',').collect();
assert_eq!(
segments.len(),
5,
"{} ja3 string has wrong segment count: {}",
f.name,
f.ja3
);
}
}
#[test]
fn fingerprint_table_ja4_is_chrome_h2_grade() {
for f in CHROME_FINGERPRINTS {
assert!(
f.ja4.starts_with("t13d"),
"{} ja4 doesn't look like Chrome TLS 1.3: {}",
f.name,
f.ja4
);
}
}
#[test]
fn diagnostic_url_default_is_a_real_endpoint() {
assert!(DEFAULT_DIAGNOSTIC_URL.starts_with("https://"));
assert!(
DEFAULT_DIAGNOSTIC_URL.contains("peet.ws")
|| DEFAULT_DIAGNOSTIC_URL.contains("browserleaks")
);
}
}