use std::{
str::FromStr,
sync::{Arc, Mutex},
};
use axum::{
Json, Router,
body::Bytes,
extract::{OriginalUri, Path, Query, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
};
use exo_core::{
crypto,
hlc::HybridClock,
types::{Did, Hash256, PublicKey, Signature, Timestamp},
};
use serde::{Deserialize, Serialize};
use super::{
DEVICE_BEHAVIORAL_AXES_FEATURE, DEVICE_BEHAVIORAL_AXES_INITIATIVE,
attestation::{
CreateAttestationInput, attester_score_impact, build_target_claim, create_attestation,
target_claim_hash, target_claim_id, target_score_impact, validate_attestation,
},
device_behavioral_axes_enabled,
session_auth::{public_key_from_session_bytes, request_signing_payload, signature_from_hex},
session_clock::{SessionClock, SessionClockError, TRUSTED_SESSION_CLOCK_UNAVAILABLE},
store::{ZERODENTITY_ERASURE_MAX_FUTURE_SKEW_MS, ZerodentityStore},
types::{
AttestationType, BehavioralSample, DeviceFingerprint, IdentityClaim, ZerodentityScore,
},
};
#[derive(Clone)]
pub struct ApiState {
pub store: Arc<Mutex<ZerodentityStore>>,
session_clock: SessionClock,
}
impl ApiState {
#[must_use]
pub fn new(store: Arc<Mutex<ZerodentityStore>>) -> Self {
Self {
store,
session_clock: SessionClock::unavailable(),
}
}
#[must_use]
#[cfg_attr(not(test), allow(dead_code))]
pub fn new_with_clock(store: Arc<Mutex<ZerodentityStore>>, clock: HybridClock) -> Self {
Self {
store,
session_clock: SessionClock::trusted(clock),
}
}
fn now_ms(&self) -> ApiResult<u64> {
self.session_clock.now_ms().map_err(|err| match err {
SessionClockError::Unavailable => json_error(
StatusCode::SERVICE_UNAVAILABLE,
TRUSTED_SESSION_CLOCK_UNAVAILABLE,
),
SessionClockError::LockPoisoned => {
json_error(StatusCode::INTERNAL_SERVER_ERROR, "Clock lock error")
}
SessionClockError::Exhausted(err) => {
tracing::error!(error = %err, "0dentity API HLC exhausted");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "Clock exhausted")
}
})
}
}
type ApiError = (StatusCode, Json<serde_json::Value>);
type ApiResult<T> = Result<T, ApiError>;
#[derive(Debug, Deserialize)]
pub struct ClaimsQuery {
pub status: Option<String>,
#[serde(rename = "type")]
pub claim_type: Option<String>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct ScoreQuery {
pub as_of_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct HistoryQuery {
pub from_ms: Option<u64>,
pub to_ms: Option<u64>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
const DEFAULT_PAGE_LIMIT: u64 = 50;
const MAX_PAGE_LIMIT: u64 = 100;
const MAX_PAGE_OFFSET: u64 = 10_000;
#[derive(Debug, Clone, Copy)]
struct PageBounds {
limit: usize,
offset: usize,
limit_u64: u64,
offset_u64: u64,
}
#[derive(Debug, Serialize)]
pub struct ScoreResponse {
pub subject_did: String,
pub composite: u32,
pub symmetry: u32,
pub axes: AxesResponse,
pub computed_ms: u64,
pub dag_state_hash: String,
pub claim_count: u32,
pub history_available: bool,
}
#[derive(Debug, Serialize)]
pub struct AxesResponse {
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,
}
#[derive(Debug, Serialize)]
pub struct ClaimItem {
pub claim_id: String,
pub claim_type: String,
pub claim_hash: String,
pub status: String,
pub created_ms: u64,
pub verified_ms: Option<u64>,
pub expires_ms: Option<u64>,
pub dag_node_hash: String,
}
#[derive(Debug, Serialize)]
pub struct ClaimsResponse {
pub claims: Vec<ClaimItem>,
pub total: usize,
pub limit: u64,
pub offset: u64,
}
#[derive(Debug, Serialize)]
pub struct HistorySnapshot {
pub computed_ms: u64,
pub composite: u32,
pub axes: AxesResponse,
pub claim_count: u32,
}
#[derive(Debug, Serialize)]
pub struct HistoryResponse {
pub snapshots: Vec<HistorySnapshot>,
pub total: usize,
pub limit: u64,
pub offset: u64,
}
#[derive(Debug, Serialize)]
pub struct FingerprintItem {
pub composite_hash: String,
pub captured_ms: u64,
pub consistency_score: Option<u32>,
pub signal_count: usize,
}
#[derive(Debug, Serialize)]
pub struct FingerprintsResponse {
pub fingerprints: Vec<FingerprintItem>,
}
#[derive(Debug, Deserialize)]
pub struct AttestRequest {
pub target_did: String,
pub attestation_type: String,
pub message_hash: Option<String>,
pub created_ms: Option<u64>,
pub attester_public_key: Option<String>,
pub signature: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AttestResponse {
pub attestation_id: String,
pub receipt_hash: String,
pub attester_score_impact: serde_json::Value,
pub target_score_impact: serde_json::Value,
}
fn extract_session_token(headers: &HeaderMap) -> Option<String> {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|s| s.to_string())
}
fn parse_did(did_str: &str) -> Result<Did, (StatusCode, Json<serde_json::Value>)> {
Did::new(did_str).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid DID format"})),
)
})
}
fn json_error(
status: StatusCode,
error: impl Into<String>,
) -> (StatusCode, Json<serde_json::Value>) {
(status, Json(serde_json::json!({ "error": error.into() })))
}
fn store_error(error: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
tracing::error!(err = %error, "0dentity API store operation failed");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "Store operation failed")
}
fn store_operation_failed(operation: &'static str) -> (StatusCode, Json<serde_json::Value>) {
tracing::error!(operation, "0dentity API store operation failed");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "Store operation failed")
}
fn erasure_store_error(error: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
let reason = error.to_string();
if reason.contains("erasure timestamp") {
return bad_request(&reason);
}
store_error(reason)
}
async fn with_store_blocking<T, F>(state: ApiState, operation: F) -> ApiResult<T>
where
T: Send + 'static,
F: FnOnce(&mut ZerodentityStore) -> ApiResult<T> + Send + 'static,
{
tokio::task::spawn_blocking(move || {
let mut store = state
.store
.lock()
.map_err(|_| store_operation_failed("lock_poisoned"))?;
operation(&mut store)
})
.await
.map_err(|e| {
tracing::error!(err = %e, "0dentity store task failed");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "Store task failed")
})?
}
async fn now_ms_blocking(state: ApiState) -> ApiResult<u64> {
tokio::task::spawn_blocking(move || state.now_ms())
.await
.map_err(|e| {
tracing::error!(err = %e, "0dentity API clock task failed");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "Clock task failed")
})?
}
fn path_and_query(uri: &axum::http::Uri) -> String {
uri.path_and_query()
.map_or_else(|| uri.path().to_owned(), |value| value.as_str().to_owned())
}
fn require_header<'a>(
headers: &'a HeaderMap,
name: &str,
missing: &str,
) -> Result<&'a str, (StatusCode, Json<serde_json::Value>)> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| json_error(StatusCode::UNAUTHORIZED, missing))
}
fn validate_nonce(nonce: &str) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
if nonce.is_empty() {
return Err(json_error(
StatusCode::BAD_REQUEST,
"X-Exo-Nonce is required",
));
}
if nonce.len() > 128 || !nonce.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(json_error(
StatusCode::BAD_REQUEST,
"X-Exo-Nonce must be 1-128 visible ASCII bytes",
));
}
Ok(())
}
async fn verify_signed_write_blocking(
state: ApiState,
headers: &HeaderMap,
expected_did: Did,
method: &'static str,
path_and_query: String,
body: Bytes,
) -> ApiResult<PublicKey> {
let token = extract_session_token(headers)
.ok_or_else(|| json_error(StatusCode::UNAUTHORIZED, "Bearer session token required"))?;
let now_ms = now_ms_blocking(state.clone()).await?;
let headers = headers.clone();
with_store_blocking(state, move |store| {
let session = store
.get_session(&token, now_ms)
.map_err(store_error)?
.ok_or_else(|| json_error(StatusCode::UNAUTHORIZED, "Invalid or expired session"))?;
if session.subject_did.as_str() != expected_did.as_str() {
return Err(json_error(StatusCode::FORBIDDEN, "Access denied"));
}
let nonce = require_header(&headers, "x-exo-nonce", "X-Exo-Nonce header required")?;
validate_nonce(nonce)?;
let signature_hex = require_header(&headers, "x-exo-sig", "X-Exo-Sig header required")?;
let signature = signature_from_hex(signature_hex)
.map_err(|e| json_error(StatusCode::BAD_REQUEST, e))?;
if signature.is_empty() {
return Err(json_error(
StatusCode::UNAUTHORIZED,
"X-Exo-Sig must not be empty",
));
}
let public_key = public_key_from_session_bytes(&session.public_key)
.map_err(|e| json_error(StatusCode::UNAUTHORIZED, e))?;
let body_hash = Hash256::digest(&body);
let payload = request_signing_payload(method, &path_and_query, &token, nonce, &body_hash)
.map_err(|e| json_error(StatusCode::INTERNAL_SERVER_ERROR, e))?;
if !crypto::verify(&payload, &signature, &public_key) {
return Err(json_error(
StatusCode::UNAUTHORIZED,
"X-Exo-Sig verification failed",
));
}
let nonce_is_new = store
.consume_session_nonce(&token, nonce)
.map_err(store_error)?;
if !nonce_is_new {
return Err(json_error(
StatusCode::CONFLICT,
"X-Exo-Nonce has already been used for this session",
));
}
Ok(public_key)
})
.await
}
async fn verify_owner_session_blocking(
state: ApiState,
headers: &HeaderMap,
expected_did: Did,
) -> ApiResult<()> {
let token = extract_session_token(headers)
.ok_or_else(|| json_error(StatusCode::UNAUTHORIZED, "Bearer session token required"))?;
let now_ms = now_ms_blocking(state.clone()).await?;
with_store_blocking(state, move |store| {
let session = store
.get_session(&token, now_ms)
.map_err(store_error)?
.ok_or_else(|| json_error(StatusCode::UNAUTHORIZED, "Invalid or expired session"))?;
if session.subject_did.as_str() != expected_did.as_str() {
return Err(json_error(StatusCode::FORBIDDEN, "Access denied"));
}
Ok(())
})
.await
}
fn hex_hash(h: &Hash256) -> String {
hex::encode(h.as_bytes())
}
fn bad_request(message: &str) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": message })),
)
}
fn parse_page_bounds(limit: Option<u64>, offset: Option<u64>) -> ApiResult<PageBounds> {
let limit_u64 = limit.unwrap_or(DEFAULT_PAGE_LIMIT);
if limit_u64 == 0 || limit_u64 > MAX_PAGE_LIMIT {
return Err(bad_request(&format!(
"limit must be between 1 and {MAX_PAGE_LIMIT}"
)));
}
let offset_u64 = offset.unwrap_or(0);
if offset_u64 > MAX_PAGE_OFFSET {
return Err(bad_request(&format!(
"offset must be between 0 and {MAX_PAGE_OFFSET}"
)));
}
let limit = usize::try_from(limit_u64)
.map_err(|_| bad_request("limit exceeds this platform's addressable range"))?;
let offset = usize::try_from(offset_u64)
.map_err(|_| bad_request("offset exceeds this platform's addressable range"))?;
Ok(PageBounds {
limit,
offset,
limit_u64,
offset_u64,
})
}
fn parse_hex_exact<const N: usize>(
field: &str,
value: &str,
) -> Result<[u8; N], (StatusCode, Json<serde_json::Value>)> {
let bytes =
hex::decode(value).map_err(|_| bad_request(&format!("{field} must be hex-encoded")))?;
if bytes.len() != N {
return Err(bad_request(&format!("{field} must be exactly {N} bytes")));
}
let mut out = [0u8; N];
out.copy_from_slice(&bytes);
Ok(out)
}
fn parse_message_hash(
value: Option<&str>,
) -> Result<Option<Hash256>, (StatusCode, Json<serde_json::Value>)> {
value
.map(|s| parse_hex_exact::<32>("message_hash", s).map(Hash256::from_bytes))
.transpose()
}
fn parse_public_key(
value: Option<&str>,
) -> Result<PublicKey, (StatusCode, Json<serde_json::Value>)> {
let Some(value) = value else {
return Err(bad_request("attester_public_key is required"));
};
parse_hex_exact::<32>("attester_public_key", value).map(PublicKey::from_bytes)
}
fn parse_signature(
value: Option<&str>,
) -> Result<Signature, (StatusCode, Json<serde_json::Value>)> {
let Some(value) = value else {
return Err(bad_request("signature is required"));
};
parse_hex_exact::<64>("signature", value).map(Signature::from_bytes)
}
fn device_behavioral_axes_refusal(
refusal_source: &'static str,
) -> (StatusCode, Json<serde_json::Value>) {
tracing::warn!(
feature_flag = DEVICE_BEHAVIORAL_AXES_FEATURE,
initiative = DEVICE_BEHAVIORAL_AXES_INITIATIVE,
refusal_source,
"refusing unaudited 0dentity device/behavioral axis surface"
);
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "zerodentity_device_behavioral_axes_disabled",
"message": "0dentity device fingerprint and behavioral biometric axes are disabled by default because the ingestion path is not wired to persist client-collected samples.",
"feature_flag": DEVICE_BEHAVIORAL_AXES_FEATURE,
"initiative": DEVICE_BEHAVIORAL_AXES_INITIATIVE,
"refusal_source": refusal_source,
})),
)
}
fn axes_from_score(s: &ZerodentityScore) -> AxesResponse {
AxesResponse {
communication: s.axes.communication,
credential_depth: s.axes.credential_depth,
device_trust: s.axes.device_trust,
behavioral_signature: s.axes.behavioral_signature,
network_reputation: s.axes.network_reputation,
temporal_stability: s.axes.temporal_stability,
cryptographic_strength: s.axes.cryptographic_strength,
constitutional_standing: s.axes.constitutional_standing,
}
}
fn claim_matches_filters(claim: &IdentityClaim, params: &ClaimsQuery) -> bool {
if let Some(ref status) = params.status {
if claim.status.to_string().to_lowercase() != status.to_lowercase() {
return false;
}
}
if let Some(ref claim_type) = params.claim_type {
if !claim
.claim_type
.to_string()
.to_lowercase()
.contains(&claim_type.to_lowercase())
{
return false;
}
}
true
}
fn score_as_of_ms(
claims: &[IdentityClaim],
fingerprints: &[DeviceFingerprint],
behavioral: &[BehavioralSample],
requested_as_of_ms: Option<u64>,
) -> Result<u64, (StatusCode, Json<serde_json::Value>)> {
if let Some(as_of_ms) = requested_as_of_ms {
if as_of_ms == 0 {
return Err(bad_request("as_of_ms must be greater than 0"));
}
return Ok(as_of_ms);
}
let mut horizon_ms = 0u64;
for claim in claims {
horizon_ms = horizon_ms.max(claim.created_ms);
if let Some(verified_ms) = claim.verified_ms {
horizon_ms = horizon_ms.max(verified_ms);
}
}
for fingerprint in fingerprints {
horizon_ms = horizon_ms.max(fingerprint.captured_ms);
}
for sample in behavioral {
horizon_ms = horizon_ms.max(sample.captured_ms);
}
Ok(horizon_ms)
}
pub async fn get_score(
State(state): State<ApiState>,
Path(did_str): Path<String>,
Query(params): Query<ScoreQuery>,
headers: HeaderMap,
) -> Result<Json<ScoreResponse>, (StatusCode, Json<serde_json::Value>)> {
let did = parse_did(&did_str)?;
verify_owner_session_blocking(state.clone(), &headers, did.clone()).await?;
let response = with_store_blocking(state, move |store| {
let claims_raw = store.get_claims(&did).map_err(store_error)?;
if claims_raw.is_empty() {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "DID not found"})),
));
}
let claims: Vec<IdentityClaim> = claims_raw.into_iter().map(|(_, c)| c).collect();
let fingerprints = store.get_fingerprints(&did).map_err(store_error)?;
let behavioral = store.get_behavioral_samples(&did).map_err(store_error)?;
let as_of_ms = score_as_of_ms(&claims, &fingerprints, &behavioral, params.as_of_ms)?;
let score = ZerodentityScore::compute(&did, &claims, &fingerprints, &behavioral, as_of_ms);
let history = store
.get_score_history(&did, None, None)
.map_err(store_error)?;
Ok(ScoreResponse {
subject_did: did.to_string(),
composite: score.composite,
symmetry: score.symmetry,
axes: axes_from_score(&score),
computed_ms: score.computed_ms,
dag_state_hash: hex_hash(&score.dag_state_hash),
claim_count: score.claim_count,
history_available: !history.is_empty(),
})
})
.await?;
Ok(Json(response))
}
pub async fn list_claims(
State(state): State<ApiState>,
Path(did_str): Path<String>,
Query(params): Query<ClaimsQuery>,
headers: HeaderMap,
) -> Result<Json<ClaimsResponse>, (StatusCode, Json<serde_json::Value>)> {
let did = parse_did(&did_str)?;
let page_bounds = parse_page_bounds(params.limit, params.offset)?;
let token = extract_session_token(&headers).ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Bearer session token required"})),
)
})?;
let now_ms = now_ms_blocking(state.clone()).await?;
let response = with_store_blocking(state, move |store| {
let session = store
.get_session(&token, now_ms)
.map_err(store_error)?
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Invalid or expired session"})),
)
})?;
if session.subject_did.as_str() != did.as_str() {
return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "Access denied"})),
));
}
let all_claims = store.get_claims(&did).map_err(store_error)?;
let mut total = 0usize;
let mut page = Vec::new();
for (claim_id, claim) in all_claims
.into_iter()
.filter(|(_, claim)| claim_matches_filters(claim, ¶ms))
{
if total >= page_bounds.offset && page.len() < page_bounds.limit {
page.push(ClaimItem {
claim_id,
claim_type: claim.claim_type.to_string(),
claim_hash: hex::encode(claim.claim_hash.as_bytes()),
status: claim.status.to_string(),
created_ms: claim.created_ms,
verified_ms: claim.verified_ms,
expires_ms: claim.expires_ms,
dag_node_hash: hex::encode(claim.dag_node_hash.as_bytes()),
});
}
total = total.saturating_add(1);
}
Ok(ClaimsResponse {
claims: page,
total,
limit: page_bounds.limit_u64,
offset: page_bounds.offset_u64,
})
})
.await?;
Ok(Json(response))
}
pub async fn score_history(
State(state): State<ApiState>,
Path(did_str): Path<String>,
Query(params): Query<HistoryQuery>,
headers: HeaderMap,
) -> Result<Json<HistoryResponse>, (StatusCode, Json<serde_json::Value>)> {
let did = parse_did(&did_str)?;
let page_bounds = parse_page_bounds(params.limit, params.offset)?;
verify_owner_session_blocking(state.clone(), &headers, did.clone()).await?;
let response = with_store_blocking(state, move |store| {
let snapshots = store
.get_score_history(&did, params.from_ms, params.to_ms)
.map_err(store_error)?;
let total = snapshots.len();
let items: Vec<HistorySnapshot> = snapshots
.iter()
.skip(page_bounds.offset)
.take(page_bounds.limit)
.map(|s| HistorySnapshot {
computed_ms: s.computed_ms,
composite: s.composite,
axes: axes_from_score(s),
claim_count: s.claim_count,
})
.collect();
Ok(HistoryResponse {
snapshots: items,
total,
limit: page_bounds.limit_u64,
offset: page_bounds.offset_u64,
})
})
.await?;
Ok(Json(response))
}
pub async fn list_fingerprints(
State(state): State<ApiState>,
Path(did_str): Path<String>,
headers: HeaderMap,
) -> Result<Json<FingerprintsResponse>, (StatusCode, Json<serde_json::Value>)> {
let did = parse_did(&did_str)?;
if !device_behavioral_axes_enabled() {
return Err(device_behavioral_axes_refusal(
"exo-node/zerodentity/api.rs::list_fingerprints",
));
}
let token = extract_session_token(&headers).ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Bearer session token required"})),
)
})?;
let now_ms = now_ms_blocking(state.clone()).await?;
let response = with_store_blocking(state, move |store| {
let session = store
.get_session(&token, now_ms)
.map_err(store_error)?
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Invalid or expired session"})),
)
})?;
if session.subject_did.as_str() != did.as_str() {
return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "Access denied"})),
));
}
let fps = store.get_fingerprints(&did).map_err(store_error)?;
let items: Vec<FingerprintItem> = fps
.iter()
.map(|fp| FingerprintItem {
composite_hash: hex::encode(fp.composite_hash.as_bytes()),
captured_ms: fp.captured_ms,
consistency_score: fp.consistency_score_bp,
signal_count: fp.signal_hashes.len(),
})
.collect();
Ok(FingerprintsResponse {
fingerprints: items,
})
})
.await?;
Ok(Json(response))
}
pub async fn create_peer_attestation(
State(state): State<ApiState>,
OriginalUri(uri): OriginalUri,
Path(did_str): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<(StatusCode, Json<AttestResponse>), (StatusCode, Json<serde_json::Value>)> {
let attester_did = parse_did(&did_str)?;
let req: AttestRequest = serde_json::from_slice(&body)
.map_err(|_| json_error(StatusCode::BAD_REQUEST, "Invalid JSON body"))?;
let target_did = parse_did(&req.target_did)?;
let request_path = path_and_query(&uri);
let authenticated_public_key = verify_signed_write_blocking(
state.clone(),
&headers,
attester_did.clone(),
"POST",
request_path,
body.clone(),
)
.await?;
let attestation_type = AttestationType::from_str(&req.attestation_type).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid attestation_type"})),
)
})?;
let message_hash = parse_message_hash(req.message_hash.as_deref())?;
let created_ms = req
.created_ms
.ok_or_else(|| bad_request("created_ms is required"))?;
let issued_ms = state.now_ms()?;
let attester_public_key = parse_public_key(req.attester_public_key.as_deref())?;
if attester_public_key.as_bytes() != authenticated_public_key.as_bytes() {
return Err(bad_request(
"attester_public_key must match authenticated session key",
));
}
let signature = parse_signature(req.signature.as_deref())?;
let response = with_store_blocking(state, move |store| {
let attester_claims: Vec<IdentityClaim> = store
.get_claims(&attester_did)
.map_err(store_error)?
.into_iter()
.map(|(_, c)| c)
.collect();
let already_exists = store
.attestation_exists(&attester_did, &target_did)
.map_err(store_error)?;
validate_attestation(&attester_did, &target_did, &attester_claims, already_exists)
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let target_claim_hash = target_claim_hash(&attester_did, &target_did).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let dag_node_hash = store
.next_claim_dag_node_hash(target_claim_hash, issued_ms)
.map_err(store_error)?;
let attestation = create_attestation(CreateAttestationInput {
attester_did: &attester_did,
target_did: &target_did,
attestation_type,
message_hash,
dag_node_hash,
created_ms,
attester_public_key,
signature,
})
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let target_claim =
build_target_claim(&attestation, dag_node_hash, issued_ms).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let claim_id = target_claim_id(&attestation).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let evidence = store
.save_claim_with_evidence(&claim_id, &target_claim)
.map_err(store_error)?;
store
.insert_attestation(&attestation)
.map_err(store_error)?;
let receipt_hash = evidence
.receipt_hash
.ok_or_else(|| store_operation_failed("create_peer_attestation_missing_receipt"))?;
let receipt_hash = hex::encode(receipt_hash.as_bytes());
Ok(AttestResponse {
attestation_id: attestation.attestation_id.clone(),
receipt_hash,
attester_score_impact: serde_json::json!({
"network_reputation": format!("+{}", attester_score_impact())
}),
target_score_impact: serde_json::json!({
"network_reputation": format!("+{}", target_score_impact())
}),
})
})
.await?;
Ok((StatusCode::CREATED, Json(response)))
}
#[derive(Debug, Serialize)]
pub struct ErasureResponse {
pub subject_did: String,
pub claims_revoked: u32,
pub receipt_hash: String,
pub message: String,
}
#[derive(Debug, Deserialize)]
pub struct ErasureRequest {
pub erased_ms: Option<u64>,
}
pub async fn delete_identity(
State(state): State<ApiState>,
OriginalUri(uri): OriginalUri,
Path(did_str): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<ErasureResponse>, (StatusCode, Json<serde_json::Value>)> {
let did = parse_did(&did_str)?;
let request_path = path_and_query(&uri);
let _token = verify_signed_write_blocking(
state.clone(),
&headers,
did.clone(),
"DELETE",
request_path,
body.clone(),
)
.await?;
let req: ErasureRequest = serde_json::from_slice(&body)
.map_err(|_| json_error(StatusCode::BAD_REQUEST, "Invalid JSON body"))?;
let erased_ms = req
.erased_ms
.ok_or_else(|| bad_request("erased_ms is required"))?;
if erased_ms == 0 {
return Err(bad_request("erased_ms must be greater than 0"));
}
let validation_ms = state.now_ms()?;
if erased_ms > validation_ms.saturating_add(ZERODENTITY_ERASURE_MAX_FUTURE_SKEW_MS) {
return Err(bad_request(
"erased_ms exceeds trusted erasure clock tolerance",
));
}
let subject_did = did.to_string();
let erasure_evidence = with_store_blocking(state, move |store| {
store
.erase_did_with_evidence(
&did,
Timestamp::new(erased_ms, 0),
Timestamp::new(validation_ms, 0),
)
.map_err(erasure_store_error)
})
.await?;
let receipt_hash = hex::encode(erasure_evidence.receipt_hash.as_bytes());
Ok(Json(ErasureResponse {
subject_did,
claims_revoked: erasure_evidence.claims_revoked,
receipt_hash,
message: "Identity erased. All sessions revoked, claims marked Revoked, scores zeroed, fingerprints removed, DAG nodes tombstoned.".into(),
}))
}
pub fn zerodentity_api_router(state: ApiState) -> Router {
Router::new()
.route("/api/v1/0dentity/:did/score", get(get_score))
.route("/api/v1/0dentity/:did/claims", get(list_claims))
.route("/api/v1/0dentity/:did/score/history", get(score_history))
.route("/api/v1/0dentity/:did/fingerprints", get(list_fingerprints))
.route(
"/api/v1/0dentity/:did/attest",
post(create_peer_attestation),
)
.route(
"/api/v1/0dentity/:did",
axum::routing::delete(delete_identity),
)
.with_state(state)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::needless_borrows_for_generic_args)]
mod tests {
use axum::{body::Body, http::Request};
use exo_core::{
crypto::{self, KeyPair},
types::{Did, Hash256, PublicKey, SecretKey, Signature},
};
use tower::ServiceExt;
use super::*;
use crate::zerodentity::{
attestation::attestation_signing_payload,
store::{ZerodentityReadFailure, ZerodentityStore},
types::{ClaimStatus, ClaimType, IDENTITY_SESSION_TTL_MS, IdentityClaim, IdentitySession},
};
const API_TEST_NOW_MS: u64 = 1_700_000_000_000;
const API_TEST_ACTIVE_SESSION_CREATED_MS: u64 = API_TEST_NOW_MS - 1_000;
const API_TEST_EXPIRED_SESSION_CREATED_MS: u64 = API_TEST_NOW_MS - IDENTITY_SESSION_TTL_MS - 1;
fn test_store() -> ZerodentityStore {
let keypair = KeyPair::from_secret_bytes([31u8; 32]).unwrap();
let signer = Arc::new(move |payload: &[u8]| keypair.sign(payload));
let mut store = ZerodentityStore::new();
store.set_receipt_signer(Did::new("did:exo:test-node").unwrap(), signer);
store
}
fn test_api_state(store: ZerodentityStore) -> ApiState {
ApiState::new_with_clock(
Arc::new(Mutex::new(store)),
HybridClock::with_wall_clock(|| API_TEST_NOW_MS),
)
}
fn make_state() -> ApiState {
test_api_state(test_store())
}
#[tokio::test]
async fn get_score_redacts_store_read_errors() {
let state = make_state_with_session("tok-redaction", "did:exo:redaction");
state
.store
.lock()
.unwrap()
.inject_read_failure(ZerodentityReadFailure::Claims);
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did:exo:redaction/score")
.header("authorization", "Bearer tok-redaction")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let body_text = std::str::from_utf8(&body).unwrap();
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body_json["error"], "Store operation failed");
assert!(!body_text.contains("Store error"));
assert!(!body_text.contains("injected 0dentity"));
}
#[test]
fn attestation_write_path_does_not_fabricate_claim_ids_or_receipts() {
let source = include_str!("api.rs");
let production = source
.split("// ---------------------------------------------------------------------------\n// Tests")
.next()
.unwrap();
let uuid_new_v4 = format!("{}{}", "Uuid::", "new_v4()");
let qualified_uuid_new_v4 = format!("{}{}", "uuid::Uuid::", "new_v4()");
let fabricated_receipt = format!("{}{}", "attest-", "receipt");
assert!(!production.contains(&uuid_new_v4));
assert!(!production.contains(&qualified_uuid_new_v4));
assert!(!production.contains(&fabricated_receipt));
}
#[test]
fn erasure_write_path_does_not_fabricate_receipt_hashes() {
let source = include_str!("api.rs");
let production = source
.split("// ---------------------------------------------------------------------------\n// Tests")
.next()
.unwrap();
let fabricated_receipt = format!("{}{}", "erasure-", "receipt");
assert!(!production.contains(&fabricated_receipt));
}
#[test]
fn attestation_write_path_uses_trusted_node_time_for_issued_artifacts() {
let source = include_str!("api.rs");
let attestation_section = source
.split("// POST /api/v1/0dentity/:did/attest\n// ---------------------------------------------------------------------------")
.nth(1)
.and_then(|section| section.split("// ---------------------------------------------------------------------------").next())
.unwrap();
assert!(attestation_section.contains("let issued_ms = state.now_ms()?;"));
assert!(
attestation_section.contains("next_claim_dag_node_hash(target_claim_hash, issued_ms)")
);
assert!(
attestation_section
.contains("build_target_claim(&attestation, dag_node_hash, issued_ms)")
);
assert!(!attestation_section.contains("Timestamp::new(created_ms, 0)"));
}
#[test]
fn score_read_path_does_not_fabricate_runtime_time() {
let source = include_str!("api.rs");
let score_section = source
.split("// GET /api/v1/0dentity/:did/score\n// ---------------------------------------------------------------------------")
.nth(1)
.and_then(|section| section.split("// ---------------------------------------------------------------------------").next())
.unwrap();
assert!(!score_section.contains("now_ms()"));
}
#[test]
fn score_and_history_reads_require_owner_session_before_store_reads() {
let source = include_str!("api.rs");
let score_section = source
.split("// GET /api/v1/0dentity/:did/score\n// ---------------------------------------------------------------------------")
.nth(1)
.expect("score section present")
.split("// GET /api/v1/0dentity/:did/claims")
.next()
.expect("claims marker present");
let history_section = source
.split("// GET /api/v1/0dentity/:did/score/history\n// ---------------------------------------------------------------------------")
.nth(1)
.expect("history section present")
.split("// GET /api/v1/0dentity/:did/fingerprints")
.next()
.expect("fingerprints marker present");
for section in [score_section, history_section] {
assert!(section.contains("headers: HeaderMap"));
assert!(
section.find("verify_owner_session_blocking").unwrap()
< section.find("with_store_blocking").unwrap(),
"0dentity score reads must verify owner sessions before reading store state"
);
}
}
#[test]
fn api_handlers_do_not_discard_store_read_errors() {
let source = include_str!("api.rs");
let production = source
.split("// ---------------------------------------------------------------------------\n// Tests")
.next()
.unwrap();
assert!(!production.contains(".unwrap_or_default()"));
assert!(!production.contains(".unwrap_or(false)"));
assert!(!production.contains(".ok().flatten()"));
}
#[test]
fn api_async_handlers_use_blocking_store_access() {
let source = include_str!("api.rs");
let production = source
.split("// ---------------------------------------------------------------------------\n// Tests")
.next()
.unwrap();
assert!(
production.contains("tokio::task::spawn_blocking"),
"0dentity API handlers must isolate synchronous store access from Tokio workers"
);
let handlers = production
.split("// GET /api/v1/0dentity/:did/score\n// ---------------------------------------------------------------------------")
.nth(1)
.and_then(|section| section.split("// ---------------------------------------------------------------------------\n// Router").next())
.unwrap();
assert!(
!handlers.contains("state.store.lock()"),
"0dentity async handlers must not lock the store mutex directly"
);
assert!(
!handlers.contains("verify_signed_write("),
"0dentity async handlers must use the blocking signed-write verifier"
);
}
#[test]
fn production_api_state_does_not_install_deterministic_session_clock() {
let source = include_str!("api.rs");
let constructor = source
.split("pub fn new(store: Arc<Mutex<ZerodentityStore>>) -> Self")
.nth(1)
.and_then(|section| section.split("pub fn new_with_clock").next())
.expect("ApiState::new constructor present");
assert!(!constructor.contains("HybridClock::new()"));
assert!(constructor.contains("SessionClock::unavailable()"));
}
#[tokio::test]
async fn production_api_state_fails_closed_without_trusted_session_clock() {
let mut store = test_store();
let did = Did::new("did:exo:prod-clock").unwrap();
let session = IdentitySession {
session_token: "tok-prod-clock".to_owned(),
subject_did: did.clone(),
public_key: vec![],
created_ms: 1_000_000,
last_active_ms: 1_000_000,
revoked: false,
};
store.insert_session(&session).unwrap();
let claim = IdentityClaim {
claim_hash: Hash256::digest(b"prod-clock-claim"),
subject_did: did,
claim_type: ClaimType::Email,
status: ClaimStatus::Verified,
created_ms: 1_000_000,
verified_ms: Some(1_000_000),
expires_ms: None,
signature: Signature::Empty,
dag_node_hash: Hash256::digest(b"prod-clock-dag-node"),
};
store.insert_claim("claim-prod-clock", &claim).unwrap();
let app = zerodentity_api_router(ApiState::new(Arc::new(Mutex::new(store))));
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did:exo:prod-clock/claims")
.header("authorization", "Bearer tok-prod-clock")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
body_json["error"],
"Trusted 0dentity session clock unavailable"
);
}
#[test]
fn erasure_write_path_uses_trusted_session_clock_for_validation_time() {
let source = include_str!("api.rs");
let erasure_section = source
.split("// DELETE /api/v1/0dentity/:did")
.nth(1)
.and_then(|section| section.split("// ---------------------------------------------------------------------------\n// Router").next())
.unwrap();
assert!(erasure_section.contains("state.now_ms()?"));
assert!(erasure_section.contains("ZERODENTITY_ERASURE_MAX_FUTURE_SKEW_MS"));
assert!(!erasure_section.contains("SystemTime"));
assert!(!erasure_section.contains("Instant::now"));
}
fn test_keypair(seed: u8) -> KeyPair {
KeyPair::from_secret_bytes([seed; 32]).unwrap()
}
fn make_state_with_session(token: &str, did_str: &str) -> ApiState {
make_state_with_session_at(token, did_str, API_TEST_ACTIVE_SESSION_CREATED_MS)
}
fn make_state_with_session_at(token: &str, did_str: &str, created_ms: u64) -> ApiState {
let mut store = test_store();
let did = Did::new(did_str).unwrap();
let session = IdentitySession {
session_token: token.to_owned(),
subject_did: did,
public_key: vec![],
created_ms,
last_active_ms: created_ms,
revoked: false,
};
store.insert_session(&session).unwrap();
test_api_state(store)
}
fn make_state_with_session_and_claim(token: &str, did_str: &str) -> ApiState {
make_state_with_session_and_claim_at(token, did_str, API_TEST_ACTIVE_SESSION_CREATED_MS)
}
fn make_state_with_session_and_claim_at(
token: &str,
did_str: &str,
created_ms: u64,
) -> ApiState {
let mut store = test_store();
let did = Did::new(did_str).unwrap();
let session = IdentitySession {
session_token: token.to_owned(),
subject_did: did.clone(),
public_key: vec![],
created_ms,
last_active_ms: created_ms,
revoked: false,
};
store.insert_session(&session).unwrap();
let claim = IdentityClaim {
claim_hash: Hash256::digest(b"email-claim"),
subject_did: did,
claim_type: ClaimType::Email,
status: ClaimStatus::Verified,
created_ms: 1000,
verified_ms: Some(2000),
expires_ms: None,
signature: Signature::Empty,
dag_node_hash: Hash256::digest(b"dag-node"),
};
store.insert_claim("claim-001", &claim).unwrap();
test_api_state(store)
}
fn make_state_with_session_and_claims(
token: &str,
did_str: &str,
claim_count: usize,
) -> ApiState {
let mut store = test_store();
let did = Did::new(did_str).unwrap();
let session = IdentitySession {
session_token: token.to_owned(),
subject_did: did.clone(),
public_key: vec![],
created_ms: API_TEST_ACTIVE_SESSION_CREATED_MS,
last_active_ms: API_TEST_ACTIVE_SESSION_CREATED_MS,
revoked: false,
};
store.insert_session(&session).unwrap();
for index in 0..claim_count {
let claim = IdentityClaim {
claim_hash: Hash256::digest(format!("email-claim-{index}").as_bytes()),
subject_did: did.clone(),
claim_type: ClaimType::Email,
status: ClaimStatus::Verified,
created_ms: 1000 + u64::try_from(index).unwrap(),
verified_ms: Some(2000 + u64::try_from(index).unwrap()),
expires_ms: None,
signature: Signature::Empty,
dag_node_hash: Hash256::digest(format!("dag-node-{index}").as_bytes()),
};
store
.insert_claim(&format!("claim-{index:03}"), &claim)
.unwrap();
}
test_api_state(store)
}
fn make_state_with_score_history(did_str: &str, timestamps: &[u64]) -> ApiState {
let mut store = test_store();
let did = Did::new(did_str).unwrap();
let session = IdentitySession {
session_token: "tok-history".to_owned(),
subject_did: did.clone(),
public_key: vec![],
created_ms: API_TEST_ACTIVE_SESSION_CREATED_MS,
last_active_ms: API_TEST_ACTIVE_SESSION_CREATED_MS,
revoked: false,
};
store.insert_session(&session).unwrap();
for timestamp in timestamps {
store
.put_score(ZerodentityScore::compute(&did, &[], &[], &[], *timestamp))
.unwrap();
}
test_api_state(store)
}
fn make_state_with_signed_session_and_claim(
token: &str,
did_str: &str,
keypair: &KeyPair,
) -> ApiState {
make_state_with_signed_session_and_claim_at(
token,
did_str,
keypair,
API_TEST_ACTIVE_SESSION_CREATED_MS,
)
}
fn make_state_with_signed_session_and_claim_at(
token: &str,
did_str: &str,
keypair: &KeyPair,
created_ms: u64,
) -> ApiState {
let mut store = test_store();
let did = Did::new(did_str).unwrap();
let session = IdentitySession {
session_token: token.to_owned(),
subject_did: did.clone(),
public_key: keypair.public_key().as_bytes().to_vec(),
created_ms,
last_active_ms: created_ms,
revoked: false,
};
store.insert_session(&session).unwrap();
let claim = IdentityClaim {
claim_hash: Hash256::digest(b"email-claim"),
subject_did: did,
claim_type: ClaimType::Email,
status: ClaimStatus::Verified,
created_ms: 1000,
verified_ms: Some(2000),
expires_ms: None,
signature: Signature::Empty,
dag_node_hash: Hash256::digest(b"dag-node"),
};
store.insert_claim("claim-001", &claim).unwrap();
test_api_state(store)
}
fn request_signature_headers(
method: &str,
uri: &str,
token: &str,
nonce: &str,
body: &[u8],
keypair: &KeyPair,
) -> (String, String) {
let body_hash = Hash256::digest(body);
let payload = crate::zerodentity::session_auth::request_signing_payload(
method, uri, token, nonce, &body_hash,
)
.unwrap();
let signature = keypair.sign(&payload);
(nonce.to_owned(), hex::encode(signature.to_bytes()))
}
async fn signed_post(
app: Router,
uri: &str,
token: &str,
nonce: &str,
body: serde_json::Value,
keypair: &KeyPair,
) -> axum::response::Response {
let body_bytes = serde_json::to_vec(&body).unwrap();
let (nonce, signature) =
request_signature_headers("POST", uri, token, nonce, &body_bytes, keypair);
app.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.header("x-exo-nonce", nonce)
.header("x-exo-sig", signature)
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap()
}
async fn signed_delete(
app: Router,
uri: &str,
token: &str,
nonce: &str,
body: serde_json::Value,
keypair: &KeyPair,
) -> axum::response::Response {
let body = serde_json::to_vec(&body).unwrap();
let (nonce, signature) =
request_signature_headers("DELETE", uri, token, nonce, &body, keypair);
app.oneshot(
Request::builder()
.method("DELETE")
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.header("x-exo-nonce", nonce)
.header("x-exo-sig", signature)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
fn signed_attest_body(
attester: &Did,
target: &Did,
attestation_type: AttestationType,
message_hash: Option<Hash256>,
created_ms: u64,
public_key: &PublicKey,
secret_key: &SecretKey,
) -> serde_json::Value {
let payload = attestation_signing_payload(
attester,
target,
&attestation_type,
message_hash.as_ref(),
created_ms,
)
.unwrap();
let signature = crypto::sign(&payload, secret_key);
serde_json::json!({
"target_did": target.as_str(),
"attestation_type": attestation_type.to_string(),
"message_hash": message_hash.map(|h| hex::encode(h.as_bytes())),
"created_ms": created_ms,
"attester_public_key": hex::encode(public_key.as_bytes()),
"signature": hex::encode(signature.to_bytes())
})
}
#[tokio::test]
async fn list_fingerprints_invalid_did_returns_400() {
let app = zerodentity_api_router(make_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/notadid/fingerprints")
.header("authorization", "Bearer tok")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[cfg(not(feature = "unaudited-zerodentity-device-behavioral-axes"))]
#[tokio::test]
async fn list_fingerprints_refused_without_device_behavioral_feature_flag() {
let state = make_state_with_session("tok-alice", "did:exo:alice");
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/fingerprints")
.header("authorization", "Bearer tok-alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
result["feature_flag"],
"unaudited-zerodentity-device-behavioral-axes"
);
assert_eq!(result["initiative"], "fix-onyx-4-r3-unwired-axes.md");
}
#[cfg(feature = "unaudited-zerodentity-device-behavioral-axes")]
#[tokio::test]
async fn list_fingerprints_no_token_returns_401() {
let app = zerodentity_api_router(make_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/fingerprints")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[cfg(feature = "unaudited-zerodentity-device-behavioral-axes")]
#[tokio::test]
async fn list_fingerprints_unknown_session_returns_401() {
let app = zerodentity_api_router(make_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/fingerprints")
.header("authorization", "Bearer unknown-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[cfg(feature = "unaudited-zerodentity-device-behavioral-axes")]
#[tokio::test]
async fn list_fingerprints_wrong_did_returns_403() {
let state = make_state_with_session("tok-bob", "did:exo:bob");
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/fingerprints")
.header("authorization", "Bearer tok-bob")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[cfg(feature = "unaudited-zerodentity-device-behavioral-axes")]
#[tokio::test]
async fn list_fingerprints_returns_empty_list() {
let state = make_state_with_session("tok-alice", "did:exo:alice");
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/fingerprints")
.header("authorization", "Bearer tok-alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["fingerprints"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn score_history_invalid_did_returns_400() {
let app = zerodentity_api_router(make_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/notadid/score/history")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn score_history_returns_empty_for_unknown_did() {
let app = zerodentity_api_router(make_state_with_session("tok-nobody", "did:exo:nobody"));
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Anobody/score/history")
.header("authorization", "Bearer tok-nobody")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["snapshots"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn score_history_applies_bounded_pagination() {
let state = make_state_with_score_history("did:exo:history", &[1000, 2000, 3000, 4000]);
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Ahistory/score/history?offset=1&limit=2")
.header("authorization", "Bearer tok-history")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
let snapshots = result["snapshots"].as_array().unwrap();
assert_eq!(snapshots.len(), 2);
assert_eq!(snapshots[0]["computed_ms"], 2000);
assert_eq!(snapshots[1]["computed_ms"], 3000);
assert_eq!(result["total"], 4);
assert_eq!(result["limit"], 2);
assert_eq!(result["offset"], 1);
}
#[tokio::test]
async fn score_history_rejects_limit_above_maximum() {
let state = make_state_with_score_history("did:exo:history", &[1000]);
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Ahistory/score/history?limit=101")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(result["error"].as_str().unwrap().contains("limit"));
}
#[tokio::test]
async fn score_history_rejects_offset_above_maximum() {
let state = make_state_with_score_history("did:exo:history", &[1000]);
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Ahistory/score/history?offset=10001")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(result["error"].as_str().unwrap().contains("offset"));
}
#[tokio::test]
async fn create_peer_attestation_invalid_target_did_returns_400() {
let app = zerodentity_api_router(make_state());
let body = serde_json::json!({
"target_did": "notadid",
"attestation_type": "Identity"
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/attest")
.header("content-type", "application/json")
.header("authorization", "Bearer tok")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_peer_attestation_wrong_session_returns_403() {
let state = make_state_with_session("tok-bob", "did:exo:bob");
let app = zerodentity_api_router(state);
let body = serde_json::json!({
"target_did": "did:exo:carol",
"attestation_type": "Identity"
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/attest")
.header("content-type", "application/json")
.header("authorization", "Bearer tok-bob")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_peer_attestation_success_with_message_hash() {
let session_keypair = test_keypair(41);
let state = make_state_with_signed_session_and_claim(
"tok-alice",
"did:exo:alice",
&session_keypair,
);
let app = zerodentity_api_router(state);
let attester = Did::new("did:exo:alice").unwrap();
let target = Did::new("did:exo:carol").unwrap();
let message_hash = Hash256::from_bytes([0u8; 32]);
let uri = "/api/v1/0dentity/did%3Aexo%3Aalice/attest";
let body = signed_attest_body(
&attester,
&target,
AttestationType::Identity,
Some(message_hash),
1_700_000_100_000,
session_keypair.public_key(),
session_keypair.secret_key(),
);
let resp = signed_post(
app,
uri,
"tok-alice",
"nonce-api-attest-1",
body,
&session_keypair,
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn create_peer_attestation_uses_node_hlc_for_target_claim_and_receipt_time() {
let session_keypair = test_keypair(47);
let state = make_state_with_signed_session_and_claim(
"tok-alice",
"did:exo:alice",
&session_keypair,
);
let store = state.store.clone();
let app = zerodentity_api_router(state);
let attester = Did::new("did:exo:alice").unwrap();
let target = Did::new("did:exo:attested-carol").unwrap();
let signed_created_ms = 1_234_000;
let uri = "/api/v1/0dentity/did%3Aexo%3Aalice/attest";
let body = signed_attest_body(
&attester,
&target,
AttestationType::Identity,
None,
signed_created_ms,
session_keypair.public_key(),
session_keypair.secret_key(),
);
let resp = signed_post(
app,
uri,
"tok-alice",
"nonce-api-attest-hlc-issued-time",
body,
&session_keypair,
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
let guard = store.lock().unwrap();
let saved_attestation = guard
.get_attestation(&attester, &target)
.unwrap()
.expect("attestation stored");
assert_eq!(saved_attestation.created_ms, signed_created_ms);
let claim_id = target_claim_id(&saved_attestation).unwrap();
let claims = guard.get_claims(&target).unwrap();
let target_claim = claims
.iter()
.find(|(id, _)| id == &claim_id)
.map(|(_, claim)| claim)
.expect("target peer-attestation claim");
assert_eq!(target_claim.created_ms, API_TEST_NOW_MS);
assert_eq!(target_claim.verified_ms, Some(API_TEST_NOW_MS));
let dag_node = guard.dag_nodes().first().expect("attestation DAG node");
assert_eq!(dag_node.timestamp.physical_ms, API_TEST_NOW_MS);
let receipt = guard
.trust_receipts()
.iter()
.find(|receipt| receipt.action_hash == target_claim.claim_hash)
.expect("claim verification receipt");
assert_eq!(receipt.timestamp.physical_ms, API_TEST_NOW_MS);
}
#[tokio::test]
async fn create_peer_attestation_short_message_hash_returns_400() {
let session_keypair = test_keypair(42);
let state = make_state_with_signed_session_and_claim(
"tok-alice",
"did:exo:alice",
&session_keypair,
);
let app = zerodentity_api_router(state);
let attester = Did::new("did:exo:alice").unwrap();
let target = Did::new("did:exo:dave").unwrap();
let uri = "/api/v1/0dentity/did%3Aexo%3Aalice/attest";
let mut body = signed_attest_body(
&attester,
&target,
AttestationType::Trustworthy,
None,
1_700_000_200_000,
session_keypair.public_key(),
session_keypair.secret_key(),
);
body["message_hash"] = serde_json::Value::String(hex::encode([0u8; 16]));
let resp = signed_post(
app,
uri,
"tok-alice",
"nonce-api-attest-2",
body,
&session_keypair,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn server_key_get_does_not_return_key_material() {
let app = zerodentity_api_router(make_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/server-key")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn list_claims_filters_by_type() {
let state = make_state_with_session_and_claim("tok-alice", "did:exo:alice");
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/claims?type=email")
.header("authorization", "Bearer tok-alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["claims"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn list_claims_rejects_expired_session() {
let state = make_state_with_session_and_claim_at(
"tok-expired",
"did:exo:alice",
API_TEST_EXPIRED_SESSION_CREATED_MS,
);
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/claims")
.header("authorization", "Bearer tok-expired")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["error"], "Invalid or expired session");
}
#[tokio::test]
async fn list_claims_pagination_with_offset() {
let state = make_state_with_session_and_claim("tok-alice", "did:exo:alice");
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/claims?offset=1&limit=10")
.header("authorization", "Bearer tok-alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["claims"].as_array().unwrap().len(), 0);
assert_eq!(result["total"], 1);
}
#[tokio::test]
async fn list_claims_rejects_limit_above_maximum() {
let state = make_state_with_session_and_claims("tok-alice", "did:exo:alice", 2);
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/claims?limit=101")
.header("authorization", "Bearer tok-alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(result["error"].as_str().unwrap().contains("limit"));
}
#[tokio::test]
async fn list_claims_rejects_offset_above_maximum() {
let state = make_state_with_session_and_claims("tok-alice", "did:exo:alice", 2);
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/0dentity/did%3Aexo%3Aalice/claims?offset=10001")
.header("authorization", "Bearer tok-alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(result["error"].as_str().unwrap().contains("offset"));
}
#[test]
fn list_claims_pagination_avoids_lossy_usize_casts() {
let source = include_str!("api.rs");
let list_claims_section = source
.split("// GET /api/v1/0dentity/:did/claims\n// ---------------------------------------------------------------------------")
.nth(1)
.and_then(|section| section.split("// ---------------------------------------------------------------------------").next())
.unwrap();
assert!(
!list_claims_section.contains("as usize"),
"claims pagination must use checked conversion, never truncating casts"
);
assert!(
!list_claims_section.contains("clippy::as_conversions"),
"claims pagination must not suppress checked conversion lints"
);
}
#[tokio::test]
async fn delete_identity_no_token_returns_401() {
let app = zerodentity_api_router(make_state());
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/0dentity/did%3Aexo%3Aalice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn delete_identity_wrong_did_returns_403() {
let state = make_state_with_session("tok-bob", "did:exo:bob");
let app = zerodentity_api_router(state);
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/0dentity/did%3Aexo%3Aalice")
.header("authorization", "Bearer tok-bob")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_identity_rejects_expired_session_before_erasure() {
let keypair = test_keypair(44);
let state = make_state_with_signed_session_and_claim_at(
"tok-expired",
"did:exo:alice",
&keypair,
API_TEST_EXPIRED_SESSION_CREATED_MS,
);
let app = zerodentity_api_router(state);
let resp = signed_delete(
app,
"/api/v1/0dentity/did%3Aexo%3Aalice",
"tok-expired",
"nonce-api-delete-expired",
serde_json::json!({ "erased_ms": 7_777_000 }),
&keypair,
)
.await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn delete_identity_success_returns_erasure_receipt() {
let keypair = test_keypair(43);
let state =
make_state_with_signed_session_and_claim("tok-alice", "did:exo:alice", &keypair);
let store = state.store.clone();
let app = zerodentity_api_router(state);
let resp = signed_delete(
app,
"/api/v1/0dentity/did%3Aexo%3Aalice",
"tok-alice",
"nonce-api-delete-1",
serde_json::json!({ "erased_ms": 7_777_000 }),
&keypair,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["claims_revoked"], 1);
assert!(result["receipt_hash"].as_str().is_some());
let guard = store.lock().unwrap();
let receipts = guard.trust_receipts();
let receipt = receipts
.iter()
.find(|receipt| receipt.action_type == "zerodentity.identity_erased")
.expect("erasure receipt");
assert_eq!(
result["receipt_hash"].as_str().unwrap(),
hex::encode(receipt.receipt_hash.as_bytes())
);
assert_eq!(receipt.timestamp.physical_ms, 7_777_000);
let nodes = guard.dag_nodes();
let erasure_node = nodes.last().expect("erasure dag node");
assert_eq!(erasure_node.timestamp.physical_ms, 7_777_000);
assert!(receipt.verify_hash().expect("verify trust receipt hash"));
assert!(
result["message"]
.as_str()
.unwrap()
.contains("Identity erased")
);
}
#[tokio::test]
async fn delete_identity_requires_erasure_timestamp() {
let keypair = test_keypair(44);
let state =
make_state_with_signed_session_and_claim("tok-alice", "did:exo:alice", &keypair);
let app = zerodentity_api_router(state);
let resp = signed_delete(
app,
"/api/v1/0dentity/did%3Aexo%3Aalice",
"tok-alice",
"nonce-api-delete-missing-time",
serde_json::json!({}),
&keypair,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["error"].as_str().unwrap(), "erased_ms is required");
}
#[tokio::test]
async fn delete_identity_rejects_zero_erasure_timestamp() {
let keypair = test_keypair(45);
let state =
make_state_with_signed_session_and_claim("tok-alice", "did:exo:alice", &keypair);
let app = zerodentity_api_router(state);
let resp = signed_delete(
app,
"/api/v1/0dentity/did%3Aexo%3Aalice",
"tok-alice",
"nonce-api-delete-zero-time",
serde_json::json!({ "erased_ms": 0 }),
&keypair,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
result["error"].as_str().unwrap(),
"erased_ms must be greater than 0"
);
}
#[tokio::test]
async fn delete_identity_rejects_far_future_erasure_timestamp() {
let keypair = test_keypair(46);
let state =
make_state_with_signed_session_and_claim("tok-alice", "did:exo:alice", &keypair);
let app = zerodentity_api_router(state);
let resp = signed_delete(
app,
"/api/v1/0dentity/did%3Aexo%3Aalice",
"tok-alice",
"nonce-api-delete-future-time",
serde_json::json!({ "erased_ms": u64::MAX }),
&keypair,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
result["error"].as_str().unwrap(),
"erased_ms exceeds trusted erasure clock tolerance"
);
}
#[tokio::test]
async fn delete_identity_invalid_did_returns_400() {
let app = zerodentity_api_router(make_state());
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/0dentity/notadid")
.header("authorization", "Bearer tok")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}