#![allow(clippy::manual_range_contains)]
use std::collections::BTreeSet;
use exo_core::types::{Did, Hash256, Signature};
use super::{
device_behavioral_axes_enabled,
types::{
BehavioralSample, BehavioralSignalType, ClaimStatus, ClaimType, DeviceFingerprint,
IdentityClaim, PolarAxes, ZerodentityScore,
},
};
const MAX_BASIS_POINTS: u32 = 10_000;
fn clamp_basis_points(value: u32) -> u32 {
value.min(MAX_BASIS_POINTS)
}
fn average_basis_points(values: impl Iterator<Item = u32>) -> Option<u32> {
let mut sum = 0_u64;
let mut count = 0_u64;
for value in values {
sum = sum.saturating_add(u64::from(clamp_basis_points(value)));
count = count.saturating_add(1);
}
sum.checked_div(count)
.map(|average| u64_to_u32_saturating(average.min(u64::from(MAX_BASIS_POINTS))))
}
fn average_axis_values(axes: &[u32; 8]) -> u32 {
let sum = axes
.iter()
.copied()
.fold(0_u64, |acc, axis| acc.saturating_add(u64::from(axis)));
u64_to_u32_saturating(sum / 8)
}
fn average_basis_point_axes(axes: &[u32; 8]) -> u32 {
average_basis_points(axes.iter().copied()).unwrap_or(0)
}
fn int_ln_milli(x: u64) -> u64 {
if x <= 1 {
return 0;
}
const LN2_MILLI: u64 = 693; let k_u32 = 63_u32.saturating_sub(x.leading_zeros()); let k = u64::from(k_u32);
let power = 1u64 << k_u32;
let f_num = (x.saturating_sub(power) << 10) / power;
let term1 = f_num * 1000 / 1024;
let term2 = f_num * f_num * 500 / (1024 * 1024);
let ln_frac = term1.saturating_sub(term2);
k * LN2_MILLI + ln_frac
}
fn isqrt(n: u64) -> u64 {
if n == 0 {
return 0;
}
let mut x = n;
let mut y = x.div_ceil(2);
while y < x {
x = y;
y = (x + n / x) / 2;
}
x
}
fn usize_to_u32_saturating(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
fn usize_to_u64_saturating(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
fn u64_to_u32_saturating(value: u64) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
fn u128_to_u32_saturating(value: u128) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
fn u128_to_u64_saturating(value: u128) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
fn capped_count_contribution(count: usize, per_item: u32, cap: u32) -> u32 {
let contribution = usize_to_u64_saturating(count)
.saturating_mul(u64::from(per_item))
.min(u64::from(cap));
u64_to_u32_saturating(contribution)
}
fn ratio_basis_points(numerator: usize, denominator: usize) -> Option<u32> {
let denominator = usize_to_u64_saturating(denominator);
if denominator == 0 {
return None;
}
let numerator = usize_to_u64_saturating(numerator).min(denominator);
let ratio = u128::from(numerator).saturating_mul(u128::from(MAX_BASIS_POINTS))
/ u128::from(denominator);
Some(u128_to_u32_saturating(ratio).min(MAX_BASIS_POINTS))
}
impl ZerodentityScore {
#[must_use]
pub fn compute(
subject_did: &Did,
claims: &[IdentityClaim],
fingerprints: &[DeviceFingerprint],
behavioral_samples: &[BehavioralSample],
now_ms: u64,
) -> Self {
let axes = PolarAxes {
communication: score_communication(claims),
credential_depth: score_credential_depth(claims),
device_trust: if device_behavioral_axes_enabled() {
score_device_trust(fingerprints)
} else {
0
},
behavioral_signature: if device_behavioral_axes_enabled() {
score_behavioral(behavioral_samples)
} else {
0
},
network_reputation: score_network_reputation(claims),
temporal_stability: score_temporal_stability(claims, now_ms),
cryptographic_strength: score_cryptographic_strength(claims),
constitutional_standing: score_constitutional_standing(claims),
};
let axis_values = axes.as_array();
let composite = average_basis_point_axes(&axis_values);
let symmetry = compute_symmetry(&axis_values);
let dag_state_hash = hash_claim_set(claims);
let claim_count = claims
.iter()
.filter(|c| c.status == ClaimStatus::Verified)
.count();
ZerodentityScore {
subject_did: subject_did.clone(),
axes,
composite,
computed_ms: now_ms,
dag_state_hash,
claim_count: usize_to_u32_saturating(claim_count),
symmetry,
}
}
}
fn score_communication(claims: &[IdentityClaim]) -> u32 {
let mut score: u32 = 0;
let verified_email = claims
.iter()
.any(|c| c.claim_type == ClaimType::Email && c.status == ClaimStatus::Verified);
let verified_phone = claims
.iter()
.any(|c| c.claim_type == ClaimType::Phone && c.status == ClaimStatus::Verified);
if verified_email {
score += 3500;
}
if verified_phone {
score += 3700;
}
if verified_email && verified_phone {
score += 1500; }
let extra = claims
.iter()
.filter(|c| {
matches!(c.claim_type, ClaimType::ProfessionalCredential { .. })
&& c.status == ClaimStatus::Verified
})
.count();
score += capped_count_contribution(extra, 400, 1300);
score.min(10_000)
}
fn score_credential_depth(claims: &[IdentityClaim]) -> u32 {
let mut score: u32 = 0;
if claims
.iter()
.any(|c| c.claim_type == ClaimType::DisplayName)
{
score += 500;
}
if claims
.iter()
.any(|c| c.claim_type == ClaimType::GovernmentId && c.status == ClaimStatus::Verified)
{
score += 3500;
}
if claims
.iter()
.any(|c| c.claim_type == ClaimType::BiometricLiveness && c.status == ClaimStatus::Verified)
{
score += 3000;
}
let pro_count = claims
.iter()
.filter(|c| {
matches!(c.claim_type, ClaimType::ProfessionalCredential { .. })
&& c.status == ClaimStatus::Verified
})
.count();
score += capped_count_contribution(pro_count, 1000, 3000);
score.min(10_000)
}
fn score_device_trust(fingerprints: &[DeviceFingerprint]) -> u32 {
if fingerprints.is_empty() {
return 0;
}
let mut score: u32 = 2000;
let latest = &fingerprints[fingerprints.len() - 1];
let signal_count = usize_to_u32_saturating(latest.signal_hashes.len()).min(15);
let coverage_bp = signal_count * 10_000 / 15;
score += coverage_bp / 4;
if let Some(consistency_bp) = latest.consistency_score_bp {
let consistency_bp = clamp_basis_points(consistency_bp);
score += consistency_bp * 2 / 5; } else {
score += 1600; }
if fingerprints.len() >= 3 {
if let Some(avg_bp) = average_basis_points(
fingerprints
.iter()
.filter_map(|fingerprint| fingerprint.consistency_score_bp),
) {
score += avg_bp * 3 / 20; }
}
score.min(10_000)
}
fn score_behavioral(samples: &[BehavioralSample]) -> u32 {
if samples.is_empty() {
return 0;
}
let mut score: u32 = 1000;
let signal_types: BTreeSet<&BehavioralSignalType> =
samples.iter().map(|s| &s.signal_type).collect();
score += capped_count_contribution(signal_types.len(), 600, 1800);
if let Some(avg_bp) = average_basis_points(
samples
.iter()
.filter_map(|sample| sample.baseline_similarity_bp),
) {
score += avg_bp * 2 / 5; } else {
score += 1600; }
let count = usize_to_u64_saturating(samples.len());
let ln_contrib = u64_to_u32_saturating(int_ln_milli(count).saturating_mul(500) / 1000);
score += ln_contrib.min(1600);
score.min(10_000)
}
fn score_network_reputation(claims: &[IdentityClaim]) -> u32 {
let mut score: u32 = 1000;
let attesters: BTreeSet<&Did> = claims
.iter()
.filter_map(|c| match &c.claim_type {
ClaimType::PeerAttestation { attester_did } if c.status == ClaimStatus::Verified => {
Some(attester_did)
}
_ => None,
})
.collect();
score += capped_count_contribution(attesters.len(), 500, 4000);
let delegations = claims
.iter()
.filter(|c| {
matches!(c.claim_type, ClaimType::DelegationGrant { .. })
&& c.status == ClaimStatus::Verified
})
.count();
score += capped_count_contribution(delegations, 800, 2400);
let resolved = claims
.iter()
.filter(|c| {
matches!(c.claim_type, ClaimType::SybilChallengeResolution { .. })
&& c.status == ClaimStatus::Verified
})
.count();
score += capped_count_contribution(resolved, 1200, 3600);
score.min(10_000)
}
fn score_temporal_stability(claims: &[IdentityClaim], now_ms: u64) -> u32 {
if claims.is_empty() {
return 0;
}
let mut score: u32 = 0;
let oldest_ms = claims.iter().map(|c| c.created_ms).min().unwrap_or(now_ms);
let age_days = now_ms.saturating_sub(oldest_ms) / 86_400_000;
if age_days > 0 {
let contrib = u64_to_u32_saturating(int_ln_milli(age_days).saturating_mul(800) / 1000);
score += contrib.min(3500);
}
let verified: Vec<&IdentityClaim> = claims
.iter()
.filter(|c| c.status == ClaimStatus::Verified)
.collect();
let total_verified = verified.len();
let fresh = verified
.iter()
.filter(|c| c.expires_ms.is_none_or(|exp| exp > now_ms))
.count();
if let Some(freshness_bp) = ratio_basis_points(fresh, total_verified) {
score += freshness_bp * 30 / 100;
}
let renewals = claims
.iter()
.filter(|c| c.verified_ms.is_some_and(|v| v != c.created_ms))
.count();
score += capped_count_contribution(renewals, 500, 2000);
let sessions = claims
.iter()
.filter(|c| c.claim_type == ClaimType::SessionContinuity)
.count();
score += capped_count_contribution(sessions, 200, 1500);
score.min(10_000)
}
fn score_cryptographic_strength(claims: &[IdentityClaim]) -> u32 {
let mut score: u32 = 1500;
if let Some(latest) = claims.last() {
match &latest.signature {
Signature::Ed25519(_) => score += 2500,
Signature::Hybrid { .. } => score += 4000, Signature::PostQuantum(_) => score += 3500,
Signature::Empty => {}
}
}
let rotations = claims
.iter()
.filter(|c| matches!(c.claim_type, ClaimType::KeyRotation { .. }))
.count();
score += capped_count_contribution(rotations, 800, 2400);
if claims
.iter()
.any(|c| c.claim_type == ClaimType::EntropyAttestation)
{
score += 1000;
}
if rotations == 0 && !claims.is_empty() {
let oldest = claims.iter().map(|c| c.created_ms).min().unwrap_or(0);
let newest = claims.iter().map(|c| c.created_ms).max().unwrap_or(0);
let age_days = newest.saturating_sub(oldest) / 86_400_000;
if age_days > 90 {
score = score.saturating_sub(1000);
}
}
score.min(10_000)
}
fn score_constitutional_standing(claims: &[IdentityClaim]) -> u32 {
let mut score: u32 = 1000;
let votes = claims
.iter()
.filter(|c| matches!(c.claim_type, ClaimType::GovernanceVote { .. }))
.count();
score += capped_count_contribution(votes, 400, 2000);
let proposals = claims
.iter()
.filter(|c| matches!(c.claim_type, ClaimType::ProposalAuthored { .. }))
.count();
score += capped_count_contribution(proposals, 700, 2100);
let validator = claims
.iter()
.filter(|c| matches!(c.claim_type, ClaimType::ValidatorService { .. }))
.count();
score += capped_count_contribution(validator, 500, 2500);
let resolutions = claims
.iter()
.filter(|c| matches!(c.claim_type, ClaimType::SybilChallengeResolution { .. }))
.count();
score += capped_count_contribution(resolutions, 800, 2400);
score.min(10_000)
}
pub(crate) fn compute_symmetry(axes: &[u32; 8]) -> u32 {
let mean = average_axis_values(axes);
if mean == 0 {
return 0;
}
let variance_sum: u128 = axes
.iter()
.map(|&a| {
let diff = u128::from(a.abs_diff(mean));
diff * diff
})
.fold(0u128, u128::saturating_add);
let variance = u128_to_u64_saturating(variance_sum / 8);
let std_dev = u64_to_u32_saturating(isqrt(variance));
let cv_bp = u64_to_u32_saturating(u64::from(std_dev).saturating_mul(10_000) / u64::from(mean));
10_000u32.saturating_sub(cv_bp)
}
pub(crate) fn hash_claim_set(claims: &[IdentityClaim]) -> Hash256 {
let mut sorted: Vec<&[u8; 32]> = claims.iter().map(|c| c.claim_hash.as_bytes()).collect();
sorted.sort_unstable();
let mut hasher = blake3::Hasher::new();
for h in sorted {
hasher.update(h);
}
Hash256::from_bytes(*hasher.finalize().as_bytes())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use exo_core::types::Did;
use super::*;
use crate::zerodentity::types::{
BehavioralSample, BehavioralSignalType, DeviceFingerprint, FingerprintSignal,
};
fn did() -> Did {
Did::new("did:exo:test0001").unwrap()
}
fn h() -> Hash256 {
Hash256::digest(b"test")
}
fn fingerprint_sample() -> DeviceFingerprint {
DeviceFingerprint {
composite_hash: h(),
signal_hashes: {
let mut m = std::collections::BTreeMap::new();
m.insert(FingerprintSignal::CanvasRendering, h());
m.insert(FingerprintSignal::UserAgent, h());
m
},
captured_ms: 1000,
consistency_score_bp: Some(9000),
}
}
fn behavioral_sample(signal_type: BehavioralSignalType, similarity: u32) -> BehavioralSample {
BehavioralSample {
sample_hash: h(),
signal_type,
captured_ms: 1000,
baseline_similarity_bp: Some(similarity),
}
}
fn claim(ct: ClaimType, status: ClaimStatus) -> IdentityClaim {
IdentityClaim {
claim_hash: h(),
subject_did: did(),
claim_type: ct,
status,
created_ms: 1_000_000,
verified_ms: None,
expires_ms: None,
signature: Signature::Empty,
dag_node_hash: h(),
}
}
#[test]
fn ln_milli_edge_cases() {
assert_eq!(int_ln_milli(0), 0);
assert_eq!(int_ln_milli(1), 0);
}
#[test]
fn ln_milli_approx_ln2() {
let v = int_ln_milli(2);
assert!(v >= 663 && v <= 723, "int_ln_milli(2) = {v}, expected ~693");
}
#[test]
fn isqrt_perfect_squares() {
assert_eq!(isqrt(0), 0);
assert_eq!(isqrt(1), 1);
assert_eq!(isqrt(4), 2);
assert_eq!(isqrt(9), 3);
assert_eq!(isqrt(100), 10);
assert_eq!(isqrt(10_000), 100);
}
#[test]
fn isqrt_rounds_down() {
assert_eq!(isqrt(2), 1);
assert_eq!(isqrt(8), 2);
assert_eq!(isqrt(15), 3);
}
#[test]
fn scoring_source_avoids_truncating_collection_count_casts() {
let source = include_str!("scoring.rs");
let production = source
.split("// ---------------------------------------------------------------------------\n// Tests")
.next()
.unwrap_or(source);
assert!(
!production.contains("clippy::unwrap_used"),
"scoring production source must not suppress unwrap safety lint"
);
assert!(
!production.contains("clippy::expect_used"),
"scoring production source must not suppress expect safety lint"
);
assert!(
!production.contains("clippy::as_conversions"),
"scoring production source must not suppress integer conversion lints"
);
assert!(
!production.contains(" as u"),
"scoring production source must use checked or widening conversions, not numeric as casts"
);
assert!(
!production.contains(".count() as u32"),
"scoring must not truncate iterator counts into u32"
);
assert!(
!production.contains(".len() as u32"),
"scoring must not truncate collection lengths into u32"
);
assert!(
!production.contains(".len() as u64"),
"scoring must not truncate collection lengths into u64"
);
}
#[test]
fn saturating_count_conversions_clamp_instead_of_truncating() {
assert_eq!(usize_to_u32_saturating(42), 42);
assert_eq!(u64_to_u32_saturating(42), 42);
assert_eq!(u64_to_u32_saturating(u64::from(u32::MAX) + 1), u32::MAX);
if let Ok(too_large_for_u32) = usize::try_from(u64::from(u32::MAX) + 1) {
assert_eq!(usize_to_u32_saturating(too_large_for_u32), u32::MAX);
}
}
#[test]
fn capped_count_contribution_uses_wide_saturating_math() {
assert_eq!(capped_count_contribution(2, 400, 1300), 800);
assert_eq!(capped_count_contribution(4, 400, 1300), 1300);
assert_eq!(capped_count_contribution(usize::MAX, 1000, 3000), 3000);
}
#[test]
fn ratio_basis_points_handles_extreme_counts() {
assert_eq!(ratio_basis_points(0, usize::MAX), Some(0));
assert_eq!(ratio_basis_points(usize::MAX, usize::MAX), Some(10_000));
assert_eq!(ratio_basis_points(1, 0), None);
}
#[test]
fn communication_no_claims() {
assert_eq!(score_communication(&[]), 0);
}
#[test]
fn communication_email_and_phone_verified() {
let claims = vec![
claim(ClaimType::Email, ClaimStatus::Verified),
claim(ClaimType::Phone, ClaimStatus::Verified),
];
assert_eq!(score_communication(&claims), 8700);
}
#[test]
fn communication_cap() {
let mut claims = vec![
claim(ClaimType::Email, ClaimStatus::Verified),
claim(ClaimType::Phone, ClaimStatus::Verified),
];
for i in 0..20u32 {
claims.push(claim(
ClaimType::ProfessionalCredential {
provider: format!("p{i}"),
},
ClaimStatus::Verified,
));
}
assert_eq!(score_communication(&claims), 10_000);
}
#[test]
fn device_trust_no_fingerprints() {
assert_eq!(score_device_trust(&[]), 0);
}
#[test]
fn device_trust_first_session_no_consistency() {
let fp = DeviceFingerprint {
composite_hash: h(),
signal_hashes: {
let mut m = std::collections::BTreeMap::new();
m.insert(FingerprintSignal::CanvasRendering, h());
m.insert(FingerprintSignal::UserAgent, h());
m
},
captured_ms: 1000,
consistency_score_bp: None,
};
let score = score_device_trust(&[fp]);
assert!(score >= 3500 && score <= 4500, "got {score}");
}
#[test]
fn device_trust_average_consistency_uses_wide_accumulator() {
let fingerprints = vec![
DeviceFingerprint {
composite_hash: h(),
signal_hashes: std::collections::BTreeMap::new(),
captured_ms: 1000,
consistency_score_bp: Some(10_000),
};
429_497
];
assert_eq!(score_device_trust(&fingerprints), 7500);
}
#[test]
fn device_trust_clamps_deserialized_consistency_basis_points() {
let fp = DeviceFingerprint {
composite_hash: h(),
signal_hashes: std::collections::BTreeMap::new(),
captured_ms: 1000,
consistency_score_bp: Some(u32::MAX),
};
assert_eq!(score_device_trust(&[fp]), 6000);
}
#[test]
fn behavioral_no_samples() {
assert_eq!(score_behavioral(&[]), 0);
}
#[test]
fn behavioral_diverse_signals() {
let samples = vec![
BehavioralSample {
sample_hash: h(),
signal_type: BehavioralSignalType::KeystrokeDynamics,
captured_ms: 1000,
baseline_similarity_bp: Some(8000),
},
BehavioralSample {
sample_hash: h(),
signal_type: BehavioralSignalType::MouseDynamics,
captured_ms: 2000,
baseline_similarity_bp: Some(9000),
},
];
let score = score_behavioral(&samples);
assert!(score > 5000, "got {score}");
}
#[test]
fn behavioral_average_similarity_uses_wide_accumulator() {
let samples =
vec![behavioral_sample(BehavioralSignalType::KeystrokeDynamics, 10_000); 429_497];
assert_eq!(score_behavioral(&samples), 7200);
}
#[test]
fn behavioral_clamps_deserialized_similarity_basis_points() {
let sample = behavioral_sample(BehavioralSignalType::KeystrokeDynamics, u32::MAX);
assert_eq!(score_behavioral(&[sample]), 5600);
}
#[test]
fn network_reputation_base() {
assert_eq!(score_network_reputation(&[]), 1000);
}
#[test]
fn temporal_stability_empty() {
assert_eq!(score_temporal_stability(&[], 1_000_000), 0);
}
#[test]
fn constitutional_standing_base() {
assert_eq!(score_constitutional_standing(&[]), 1000);
}
#[test]
fn symmetry_uniform() {
let axes = [5000u32; 8];
assert_eq!(compute_symmetry(&axes), 10_000);
}
#[test]
fn symmetry_uses_wide_axis_sum() {
let axes = [u32::MAX; 8];
assert_eq!(compute_symmetry(&axes), 10_000);
}
#[test]
fn symmetry_uses_wide_variance_sum() {
let axes = [u32::MAX, u32::MAX, u32::MAX, u32::MAX, 0, 0, 0, 0];
assert_eq!(compute_symmetry(&axes), 0);
}
#[test]
fn symmetry_all_zero() {
assert_eq!(compute_symmetry(&[0u32; 8]), 0);
}
#[test]
fn symmetry_highly_skewed() {
let mut axes = [0u32; 8];
axes[0] = 10_000;
let sym = compute_symmetry(&axes);
assert!(sym < 5000, "skewed → symmetry should be low, got {sym}");
}
#[test]
fn compute_deterministic() {
let d = did();
let claims = vec![
claim(ClaimType::Email, ClaimStatus::Verified),
claim(ClaimType::Phone, ClaimStatus::Verified),
];
let s1 = ZerodentityScore::compute(&d, &claims, &[], &[], 10_000_000);
let s2 = ZerodentityScore::compute(&d, &claims, &[], &[], 10_000_000);
assert_eq!(s1.composite, s2.composite);
assert_eq!(s1.symmetry, s2.symmetry);
assert_eq!(s1.dag_state_hash, s2.dag_state_hash);
}
#[test]
fn compute_zero_drift_on_recompute() {
let d = did();
let claims = vec![
claim(ClaimType::Email, ClaimStatus::Verified),
claim(ClaimType::GovernmentId, ClaimStatus::Verified),
];
let stored = ZerodentityScore::compute(&d, &claims, &[], &[], 5_000_000);
let recomputed = ZerodentityScore::compute(&d, &claims, &[], &[], stored.computed_ms);
let drift = stored.composite.abs_diff(recomputed.composite);
assert_eq!(drift, 0, "deterministic algorithm must produce zero drift");
}
#[cfg(not(feature = "unaudited-zerodentity-device-behavioral-axes"))]
#[test]
fn compute_ignores_device_behavioral_samples_without_feature_flag() {
let d = did();
let fingerprints = vec![fingerprint_sample()];
let behavioral = vec![
behavioral_sample(BehavioralSignalType::KeystrokeDynamics, 9000),
behavioral_sample(BehavioralSignalType::MouseDynamics, 8000),
];
let score = ZerodentityScore::compute(&d, &[], &fingerprints, &behavioral, 10_000_000);
assert_eq!(
score.axes.device_trust, 0,
"device_trust must stay zero while R3 axes are feature-gated"
);
assert_eq!(
score.axes.behavioral_signature, 0,
"behavioral_signature must stay zero while R3 axes are feature-gated"
);
}
#[cfg(feature = "unaudited-zerodentity-device-behavioral-axes")]
#[test]
fn compute_uses_device_behavioral_samples_with_feature_flag() {
let d = did();
let fingerprints = vec![fingerprint_sample()];
let behavioral = vec![
behavioral_sample(BehavioralSignalType::KeystrokeDynamics, 9000),
behavioral_sample(BehavioralSignalType::MouseDynamics, 8000),
];
let score = ZerodentityScore::compute(&d, &[], &fingerprints, &behavioral, 10_000_000);
assert!(
score.axes.device_trust > 0,
"feature-on build must preserve existing device_trust scoring"
);
assert!(
score.axes.behavioral_signature > 0,
"feature-on build must preserve existing behavioral scoring"
);
}
#[test]
fn production_symmetry_has_no_unchecked_sum() {
let production = include_str!("scoring.rs")
.split("#[cfg(test)]")
.next()
.expect("production section");
assert!(
!production.contains(".sum()"),
"production 0dentity scoring must use explicit bounded accumulation"
);
}
}