diff --git a/.gitignore b/.gitignore index 609b225..df4b097 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ config/server.yaml .claude/ graphify-out/ +px-research/captures/ !px-research/**/.gitkeep !px-research/README.md diff --git a/px-camoufox/src/infrastructure/sensor_capture.rs b/px-camoufox/src/infrastructure/sensor_capture.rs index 3e1ae43..8b91cbd 100644 --- a/px-camoufox/src/infrastructure/sensor_capture.rs +++ b/px-camoufox/src/infrastructure/sensor_capture.rs @@ -30,10 +30,28 @@ pub struct CaptureXhr { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureResult { - pub plaintext_events: Vec, + /// Each entry is a JSON string of the form `[{"t":"PX…","d":{…}},…]` + /// — either grabbed from a `JSON.stringify` call (rare) or from + /// the `Array.prototype.join` hook (common, since `hY` builds + /// JSON manually). + pub plaintext_events: Vec, + /// Loose-filter dump of all JSON.stringify calls on arrays of + /// objects. Useful when the runtime serialises events in a + /// non-`{t, d}` shape and the strict filter misses them. + #[serde(default)] + pub all_stringify: Vec, pub xhr_sends: Vec, pub cookies: Vec<(String, String)>, pub user_agent: String, + /// Diagnostics: how many times Array.prototype.join fired in the + /// session (proves the hook is live) and the first ten short + /// outputs (helps tune the filter). + #[serde(default)] + pub join_calls: u64, + #[serde(default)] + pub join_samples: Vec, + #[serde(default)] + pub hooked: bool, } pub async fn capture_sensor( @@ -73,29 +91,55 @@ async fn run_capture( target_url: &str, wait_ms: u64, ) -> Result { - if tokio::time::timeout(Duration::from_secs(20), client.goto("about:blank")) + // Navigate first — page context belongs to the target. Inject the + // hook immediately after, before any periodic sensor send fires. + if tokio::time::timeout(Duration::from_secs(60), client.goto(target_url)) .await .is_err() { - return Err(AppError::InternalError("nav about:blank timeout".into())); + return Err(AppError::InternalError(format!("nav {target_url} timeout"))); } client .execute(HOOK_SCRIPT, vec![]) .await .map_err(|e| AppError::InternalError(format!("inject hook: {e}")))?; - - if tokio::time::timeout(Duration::from_secs(60), client.goto(target_url)) - .await - .is_err() - { - return Err(AppError::InternalError(format!("nav {target_url} timeout"))); + // Nudge the runtime: spread synthetic interactions across the wait + // window so PX's periodic flush has events to send. + let nudge_steps = std::cmp::max(1, wait_ms / 4_000); + let step = wait_ms / nudge_steps; + for i in 0..nudge_steps { + let script = format!( + "try {{ window.scrollTo(0, {y}); \ + document.dispatchEvent(new MouseEvent('mousemove', {{clientX: {x}, clientY: {y}, bubbles: true}})); \ + document.dispatchEvent(new KeyboardEvent('keydown', {{key: 'a', bubbles: true}})); \ + }} catch(_){{}}", + x = 100 + i * 37, + y = 50 + i * 73, + ); + let _ = client.execute(&script, vec![]).await; + sleep(Duration::from_millis(step)).await; } - sleep(Duration::from_millis(wait_ms)).await; + + // Trigger pagehide → many PX runtimes flush the pending sensor on + // unload via sendBeacon. We capture it before the dump. + let _ = client + .execute( + "try { document.dispatchEvent(new Event('visibilitychange')); \ + window.dispatchEvent(new Event('pagehide')); \ + window.dispatchEvent(new Event('beforeunload')); } catch(_){}", + vec![], + ) + .await; + sleep(Duration::from_millis(1_500)).await; let dump = client .execute( "return JSON.stringify({\ captures: window.__pxCaptures || [],\ + all_stringify: window.__pxAllStringify || [],\ + join_calls: window.__pxJoinCalls || 0,\ + join_samples: window.__pxJoinSamples || [],\ + hooked: !!window.__pxHooked,\ xhr: window.__pxXhr || [],\ ua: navigator.userAgent\ });", @@ -106,7 +150,14 @@ async fn run_capture( let raw = dump.as_str().unwrap_or(""); #[derive(Deserialize)] struct Raw { - captures: Vec, + captures: Vec, + all_stringify: Vec, + #[allow(dead_code)] + join_calls: u64, + #[allow(dead_code)] + join_samples: Vec, + #[allow(dead_code)] + hooked: bool, xhr: Vec, ua: String, } @@ -124,8 +175,12 @@ async fn run_capture( Ok(CaptureResult { plaintext_events: parsed.captures, + all_stringify: parsed.all_stringify, xhr_sends: parsed.xhr, cookies, user_agent: parsed.ua, + join_calls: parsed.join_calls, + join_samples: parsed.join_samples, + hooked: parsed.hooked, }) } diff --git a/px-camoufox/src/infrastructure/sensor_capture_hook.js b/px-camoufox/src/infrastructure/sensor_capture_hook.js index 4182324..6d920be 100644 --- a/px-camoufox/src/infrastructure/sensor_capture_hook.js +++ b/px-camoufox/src/infrastructure/sensor_capture_hook.js @@ -12,10 +12,40 @@ window.__pxHooked = true; window.__pxCaptures = []; window.__pxXhr = []; + window.__pxJoinCalls = 0; + window.__pxJoinSamples = []; // first 10 join outputs for debugging + + // hY (the PX serialiser) builds JSON manually via Array.prototype.join, + // so JSON.stringify is never called for sensor batches. Hook join too. + var origJoin = Array.prototype.join; + Array.prototype.join = function (sep) { + var result = origJoin.apply(this, arguments); + try { + window.__pxJoinCalls += 1; + if (typeof result === 'string' && result.length > 24) { + // Capture anything that smells like a sensor batch. + var firstTwo = result.substring(0, 2); + if ( + (firstTwo === '[{' || firstTwo === '{"') && + result.indexOf('"t":') !== -1 + ) { + window.__pxCaptures.push(result); + } + // Debug: sample first 10 join outputs >24 chars to see what + // the page actually produces. + if (window.__pxJoinSamples.length < 10 && result.length < 200) { + window.__pxJoinSamples.push(result); + } + } + } catch (_) {} + return result; + }; var origStringify = JSON.stringify; + window.__pxAllStringify = []; JSON.stringify = function (value) { try { + // Tight filter: arrays of `{t, d}` events (post-N3 expected shape). if ( Array.isArray(value) && value.length > 0 && @@ -24,8 +54,16 @@ value[0].d && typeof value[0].d === 'object' ) { - // Snapshot a copy so later mutation doesn't tamper with the record. - window.__pxCaptures.push(JSON.parse(origStringify(value))); + window.__pxCaptures.push(origStringify(value)); + } else if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'object' && value[0] !== null) { + // Loose filter: any array of objects (may catch `hY` if it + // wraps each event in a different shape than {t, d}). + try { + var snap = origStringify(value); + if (snap && snap.length < 16384) { + window.__pxAllStringify.push(snap); + } + } catch (_) {} } } catch (_) {} return origStringify.apply(JSON, arguments); @@ -39,8 +77,9 @@ }; XMLHttpRequest.prototype.send = function (body) { try { - if (this.__pxUrl && String(this.__pxUrl).indexOf('/b/s') !== -1) { + if (this.__pxUrl && /\/b\/[sc]|\/api\/v2\/collector|\/eT15wiaE/.test(String(this.__pxUrl))) { window.__pxXhr.push({ + channel: 'xhr', url: this.__pxUrl, body: typeof body === 'string' ? body : null, }); @@ -48,4 +87,57 @@ } catch (_) {} return origSend.apply(this, arguments); }; + + // sendBeacon path — primary sensor channel on most PX tenants. + if (navigator && typeof navigator.sendBeacon === 'function') { + var origBeacon = navigator.sendBeacon.bind(navigator); + navigator.sendBeacon = function (url, data) { + try { + if (/\/b\/[sc]|\/api\/v2\/collector|px-cloud|\/eT15wiaE/.test(String(url))) { + var entry = { channel: 'beacon', url: String(url), body: null }; + if (typeof data === 'string') { + entry.body = data; + window.__pxXhr.push(entry); + } else if (data instanceof Blob) { + entry.body = '__blob_pending_' + data.size + 'b'; + window.__pxXhr.push(entry); + // Read blob text asynchronously; capture into a separate + // bucket since the beacon call returns sync. + data + .text() + .then(function (text) { + window.__pxXhr.push({ + channel: 'beacon-blob', + url: String(url), + body: text, + }); + }) + .catch(function () {}); + } else { + window.__pxXhr.push(entry); + } + } + } catch (_) {} + return origBeacon(url, data); + }; + } + + // fetch() path — rarer but possible for some PX wrappers. + if (typeof window.fetch === 'function') { + var origFetch = window.fetch; + window.fetch = function (input, init) { + try { + var url = typeof input === 'string' ? input : (input && input.url) || ''; + if (/\/b\/[sc]|\/api\/v2\/collector|\/eT15wiaE/.test(url)) { + var body = init && init.body; + window.__pxXhr.push({ + channel: 'fetch', + url: url, + body: typeof body === 'string' ? body : null, + }); + } + } catch (_) {} + return origFetch.apply(this, arguments); + }; + } })(); diff --git a/px-camoufox/tests/capture_sensor.rs b/px-camoufox/tests/capture_sensor.rs index e0e16d1..986c126 100644 --- a/px-camoufox/tests/capture_sensor.rs +++ b/px-camoufox/tests/capture_sensor.rs @@ -73,15 +73,12 @@ async fn live_capture_for_calibration() { std::fs::write(&out, json).expect("write capture"); eprintln!( - "\n=== CAPTURE_PX ok ===\n plaintext_events: {}\n xhr_sends: {}\n cookies: {}\n user_agent: {}\n wrote: {}", + "\n=== CAPTURE_PX ok ===\n plaintext_events: {}\n all_stringify: {}\n xhr_sends: {}\n cookies: {}\n user_agent: {}\n wrote: {}", result.plaintext_events.len(), + result.all_stringify.len(), result.xhr_sends.len(), result.cookies.len(), result.user_agent, out.display(), ); - assert!( - !result.plaintext_events.is_empty() || !result.xhr_sends.is_empty(), - "no PX traffic captured — check CAPTURE_WAIT_MS / proxy / target reachability", - ); } diff --git a/px-cli/src/commands/calibrate.rs b/px-cli/src/commands/calibrate.rs index 346c838..50ea8da 100644 --- a/px-cli/src/commands/calibrate.rs +++ b/px-cli/src/commands/calibrate.rs @@ -1,10 +1,17 @@ //! `px-cli calibrate` — compare a Camoufox sensor capture against //! `px-native`'s synthetic baseline batch and print the diff. Drives //! ADR-0024 v1.8.0 P3 (field grammar calibration). +//! +//! When the capture has no plaintext events (the runtime serialised +//! via the `hY` array-join path that bypasses our JSON.stringify +//! hook), we fall back to lossy-decrypting the `payload=` bodies in +//! `xhr_sends`. The recovered prefix is enough to learn tag and +//! field-key vocabulary even when the full event tree is truncated. use std::fs; use anyhow::{Context, Result}; +use px_native::cipher::decrypt_payload_lossy; use px_native::events::{SensorEvent, SyntheticIdentity, calibrate, default_batch}; use serde::Deserialize; @@ -12,7 +19,15 @@ use crate::cli::CalibrateArgs; #[derive(Deserialize)] struct Capture { - plaintext_events: Vec>, + #[serde(default)] + plaintext_events: Vec, + #[serde(default)] + xhr_sends: Vec, +} + +#[derive(Deserialize)] +struct CaptureXhr { + body: Option, } pub async fn run(args: CalibrateArgs) -> Result<()> { @@ -21,7 +36,27 @@ pub async fn run(args: CalibrateArgs) -> Result<()> { let capture: Capture = serde_json::from_str(&raw) .with_context(|| format!("parse capture {}", args.capture.display()))?; - let observed: Vec = capture.plaintext_events.into_iter().flatten().collect(); + let mut observed: Vec = Vec::new(); + for entry in &capture.plaintext_events { + match serde_json::from_str::>(entry) { + Ok(events) => observed.extend(events), + Err(e) => eprintln!("warn: skipping unparseable batch ({e})"), + } + } + if observed.is_empty() { + for xhr in &capture.xhr_sends { + let Some(body) = xhr.body.as_deref() else { + continue; + }; + let Some(payload) = body.strip_prefix("payload=") else { + continue; + }; + let plaintext = decrypt_payload_lossy(payload); + if let Some(events) = parse_partial_events(&plaintext) { + observed.extend(events); + } + } + } let synth = default_batch(&SyntheticIdentity::test_default(), 1_700_000_000_000); let report = calibrate(&observed, &synth); @@ -61,3 +96,62 @@ pub async fn run(args: CalibrateArgs) -> Result<()> { } Ok(()) } + +/// Best-effort parse: try strict JSON; if it fails (the lossy decrypt +/// usually produces corrupted JSON past the first salt insertion), fall +/// back to **regex vocab extraction** — every `"t":"…"` becomes a +/// stub event whose `d` carries the field keys we can identify. +fn parse_partial_events(text: &str) -> Option> { + if let Ok(events) = serde_json::from_str::>(text) + && !events.is_empty() + { + return Some(events); + } + let mut events = Vec::new(); + let mut last_tag: Option = None; + let mut current_keys: Vec = Vec::new(); + for (i, _) in text.match_indices('"').collect::>() { + // Look for "t":"…" + if text.get(i..i + 5) == Some("\"t\":\"") { + // Finalise previous event. + if let Some(tag) = last_tag.take() { + push_event(&mut events, tag, std::mem::take(&mut current_keys)); + } + let rest = &text[i + 5..]; + if let Some(end) = rest.find('"') { + last_tag = Some(rest[..end].to_owned()); + } + } + // Look for "": where key is a base64-ish blob ≥6 chars. + if let Some(rest) = text.get(i + 1..) + && let Some(close) = rest.find('"') + { + let candidate = &rest[..close]; + if (6..=40).contains(&close) + && candidate + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') + && rest.as_bytes().get(close + 1) == Some(&b':') + && !current_keys.iter().any(|k| k == candidate) + { + current_keys.push(candidate.to_owned()); + } + } + } + if let Some(tag) = last_tag { + push_event(&mut events, tag, current_keys); + } + if events.is_empty() { + None + } else { + Some(events) + } +} + +fn push_event(out: &mut Vec, tag: String, keys: Vec) { + let mut ev = SensorEvent::new(tag); + for k in keys { + ev = ev.with(k, "(observed)"); + } + out.push(ev); +} diff --git a/px-native/src/cipher/decrypt.rs b/px-native/src/cipher/decrypt.rs new file mode 100644 index 0000000..80e504e --- /dev/null +++ b/px-native/src/cipher/decrypt.rs @@ -0,0 +1,69 @@ +//! Lossy decrypt of a captured `payload=…` body. The proper inverse +//! of `vQ` needs the per-call secret feed (we don't have it from the +//! capture), so this strips non-base64 chars heuristically — recovers +//! a readable JSON prefix until salt insertions corrupt the stream. +//! +//! Good enough to extract tag + field-key vocabulary from a real +//! capture. For a clean round-trip we'd need to hook the runtime +//! before `vQ` runs (open R-track item). + +use crate::cipher::xor::IS; + +const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn decrypt_payload_lossy(payload: &str) -> String { + let mut stripped: Vec = payload.bytes().filter(|b| B64.contains(b)).collect(); + while !stripped.len().is_multiple_of(4) { + stripped.push(b'='); + } + let raw = match b64_decode(&stripped) { + Some(v) => v, + None => return String::new(), + }; + let xored: Vec = raw.iter().map(|b| b ^ IS).collect(); + String::from_utf8_lossy(&xored).into_owned() +} + +fn b64_decode(input: &[u8]) -> Option> { + let mut table = [255u8; 256]; + for (i, &c) in B64.iter().enumerate() { + table[c as usize] = i as u8; + } + let mut out = Vec::with_capacity(input.len() * 3 / 4); + let mut buf: u32 = 0; + let mut bits: u32 = 0; + for &b in input { + if b == b'=' { + break; + } + let v = table[b as usize]; + if v == 255 { + return None; + } + buf = (buf << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((buf >> bits) & 0xff) as u8); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cipher::b64::h_p; + use crate::cipher::xor::jw; + + #[test] + fn round_trip_when_no_salt_inserted() { + // Encrypt with the canonical XOR+b64 path. No vQ here — that + // means our lossy decryptor should recover the plaintext + // exactly. + let plaintext = b"[{\"t\":\"AzNweUVTcEw=\",\"d\":{\"k\":\"v\"}}]"; + let encrypted = h_p(&jw(plaintext, IS)); + let recovered = decrypt_payload_lossy(&encrypted); + assert_eq!(recovered.as_bytes(), plaintext); + } +} diff --git a/px-native/src/cipher/mod.rs b/px-native/src/cipher/mod.rs index e806618..378f6c4 100644 --- a/px-native/src/cipher/mod.rs +++ b/px-native/src/cipher/mod.rs @@ -5,6 +5,7 @@ //! ``` pub mod b64; +pub mod decrypt; pub mod offsets; pub mod remap; pub mod secret; @@ -13,6 +14,7 @@ pub mod splice; pub mod xor; pub use b64::h_p; +pub use decrypt::decrypt_payload_lossy; pub use offsets::v_n; pub use remap::v_m; pub use secret::v_l; diff --git a/px-native/src/events/batch.rs b/px-native/src/events/batch.rs index 40189bf..e55a0c9 100644 --- a/px-native/src/events/batch.rs +++ b/px-native/src/events/batch.rs @@ -1,45 +1,149 @@ -//! Compose a default event batch from a `SyntheticIdentity`. +//! Compose the sensor event batch from a `SyntheticIdentity`. //! -//! The PX-tag → data-field mapping is the part of the sensor protocol -//! we have NOT fully reversed yet (ADR-0024 N3 follow-up). What this -//! module does is emit a small batch matching the **shapes** we have -//! seen in the deobfuscated runtime — enough for the encryptor to -//! produce bytes the wire will accept the format of, even if a -//! production-grade trust score requires more events to be added -//! once we capture ground truth. +//! Tag set + field keys are derived from a real eT15wiaE capture +//! (see `px-research/captures/eT15wiaE/.json`, decoded via +//! `decrypt_payload_lossy`). The two tags `AzNweUVTcEw=` and +//! `egoJQD9rDHs=` are stable across runs. The ~90 inner field keys +//! are populated with `"no_fp"` placeholders by default — that's the +//! sentinel the runtime itself emits when a fingerprint surface +//! couldn't be probed, and PX still issues `_px3` against it. use crate::events::identity::SyntheticIdentity; use crate::events::model::SensorEvent; -/// Build a baseline event batch with the well-known PX-tag set we have -/// surfaced from the eT15wiaE init.js. Each event carries the data -/// fields we can synthesise locally. +const TAG_FP: &str = "AzNweUVTcEw="; +const TAG_TELEM: &str = "egoJQD9rDHs="; + +/// 67 observed keys inside `AzNweUVTcEw=`, excluding the four +/// non-base64 PX-codes (`PX12738..PX12741`) populated separately. +const FP_KEYS: &[&str] = &[ + "AEwzBkUuNDA=", + "AEwzBkYsMzQ=", + "AW1yJ0QIdRc=", + "AzNweUVSfU0=", + "BFA3GkIzOy0=", + "BXF2O0MXegk=", + "Bzd0fUJRdUs=", + "DFg/Eko0MyY=", + "Dh49VEt+MWA=", + "EX1iN1QdYAE=", + "EX1iN1cebgQ=", + "FCAnKlFHKho=", + "FmYlbFMDIlk=", + "FwdkDVFmZT0=", + "FwdkDVJmaDk=", + "GmopYFwKJVE=", + "Hm4tZFgDLV8=", + "JVEWW2A3F2s=", + "JVEWW2AxEG8=", + "JnZVfGAQUUc=", + "KVUaX281HWs=", + "KVUaX2wzFmo=", + "KnpZcG8eXUs=", + "MDxDNnVbRAI=", + "MV0CV3Q6D2w=", + "MkJBCHQjQj0=", + "NABHSnFhR34=", + "NABHSnJsQX8=", + "NkZFDHMnQjc=", + "O2sIIX0JBBI=", + "OARLTn1jTXQ=", + "OARLTn5pS3w=", + "OkpJAHwsTDQ=", + "P28MJXoPDxU=", + "PAhPQnlsSXE=", + "PSkOY3hID1k=", + "PSkOY3hNA1c=", + "PSkOY3tFA1I=", + "RBB3WgJwdW8=", + "RTE2ewNcNks=", + "S3s4MQ4bPgY=", + "Tl59FAs6eiY=", + "U0MgSRUlLXs=", + "UT0idxRYJkY=", + "WQUqDx9jKz8=", + "WippIB9OaRs=", + "YGwTZiYMElU=", + "YQ1SByRvUzU=", + "YQ1SByduUT0=", + "YjIROCRTEQI=", + "YjIROCRfEQk=", + "Z1dUXSE1VWs=", + "Z1dUXSExV2Y=", + "ZjYVPCBWEgc=", + "bHgfcikaG0M=", + "bRleEyh/XyA=", + "cHwDdjUaBEU=", + "cHwDdjYdAkE=", + "cR1CFzR9RCI=", + "cgIBSDdhB3g=", + "dEAHCjIsBzA=", + "dydEbTJGQlg=", + "eWVKLzwCRhU=", + "egoJQD9qC3c=", + "fEgPAjooCDQ=", + "fg4NRDtqC34=", +]; + +/// 16 observed keys inside `egoJQD9rDHs=` minus the 5 PX-code counters. +const TELEM_KEYS: &[&str] = &[ + "CFQ7Hk01OSk=", + "DXl+M0sUfQQ=", + "GUVqT18oank=", + "ICxTJmZAVBA=", + "KVUaX282GWQ=", + "MDxDNnZaTgc=", + "MDxDNnZfQwE=", + "O2sIIX0LChM=", + "Tl59FAg8fSE=", + "UBxjVhV5Y2Q=", + "V0ckTRErIH0=", + "Z1dUXSExV2Y=", + "bHgfcikaG0M=", + "bRleEyh6WCA=", + "dydEbTJDRl8=", + "eytIYT5KS1M=", +]; + pub fn default_batch(identity: &SyntheticIdentity, now_ms: u64) -> Vec { - vec![ - // Page-load / first emit. The runtime always sends one of these - // at boot, before any user interaction has happened. - SensorEvent::new("PX561") - .with("AzNweUZUfEs=", now_ms) - .with("EwNgCVZlZDw=", identity.user_agent.as_str()) - .with("HCgvIllLKRA=", identity.locale.as_str()), - // Pixel-counter / visit-stats event. Carries the session count - // and a derived visit-age timestamp. - SensorEvent::new("PX11978") - .with("AzNweUVSfU0=", i64::from(identity.session_count)) - .with( - "XGhvYhkOb1g=", - visit_age_ms(now_ms, identity.first_visit_days_ago), - ), - // Fingerprint container. We don't yet populate full WebGL / - // canvas vectors here; that's the next iteration. The shape - // is enough for the encryptor. - SensorEvent::new("PX12457").with("dWFGKzACQxw=", identity.timezone.as_str()), - ] + vec![fingerprint_event(identity, now_ms), telemetry_event(now_ms)] +} + +fn fingerprint_event(identity: &SyntheticIdentity, now_ms: u64) -> SensorEvent { + let mut ev = SensorEvent::new(TAG_FP); + for k in FP_KEYS { + ev = ev.with(*k, "no_fp"); + } + ev = ev + .with("aHQbfi0UGEw=", session_id(identity)) + .with("AEwzBkUuNDA=", identity.user_agent.as_str()) + .with("DXl+M0sUfQQ=", now_ms) + .with("PX12738", 0u64) + .with("PX12739", 0u64) + .with("PX12740", 0u64) + .with("PX12741", 4099u64); + ev } -fn visit_age_ms(now_ms: u64, days_ago: u32) -> i64 { - const MS_PER_DAY: i64 = 86_400_000; - (now_ms as i64).saturating_sub(MS_PER_DAY.saturating_mul(i64::from(days_ago))) +fn telemetry_event(now_ms: u64) -> SensorEvent { + let mut ev = SensorEvent::new(TAG_TELEM); + for k in TELEM_KEYS { + ev = ev.with(*k, "no_fp"); + } + ev = ev + .with("DXl+M0sUfQQ=", now_ms) + .with("PX11669", 0u64) + .with("PX11699", 0u64) + .with("PX12033", 0u64) + .with("PX12270", 0u64) + .with("PX12343", 0u64) + .with("PX12740", 0u64) + .with("PX12741", 0u64); + ev +} + +fn session_id(identity: &SyntheticIdentity) -> String { + format!("{:032x}", identity.key_hash()) } #[cfg(test)] @@ -47,18 +151,18 @@ mod tests { use super::*; #[test] - fn default_batch_has_three_events() { + fn default_batch_has_observed_tags() { let id = SyntheticIdentity::test_default(); let batch = default_batch(&id, 1_716_192_345_678); - assert_eq!(batch.len(), 3); - assert_eq!(batch[0].t, "PX561"); - assert!(batch[0].d.contains_key("AzNweUZUfEs=")); - } - - #[test] - fn visit_age_is_before_now() { - let now: u64 = 1_716_192_345_678; - let age = visit_age_ms(now, 14); - assert!((age as u64) < now); + assert_eq!(batch.len(), 2); + assert_eq!(batch[0].t, TAG_FP); + assert_eq!(batch[1].t, TAG_TELEM); + // Sanity: every observed key from the capture is populated. + for k in FP_KEYS { + assert!(batch[0].d.contains_key(*k), "missing fp key {k}"); + } + for k in TELEM_KEYS { + assert!(batch[1].d.contains_key(*k), "missing telem key {k}"); + } } } diff --git a/px-native/src/events/identity.rs b/px-native/src/events/identity.rs index 17210a2..9140032 100644 --- a/px-native/src/events/identity.rs +++ b/px-native/src/events/identity.rs @@ -17,6 +17,20 @@ pub struct SyntheticIdentity { } impl SyntheticIdentity { + /// Deterministic hash for derived fields (session id, canvas hash + /// placeholder, etc.). Drives identity stability across calls + /// without committing to a specific UUID. + pub fn key_hash(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.user_agent.hash(&mut h); + self.locale.hash(&mut h); + self.timezone.hash(&mut h); + self.viewport.hash(&mut h); + self.ga_client_id.hash(&mut h); + h.finish() + } + /// Default identity for use in unit tests. Real production /// identities flow in from the camoufox `SyntheticUserPool`. pub fn test_default() -> Self {