use std::collections::BTreeMap;
use exo_core::{hash::hash_structured, types::Hash256};
use serde::Serialize;
use super::types::{DeviceFingerprint, FingerprintSignal};
const FINGERPRINT_COMPOSITE_HASH_DOMAIN: &str = "exo.node.zerodentity.fingerprint.v1";
const FINGERPRINT_COMPOSITE_HASH_SCHEMA_VERSION: u16 = 1;
#[derive(Debug, Serialize)]
struct FingerprintCompositeHashPayload<'a> {
domain: &'static str,
schema_version: u16,
signal_hashes: &'a BTreeMap<FingerprintSignal, Hash256>,
}
#[allow(dead_code)]
pub fn compute_composite_hash(
signal_hashes: &BTreeMap<FingerprintSignal, Hash256>,
) -> anyhow::Result<Hash256> {
hash_structured(&FingerprintCompositeHashPayload {
domain: FINGERPRINT_COMPOSITE_HASH_DOMAIN,
schema_version: FINGERPRINT_COMPOSITE_HASH_SCHEMA_VERSION,
signal_hashes,
})
.map_err(|e| anyhow::anyhow!("fingerprint composite hash canonical encoding failed: {e}"))
}
#[allow(dead_code)]
pub fn compute_consistency(
previous: &DeviceFingerprint,
new_signals: &BTreeMap<FingerprintSignal, Hash256>,
) -> u32 {
let prev_signals = &previous.signal_hashes;
if prev_signals.is_empty() && new_signals.is_empty() {
return 10_000; }
let total_keys: std::collections::BTreeSet<&FingerprintSignal> =
prev_signals.keys().chain(new_signals.keys()).collect();
let total = u64::try_from(total_keys.len()).unwrap_or(0);
if total == 0 {
return 10_000;
}
let matching = u64::try_from(
total_keys
.iter()
.filter(|&&k| {
prev_signals.get(k) == new_signals.get(k)
&& prev_signals.contains_key(k)
&& new_signals.contains_key(k)
})
.count(),
)
.unwrap_or(0);
u32::try_from((matching * 10_000) / total).unwrap_or(u32::MAX)
}
#[allow(dead_code)]
pub fn build_fingerprint(
signal_hashes: BTreeMap<FingerprintSignal, Hash256>,
previous: Option<&DeviceFingerprint>,
captured_ms: u64,
) -> anyhow::Result<DeviceFingerprint> {
let composite_hash = compute_composite_hash(&signal_hashes)?;
let consistency_score_bp = previous.map(|prev| compute_consistency(prev, &signal_hashes));
Ok(DeviceFingerprint {
composite_hash,
signal_hashes,
captured_ms,
consistency_score_bp,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn hash(b: &[u8]) -> Hash256 {
Hash256::digest(b)
}
fn sig(name: &str) -> FingerprintSignal {
match name {
"Canvas" => FingerprintSignal::CanvasRendering,
"UserAgent" => FingerprintSignal::UserAgent,
"Screen" => FingerprintSignal::ScreenGeometry,
"WebGL" => FingerprintSignal::WebGLParameters,
"Audio" => FingerprintSignal::AudioContext,
_ => FingerprintSignal::Platform,
}
}
fn composite(signal_hashes: &BTreeMap<FingerprintSignal, Hash256>) -> Hash256 {
compute_composite_hash(signal_hashes).expect("canonical fingerprint composite hash")
}
fn fp(signals: Vec<(&str, &[u8])>) -> DeviceFingerprint {
let mut map = BTreeMap::new();
for (name, data) in signals {
map.insert(sig(name), hash(data));
}
let composite = composite(&map);
DeviceFingerprint {
composite_hash: composite,
signal_hashes: map,
captured_ms: 0,
consistency_score_bp: None,
}
}
#[test]
fn consistency_identical_is_10000() {
let prev = fp(vec![
("Canvas", b"canvas-data"),
("UserAgent", b"ua-data"),
("Screen", b"screen-data"),
]);
let new_map = prev.signal_hashes.clone();
assert_eq!(compute_consistency(&prev, &new_map), 10_000);
}
#[test]
fn consistency_completely_different_is_0() {
let prev = fp(vec![("Canvas", b"canvas-A")]);
let mut new_map = BTreeMap::new();
new_map.insert(sig("Canvas"), hash(b"canvas-B"));
assert_eq!(compute_consistency(&prev, &new_map), 0);
}
#[test]
fn consistency_partial_overlap_is_intermediate() {
let prev = fp(vec![("Canvas", b"data"), ("UserAgent", b"ua")]);
let mut new_map = BTreeMap::new();
new_map.insert(sig("Canvas"), hash(b"data")); new_map.insert(sig("UserAgent"), hash(b"ua-new")); let score = compute_consistency(&prev, &new_map);
assert_eq!(score, 5000);
}
#[test]
fn consistency_one_match_out_of_three() {
let prev = fp(vec![
("Canvas", b"canvas"),
("UserAgent", b"ua"),
("Screen", b"screen"),
]);
let mut new_map = BTreeMap::new();
new_map.insert(sig("Canvas"), hash(b"canvas")); new_map.insert(sig("UserAgent"), hash(b"ua-new")); new_map.insert(sig("Screen"), hash(b"screen-new")); let score = compute_consistency(&prev, &new_map);
assert_eq!(score, 3333);
}
#[test]
fn consistency_both_empty_is_10000() {
let prev = fp(vec![]);
let new_map = BTreeMap::new();
assert_eq!(compute_consistency(&prev, &new_map), 10_000);
}
#[test]
fn composite_hash_deterministic() {
let mut m1 = BTreeMap::new();
m1.insert(sig("Canvas"), hash(b"a"));
m1.insert(sig("UserAgent"), hash(b"b"));
let mut m2 = BTreeMap::new();
m2.insert(sig("UserAgent"), hash(b"b"));
m2.insert(sig("Canvas"), hash(b"a"));
assert_eq!(composite(&m1), composite(&m2));
}
#[test]
fn composite_hash_binds_signal_kind_to_hash_value() {
let mut browser_signals = BTreeMap::new();
browser_signals.insert(FingerprintSignal::AudioContext, hash(b"first"));
browser_signals.insert(FingerprintSignal::BatteryStatus, hash(b"second"));
let mut environment_signals = BTreeMap::new();
environment_signals.insert(FingerprintSignal::CanvasRendering, hash(b"first"));
environment_signals.insert(FingerprintSignal::ColorDepthDPR, hash(b"second"));
assert_ne!(
composite(&browser_signals),
composite(&environment_signals),
"composite hashes must bind the signal kind, not only the ordered signal hashes"
);
}
#[test]
fn composite_hash_changes_with_different_signals() {
let mut m1 = BTreeMap::new();
m1.insert(sig("Canvas"), hash(b"a"));
let mut m2 = BTreeMap::new();
m2.insert(sig("Canvas"), hash(b"b"));
assert_ne!(composite(&m1), composite(&m2));
}
#[test]
fn build_fingerprint_first_session_no_consistency() {
let mut signals = BTreeMap::new();
signals.insert(sig("Canvas"), hash(b"canvas-data"));
let fp = build_fingerprint(signals, None, 1_000_000).expect("canonical fingerprint build");
assert!(
fp.consistency_score_bp.is_none(),
"first session has no consistency"
);
}
#[test]
fn build_fingerprint_second_session_identical() {
let signals: BTreeMap<_, _> = {
let mut m = BTreeMap::new();
m.insert(sig("Canvas"), hash(b"same"));
m
};
let first =
build_fingerprint(signals.clone(), None, 1_000).expect("first canonical fingerprint");
let second =
build_fingerprint(signals, Some(&first), 2_000).expect("second canonical fingerprint");
assert_eq!(second.consistency_score_bp, Some(10_000));
}
}