use std::{collections::BTreeMap, fmt};
pub use exo_core::types::Signature;
use exo_core::types::{Did, Hash256, PublicKey};
use serde::{
Deserialize, Deserializer, Serialize, Serializer, de::Error as SerdeDeError,
ser::Error as SerdeSerError,
};
use zeroize::{Zeroize, Zeroizing};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum ClaimType {
DisplayName,
Email,
Phone,
GovernmentId,
BiometricLiveness,
ProfessionalCredential { provider: String },
DeviceFingerprint,
BehavioralSignature,
GeographicConsistency,
SessionContinuity,
PeerAttestation { attester_did: Did },
DelegationGrant { delegator_did: Did },
SybilChallengeResolution { challenge_id: String },
GovernanceVote { proposal_hash: Hash256 },
ProposalAuthored { proposal_hash: Hash256 },
ValidatorService { round_range: (u64, u64) },
KeyRotation { old_key_hash: Hash256 },
EntropyAttestation,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum ClaimStatus {
Pending,
Verified,
Expired,
Revoked,
Challenged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdentityClaim {
pub claim_hash: Hash256,
pub subject_did: Did,
pub claim_type: ClaimType,
pub status: ClaimStatus,
pub created_ms: u64,
pub verified_ms: Option<u64>,
pub expires_ms: Option<u64>,
pub signature: Signature,
pub dag_node_hash: Hash256,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolarAxes {
pub communication: u32,
pub credential_depth: u32,
pub device_trust: u32,
pub behavioral_signature: u32,
pub network_reputation: u32,
pub temporal_stability: u32,
pub cryptographic_strength: u32,
pub constitutional_standing: u32,
}
impl PolarAxes {
#[must_use]
pub fn as_array(&self) -> [u32; 8] {
[
self.communication,
self.credential_depth,
self.device_trust,
self.behavioral_signature,
self.network_reputation,
self.temporal_stability,
self.cryptographic_strength,
self.constitutional_standing,
]
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZerodentityScore {
pub subject_did: Did,
pub axes: PolarAxes,
pub composite: u32,
pub computed_ms: u64,
pub dag_state_hash: Hash256,
pub claim_count: u32,
pub symmetry: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceFingerprint {
pub composite_hash: Hash256,
pub signal_hashes: BTreeMap<FingerprintSignal, Hash256>,
pub captured_ms: u64,
pub consistency_score_bp: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum BehavioralSignalType {
KeystrokeDynamics,
MouseDynamics,
TouchDynamics,
ScrollBehavior,
FormNavigationCadence,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BehavioralSample {
pub sample_hash: Hash256,
pub signal_type: BehavioralSignalType,
pub captured_ms: u64,
pub baseline_similarity_bp: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OtpChannel {
Email,
Sms,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OtpState {
Pending,
Verified,
Expired,
LockedOut,
}
#[derive(Clone, PartialEq, Eq)]
pub struct OtpHmacSecret {
bytes: Zeroizing<[u8; 32]>,
}
impl OtpHmacSecret {
pub(crate) fn new(mut bytes: [u8; 32]) -> Option<Self> {
let secret = Zeroizing::new(bytes);
bytes.zeroize();
Self::from_zeroizing(secret)
}
pub(crate) fn from_zeroizing(mut bytes: Zeroizing<[u8; 32]>) -> Option<Self> {
if bytes.iter().all(|byte| *byte == 0) {
bytes.zeroize();
return None;
}
Some(Self { bytes })
}
pub(crate) fn expose_secret(&self) -> &[u8; 32] {
&self.bytes
}
}
impl fmt::Debug for OtpHmacSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("OtpHmacSecret(<redacted>)")
}
}
impl Serialize for OtpHmacSecret {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let _ = serializer;
Err(S::Error::custom("OTP HMAC secret must not be serialized"))
}
}
impl<'de> Deserialize<'de> for OtpHmacSecret {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let bytes = <[u8; 32]>::deserialize(deserializer)?;
Self::new(bytes).ok_or_else(|| {
D::Error::custom("OTP HMAC secret must not be all zero when deserializing challenge")
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OtpChallenge {
pub challenge_id: String,
pub subject_did: Did,
pub channel: OtpChannel,
pub hmac_secret: OtpHmacSecret,
pub dispatched_ms: u64,
pub ttl_ms: u64,
pub attempts: u32,
pub max_attempts: u32,
pub state: OtpState,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum FingerprintSignal {
AudioContext,
BatteryStatus,
CanvasRendering,
ColorDepthDPR,
DeviceMemory,
DoNotTrack,
FontEnumeration,
HardwareConcurrency,
Platform,
ScreenGeometry,
TimezoneLocale,
TouchSupport,
UserAgent,
WebGLParameters,
WebRTCLocalIPs,
}
impl fmt::Display for FingerprintSignal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::AudioContext => "AudioContext",
Self::BatteryStatus => "BatteryStatus",
Self::CanvasRendering => "CanvasRendering",
Self::ColorDepthDPR => "ColorDepthDPR",
Self::DeviceMemory => "DeviceMemory",
Self::DoNotTrack => "DoNotTrack",
Self::FontEnumeration => "FontEnumeration",
Self::HardwareConcurrency => "HardwareConcurrency",
Self::Platform => "Platform",
Self::ScreenGeometry => "ScreenGeometry",
Self::TimezoneLocale => "TimezoneLocale",
Self::TouchSupport => "TouchSupport",
Self::UserAgent => "UserAgent",
Self::WebGLParameters => "WebGLParameters",
Self::WebRTCLocalIPs => "WebRTCLocalIPs",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum AttestationType {
Identity,
Trustworthy,
Professional,
Character,
}
impl fmt::Display for AttestationType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Identity => "Identity",
Self::Trustworthy => "Trustworthy",
Self::Professional => "Professional",
Self::Character => "Character",
};
f.write_str(s)
}
}
impl std::str::FromStr for AttestationType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Identity" => Ok(Self::Identity),
"Trustworthy" => Ok(Self::Trustworthy),
"Professional" => Ok(Self::Professional),
"Character" => Ok(Self::Character),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerAttestation {
pub attestation_id: String,
pub attester_did: Did,
pub target_did: Did,
pub attestation_type: AttestationType,
pub message_hash: Option<Hash256>,
pub created_ms: u64,
pub attester_public_key: PublicKey,
pub signature: Signature,
pub dag_node_hash: Hash256,
}
pub const IDENTITY_SESSION_TTL_MS: u64 = 24 * 60 * 60 * 1_000;
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdentitySession {
pub session_token: String,
pub subject_did: Did,
pub public_key: Vec<u8>,
pub created_ms: u64,
pub last_active_ms: u64,
pub revoked: bool,
}
impl fmt::Debug for IdentitySession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("IdentitySession")
.field("session_token", &"<redacted>")
.field("subject_did", &self.subject_did)
.field("public_key", &self.public_key)
.field("created_ms", &self.created_ms)
.field("last_active_ms", &self.last_active_ms)
.field("revoked", &self.revoked)
.finish()
}
}
impl IdentitySession {
#[must_use]
pub fn expires_at_ms(&self) -> Option<u64> {
self.created_ms.checked_add(IDENTITY_SESSION_TTL_MS)
}
#[must_use]
pub fn is_expired_at(&self, now_ms: u64) -> bool {
self.expires_at_ms()
.is_none_or(|expires_at| now_ms >= expires_at)
}
}
impl fmt::Display for ClaimType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DisplayName => f.write_str("DisplayName"),
Self::Email => f.write_str("Email"),
Self::Phone => f.write_str("Phone"),
Self::GovernmentId => f.write_str("GovernmentId"),
Self::BiometricLiveness => f.write_str("BiometricLiveness"),
Self::ProfessionalCredential { provider } => {
write!(f, "ProfessionalCredential:{provider}")
}
Self::DeviceFingerprint => f.write_str("DeviceFingerprint"),
Self::BehavioralSignature => f.write_str("BehavioralSignature"),
Self::GeographicConsistency => f.write_str("GeographicConsistency"),
Self::SessionContinuity => f.write_str("SessionContinuity"),
Self::PeerAttestation { attester_did } => {
write!(f, "PeerAttestation:{}", attester_did.as_str())
}
Self::DelegationGrant { delegator_did } => {
write!(f, "DelegationGrant:{}", delegator_did.as_str())
}
Self::SybilChallengeResolution { challenge_id } => {
write!(f, "SybilChallengeResolution:{challenge_id}")
}
Self::GovernanceVote { proposal_hash } => write!(
f,
"GovernanceVote:{}",
hex::encode(proposal_hash.as_bytes())
),
Self::ProposalAuthored { proposal_hash } => write!(
f,
"ProposalAuthored:{}",
hex::encode(proposal_hash.as_bytes())
),
Self::ValidatorService {
round_range: (start, end),
} => write!(f, "ValidatorService:{start}:{end}"),
Self::KeyRotation { old_key_hash } => {
write!(f, "KeyRotation:{}", hex::encode(old_key_hash.as_bytes()))
}
Self::EntropyAttestation => f.write_str("EntropyAttestation"),
}
}
}
impl fmt::Display for ClaimStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Pending => "Pending",
Self::Verified => "Verified",
Self::Expired => "Expired",
Self::Revoked => "Revoked",
Self::Challenged => "Challenged",
};
f.write_str(s)
}
}
impl std::str::FromStr for ClaimStatus {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Pending" => Ok(Self::Pending),
"Verified" => Ok(Self::Verified),
"Expired" => Ok(Self::Expired),
"Revoked" => Ok(Self::Revoked),
"Challenged" => Ok(Self::Challenged),
_ => Err(()),
}
}
}
impl fmt::Display for BehavioralSignalType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::KeystrokeDynamics => "KeystrokeDynamics",
Self::MouseDynamics => "MouseDynamics",
Self::TouchDynamics => "TouchDynamics",
Self::ScrollBehavior => "ScrollBehavior",
Self::FormNavigationCadence => "FormNavigationCadence",
};
f.write_str(s)
}
}
impl std::str::FromStr for BehavioralSignalType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"KeystrokeDynamics" => Ok(Self::KeystrokeDynamics),
"MouseDynamics" => Ok(Self::MouseDynamics),
"TouchDynamics" => Ok(Self::TouchDynamics),
"ScrollBehavior" => Ok(Self::ScrollBehavior),
"FormNavigationCadence" => Ok(Self::FormNavigationCadence),
_ => Err(()),
}
}
}
impl fmt::Display for OtpChannel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Email => "Email",
Self::Sms => "Sms",
};
f.write_str(s)
}
}
impl std::str::FromStr for OtpChannel {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Email" => Ok(Self::Email),
"Sms" => Ok(Self::Sms),
_ => Err(()),
}
}
}
impl OtpChannel {
#[must_use]
pub fn ttl_ms(&self) -> u64 {
match self {
Self::Email => 300_000,
Self::Sms => 180_000,
}
}
}
impl fmt::Display for OtpState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Pending => "Pending",
Self::Verified => "Verified",
Self::Expired => "Expired",
Self::LockedOut => "LockedOut",
};
f.write_str(s)
}
}
impl std::str::FromStr for OtpState {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Pending" => Ok(Self::Pending),
"Verified" => Ok(Self::Verified),
"Expired" => Ok(Self::Expired),
"LockedOut" => Ok(Self::LockedOut),
_ => Err(()),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::str::FromStr;
use super::*;
use crate::zerodentity::OTP_MAX_ATTEMPTS;
#[test]
fn attestation_type_from_str_roundtrips() {
for s in ["Identity", "Trustworthy", "Professional", "Character"] {
let t = AttestationType::from_str(s).unwrap();
assert_eq!(t.to_string(), s);
}
assert!(AttestationType::from_str("Unknown").is_err());
}
#[test]
fn claim_status_from_str_roundtrips() {
for s in ["Pending", "Verified", "Expired", "Revoked", "Challenged"] {
let t = ClaimStatus::from_str(s).unwrap();
assert_eq!(t.to_string(), s);
}
assert!(ClaimStatus::from_str("X").is_err());
}
#[test]
fn behavioral_signal_type_from_str_roundtrips() {
for s in [
"KeystrokeDynamics",
"MouseDynamics",
"TouchDynamics",
"ScrollBehavior",
"FormNavigationCadence",
] {
let t = BehavioralSignalType::from_str(s).unwrap();
assert_eq!(t.to_string(), s);
}
assert!(BehavioralSignalType::from_str("Unknown").is_err());
}
#[test]
fn otp_channel_from_str_roundtrips() {
assert_eq!(OtpChannel::from_str("Email").unwrap(), OtpChannel::Email);
assert_eq!(OtpChannel::from_str("Sms").unwrap(), OtpChannel::Sms);
assert!(OtpChannel::from_str("Unknown").is_err());
}
#[test]
fn otp_channel_ttl_ms_email_5_min() {
assert_eq!(OtpChannel::Email.ttl_ms(), 300_000);
}
#[test]
fn otp_channel_ttl_ms_sms_3_min() {
assert_eq!(OtpChannel::Sms.ttl_ms(), 180_000);
}
#[test]
fn otp_state_from_str_roundtrips() {
for s in ["Pending", "Verified", "Expired", "LockedOut"] {
let t = OtpState::from_str(s).unwrap();
assert_eq!(t.to_string(), s);
}
assert!(OtpState::from_str("X").is_err());
}
#[test]
fn otp_challenge_serialization_fails_closed_before_secret_exposure() {
let challenge = OtpChallenge {
challenge_id: "challenge-secret-serialization".into(),
subject_did: Did::new("did:exo:otp-subject").unwrap(),
channel: OtpChannel::Email,
hmac_secret: OtpHmacSecret::new([9u8; 32]).unwrap(),
dispatched_ms: 1_000,
ttl_ms: OtpChannel::Email.ttl_ms(),
attempts: 0,
max_attempts: OTP_MAX_ATTEMPTS,
state: OtpState::Pending,
};
let error = serde_json::to_string(&challenge)
.expect_err("OTP challenge serialization must fail before exposing HMAC secret bytes");
assert!(error.to_string().contains("OTP HMAC secret"));
}
#[test]
fn identity_session_debug_redacts_session_token() {
let session = IdentitySession {
session_token: "session-token-must-not-appear".into(),
subject_did: Did::new("did:exo:session-subject").unwrap(),
public_key: vec![1, 2, 3],
created_ms: 1_000,
last_active_ms: 2_000,
revoked: false,
};
let debug = format!("{session:?}");
assert!(debug.contains("session_token"));
assert!(debug.contains("<redacted>"));
assert!(
!debug.contains("session-token-must-not-appear"),
"IdentitySession Debug output must not expose bearer session tokens: {debug}"
);
}
#[test]
fn fingerprint_signal_display_all_variants() {
let variants = [
(FingerprintSignal::AudioContext, "AudioContext"),
(FingerprintSignal::BatteryStatus, "BatteryStatus"),
(FingerprintSignal::CanvasRendering, "CanvasRendering"),
(FingerprintSignal::ColorDepthDPR, "ColorDepthDPR"),
(FingerprintSignal::DeviceMemory, "DeviceMemory"),
(FingerprintSignal::DoNotTrack, "DoNotTrack"),
(FingerprintSignal::FontEnumeration, "FontEnumeration"),
(
FingerprintSignal::HardwareConcurrency,
"HardwareConcurrency",
),
(FingerprintSignal::Platform, "Platform"),
(FingerprintSignal::ScreenGeometry, "ScreenGeometry"),
(FingerprintSignal::TimezoneLocale, "TimezoneLocale"),
(FingerprintSignal::TouchSupport, "TouchSupport"),
(FingerprintSignal::UserAgent, "UserAgent"),
(FingerprintSignal::WebGLParameters, "WebGLParameters"),
(FingerprintSignal::WebRTCLocalIPs, "WebRTCLocalIPs"),
];
for (v, expected) in &variants {
assert_eq!(v.to_string(), *expected);
}
}
#[test]
fn claim_type_display_simple_variants() {
assert_eq!(ClaimType::DisplayName.to_string(), "DisplayName");
assert_eq!(ClaimType::Email.to_string(), "Email");
assert_eq!(ClaimType::Phone.to_string(), "Phone");
assert_eq!(ClaimType::GovernmentId.to_string(), "GovernmentId");
assert_eq!(
ClaimType::BiometricLiveness.to_string(),
"BiometricLiveness"
);
assert_eq!(
ClaimType::DeviceFingerprint.to_string(),
"DeviceFingerprint"
);
assert_eq!(
ClaimType::BehavioralSignature.to_string(),
"BehavioralSignature"
);
assert_eq!(
ClaimType::GeographicConsistency.to_string(),
"GeographicConsistency"
);
assert_eq!(
ClaimType::SessionContinuity.to_string(),
"SessionContinuity"
);
assert_eq!(
ClaimType::EntropyAttestation.to_string(),
"EntropyAttestation"
);
}
#[test]
fn claim_type_display_parameterised_variants() {
let did = Did::new("did:exo:x").unwrap();
let pc = ClaimType::ProfessionalCredential {
provider: "ACME".into(),
};
assert_eq!(pc.to_string(), "ProfessionalCredential:ACME");
let pa = ClaimType::PeerAttestation {
attester_did: did.clone(),
};
assert!(pa.to_string().starts_with("PeerAttestation:did:exo:x"));
let dg = ClaimType::DelegationGrant { delegator_did: did };
assert!(dg.to_string().starts_with("DelegationGrant:did:exo:x"));
let scr = ClaimType::SybilChallengeResolution {
challenge_id: "ch1".into(),
};
assert_eq!(scr.to_string(), "SybilChallengeResolution:ch1");
let vs = ClaimType::ValidatorService {
round_range: (10, 20),
};
assert_eq!(vs.to_string(), "ValidatorService:10:20");
}
#[test]
fn claim_type_display_hash_variants() {
let hash = Hash256::digest(b"test");
let gv = ClaimType::GovernanceVote {
proposal_hash: hash,
};
assert!(gv.to_string().starts_with("GovernanceVote:"));
let prop = ClaimType::ProposalAuthored {
proposal_hash: hash,
};
assert!(prop.to_string().starts_with("ProposalAuthored:"));
let kr = ClaimType::KeyRotation { old_key_hash: hash };
assert!(kr.to_string().starts_with("KeyRotation:"));
}
#[test]
fn polar_axes_as_array_returns_correct_order() {
let axes = PolarAxes {
communication: 1,
credential_depth: 2,
device_trust: 3,
behavioral_signature: 4,
network_reputation: 5,
temporal_stability: 6,
cryptographic_strength: 7,
constitutional_standing: 8,
};
assert_eq!(axes.as_array(), [1, 2, 3, 4, 5, 6, 7, 8]);
}
}