// Copyright 2026 Exochain Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at:
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
//! Node API surface for Autonomous Volition Credentials.
//!
//! Routes are merged into the gateway's axum router via the same
//! `serve_with_extra_routes` pattern used by governance, passport, and
//! provenance. Mutating endpoints (`POST`) inherit bearer-token auth
//! from the merged write guard; ordinary AVC denials return HTTP `200`
//! with `decision: Deny` rather than `403` so callers see structured
//! reason codes.
//!
//! ## Routes
//!
//! | Method | Path | Purpose |
//! |--------|------|---------|
//! | `POST` | `/api/v1/avc/issue` | Register a signed credential. |
//! | `POST` | `/api/v1/avc/validate` | Validate a credential and optional action. |
//! | `POST` | `/api/v1/avc/receipts/emit` | Validate a subject-signed action and mint a node-signed receipt. |
//! | `GET` | `/api/v1/avc/receipts/:hash` | Fetch a stored AVC trust receipt by hash. |
//! | `GET` | `/api/v1/avc/receipts?actor=<did>&limit=N` | List stored AVC trust receipts for a subject DID. |
//! | `GET` | `/api/v1/avc/protocol` | Discover node AVC protocol compatibility metadata. |
//! | `POST` | `/api/v1/avc/delegate` | Register a signed child credential. |
//! | `POST` | `/api/v1/avc/revoke` | Register a signed revocation. |
//! | `GET` | `/api/v1/avc/:id` | Fetch a registered credential by hex ID. |
//! | `GET` | `/api/v1/agents/:did/avcs` | List credentials for a subject DID. |
//!
//! ## State
//!
//! The handlers operate against a shared [`AvcApiState`] that wraps an
//! `InMemoryAvcRegistry` behind `Arc<Mutex<_>>`. All synchronous store
//! access is performed inside `tokio::task::spawn_blocking` so async
//! workers are not held under the registry lock.
use std::{
collections::BTreeSet,
fs::{self, File},
io::Write,
path::{Path as FsPath, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use axum::{
Json, Router,
extract::{DefaultBodyLimit, Path, Query, State},
http::StatusCode,
routing::{get, post},
};
use exo_authority::permission::Permission;
use exo_avc::{
AVC_MAX_SUPPORTED_PROTOCOL_VERSION, AVC_MIN_SUPPORTED_PROTOCOL_VERSION,
AVC_PROTOCOL_DEPRECATION_WINDOW_DAYS, AVC_PROTOCOL_VERSION, AVC_SCHEMA_VERSION,
AutonomousVolitionCredential, AvcActionDescriptor, AvcActionRequest, AvcDecision,
AvcReceiptEvidenceSubject, AvcReceiptExternalTimestampProof, AvcReceiptRfc3161TimestampProof,
AvcReceiptRfc3161TrustAnchorKind, AvcReceiptTimestampProvenance, AvcRegistryDurableState,
AvcRegistryRead, AvcRegistryWrite, AvcRevocation, AvcTrustReceipt, AvcTrustReceiptEvidence,
AvcValidationRequest, AvcValidationResult, InMemoryAvcRegistry, avc_action_commitment_hash,
avc_action_descriptor_hash, avc_action_signature_payload, create_trust_receipt_with_evidence,
require_supported_avc_protocol_version, validate_avc,
};
use exo_core::{
Did, Hash256, PublicKey, Signature, Timestamp, crypto,
hash::hash_structured,
hlc::HybridClock,
types::{ReceiptOutcome, TrustReceipt},
};
use exo_dag::dag::{DagNode, compute_node_hash};
use exo_root::{RootSignature, RootTrustBundle, verify_root_bundle, verify_root_signature};
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Postgres, Row, Transaction};
use tower::limit::ConcurrencyLimitLayer;
const MAX_AVC_API_BODY_BYTES: usize = 64 * 1024;
const MAX_AVC_API_CONCURRENT_REQUESTS: usize = 64;
const AVC_EXTERNAL_TIMESTAMP_AUTHORITY_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(not(test))]
const AVC_RFC3161_TIMESTAMP_FETCH_RETRY_DELAYS: [Duration; 2] =
[Duration::from_millis(250), Duration::from_millis(1_000)];
#[cfg(test)]
const AVC_RFC3161_TIMESTAMP_FETCH_RETRY_DELAYS: [Duration; 2] =
[Duration::from_millis(0), Duration::from_millis(0)];
pub const AVC_ROOT_TRUST_BUNDLE_ENV: &str = "EXO_AVC_ROOT_TRUST_BUNDLE";
pub const AVC_REQUIRE_POSTGRES_DURABILITY_ENV: &str = "EXO_AVC_REQUIRE_POSTGRES_DURABILITY";
pub const AVC_REQUIRE_EXTERNAL_TIMESTAMP_AUTHORITY_ENV: &str =
"EXO_AVC_REQUIRE_EXTERNAL_TIMESTAMP_AUTHORITY";
pub const AVC_ROOT_TRUST_CEREMONY_ID: &str = "avc-exo-ceremony-2026";
pub const AVC_ROOT_TRUST_BUNDLE_ID_HEX: &str =
"7d9954a797ef244c15ad1b733cf77598125ccef0f812a404137e827c192d6a58";
pub const AVC_ROOT_TRUST_ISSUER_DID: &str = "did:exo:8EVGmqLo15JEnrbcrLo9r84qX1mtrVeBdPjHLUtb1sXX";
pub const AVC_ROOT_TRUST_ISSUER_PUBLIC_KEY_HEX: &str =
"6b765381964de7f74e77e4f9d265105f415e58722d19ff71603f62c31d5aff32";
pub const AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV: &str =
"EXO_AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL";
pub const AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV: &str =
"EXO_AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND";
pub const AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_JSON_ED25519: &str = "json-ed25519";
pub const AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161: &str = "rfc3161";
pub const AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV: &str =
"EXO_AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID";
pub const AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV: &str =
"EXO_AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX";
pub const AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV: &str = "EXO_AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX";
pub const AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV: &str = "EXO_AVC_RFC3161_TIMESTAMP_POLICY_OID";
const AVC_REGISTRY_DURABLE_STATE_FILE: &str = "avc-registry.cbor";
const AVC_REGISTRY_POSTGRES_TABLE: &str = "avc_registry_state";
const AVC_REGISTRY_POSTGRES_KEY: &str = "default";
const AVC_REGISTRY_POSTGRES_LOCK_KEY: i64 = 0x4156_435F_5245_4749;
const AVC_ROOT_BUNDLE_RECEIPT_SCHEMA_VERSION: &str = "dagdb_root_bundle_verification_receipt_v1";
const AVC_ROOT_BUNDLE_RECEIPT_VERIFIER_VERSION: &str = "exo-node-avc-root-trust-loader-v1";
const AVC_EXOCHAIN_FINALITY_DAG_DOMAIN: &str = "exo.avc.receipt.exochain_finality.v1";
const AVC_EXOCHAIN_FINALITY_ACTION_TYPE: &str = "avc.receipt.exochain_finality";
const DEFAULT_AVC_RECEIPT_LIST_LIMIT: u32 = 50;
const MAX_AVC_RECEIPT_LIST_LIMIT: u32 = 500;
const WASM_PACKAGE_NAME: &str = "@exochain/exochain-wasm";
const WASM_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
pub type AvcReceiptSigner = Arc<dyn Fn(&[u8]) -> Signature + Send + Sync>;
#[derive(Clone)]
enum AvcReceiptExternalTimestampSource {
Unconfigured,
HttpJson {
endpoint: Arc<String>,
authority_did: Did,
authority_public_key: PublicKey,
client: reqwest::Client,
},
Rfc3161 {
endpoint: Arc<String>,
authority_did: Did,
authority_public_key_spki_der_hexes: Arc<Vec<String>>,
issuing_ca_spki_der_hexes: Arc<Vec<String>>,
policy_oid: Arc<String>,
client: reqwest::Client,
},
#[cfg(test)]
Fixed {
authority_did: Did,
authority_public_key: PublicKey,
issued_at: Timestamp,
signer: AvcReceiptSigner,
},
#[cfg(test)]
FixedRfc3161 {
authority_did: Did,
issued_at: Timestamp,
token_der_base64: String,
policy_oid: String,
serial_number_hex: String,
nonce_hex: String,
tsa_subject: String,
tsa_public_key_spki_der_hex: String,
},
}
impl core::fmt::Debug for AvcReceiptExternalTimestampSource {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Unconfigured => f.write_str("AvcReceiptExternalTimestampSource::Unconfigured"),
Self::HttpJson {
endpoint,
authority_did,
..
} => f
.debug_struct("AvcReceiptExternalTimestampSource::HttpJson")
.field("endpoint", endpoint)
.field("authority_did", authority_did)
.finish_non_exhaustive(),
Self::Rfc3161 {
endpoint,
authority_did,
policy_oid,
..
} => f
.debug_struct("AvcReceiptExternalTimestampSource::Rfc3161")
.field("endpoint", endpoint)
.field("authority_did", authority_did)
.field("policy_oid", policy_oid)
.finish_non_exhaustive(),
#[cfg(test)]
Self::Fixed {
authority_did,
issued_at,
..
} => f
.debug_struct("AvcReceiptExternalTimestampSource::Fixed")
.field("authority_did", authority_did)
.field("issued_at", issued_at)
.finish_non_exhaustive(),
#[cfg(test)]
Self::FixedRfc3161 {
authority_did,
issued_at,
policy_oid,
..
} => f
.debug_struct("AvcReceiptExternalTimestampSource::FixedRfc3161")
.field("authority_did", authority_did)
.field("issued_at", issued_at)
.field("policy_oid", policy_oid)
.finish_non_exhaustive(),
}
}
}
#[derive(Serialize)]
struct AvcExternalTimestampRequest {
schema_version: u16,
domain: &'static str,
subject_hash: String,
}
#[derive(Deserialize)]
struct AvcExternalTimestampResponse {
authority_did: String,
subject_hash: String,
issued_at_physical_ms: u64,
issued_at_logical: u32,
signature_hex: String,
}
#[derive(Debug, thiserror::Error)]
enum AvcExternalTimestampFailure {
#[error(
"AVC external timestamp authority is not configured; set {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV}, {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV}, and {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV}"
)]
Unconfigured,
#[error("AVC external timestamp authority is unreachable: {reason}")]
Unreachable { reason: String },
#[error("AVC external timestamp authority returned non-success status {status}")]
Rejected { status: String },
#[error("AVC external timestamp authority response was invalid: {reason}")]
InvalidResponse { reason: String },
#[error("AVC external timestamp authority proof was invalid: {reason}")]
InvalidProof { reason: String },
}
impl AvcExternalTimestampFailure {
const fn operator_class(&self) -> &'static str {
match self {
Self::Unconfigured => "unconfigured",
Self::Unreachable { .. } => "unreachable",
Self::Rejected { .. } => "rejected",
Self::InvalidResponse { .. } => "invalid_response",
Self::InvalidProof { .. } => "invalid_proof",
}
}
}
fn external_timestamp_error_class(err: &anyhow::Error) -> &'static str {
err.downcast_ref::<AvcExternalTimestampFailure>()
.map_or("unknown", AvcExternalTimestampFailure::operator_class)
}
fn rfc3161_fetch_status_is_retryable(status: StatusCode) -> bool {
status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS
}
fn rfc3161_fetch_retry_delay(attempt_index: usize) -> Option<Duration> {
AVC_RFC3161_TIMESTAMP_FETCH_RETRY_DELAYS
.get(attempt_index)
.copied()
}
async fn wait_before_rfc3161_fetch_retry(delay: Duration) {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
async fn fetch_rfc3161_timestamp_response(
client: &reqwest::Client,
endpoint: &str,
request_der: &[u8],
) -> Result<Vec<u8>, AvcExternalTimestampFailure> {
let max_attempts = AVC_RFC3161_TIMESTAMP_FETCH_RETRY_DELAYS.len() + 1;
for attempt_index in 0..max_attempts {
let attempt = attempt_index + 1;
let response = client
.post(endpoint)
.header("Content-Type", "application/timestamp-query")
.header("Accept", "application/timestamp-reply")
.body(request_der.to_vec())
.send()
.await;
let response = match response {
Ok(response) => response,
Err(error) => {
if let Some(delay) = rfc3161_fetch_retry_delay(attempt_index) {
tracing::warn!(
attempt = attempt,
max_attempts = max_attempts,
err = %error,
"retrying RFC 3161 timestamp authority fetch after transport failure"
);
wait_before_rfc3161_fetch_retry(delay).await;
continue;
}
return Err(AvcExternalTimestampFailure::Unreachable {
reason: format!("after {attempt} attempts: {error}"),
});
}
};
let status = response.status();
if status.is_success() {
return response
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(|error| AvcExternalTimestampFailure::InvalidResponse {
reason: error.to_string(),
});
}
if rfc3161_fetch_status_is_retryable(status) {
if let Some(delay) = rfc3161_fetch_retry_delay(attempt_index) {
tracing::warn!(
attempt = attempt,
max_attempts = max_attempts,
status = %status,
"retrying RFC 3161 timestamp authority fetch after transient status"
);
wait_before_rfc3161_fetch_retry(delay).await;
continue;
}
}
return Err(AvcExternalTimestampFailure::Rejected {
status: status.to_string(),
});
}
Err(AvcExternalTimestampFailure::Unreachable {
reason: format!("exhausted {max_attempts} RFC 3161 timestamp fetch attempts"),
})
}
impl AvcReceiptExternalTimestampSource {
async fn issue_proof(
&self,
evidence_subject: &AvcReceiptEvidenceSubject,
) -> anyhow::Result<AvcReceiptExternalTimestampProof> {
let subject_hash = evidence_subject.hash().map_err(|error| {
anyhow::anyhow!("AVC timestamp evidence subject hash failed: {error}")
})?;
match self {
Self::Unconfigured => Err(AvcExternalTimestampFailure::Unconfigured.into()),
Self::HttpJson {
endpoint,
authority_did,
authority_public_key,
client,
} => {
let request = AvcExternalTimestampRequest {
schema_version: AVC_SCHEMA_VERSION,
domain: exo_avc::AVC_RECEIPT_EVIDENCE_SUBJECT_DOMAIN,
subject_hash: subject_hash.to_string(),
};
let response = client
.post(endpoint.as_str())
.json(&request)
.send()
.await
.map_err(|error| AvcExternalTimestampFailure::Unreachable {
reason: error.to_string(),
})?;
let status = response.status();
if !status.is_success() {
return Err(AvcExternalTimestampFailure::Rejected {
status: status.to_string(),
}
.into());
}
let wire: AvcExternalTimestampResponse =
response.json().await.map_err(|error| {
AvcExternalTimestampFailure::InvalidResponse {
reason: error.to_string(),
}
})?;
let proof = external_timestamp_proof_from_wire(wire).map_err(|error| {
AvcExternalTimestampFailure::InvalidResponse {
reason: error.to_string(),
}
})?;
validate_external_timestamp_proof(
&proof,
subject_hash,
authority_did,
authority_public_key,
)
.map_err(|error| AvcExternalTimestampFailure::InvalidProof {
reason: error.to_string(),
})?;
Ok(proof)
}
Self::Rfc3161 {
endpoint,
authority_did,
authority_public_key_spki_der_hexes,
issuing_ca_spki_der_hexes,
policy_oid,
client,
} => {
let request = crate::avc_rfc3161::build_timestamp_request(
evidence_subject,
policy_oid.as_str(),
)
.map_err(|error| AvcExternalTimestampFailure::InvalidProof {
reason: error.to_string(),
})?;
let response_der =
fetch_rfc3161_timestamp_response(client, endpoint.as_str(), &request.der)
.await?;
let trust_anchors = crate::avc_rfc3161::Rfc3161TrustAnchors::new(
authority_public_key_spki_der_hexes.as_ref().clone(),
issuing_ca_spki_der_hexes.as_ref().clone(),
)
.map_err(|error| AvcExternalTimestampFailure::InvalidProof {
reason: error.to_string(),
})?;
let verified = crate::avc_rfc3161::verify_timestamp_response_with_trust_anchors(
&response_der,
subject_hash,
request.message_imprint_sha256,
&request.nonce_hex,
policy_oid.as_str(),
&trust_anchors,
)
.map_err(|error| AvcExternalTimestampFailure::InvalidProof {
reason: error.to_string(),
})?;
let (tsa_trust_anchor_kind, tsa_issuer_subject) = match verified.trust_anchor.kind {
crate::avc_rfc3161::Rfc3161TrustAnchorKind::SignerSpki => {
(AvcReceiptRfc3161TrustAnchorKind::SignerSpki, None)
}
crate::avc_rfc3161::Rfc3161TrustAnchorKind::IssuingCaSpki => (
AvcReceiptRfc3161TrustAnchorKind::IssuingCaSpki,
Some(verified.trust_anchor.subject.clone()),
),
};
Ok(AvcReceiptExternalTimestampProof::rfc3161(
authority_did.clone(),
verified.subject_hash,
verified.issued_at,
AvcReceiptRfc3161TimestampProof {
message_imprint_sha256_hex: verified.message_imprint_sha256_hex,
token_der_base64: verified.token_der_base64,
policy_oid: verified.policy_oid,
serial_number_hex: verified.serial_number_hex,
nonce_hex: verified.nonce_hex,
tsa_subject: verified.tsa_subject,
tsa_public_key_spki_der_hex: verified.tsa_public_key_spki_der_hex,
tsa_trust_anchor_kind: Some(tsa_trust_anchor_kind),
tsa_trust_anchor_spki_der_hex: Some(verified.trust_anchor.spki_der_hex),
tsa_issuer_subject,
},
))
}
#[cfg(test)]
Self::Fixed {
authority_did,
authority_public_key,
issued_at,
signer,
} => {
let proof = AvcReceiptExternalTimestampProof::signed(
authority_did.clone(),
subject_hash,
*issued_at,
|bytes| signer(bytes),
)
.map_err(|error| {
anyhow::anyhow!("fixed AVC external timestamp proof failed: {error}")
})?;
validate_external_timestamp_proof(
&proof,
subject_hash,
authority_did,
authority_public_key,
)
.map_err(|error| AvcExternalTimestampFailure::InvalidProof {
reason: error.to_string(),
})?;
Ok(proof)
}
#[cfg(test)]
Self::FixedRfc3161 {
authority_did,
issued_at,
token_der_base64,
policy_oid,
serial_number_hex,
nonce_hex,
tsa_subject,
tsa_public_key_spki_der_hex,
} => Ok(AvcReceiptExternalTimestampProof::rfc3161(
authority_did.clone(),
subject_hash,
*issued_at,
AvcReceiptRfc3161TimestampProof {
message_imprint_sha256_hex: hex::encode(
evidence_subject
.rfc3161_sha256_message_imprint()
.map_err(|error| AvcExternalTimestampFailure::InvalidProof {
reason: error.to_string(),
})?,
),
token_der_base64: token_der_base64.clone(),
policy_oid: policy_oid.clone(),
serial_number_hex: serial_number_hex.clone(),
nonce_hex: nonce_hex.clone(),
tsa_subject: tsa_subject.clone(),
tsa_public_key_spki_der_hex: tsa_public_key_spki_der_hex.clone(),
tsa_trust_anchor_kind: Some(AvcReceiptRfc3161TrustAnchorKind::SignerSpki),
tsa_trust_anchor_spki_der_hex: Some(tsa_public_key_spki_der_hex.clone()),
tsa_issuer_subject: None,
},
)),
}
}
}
fn external_timestamp_proof_from_wire(
wire: AvcExternalTimestampResponse,
) -> anyhow::Result<AvcReceiptExternalTimestampProof> {
let authority_did = Did::new(&wire.authority_did).map_err(|error| {
anyhow::anyhow!("invalid AVC external timestamp authority DID: {error}")
})?;
let subject_hash = parse_hash_anyhow(&wire.subject_hash, "AVC timestamp subject hash")?;
let signature = parse_signature_hex(&wire.signature_hex, "AVC timestamp signature")?;
let mut proof = AvcReceiptExternalTimestampProof::unsigned(
authority_did,
subject_hash,
Timestamp::new(wire.issued_at_physical_ms, wire.issued_at_logical),
);
proof.signature = signature;
Ok(proof)
}
fn validate_external_timestamp_proof(
proof: &AvcReceiptExternalTimestampProof,
expected_subject_hash: Hash256,
expected_authority_did: &Did,
authority_public_key: &PublicKey,
) -> anyhow::Result<()> {
if proof.subject_hash != expected_subject_hash {
anyhow::bail!(
"AVC external timestamp proof subject {} did not match expected {}",
proof.subject_hash,
expected_subject_hash
);
}
if proof.authority_did != *expected_authority_did {
anyhow::bail!(
"AVC external timestamp proof authority {} did not match expected {}",
proof.authority_did,
expected_authority_did
);
}
let verified = proof
.verify_signature(authority_public_key)
.map_err(|error| {
anyhow::anyhow!("AVC external timestamp signature payload failed: {error}")
})?;
if !verified {
anyhow::bail!("AVC external timestamp proof signature is invalid");
}
Ok(())
}
fn parse_hash_anyhow(raw: &str, label: &str) -> anyhow::Result<Hash256> {
let bytes = hex::decode(raw).map_err(|error| anyhow::anyhow!("{label} is not hex: {error}"))?;
if bytes.len() != 32 {
anyhow::bail!("{label} must be 32 bytes, got {}", bytes.len());
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&bytes);
Ok(Hash256::from_bytes(buf))
}
fn parse_signature_hex(raw: &str, label: &str) -> anyhow::Result<Signature> {
let bytes = hex::decode(raw).map_err(|error| anyhow::anyhow!("{label} is not hex: {error}"))?;
if bytes.len() != 64 {
anyhow::bail!("{label} must be 64 bytes, got {}", bytes.len());
}
let mut buf = [0u8; 64];
buf.copy_from_slice(&bytes);
Ok(Signature::from_bytes(buf))
}
#[derive(Serialize)]
struct AvcReceiptFinalityPayload<'a> {
domain: &'static str,
schema_version: u16,
receipt: &'a AvcTrustReceipt,
external_timestamp_subject_hash: Option<&'a Hash256>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AvcExochainFinalityCommitment {
finality_hash: Hash256,
finality_height: u64,
finality_receipt_hash: Hash256,
}
fn avc_receipt_finality_payload_hash(receipt: &AvcTrustReceipt) -> anyhow::Result<Hash256> {
let external_timestamp_subject_hash = receipt
.external_timestamp_proof
.as_ref()
.map(|proof| &proof.subject_hash);
hash_structured(&AvcReceiptFinalityPayload {
domain: AVC_EXOCHAIN_FINALITY_DAG_DOMAIN,
schema_version: AVC_SCHEMA_VERSION,
receipt,
external_timestamp_subject_hash,
})
.map_err(|error| anyhow::anyhow!("AVC EXOCHAIN finality payload hash failed: {error}"))
}
fn build_exochain_finality_node_and_receipt(
receipt: &AvcTrustReceipt,
validator_did: &Did,
receipt_signer: &AvcReceiptSigner,
) -> anyhow::Result<(DagNode, TrustReceipt)> {
let payload_hash = avc_receipt_finality_payload_hash(receipt)?;
let parents = receipt
.previous_receipt_hash
.iter()
.copied()
.collect::<Vec<_>>();
let node_hash = compute_node_hash(&parents, &payload_hash, validator_did, &receipt.created_at)
.map_err(|error| anyhow::anyhow!("AVC EXOCHAIN finality node hash failed: {error}"))?;
let node = DagNode {
hash: node_hash,
parents,
payload_hash,
creator_did: validator_did.clone(),
timestamp: receipt.created_at,
signature: (receipt_signer)(node_hash.as_bytes()),
};
let finality_receipt = TrustReceipt::new(
validator_did.clone(),
receipt.receipt_id,
None,
AVC_EXOCHAIN_FINALITY_ACTION_TYPE.to_owned(),
node.hash,
ReceiptOutcome::Executed,
receipt.created_at,
&|bytes| (receipt_signer)(bytes),
)
.map_err(|error| anyhow::anyhow!("AVC EXOCHAIN finality trust receipt failed: {error}"))?;
Ok((node, finality_receipt))
}
fn commit_exochain_finality(
finality_store: &Option<Arc<Mutex<crate::store::SqliteDagStore>>>,
receipt: &AvcTrustReceipt,
validator_did: &Did,
receipt_signer: &AvcReceiptSigner,
) -> anyhow::Result<Option<AvcExochainFinalityCommitment>> {
let Some(finality_store) = finality_store else {
return Ok(None);
};
let (node, finality_receipt) =
build_exochain_finality_node_and_receipt(receipt, validator_did, receipt_signer)?;
let mut store = finality_store
.lock()
.map_err(|_| anyhow::anyhow!("AVC EXOCHAIN finality store mutex poisoned"))?;
let finality_height = match store.committed_height_for(&node.hash)? {
Some(height) => height,
None => store
.committed_height_value()?
.checked_add(1)
.ok_or_else(|| {
anyhow::anyhow!("AVC EXOCHAIN finality height overflow for {}", node.hash)
})?,
};
store.put_committed_node_with_receipt_sync(&node, finality_height, &finality_receipt)?;
Ok(Some(AvcExochainFinalityCommitment {
finality_hash: node.hash,
finality_height,
finality_receipt_hash: finality_receipt.receipt_hash,
}))
}
#[derive(Clone)]
enum AvcRegistryDurability {
#[cfg(test)]
None,
File(Arc<PathBuf>),
Postgres(PgPool),
}
/// Shared state for AVC route handlers.
#[derive(Clone)]
pub struct AvcApiState {
pub registry: Arc<Mutex<InMemoryAvcRegistry>>,
validator_did: Did,
receipt_signer: AvcReceiptSigner,
external_timestamp_source: AvcReceiptExternalTimestampSource,
receipt_clock: Arc<Mutex<HybridClock>>,
require_external_timestamp: bool,
finality_store: Option<Arc<Mutex<crate::store::SqliteDagStore>>>,
durability: AvcRegistryDurability,
}
impl AvcApiState {
/// Wrap a fresh registry in the standard `Arc<Mutex<_>>` envelope.
#[cfg(test)]
#[must_use]
pub fn new(validator_did: Did, receipt_signer: AvcReceiptSigner) -> Self {
Self::new_with_external_timestamp_source(
validator_did,
receipt_signer,
AvcReceiptExternalTimestampSource::Unconfigured,
)
}
#[cfg(test)]
fn new_with_external_timestamp_source(
validator_did: Did,
receipt_signer: AvcReceiptSigner,
external_timestamp_source: AvcReceiptExternalTimestampSource,
) -> Self {
Self::new_with_external_timestamp_source_and_finality_store(
validator_did,
receipt_signer,
external_timestamp_source,
None,
)
}
#[cfg(test)]
fn new_with_external_timestamp_source_and_finality_store(
validator_did: Did,
receipt_signer: AvcReceiptSigner,
external_timestamp_source: AvcReceiptExternalTimestampSource,
finality_store: Option<Arc<Mutex<crate::store::SqliteDagStore>>>,
) -> Self {
Self {
registry: Arc::new(Mutex::new(InMemoryAvcRegistry::new())),
validator_did,
receipt_signer,
external_timestamp_source,
receipt_clock: Arc::new(Mutex::new(HybridClock::new())),
require_external_timestamp: false,
finality_store,
durability: AvcRegistryDurability::None,
}
}
/// Open the AVC registry with durable runtime-record persistence.
///
/// Credentials, revocations, and receipts are restored from the configured
/// Postgres database when available, otherwise from the node data directory.
/// Public-key trust anchors are intentionally reloaded separately from
/// verified startup configuration.
pub async fn with_durable_registry(
data_dir: &FsPath,
validator_did: Did,
receipt_signer: AvcReceiptSigner,
database_pool: Option<PgPool>,
finality_store: Option<Arc<Mutex<crate::store::SqliteDagStore>>>,
) -> anyhow::Result<Self> {
let durable_state_path = data_dir.join(AVC_REGISTRY_DURABLE_STATE_FILE);
let (registry, durability) = match database_pool {
Some(pool) => {
let registry =
load_postgres_durable_registry_or_import_file(&pool, &durable_state_path)
.await?;
(registry, AvcRegistryDurability::Postgres(pool.clone()))
}
None => {
tracing::warn!(
path = %durable_state_path.display(),
"AVC registry using local file fallback without Postgres-backed durability; set DATABASE_URL for production AVC registry durability"
);
(
load_file_durable_registry(&durable_state_path)?,
AvcRegistryDurability::File(Arc::new(durable_state_path)),
)
}
};
Ok(Self {
registry: Arc::new(Mutex::new(registry)),
validator_did,
receipt_signer,
external_timestamp_source: configured_external_timestamp_source_from_env()?,
receipt_clock: Arc::new(Mutex::new(HybridClock::new())),
require_external_timestamp: configured_require_external_timestamp_from_env()?,
finality_store,
durability,
})
}
pub fn register_validator_public_keys<I>(&self, public_keys: I) -> anyhow::Result<()>
where
I: IntoIterator<Item = (Did, PublicKey)>,
{
let mut registry = self.registry.lock().map_err(|_| {
anyhow::anyhow!("AVC registry unavailable while registering validator public key")
})?;
let mut candidate = registry.clone();
for (did, public_key) in public_keys {
candidate.put_receipt_validator_public_key(did, public_key);
}
candidate.validate_loaded_receipts().map_err(|error| {
anyhow::anyhow!(
"AVC durable receipt validation failed after validator public key registration: {error}"
)
})?;
*registry = candidate;
Ok(())
}
}
fn configured_external_timestamp_source_from_env()
-> anyhow::Result<AvcReceiptExternalTimestampSource> {
configured_external_timestamp_source_from_reader(|name| match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => anyhow::bail!("{name} must be valid UTF-8"),
})
}
fn clean_optional_env_value(
value: Option<String>,
name: &'static str,
) -> anyhow::Result<Option<String>> {
match value {
Some(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
anyhow::bail!("{name} must not be empty");
}
Ok(Some(trimmed.to_owned()))
}
None => Ok(None),
}
}
fn require_optional_env_value(value: Option<String>, name: &'static str) -> anyhow::Result<String> {
clean_optional_env_value(value, name)?
.ok_or_else(|| anyhow::anyhow!("{name} is required for RFC 3161 AVC timestamp authority"))
}
fn parse_non_empty_hex_string(raw: &str, label: &str) -> anyhow::Result<String> {
let bytes = hex::decode(raw)
.map_err(|error| anyhow::anyhow!("invalid {label} hex constant: {error}"))?;
if bytes.is_empty() {
anyhow::bail!("invalid {label} hex constant: expected non-empty DER bytes");
}
Ok(hex::encode(bytes))
}
fn parse_non_empty_hex_string_set(raw: &str, label: &str) -> anyhow::Result<Vec<String>> {
let mut pins = Vec::new();
let mut seen = BTreeSet::new();
for (index, part) in raw.split(',').enumerate() {
let trimmed = part.trim();
if trimmed.is_empty() {
anyhow::bail!(
"invalid {label} hex constant list: member {} is empty",
index + 1
);
}
let pin = parse_non_empty_hex_string(trimmed, label)?;
if !seen.insert(pin.clone()) {
anyhow::bail!(
"invalid {label} hex constant list: duplicate member {}",
index + 1
);
}
pins.push(pin);
}
if pins.is_empty() {
anyhow::bail!("invalid {label} hex constant list: expected at least one member");
}
Ok(pins)
}
fn external_timestamp_http_client() -> anyhow::Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_TIMEOUT)
.build()
.map_err(|error| anyhow::anyhow!("AVC external timestamp HTTP client failed: {error}"))
}
fn configured_external_timestamp_source_from_reader<F>(
read: F,
) -> anyhow::Result<AvcReceiptExternalTimestampSource>
where
F: Fn(&'static str) -> anyhow::Result<Option<String>>,
{
let kind = clean_optional_env_value(
read(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV)?,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
)?;
let endpoint = clean_optional_env_value(
read(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV)?,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
)?;
let authority_did = clean_optional_env_value(
read(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV)?,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
)?;
let authority_public_key = clean_optional_env_value(
read(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV)?,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
)?;
let issuing_ca_spki = clean_optional_env_value(
read(AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV)?,
AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV,
)?;
let policy_oid = clean_optional_env_value(
read(AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV)?,
AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
)?;
if kind.is_none()
&& endpoint.is_none()
&& authority_did.is_none()
&& authority_public_key.is_none()
&& issuing_ca_spki.is_none()
&& policy_oid.is_none()
{
tracing::warn!(
kind_env = AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
url_env = AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
did_env = AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
key_env = AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
ca_env = AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV,
policy_env = AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
require_env = AVC_REQUIRE_EXTERNAL_TIMESTAMP_AUTHORITY_ENV,
"AVC external timestamp authority is not configured; receipt emission will use local EXOCHAIN HLC finality unless external timestamp proof is explicitly required"
);
return Ok(AvcReceiptExternalTimestampSource::Unconfigured);
}
let kind =
kind.unwrap_or_else(|| AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_JSON_ED25519.to_owned());
match kind.as_str() {
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_JSON_ED25519 => {
if endpoint.is_none() || authority_did.is_none() || authority_public_key.is_none() {
anyhow::bail!(
"AVC external timestamp authority configuration is incomplete for {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_JSON_ED25519}; set {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV}, {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV}, and {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV} together"
);
}
if policy_oid.is_some() {
anyhow::bail!(
"{AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV} is only valid with {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV}={AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161}"
);
}
if issuing_ca_spki.is_some() {
anyhow::bail!(
"{AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV} is only valid with {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV}={AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161}"
);
}
let endpoint = endpoint.unwrap_or_default();
let authority_did_raw = authority_did.unwrap_or_default();
let authority_did = Did::new(&authority_did_raw).map_err(|error| {
anyhow::anyhow!(
"{AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV} is not a valid DID: {error}"
)
})?;
let authority_public_key = parse_expected_public_key(
&authority_public_key.unwrap_or_default(),
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
)?;
Ok(AvcReceiptExternalTimestampSource::HttpJson {
endpoint: Arc::new(endpoint),
authority_did,
authority_public_key,
client: external_timestamp_http_client()?,
})
}
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161 => {
let endpoint =
require_optional_env_value(endpoint, AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV)?;
let authority_did_raw = require_optional_env_value(
authority_did,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
)?;
if authority_public_key.is_none() && issuing_ca_spki.is_none() {
anyhow::bail!(
"RFC 3161 AVC timestamp authority requires at least one trust anchor; set either {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV} for signer leaf SPKI pins or {AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV} for issuing CA SPKI pins"
);
}
let authority_public_key_spki_der_hexes = match authority_public_key {
Some(authority_public_key) => parse_non_empty_hex_string_set(
&authority_public_key,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
)?,
None => Vec::new(),
};
let issuing_ca_spki_der_hexes = match issuing_ca_spki {
Some(issuing_ca_spki) => parse_non_empty_hex_string_set(
&issuing_ca_spki,
AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV,
)?,
None => Vec::new(),
};
crate::avc_rfc3161::Rfc3161TrustAnchors::new(
authority_public_key_spki_der_hexes.clone(),
issuing_ca_spki_der_hexes.clone(),
)?;
let policy_oid =
require_optional_env_value(policy_oid, AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV)?;
let authority_did = Did::new(&authority_did_raw).map_err(|error| {
anyhow::anyhow!(
"{AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV} is not a valid DID: {error}"
)
})?;
Ok(AvcReceiptExternalTimestampSource::Rfc3161 {
endpoint: Arc::new(endpoint),
authority_did,
authority_public_key_spki_der_hexes: Arc::new(authority_public_key_spki_der_hexes),
issuing_ca_spki_der_hexes: Arc::new(issuing_ca_spki_der_hexes),
policy_oid: Arc::new(policy_oid),
client: external_timestamp_http_client()?,
})
}
_ => anyhow::bail!(
"{AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV} must be {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_JSON_ED25519} or {AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161}"
),
}
}
fn configured_require_external_timestamp_from_env() -> anyhow::Result<bool> {
let value = match std::env::var(AVC_REQUIRE_EXTERNAL_TIMESTAMP_AUTHORITY_ENV) {
Ok(value) => value,
Err(std::env::VarError::NotPresent) => return Ok(false),
Err(std::env::VarError::NotUnicode(_)) => {
anyhow::bail!("{AVC_REQUIRE_EXTERNAL_TIMESTAMP_AUTHORITY_ENV} must be valid UTF-8");
}
};
if value == "1" || value.eq_ignore_ascii_case("true") {
return Ok(true);
}
if value == "0" || value.eq_ignore_ascii_case("false") {
return Ok(false);
}
anyhow::bail!("{AVC_REQUIRE_EXTERNAL_TIMESTAMP_AUTHORITY_ENV} must be true/false or 1/0")
}
fn load_file_durable_registry(path: &FsPath) -> anyhow::Result<InMemoryAvcRegistry> {
if !path.exists() {
return Ok(InMemoryAvcRegistry::new());
}
let bytes = fs::read(path).map_err(|error| {
anyhow::anyhow!(
"failed to read AVC durable registry at {}: {error}",
path.display()
)
})?;
if bytes.is_empty() {
anyhow::bail!("AVC durable registry at {} is empty", path.display());
}
decode_durable_registry_bytes(&bytes, &format!("{}", path.display()))
}
fn decode_durable_registry_bytes(
bytes: &[u8],
location: &str,
) -> anyhow::Result<InMemoryAvcRegistry> {
let state: AvcRegistryDurableState = ciborium::from_reader(bytes).map_err(|error| {
anyhow::anyhow!("failed to decode AVC durable registry at {location}: {error}")
})?;
InMemoryAvcRegistry::from_durable_state(state).map_err(|error| {
anyhow::anyhow!("failed to validate AVC durable registry at {location}: {error}")
})
}
fn durable_state_has_runtime_records(state: &AvcRegistryDurableState) -> bool {
!(state.credentials.is_empty() && state.revocations.is_empty() && state.receipts.is_empty())
}
fn encode_durable_registry_state(state: &AvcRegistryDurableState) -> anyhow::Result<Vec<u8>> {
let mut bytes = Vec::new();
ciborium::into_writer(state, &mut bytes)
.map_err(|error| anyhow::anyhow!("failed to encode AVC durable registry: {error}"))?;
Ok(bytes)
}
fn persist_file_durable_registry_state(
state: &AvcRegistryDurableState,
path: &FsPath,
) -> anyhow::Result<()> {
let bytes = encode_durable_registry_state(state)?;
let tmp_path = path.with_extension("cbor.tmp");
{
let mut file = File::create(&tmp_path).map_err(|error| {
anyhow::anyhow!(
"failed to create AVC durable registry temp file at {}: {error}",
tmp_path.display()
)
})?;
file.write_all(&bytes).map_err(|error| {
anyhow::anyhow!(
"failed to write AVC durable registry temp file at {}: {error}",
tmp_path.display()
)
})?;
file.sync_all().map_err(|error| {
anyhow::anyhow!(
"failed to sync AVC durable registry temp file at {}: {error}",
tmp_path.display()
)
})?;
}
fs::rename(&tmp_path, path).map_err(|error| {
anyhow::anyhow!(
"failed to install AVC durable registry at {}: {error}",
path.display()
)
})?;
if let Some(parent) = path.parent() {
let dir = File::open(parent).map_err(|error| {
anyhow::anyhow!(
"failed to open AVC durable registry directory {}: {error}",
parent.display()
)
})?;
dir.sync_all().map_err(|error| {
anyhow::anyhow!(
"failed to sync AVC durable registry directory {}: {error}",
parent.display()
)
})?;
}
Ok(())
}
async fn begin_postgres_registry_transaction(
pool: &PgPool,
) -> anyhow::Result<Transaction<'_, Postgres>> {
let mut transaction = pool.begin().await.map_err(|error| {
anyhow::anyhow!("failed to begin AVC Postgres registry transaction: {error}")
})?;
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(AVC_REGISTRY_POSTGRES_LOCK_KEY)
.execute(&mut *transaction)
.await
.map_err(|error| anyhow::anyhow!("failed to lock AVC Postgres registry state: {error}"))?;
Ok(transaction)
}
async fn load_postgres_durable_registry_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
) -> anyhow::Result<Option<InMemoryAvcRegistry>> {
let statement = format!(
"SELECT state_cbor FROM {AVC_REGISTRY_POSTGRES_TABLE} WHERE registry_key = $1 FOR UPDATE"
);
let Some(row) = sqlx::query(&statement)
.bind(AVC_REGISTRY_POSTGRES_KEY)
.fetch_optional(&mut **transaction)
.await
.map_err(|error| anyhow::anyhow!("failed to load AVC Postgres registry state: {error}"))?
else {
return Ok(None);
};
let bytes: Vec<u8> = row.try_get("state_cbor").map_err(|error| {
anyhow::anyhow!("failed to read AVC Postgres registry state bytes: {error}")
})?;
if bytes.is_empty() {
anyhow::bail!("AVC Postgres registry state is empty");
}
decode_durable_registry_bytes(&bytes, AVC_REGISTRY_POSTGRES_TABLE).map(Some)
}
async fn persist_postgres_durable_registry_state_in_transaction(
state: &AvcRegistryDurableState,
transaction: &mut Transaction<'_, Postgres>,
) -> anyhow::Result<()> {
let bytes = encode_durable_registry_state(state)?;
let statement = format!(
"INSERT INTO {AVC_REGISTRY_POSTGRES_TABLE} (registry_key, state_cbor)
VALUES ($1, $2)
ON CONFLICT (registry_key)
DO UPDATE SET state_cbor = EXCLUDED.state_cbor"
);
sqlx::query(&statement)
.bind(AVC_REGISTRY_POSTGRES_KEY)
.bind(bytes)
.execute(&mut **transaction)
.await
.map_err(|error| {
anyhow::anyhow!("failed to persist AVC Postgres registry state: {error}")
})?;
Ok(())
}
async fn persist_postgres_durable_registry_state(
state: &AvcRegistryDurableState,
pool: &PgPool,
) -> anyhow::Result<()> {
let mut transaction = begin_postgres_registry_transaction(pool).await?;
persist_postgres_durable_registry_state_in_transaction(state, &mut transaction).await?;
transaction.commit().await.map_err(|error| {
anyhow::anyhow!("failed to commit AVC Postgres registry state: {error}")
})?;
Ok(())
}
async fn load_postgres_durable_registry_or_import_file(
pool: &PgPool,
file_path: &FsPath,
) -> anyhow::Result<InMemoryAvcRegistry> {
let mut transaction = begin_postgres_registry_transaction(pool).await?;
if let Some(registry) = load_postgres_durable_registry_in_transaction(&mut transaction).await? {
transaction.commit().await.map_err(|error| {
anyhow::anyhow!("failed to commit AVC Postgres registry load: {error}")
})?;
return Ok(registry);
}
let registry = load_file_durable_registry(file_path)?;
let state = registry.durable_state();
if durable_state_has_runtime_records(&state) {
persist_postgres_durable_registry_state_in_transaction(&state, &mut transaction).await?;
tracing::info!(
path = %file_path.display(),
table = AVC_REGISTRY_POSTGRES_TABLE,
"Imported existing AVC file registry state into Postgres"
);
}
transaction.commit().await.map_err(|error| {
anyhow::anyhow!("failed to commit AVC Postgres registry import: {error}")
})?;
Ok(registry)
}
fn persist_durable_registry(
registry: &InMemoryAvcRegistry,
durability: &AvcRegistryDurability,
) -> anyhow::Result<()> {
let state = registry.durable_state();
match durability {
#[cfg(test)]
AvcRegistryDurability::None => Ok(()),
AvcRegistryDurability::File(path) => persist_file_durable_registry_state(&state, path),
AvcRegistryDurability::Postgres(pool) => tokio::runtime::Handle::current()
.block_on(persist_postgres_durable_registry_state(&state, pool)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RootTrustIssuerRegistration {
pub bundle_id: Hash256,
pub ceremony_id: String,
pub issuer_did: Did,
pub issuer_public_key: PublicKey,
pub granted_permissions: Vec<Permission>,
}
fn parse_expected_hash(hex_value: &str, label: &str) -> anyhow::Result<Hash256> {
let bytes = hex::decode(hex_value)
.map_err(|error| anyhow::anyhow!("invalid {label} hex constant: {error}"))?;
if bytes.len() != 32 {
anyhow::bail!(
"invalid {label} hex constant: expected 32 bytes, got {}",
bytes.len()
);
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&bytes);
Ok(Hash256::from_bytes(buf))
}
fn parse_expected_public_key(hex_value: &str, label: &str) -> anyhow::Result<PublicKey> {
let bytes = hex::decode(hex_value)
.map_err(|error| anyhow::anyhow!("invalid {label} hex constant: {error}"))?;
if bytes.len() != 32 {
anyhow::bail!(
"invalid {label} hex constant: expected 32 bytes, got {}",
bytes.len()
);
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&bytes);
Ok(PublicKey::from_bytes(buf))
}
#[derive(Serialize)]
struct LegacyRootArtifactPayload<'a> {
domain: &'static str,
config_hash: Hash256,
public_key_package_hash: Hash256,
transcript_hash: Hash256,
issuer_delegation_hash: Hash256,
issuer_did: &'a Did,
}
#[derive(Serialize)]
struct LegacyRootBundleIdPayload<'a> {
domain: &'static str,
artifact_payload_hash: Hash256,
root_signature: &'a RootSignature,
}
fn avc_root_canonical_bytes<T: Serialize>(value: &T) -> anyhow::Result<Vec<u8>> {
let mut bytes = Vec::new();
ciborium::into_writer(value, &mut bytes)
.map_err(|error| anyhow::anyhow!("AVC root trust canonical encoding failed: {error}"))?;
Ok(bytes)
}
fn avc_root_structured_hash<T: Serialize>(value: &T) -> anyhow::Result<Hash256> {
hash_structured(value)
.map_err(|error| anyhow::anyhow!("AVC root trust structured hash failed: {error}"))
}
fn legacy_avc_root_artifact_payload(bundle: &RootTrustBundle) -> anyhow::Result<Vec<u8>> {
let payload = LegacyRootArtifactPayload {
domain: "EXOCHAIN_ROOT_ARTIFACT_V1",
config_hash: avc_root_structured_hash(&bundle.config)?,
public_key_package_hash: avc_root_structured_hash(&bundle.public_key_package)?,
transcript_hash: bundle.transcript_hash,
issuer_delegation_hash: avc_root_structured_hash(&bundle.issuer_delegation)?,
issuer_did: &bundle.issuer_delegation.issuer_did,
};
avc_root_canonical_bytes(&payload)
}
fn legacy_avc_root_bundle_id(bundle: &RootTrustBundle) -> anyhow::Result<Hash256> {
let artifact_payload = legacy_avc_root_artifact_payload(bundle)?;
let payload = LegacyRootBundleIdPayload {
domain: "EXOCHAIN_ROOT_BUNDLE_V1",
artifact_payload_hash: Hash256::digest(&artifact_payload),
root_signature: &bundle.root_signature,
};
avc_root_structured_hash(&payload)
}
fn verify_pinned_legacy_avc_root_bundle(
bundle: &RootTrustBundle,
expected_bundle_id: Hash256,
) -> anyhow::Result<()> {
if bundle.bundle_id != expected_bundle_id {
anyhow::bail!(
"legacy AVC root trust bundle id mismatch: expected {}, got {}",
expected_bundle_id,
bundle.bundle_id
);
}
bundle
.config
.validate()
.map_err(|error| anyhow::anyhow!("legacy AVC root trust config invalid: {error}"))?;
if bundle.root_signature.signer_ids != bundle.config.signing_set {
anyhow::bail!(
"legacy AVC root trust signer metadata mismatch: root_signature.signer_ids must equal config.signing_set"
);
}
let recomputed_bundle_id = legacy_avc_root_bundle_id(bundle)?;
if recomputed_bundle_id != expected_bundle_id {
anyhow::bail!(
"legacy AVC root trust recomputed bundle id mismatch: expected {}, got {}",
expected_bundle_id,
recomputed_bundle_id
);
}
let payload = legacy_avc_root_artifact_payload(bundle)?;
verify_root_signature(
&bundle.public_key_package.root_public_key,
&payload,
bundle.root_signature.signature.as_slice(),
)
.map_err(|error| anyhow::anyhow!("legacy AVC root trust signature rejected: {error}"))
}
fn verify_current_or_pinned_legacy_avc_root_bundle(
bundle: &RootTrustBundle,
expected_bundle_id: Hash256,
) -> anyhow::Result<()> {
match verify_root_bundle(bundle) {
Ok(()) => Ok(()),
Err(strict_error) => {
verify_pinned_legacy_avc_root_bundle(bundle, expected_bundle_id).map_err(
|legacy_error| {
anyhow::anyhow!(
"strict root trust verification failed: {strict_error}; pinned legacy AVC root trust verification failed: {legacy_error}"
)
},
)
}
}
}
#[derive(Serialize)]
struct RootBundleReceiptHashMaterial<'a> {
schema_version: &'static str,
bundle_id: Hash256,
root_bundle_hash: Hash256,
ceremony_id: &'a str,
issuer_did: &'a Did,
issuer_public_key_hash: Hash256,
signing_set_hash: Hash256,
quorum_threshold: u16,
verifier_version: &'static str,
verified_at: Timestamp,
}
struct RootBundleReceiptRecord {
root_bundle_hash: Hash256,
issuer_public_key_hash: Hash256,
signing_set_hash: Hash256,
verification_receipt_hash: Hash256,
verification_receipt_body: serde_json::Value,
verified_at: Timestamp,
created_at: Timestamp,
}
fn root_bundle_receipt_record(bundle: &RootTrustBundle) -> anyhow::Result<RootBundleReceiptRecord> {
let root_bundle_hash = hash_structured(bundle)
.map_err(|error| anyhow::anyhow!("AVC root bundle receipt hash failed: {error}"))?;
let issuer_public_key_hash =
Hash256::digest(bundle.issuer_delegation.issuer_public_key.as_bytes());
let signing_set_hash = hash_structured(&bundle.config.signing_set)
.map_err(|error| anyhow::anyhow!("AVC root bundle signing set hash failed: {error}"))?;
let verified_at = bundle.issuer_delegation.effective_at;
let material = RootBundleReceiptHashMaterial {
schema_version: AVC_ROOT_BUNDLE_RECEIPT_SCHEMA_VERSION,
bundle_id: bundle.bundle_id,
root_bundle_hash,
ceremony_id: &bundle.config.ceremony_id,
issuer_did: &bundle.issuer_delegation.issuer_did,
issuer_public_key_hash,
signing_set_hash,
quorum_threshold: bundle.config.threshold,
verifier_version: AVC_ROOT_BUNDLE_RECEIPT_VERIFIER_VERSION,
verified_at,
};
let verification_receipt_hash = hash_structured(&material).map_err(|error| {
anyhow::anyhow!("AVC root bundle verification receipt hash failed: {error}")
})?;
let verification_receipt_body = serde_json::json!({
"schema_version": AVC_ROOT_BUNDLE_RECEIPT_SCHEMA_VERSION,
"bundle_id": bundle.bundle_id.to_string(),
"root_bundle_hash": root_bundle_hash.to_string(),
"ceremony_id": bundle.config.ceremony_id,
"issuer_did": bundle.issuer_delegation.issuer_did.to_string(),
"issuer_public_key_hash": issuer_public_key_hash.to_string(),
"signing_set_hash": signing_set_hash.to_string(),
"quorum_threshold": bundle.config.threshold,
"verifier_version": AVC_ROOT_BUNDLE_RECEIPT_VERIFIER_VERSION,
"verified_at": {
"physical_ms": verified_at.physical_ms,
"logical": verified_at.logical
},
});
Ok(RootBundleReceiptRecord {
root_bundle_hash,
issuer_public_key_hash,
signing_set_hash,
verification_receipt_hash,
verification_receipt_body,
verified_at,
created_at: verified_at,
})
}
async fn insert_root_bundle_receipt(
pool: &PgPool,
bundle: &RootTrustBundle,
record: RootBundleReceiptRecord,
) -> anyhow::Result<()> {
let verified_at_logical = i32::try_from(record.verified_at.logical).map_err(|_| {
anyhow::anyhow!("AVC root bundle verified_at logical counter is out of SQL range")
})?;
let created_at_logical = i32::try_from(record.created_at.logical).map_err(|_| {
anyhow::anyhow!("AVC root bundle created_at logical counter is out of SQL range")
})?;
sqlx::query(
"INSERT INTO dagdb_root_bundle_receipts \
(bundle_id, root_bundle_hash, ceremony_id, issuer_did, issuer_public_key_hash, \
signing_set_hash, quorum_threshold, verifier_version, verification_receipt_hash, \
verification_receipt_body, verified_at_physical_ms, verified_at_logical, \
created_at_physical_ms, created_at_logical, immutable) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, true) \
ON CONFLICT (bundle_id) DO NOTHING",
)
.bind(bundle.bundle_id.as_bytes().to_vec())
.bind(record.root_bundle_hash.as_bytes().to_vec())
.bind(&bundle.config.ceremony_id)
.bind(bundle.issuer_delegation.issuer_did.to_string())
.bind(record.issuer_public_key_hash.as_bytes().to_vec())
.bind(record.signing_set_hash.as_bytes().to_vec())
.bind(i32::from(bundle.config.threshold))
.bind(AVC_ROOT_BUNDLE_RECEIPT_VERIFIER_VERSION)
.bind(record.verification_receipt_hash.as_bytes().to_vec())
.bind(record.verification_receipt_body)
.bind(i64::try_from(record.verified_at.physical_ms).map_err(|_| {
anyhow::anyhow!("AVC root bundle verified_at physical_ms is out of SQL range")
})?)
.bind(verified_at_logical)
.bind(i64::try_from(record.created_at.physical_ms).map_err(|_| {
anyhow::anyhow!("AVC root bundle created_at physical_ms is out of SQL range")
})?)
.bind(created_at_logical)
.execute(pool)
.await
.map_err(|error| {
anyhow::anyhow!("failed to persist AVC root bundle DAG DB receipt: {error}")
})?;
Ok(())
}
fn persist_verified_root_bundle_receipt(
state: &AvcApiState,
bundle: &RootTrustBundle,
) -> anyhow::Result<()> {
let AvcRegistryDurability::Postgres(pool) = &state.durability else {
return Ok(());
};
let record = root_bundle_receipt_record(bundle)?;
let pool = pool.clone();
let bundle = bundle.clone();
match tokio::runtime::Handle::try_current() {
Ok(_) => std::thread::spawn(move || {
let runtime = tokio::runtime::Runtime::new().map_err(|error| {
anyhow::anyhow!("failed to create AVC root bundle receipt runtime: {error}")
})?;
runtime.block_on(insert_root_bundle_receipt(&pool, &bundle, record))
})
.join()
.map_err(|_| anyhow::anyhow!("AVC root bundle receipt worker panicked"))?,
Err(_) => {
let runtime = tokio::runtime::Runtime::new().map_err(|error| {
anyhow::anyhow!("failed to create AVC root bundle receipt runtime: {error}")
})?;
runtime.block_on(insert_root_bundle_receipt(&pool, &bundle, record))
}
}
}
/// Load the configured AVC root trust bundle, verify it in-process, and
/// register the delegated operational issuer public key.
///
/// If `EXO_AVC_ROOT_TRUST_BUNDLE` is absent, no registration is performed.
/// If it is present, any read, parse, verification, or expected-identity
/// mismatch error is fatal to preserve fail-closed production startup.
pub fn load_configured_root_trust_bundle(
state: &AvcApiState,
) -> anyhow::Result<Option<RootTrustIssuerRegistration>> {
let Some(path) = std::env::var_os(AVC_ROOT_TRUST_BUNDLE_ENV) else {
let registry = state.registry.lock().map_err(|_| {
anyhow::anyhow!("AVC registry unavailable while checking durable revocations")
})?;
if registry.revocation_count() > 0 {
anyhow::bail!(
"AVC durable registry contains revocations but {AVC_ROOT_TRUST_BUNDLE_ENV} is not configured; durable revocation signatures cannot be verified"
);
}
return Ok(None);
};
if path.is_empty() {
anyhow::bail!("{AVC_ROOT_TRUST_BUNDLE_ENV} is set but empty");
}
load_root_trust_bundle_from_path(state, FsPath::new(&path)).map(Some)
}
pub fn load_root_trust_bundle_from_path(
state: &AvcApiState,
path: &FsPath,
) -> anyhow::Result<RootTrustIssuerRegistration> {
let bytes = std::fs::read(path).map_err(|error| {
anyhow::anyhow!(
"failed to read AVC root trust bundle at {}: {error}",
path.display()
)
})?;
let bundle: RootTrustBundle = serde_json::from_slice(&bytes).map_err(|error| {
anyhow::anyhow!(
"failed to parse AVC root trust bundle at {}: {error}",
path.display()
)
})?;
let expected_bundle_id =
parse_expected_hash(AVC_ROOT_TRUST_BUNDLE_ID_HEX, "AVC root trust bundle id")?;
verify_current_or_pinned_legacy_avc_root_bundle(&bundle, expected_bundle_id).map_err(
|error| {
anyhow::anyhow!(
"AVC root trust bundle verification failed for {}: {error}",
path.display()
)
},
)?;
if bundle.config.ceremony_id != AVC_ROOT_TRUST_CEREMONY_ID {
anyhow::bail!(
"AVC root trust bundle ceremony mismatch: expected {}, got {}",
AVC_ROOT_TRUST_CEREMONY_ID,
bundle.config.ceremony_id
);
}
if bundle.bundle_id != expected_bundle_id {
anyhow::bail!(
"AVC root trust bundle id mismatch: expected {}, got {}",
expected_bundle_id,
bundle.bundle_id
);
}
let expected_issuer_did = Did::new(AVC_ROOT_TRUST_ISSUER_DID)
.map_err(|error| anyhow::anyhow!("invalid AVC root trust issuer DID constant: {error}"))?;
if bundle.issuer_delegation.issuer_did != expected_issuer_did {
anyhow::bail!(
"AVC root trust issuer DID mismatch: expected {}, got {}",
expected_issuer_did,
bundle.issuer_delegation.issuer_did
);
}
let expected_public_key = parse_expected_public_key(
AVC_ROOT_TRUST_ISSUER_PUBLIC_KEY_HEX,
"AVC root trust issuer public key",
)?;
if bundle.issuer_delegation.issuer_public_key != expected_public_key {
anyhow::bail!(
"AVC root trust issuer public key mismatch for {}",
expected_issuer_did
);
}
let registration = RootTrustIssuerRegistration {
bundle_id: bundle.bundle_id,
ceremony_id: bundle.config.ceremony_id.clone(),
issuer_did: bundle.issuer_delegation.issuer_did.clone(),
issuer_public_key: bundle.issuer_delegation.issuer_public_key,
granted_permissions: bundle.issuer_delegation.granted_permissions.clone(),
};
let mut registry = state.registry.lock().map_err(|_| {
anyhow::anyhow!("AVC registry unavailable while registering root trust issuer")
})?;
let mut candidate = registry.clone();
candidate.put_public_key(
registration.issuer_did.clone(),
registration.issuer_public_key,
);
candidate.put_issuer_permission_grant(
registration.issuer_did.clone(),
registration.granted_permissions.clone(),
);
candidate.validate_loaded_revocations().map_err(|error| {
anyhow::anyhow!(
"AVC durable revocation validation failed after root trust issuer registration: {error}"
)
})?;
persist_verified_root_bundle_receipt(state, &bundle)?;
*registry = candidate;
Ok(registration)
}
// ---------------------------------------------------------------------------
// Request / Response shapes
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize)]
pub struct IssueRequest {
pub credential: AutonomousVolitionCredential,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IssueResponse {
pub credential_id: String,
pub status: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DelegateRequest {
pub child_credential: AutonomousVolitionCredential,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DelegateResponse {
pub credential_id: String,
pub parent_avc_id: Option<String>,
pub status: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RevokeRequest {
pub revocation: AvcRevocation,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RevokeResponse {
pub credential_id: String,
pub status: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EmitReceiptRequest {
pub validation: AvcValidationRequest,
pub subject_signature: Signature,
/// Optional subject public key for did:exo values derived from a key.
/// If the registry already has a trusted key for the actor DID, that
/// registered key wins and this field is ignored.
pub subject_public_key: Option<PublicKey>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EmitReceiptResponse {
pub receipt_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exochain_finality_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exochain_finality_height: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exochain_finality_receipt_hash: Option<String>,
pub receipt: AvcTrustReceipt,
pub validation: AvcValidationResult,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ListAvcReceiptsResponse {
pub did: String,
pub receipts: Vec<AvcTrustReceipt>,
}
#[derive(Debug, Deserialize)]
struct ListAvcReceiptsQuery {
actor: Option<String>,
limit: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct AvcProtocolQuery {
protocol_version: Option<u16>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AvcProtocolInfo {
pub protocol_version: u16,
pub min_supported_protocol_version: u16,
pub max_supported_protocol_version: u16,
pub schema_version: u16,
pub wasm_package_name: String,
pub wasm_package_version: String,
pub deprecation_window_days: u16,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AvcSummary {
pub credential_id: String,
pub subject_did: String,
pub issuer_did: String,
pub principal_did: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ListAvcResponse {
pub did: String,
pub credentials: Vec<AvcSummary>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
type ApiError = (StatusCode, String);
type ApiResult<T> = Result<T, ApiError>;
fn parse_did(raw: &str) -> ApiResult<Did> {
Did::new(raw).map_err(|err| {
tracing::warn!(%err, "rejected malformed AVC DID");
(StatusCode::BAD_REQUEST, "Invalid DID".into())
})
}
fn parse_hash(raw: &str) -> ApiResult<Hash256> {
if !raw
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
{
return Err((
StatusCode::BAD_REQUEST,
"credential id must be lowercase hex".into(),
));
}
let bytes = hex::decode(raw).map_err(|_| {
(
StatusCode::BAD_REQUEST,
"credential id must be lowercase hex".into(),
)
})?;
if bytes.len() != 32 {
return Err((
StatusCode::BAD_REQUEST,
"credential id must be 32 bytes (64 hex chars)".into(),
));
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&bytes);
Ok(Hash256::from_bytes(buf))
}
fn persistence_error(err: anyhow::Error) -> ApiError {
tracing::error!(err = %err, "AVC registry persistence failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"AVC registry persistence failed".into(),
)
}
fn external_timestamp_error(err: anyhow::Error) -> ApiError {
let error_class = external_timestamp_error_class(&err);
tracing::error!(
err = %err,
error_class = error_class,
"AVC external timestamp proof could not be obtained"
);
// Surface the stable operator class in the PUBLIC message too. Callers (the
// emit smoke, operators) often see only the response, not the node logs, and
// a blanket "authority unavailable" misreads a signer-SPKI pin rejection
// (class=invalid_proof) or a TSA-returned error status (class=rejected) as
// upstream downtime (class=unreachable) — exactly the misdiagnosis we hit in
// production. The class tokens are already the sanctioned operator-facing
// classification, so this leaks no response detail.
(
StatusCode::SERVICE_UNAVAILABLE,
format!("AVC external timestamp proof could not be obtained (class: {error_class})"),
)
}
fn exochain_finality_error(err: anyhow::Error) -> ApiError {
tracing::error!(err = %err, "AVC EXOCHAIN finality persistence failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"AVC EXOCHAIN finality persistence failed".into(),
)
}
fn local_hlc_timestamp_error(err: anyhow::Error) -> ApiError {
tracing::error!(err = %err, "AVC local HLC timestamp failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"AVC local HLC timestamp failed".into(),
)
}
struct AvcReceiptTimestampEvidence {
trusted_now: Timestamp,
provenance: AvcReceiptTimestampProvenance,
external_timestamp_proof: Option<AvcReceiptExternalTimestampProof>,
}
fn trusted_local_hlc_timestamp(state: &AvcApiState) -> anyhow::Result<Timestamp> {
let mut clock = state
.receipt_clock
.lock()
.map_err(|_| anyhow::anyhow!("AVC receipt HLC mutex poisoned"))?;
clock
.now()
.map_err(|error| anyhow::anyhow!("AVC receipt HLC could not advance: {error}"))
}
async fn trusted_external_timestamp_proof(
state: &AvcApiState,
evidence_subject: &AvcReceiptEvidenceSubject,
) -> ApiResult<AvcReceiptExternalTimestampProof> {
state
.external_timestamp_source
.issue_proof(evidence_subject)
.await
.map_err(external_timestamp_error)
}
async fn trusted_receipt_timestamp_evidence(
state: &AvcApiState,
evidence_subject: &AvcReceiptEvidenceSubject,
) -> ApiResult<AvcReceiptTimestampEvidence> {
if matches!(
state.external_timestamp_source,
AvcReceiptExternalTimestampSource::Unconfigured
) && !state.require_external_timestamp
{
let trusted_now = trusted_local_hlc_timestamp(state).map_err(local_hlc_timestamp_error)?;
return Ok(AvcReceiptTimestampEvidence {
trusted_now,
provenance: AvcReceiptTimestampProvenance::LocalHybridLogicalClock,
external_timestamp_proof: None,
});
}
let external_timestamp_proof =
trusted_external_timestamp_proof(state, evidence_subject).await?;
Ok(AvcReceiptTimestampEvidence {
trusted_now: external_timestamp_proof.issued_at,
provenance: AvcReceiptTimestampProvenance::ExternalTimestampAuthority,
external_timestamp_proof: Some(external_timestamp_proof),
})
}
fn avc_receipt_list_limit(limit: Option<u32>) -> usize {
let capped = limit
.unwrap_or(DEFAULT_AVC_RECEIPT_LIST_LIMIT)
.min(MAX_AVC_RECEIPT_LIST_LIMIT);
match usize::try_from(capped) {
Ok(value) => value,
Err(_) => usize::from(u16::MAX),
}
}
fn emit_receipt_response(
receipt: AvcTrustReceipt,
validation: AvcValidationResult,
) -> EmitReceiptResponse {
EmitReceiptResponse {
receipt_hash: format!("{}", receipt.receipt_id),
exochain_finality_hash: None,
exochain_finality_height: None,
exochain_finality_receipt_hash: None,
receipt,
validation,
}
}
fn attach_exochain_finality(
response: &mut EmitReceiptResponse,
commitment: Option<AvcExochainFinalityCommitment>,
) {
if let Some(commitment) = commitment {
response.exochain_finality_hash = Some(format!("{}", commitment.finality_hash));
response.exochain_finality_height = Some(commitment.finality_height);
response.exochain_finality_receipt_hash =
Some(format!("{}", commitment.finality_receipt_hash));
}
}
fn avc_protocol_info() -> AvcProtocolInfo {
AvcProtocolInfo {
protocol_version: AVC_PROTOCOL_VERSION,
min_supported_protocol_version: AVC_MIN_SUPPORTED_PROTOCOL_VERSION,
max_supported_protocol_version: AVC_MAX_SUPPORTED_PROTOCOL_VERSION,
schema_version: AVC_SCHEMA_VERSION,
wasm_package_name: WASM_PACKAGE_NAME.into(),
wasm_package_version: WASM_PACKAGE_VERSION.into(),
deprecation_window_days: AVC_PROTOCOL_DEPRECATION_WINDOW_DAYS,
}
}
fn mutate_postgres_registry_blocking<T, F>(
pool: &PgPool,
guard: &mut InMemoryAvcRegistry,
op: F,
) -> ApiResult<T>
where
F: FnOnce(&mut InMemoryAvcRegistry) -> ApiResult<T>,
{
let handle = tokio::runtime::Handle::current();
let mut transaction = handle
.block_on(begin_postgres_registry_transaction(pool))
.map_err(persistence_error)?;
if let Some(fresh_registry) = handle
.block_on(load_postgres_durable_registry_in_transaction(
&mut transaction,
))
.map_err(persistence_error)?
{
guard
.apply_durable_state(fresh_registry.durable_state())
.map_err(|error| {
persistence_error(anyhow::anyhow!(
"failed to apply AVC Postgres registry state: {error}"
))
})?;
}
let rollback = guard.clone();
let result = match op(guard) {
Ok(result) => result,
Err(err) => {
*guard = rollback;
if let Err(rollback_error) = handle.block_on(transaction.rollback()) {
tracing::error!(
err = %rollback_error,
"failed to roll back AVC Postgres registry transaction after rejected operation"
);
}
return Err(err);
}
};
let state = guard.durable_state();
handle
.block_on(async {
persist_postgres_durable_registry_state_in_transaction(&state, &mut transaction)
.await?;
transaction.commit().await.map_err(|error| {
anyhow::anyhow!("failed to commit AVC Postgres registry state: {error}")
})?;
Ok::<(), anyhow::Error>(())
})
.map_err(|err| {
*guard = rollback;
persistence_error(err)
})?;
Ok(result)
}
async fn with_registry_blocking<T, F>(
state: Arc<AvcApiState>,
persist_after: bool,
op: F,
) -> ApiResult<T>
where
T: Send + 'static,
F: FnOnce(&mut InMemoryAvcRegistry) -> ApiResult<T> + Send + 'static,
{
tokio::task::spawn_blocking(move || {
let mut guard = state.registry.lock().map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"AVC registry unavailable".into(),
)
})?;
if persist_after {
if let AvcRegistryDurability::Postgres(pool) = &state.durability {
return mutate_postgres_registry_blocking(pool, &mut guard, op);
}
}
let rollback = guard.clone();
let result = match op(&mut guard) {
Ok(result) => result,
Err(err) => {
*guard = rollback;
return Err(err);
}
};
if persist_after {
persist_durable_registry(&guard, &state.durability).map_err(|err| {
*guard = rollback;
persistence_error(err)
})?;
}
Ok(result)
})
.await
.map_err(|err| {
tracing::error!(err = %err, "AVC registry task failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"AVC registry task failed".into(),
)
})?
}
fn map_avc_error(err: exo_avc::AvcError) -> ApiError {
tracing::warn!(?err, "AVC operation rejected");
match err {
exo_avc::AvcError::EmptyField { .. }
| exo_avc::AvcError::UnsupportedSchema { .. }
| exo_avc::AvcError::UnsupportedProtocol { .. }
| exo_avc::AvcError::BasisPointOutOfRange { .. }
| exo_avc::AvcError::InvalidTimestamp { .. }
| exo_avc::AvcError::DelegationWidens { .. }
| exo_avc::AvcError::DelegationRejected { .. }
| exo_avc::AvcError::InvalidInput { .. } => (StatusCode::BAD_REQUEST, err.to_string()),
exo_avc::AvcError::Registry { .. } | exo_avc::AvcError::Serialization { .. } => {
(StatusCode::INTERNAL_SERVER_ERROR, "AVC error".into())
}
}
}
fn summary_of(credential: &AutonomousVolitionCredential) -> ApiResult<AvcSummary> {
let id = credential.id().map_err(map_avc_error)?;
Ok(AvcSummary {
credential_id: format!("{id}"),
subject_did: credential.subject_did.to_string(),
issuer_did: credential.issuer_did.to_string(),
principal_did: credential.principal_did.to_string(),
})
}
fn require_action(request: &AvcValidationRequest) -> ApiResult<&AvcActionRequest> {
request.action.as_ref().ok_or((
StatusCode::BAD_REQUEST,
"receipt emission requires an action".into(),
))
}
fn resolve_subject_public_key(
registry: &InMemoryAvcRegistry,
action: &AvcActionRequest,
supplied_public_key: Option<PublicKey>,
) -> ApiResult<PublicKey> {
if let Some(public_key) = registry.resolve_public_key(&action.actor_did) {
return Ok(public_key);
}
let Some(public_key) = supplied_public_key else {
return Err((
StatusCode::UNAUTHORIZED,
"subject public key is unresolved".into(),
));
};
let derived_did = crate::identity::did_from_public_key(&public_key).map_err(|err| {
tracing::warn!(%err, "rejected AVC receipt action public key");
(
StatusCode::UNAUTHORIZED,
"subject public key is not a valid did:exo actor key".into(),
)
})?;
if derived_did != action.actor_did {
return Err((
StatusCode::UNAUTHORIZED,
"subject public key does not match action actor DID".into(),
));
}
Ok(public_key)
}
fn verify_subject_action_signature(
registry: &InMemoryAvcRegistry,
request: &AvcValidationRequest,
subject_signature: &Signature,
subject_public_key: Option<PublicKey>,
) -> ApiResult<()> {
if subject_signature.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"subject action signature must not be empty".into(),
));
}
let action = require_action(request)?;
let public_key = resolve_subject_public_key(registry, action, subject_public_key)?;
let payload = avc_action_signature_payload(&request.credential, action, &request.now)
.map_err(map_avc_error)?;
if !crypto::verify(&payload, subject_signature, &public_key) {
return Err((
StatusCode::UNAUTHORIZED,
"subject action signature is invalid".into(),
));
}
Ok(())
}
fn require_registered_credential(
registry: &InMemoryAvcRegistry,
request: &AvcValidationRequest,
) -> ApiResult<Hash256> {
let credential_id = request.credential.id().map_err(map_avc_error)?;
let registered = registry
.get_credential(&credential_id)
.ok_or((StatusCode::NOT_FOUND, "credential is not registered".into()))?;
if registered != request.credential {
return Err((
StatusCode::BAD_REQUEST,
"credential does not match registered AVC".into(),
));
}
Ok(credential_id)
}
fn store_receipt_idempotent(
registry: &mut InMemoryAvcRegistry,
receipt: AvcTrustReceipt,
) -> ApiResult<()> {
let receipt_id = receipt.receipt_id;
match registry.put_receipt(receipt.clone()) {
Ok(()) => Ok(()),
Err(exo_avc::AvcError::Registry { .. })
if registry.get_receipt(&receipt_id).as_ref() == Some(&receipt) =>
{
Ok(())
}
Err(err) => Err(map_avc_error(err)),
}
}
fn validate_idempotent_receipt_hit(
receipt: &AvcTrustReceipt,
credential_id: Hash256,
action_id: Hash256,
action_commitment_hash: Hash256,
validation: &AvcValidationResult,
) -> ApiResult<()> {
let validation_hash = hash_structured(validation)
.map_err(exo_avc::AvcError::from)
.map_err(map_avc_error)?;
if receipt.credential_id == credential_id
&& receipt.action_id == Some(action_id)
&& receipt.action_commitment_hash == Some(action_commitment_hash)
&& receipt.validation_hash == validation_hash
&& receipt.decision == validation.decision
&& receipt.reason_codes == validation.reason_codes
{
return Ok(());
}
tracing::error!(
receipt_id = %receipt.receipt_id,
stored_credential_id = %receipt.credential_id,
expected_credential_id = %credential_id,
stored_action_id = ?receipt.action_id,
expected_action_id = %action_id,
action_commitment_hash = %action_commitment_hash,
"AVC receipt action commitment conflict"
);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"AVC receipt action commitment conflict".into(),
))
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn handle_issue(
State(state): State<Arc<AvcApiState>>,
Json(payload): Json<IssueRequest>,
) -> ApiResult<Json<IssueResponse>> {
let credential = payload.credential;
let id = with_registry_blocking(state, true, move |registry| {
registry.put_credential(credential).map_err(map_avc_error)
})
.await?;
Ok(Json(IssueResponse {
credential_id: format!("{id}"),
status: "registered".into(),
}))
}
async fn handle_validate(
State(state): State<Arc<AvcApiState>>,
Json(request): Json<AvcValidationRequest>,
) -> ApiResult<Json<AvcValidationResult>> {
let result = with_registry_blocking(state, false, move |registry| {
validate_avc(&request, registry).map_err(map_avc_error)
})
.await?;
Ok(Json(result))
}
async fn handle_emit_receipt(
State(state): State<Arc<AvcApiState>>,
Json(payload): Json<EmitReceiptRequest>,
) -> ApiResult<Json<EmitReceiptResponse>> {
let validator_did = state.validator_did.clone();
let receipt_signer = Arc::clone(&state.receipt_signer);
let submitted_request = payload.validation.clone();
let action = require_action(&submitted_request)?;
let action_id = action.action_id;
let action_commitment_hash = avc_action_commitment_hash(
&submitted_request.credential,
action,
&submitted_request.now,
)
.map_err(map_avc_error)?;
let action_descriptor = AvcActionDescriptor::from_action(action);
let action_descriptor_hash =
avc_action_descriptor_hash(&action_descriptor).map_err(map_avc_error)?;
let subject_signature = payload.subject_signature.clone();
let subject_public_key = payload.subject_public_key;
let preflight_request = submitted_request.clone();
let preflight_signature = subject_signature.clone();
let preflight = with_registry_blocking(Arc::clone(&state), false, move |registry| {
let credential_id = require_registered_credential(registry, &preflight_request)?;
verify_subject_action_signature(
registry,
&preflight_request,
&preflight_signature,
subject_public_key,
)?;
Ok((credential_id, registry.receipt_chain_head()))
})
.await?;
let (credential_id, previous_receipt_hash) = preflight;
let evidence_subject = AvcReceiptEvidenceSubject {
credential_id,
action_id,
action_commitment_hash,
action_descriptor_hash,
previous_receipt_hash,
};
let timestamp_evidence = trusted_receipt_timestamp_evidence(&state, &evidence_subject).await?;
let trusted_now = timestamp_evidence.trusted_now;
let state_for_registry = Arc::clone(&state);
let mut response = with_registry_blocking(state_for_registry, true, move |registry| {
let credential_id = require_registered_credential(registry, &submitted_request)?;
verify_subject_action_signature(
registry,
&submitted_request,
&subject_signature,
subject_public_key,
)?;
let mut validation_request = submitted_request;
validation_request.now = trusted_now;
let validation = validate_avc(&validation_request, registry).map_err(map_avc_error)?;
if validation.decision != AvcDecision::Allow {
return Err((
StatusCode::FORBIDDEN,
format!("AVC validation denied: {:?}", validation.reason_codes),
));
}
if let Some(receipt) = registry.get_receipt_by_action_commitment(&action_commitment_hash) {
validate_idempotent_receipt_hit(
&receipt,
credential_id,
action_id,
action_commitment_hash,
&validation,
)?;
return Ok(emit_receipt_response(receipt, validation));
}
if registry.receipt_chain_head() != previous_receipt_hash {
return Err((
StatusCode::CONFLICT,
"AVC receipt chain advanced during external timestamp attestation".into(),
));
}
let receipt = create_trust_receipt_with_evidence(
&validation,
Some(action_id),
AvcTrustReceiptEvidence {
action_commitment_hash: Some(action_commitment_hash),
action_descriptor: Some(action_descriptor),
previous_receipt_hash,
timestamp_provenance: Some(timestamp_evidence.provenance),
external_timestamp_proof: timestamp_evidence.external_timestamp_proof,
},
validator_did,
trusted_now,
|bytes| (receipt_signer)(bytes),
)
.map_err(map_avc_error)?;
store_receipt_idempotent(registry, receipt.clone())?;
Ok(emit_receipt_response(receipt, validation))
})
.await?;
let finality = commit_exochain_finality(
&state.finality_store,
&response.receipt,
&state.validator_did,
&state.receipt_signer,
)
.map_err(exochain_finality_error)?;
attach_exochain_finality(&mut response, finality);
Ok(Json(response))
}
async fn handle_get_receipt(
State(state): State<Arc<AvcApiState>>,
Path(id): Path<String>,
) -> ApiResult<Json<AvcTrustReceipt>> {
let hash = parse_hash(&id)?;
let receipt = with_registry_blocking(state, false, move |registry| {
registry
.get_receipt(&hash)
.ok_or((StatusCode::NOT_FOUND, "receipt not found".into()))
})
.await?;
Ok(Json(receipt))
}
async fn handle_list_receipts(
State(state): State<Arc<AvcApiState>>,
Query(query): Query<ListAvcReceiptsQuery>,
) -> ApiResult<Json<ListAvcReceiptsResponse>> {
let actor = query.actor.ok_or((
StatusCode::BAD_REQUEST,
"actor query parameter is required".into(),
))?;
let did = parse_did(&actor)?;
let did_for_response = did.to_string();
let did_for_lookup = did.clone();
let limit = avc_receipt_list_limit(query.limit);
let receipts = with_registry_blocking(state, false, move |registry| {
Ok(registry.list_receipts_for_subject(&did_for_lookup, limit))
})
.await?;
Ok(Json(ListAvcReceiptsResponse {
did: did_for_response,
receipts,
}))
}
async fn handle_protocol_info(
Query(query): Query<AvcProtocolQuery>,
) -> ApiResult<Json<AvcProtocolInfo>> {
if query.protocol_version.is_none() {
tracing::info!(
protocol_version = AVC_PROTOCOL_VERSION,
"served legacy AVC protocol discovery without explicit requested version"
);
}
require_supported_avc_protocol_version(query.protocol_version).map_err(map_avc_error)?;
Ok(Json(avc_protocol_info()))
}
async fn handle_delegate(
State(state): State<Arc<AvcApiState>>,
Json(payload): Json<DelegateRequest>,
) -> ApiResult<Json<DelegateResponse>> {
let credential = payload.child_credential;
let parent_avc_id = credential.parent_avc_id.map(|h| format!("{h}"));
let id = with_registry_blocking(state, true, move |registry| {
registry.put_credential(credential).map_err(map_avc_error)
})
.await?;
Ok(Json(DelegateResponse {
credential_id: format!("{id}"),
parent_avc_id,
status: "registered".into(),
}))
}
async fn handle_revoke(
State(state): State<Arc<AvcApiState>>,
Json(payload): Json<RevokeRequest>,
) -> ApiResult<Json<RevokeResponse>> {
let revocation = payload.revocation;
let id = revocation.credential_id;
with_registry_blocking(state, true, move |registry| {
registry.put_revocation(revocation).map_err(map_avc_error)
})
.await?;
Ok(Json(RevokeResponse {
credential_id: format!("{id}"),
status: "revoked".into(),
}))
}
async fn handle_get(
State(state): State<Arc<AvcApiState>>,
Path(id): Path<String>,
) -> ApiResult<Json<AutonomousVolitionCredential>> {
let hash = parse_hash(&id)?;
let credential = with_registry_blocking(state, false, move |registry| {
registry
.get_credential(&hash)
.ok_or((StatusCode::NOT_FOUND, "credential not found".into()))
})
.await?;
Ok(Json(credential))
}
async fn handle_list_for_subject(
State(state): State<Arc<AvcApiState>>,
Path(did_str): Path<String>,
) -> ApiResult<Json<ListAvcResponse>> {
let did = parse_did(&did_str)?;
let did_for_response = did.to_string();
let did_for_lookup = did.clone();
let credentials = with_registry_blocking(state, false, move |registry| {
let creds = registry.list_credentials_for_subject(&did_for_lookup);
let mut summaries = Vec::with_capacity(creds.len());
for c in &creds {
summaries.push(summary_of(c)?);
}
// Deterministic ordering by credential ID.
summaries.sort_by(|a, b| a.credential_id.cmp(&b.credential_id));
Ok(summaries)
})
.await?;
Ok(Json(ListAvcResponse {
did: did_for_response,
credentials,
}))
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
/// Build the AVC API router. POST routes inherit bearer-token auth
/// from the merged write guard in `main.rs`. The validation route returns
/// ordinary AVC denials as `200 OK` with `decision: Deny`; receipt emission
/// returns `403` because no receipt is minted for a denied action.
pub fn avc_router(state: Arc<AvcApiState>) -> Router {
Router::new()
.route("/api/v1/avc/issue", post(handle_issue))
.route("/api/v1/avc/validate", post(handle_validate))
.route("/api/v1/avc/receipts/emit", post(handle_emit_receipt))
.route("/api/v1/avc/receipts", get(handle_list_receipts))
.route("/api/v1/avc/receipts/:hash", get(handle_get_receipt))
.route("/api/v1/avc/protocol", get(handle_protocol_info))
.route("/api/v1/avc/delegate", post(handle_delegate))
.route("/api/v1/avc/revoke", post(handle_revoke))
.route("/api/v1/avc/:id", get(handle_get))
.route("/api/v1/agents/:did/avcs", get(handle_list_for_subject))
.with_state(state)
.layer(DefaultBodyLimit::max(MAX_AVC_API_BODY_BYTES))
.layer(ConcurrencyLimitLayer::new(MAX_AVC_API_CONCURRENT_REQUESTS))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use std::{
collections::VecDeque,
sync::atomic::{AtomicUsize, Ordering},
};
use axum::{
body::{self, Body},
http::{Method, Request},
response::IntoResponse,
};
use exo_authority::permission::Permission;
use exo_avc::{
AVC_SCHEMA_VERSION, AuthorityScope, AutonomyLevel, AvcActionDescriptor, AvcActionRequest,
AvcConstraints, AvcDecision, AvcDraft, AvcReasonCode, AvcReceiptEvidenceSubject,
AvcReceiptExternalTimestampProofKind, AvcRevocationReason, AvcSubjectKind, DelegatedIntent,
avc_action_descriptor_hash, create_trust_receipt, issue_avc, revoke_avc,
};
use exo_core::{Hash256, Signature, Timestamp, crypto, crypto::KeyPair};
use tower::ServiceExt;
use super::*;
const ISSUER_SEED: [u8; 32] = [0x11; 32];
const SUBJECT_SEED: [u8; 32] = [0x22; 32];
const VALIDATOR_SEED: [u8; 32] = [0x33; 32];
const TIMESTAMP_AUTHORITY_SEED: [u8; 32] = [0x44; 32];
const MICROSOFT_PUBLIC_RSA_TSA_DID: &str = "did:exo:microsoft-public-rsa-tsa";
const MICROSOFT_FIXTURE_SIGNER_SPKI_HEX: &str = "30820222300d06092a864886f70d01010105000382020f003082020a0282020100b4a59f9bfba5d36eff77c4656fc327fe0d1052fbcba98d95b32ded23c536b454aca53668999383dc11d3f0b911f91ae130981bd558c0285372b1a2bd70b49789f3c648806b3c282cf4fe32db896b2449ab57a439cf8066a8c8483eb66112f6675a9092e073bb8d849e8bf9f1982effd44afe9792e0dcf992c5bf1dd8855c011c52c350789b107a5c8d2791e97dc1ad5d61bdb07c6a687eb6859b164ec53f5e361b782c7d1105256e79b6ba64da634bfd20b5f9bbaa2222c8fea9e8f4734d36cc9d5aac1e757f77fad6d331f1f90f90359e7052a2a64d9241f6153ce77fb6a57e6b0df2b7dae358f7f5813809b36ea82911d4246e231abd43325034a19b2708be01dd4274b6d3bb138fc33e9092f7b4e75a84fb8fa8cc2c6820a075fc30431d0ef5329eec54af6c0118b3502795d0a5fca1c6642395bd436a8f22f5d092ded3ff860fdff29ea5c6585a573a36ae9ef67f70a44e8633783397bac71d1bda68aa70f8a2e3f8a2d9985e29a9652444fb08a96915286cdf0ca0e85fdfa2343142f3e76d60f8372c7a9618d68f09a82dcc7ac351520ad6af2c2972df704b452953538a8a53169af1ded837b12aa67f573b4498d2e98ebca157ad61fbaf197ef626a2722b5d9d34e4b009d18ef7a474a4f7960ee544c7e67d953cbd73623745182734fd123aa3466d2e37f874a17c4f84d7cf62a7856f23d7186c73698533eb3c77a9370203010001";
const MICROSOFT_LIVE_SIGNER_SPKI_HEX_20260627: &str = "30820222300d06092a864886f70d01010105000382020f003082020a02820201009d7834a47690ecf5409659fe1d966b24570ba0a6de9215b5c8bf9034152014552c8d920a6aaa8de28209b09337a6cd2b24d48eee7742351b990d7d9682eaf7024efb797ae5a015ea6663ba6555de0cd4422e5756e00d3f35f8f327b5d791d1218ebf358215c4a51ef30bec1b68d37eb0f4b1ccb01905e89b0c53fb5f0b39c17d19b48b0dd5adbe5eae5bbd6a77911332b70b244e3ba746078b64bfed069db7ec955d44f14043d8d844aa42a94068fefd718c12d1095dcf6a52a39c67dbdcc37853b8d5caa89f1474a17275b9084451a019946bab32803cc54abf1ede0f774cf34b1548af504d0698b7db5f971e0f51add45719eb1fc92d5013ce4e7e0561db331c092159153d3a9248c8d0e8a4ca75c9eade91f4738005269fe096f729ab453d7f36488c9186bdda62b2195197bed142d5214a3c47bc29f72c2ff1a904303874900ec1a1e8d5f60f445fb12c84b53001c8069efb6c351c1c930d372695334b12e40b7828f580d05d2168f458e6320ed8e343ff224d663a7b2d6f6fda87963223e478089dd4f93fd318936560d9eee129464d04d6c0fe1b2006cba867e217f3d5af8c437d69b17dd52e0e255ba29e62ac2cefcc2db9e5ee292e0f474dea803461ec320d09dcb35dac33d1ceb6eef6400fd366579fbd6f2bf71b4c5c06284257068ec93c5b851cedc7ea56a6c83e376873c6710732dc5dc5723f8a797322f0be430203010001";
const MICROSOFT_FIXTURE_CA_SPKI_HEX: &str = "30820222300d06092a864886f70d01010105000382020f003082020a02820201009e7ce75263fde0c59f057d63b50622a31c1ed7e79733d11305bd6546477791c15d706f7fb2ab43970c4aa1521c6aa0dbfa89858a8e431c2e1105c6f24078d70b0324fe5dd3398b60a018f19c6fde5624b8b0ec7ccb8812abc660e3d44401fe61b9784891044a7b7431b3c4a0a74d8a1c0ce711afd2b1a87c9d6a39849335c739e446c14fbbaadf0c7799786d566b5c084af964a4e428a1350b166f34f59d1962543c2e9ee2e45f58722165c802b09faca337f911e1f92ab9459f1a6328a4dabf07c53fa5da199196506f1365a893a20468025a9c7af6e2aa2a14cf562de0544ae773faa2f9d47c036322033d243749e1ed2a883466e6c39388442d04b19df5585dd4c69dc6819c1eb442b12e6b3bdca1bf67e3247ae6950d042179a9e0384306278a50647e799e02344ddcb56e2ebd20d055e4a9f61d5268f57c51611fc93c601a33ac46979ec48bde47530f4d57fb82df2163ae1734f3ba8b2506b0482df1cd8fc45f3b13e08eec0dbc4e98cdab978b8a2ba784a6ead176e390da14e4986d614ae59806e9c518dbf6d4ab78376d002a66deb929c69ec04277672344a1bbf7e4d7fac4de85ac0ea317de38efe347bc28de58b09067733c9607827279e14c5b72417dd7802a1ce88457bc539c3d5aebdc3f513c708c4ba0a483cc20813aed2159d8f328dbbc6394b007596de5d421001632cd1dddc443bf4f52bf055177ad5ebd0203010001";
const MICROSOFT_FIXTURE_TSA_SUBJECT: &str = "C=US, ST=Washington, L=Redmond, O=Microsoft Corporation, OU=Microsoft America Operations, OU=nShield TSS ESN:A500-05E0-D947, CN=Microsoft Public RSA Time Stamping Authority";
#[derive(Clone, Copy)]
enum TestTimestampAuthorityMode {
Valid,
NonSuccess,
InvalidJson,
WrongSubject,
WrongAuthority,
BadSignature,
}
fn issuer_keypair() -> KeyPair {
KeyPair::from_secret_bytes(ISSUER_SEED).expect("valid seed")
}
fn subject_keypair() -> KeyPair {
KeyPair::from_secret_bytes(SUBJECT_SEED).expect("valid seed")
}
fn validator_keypair() -> KeyPair {
KeyPair::from_secret_bytes(VALIDATOR_SEED).expect("valid seed")
}
fn timestamp_authority_keypair() -> KeyPair {
KeyPair::from_secret_bytes(TIMESTAMP_AUTHORITY_SEED).expect("valid seed")
}
fn validator_did() -> Did {
Did::new("did:exo:validator").unwrap()
}
fn timestamp_authority_did() -> Did {
Did::new("did:exo:timestamp-authority").unwrap()
}
fn seed_avc_trust_keys(state: &AvcApiState) {
let kp = issuer_keypair();
let did = Did::new("did:exo:issuer").unwrap();
let mut registry = state.registry.lock().unwrap();
registry.put_public_key(did, kp.public);
registry.put_public_key(Did::new("did:exo:agent").unwrap(), subject_keypair().public);
registry.put_receipt_validator_public_key(validator_did(), validator_keypair().public);
}
fn fixed_external_timestamp_source(timestamp: Timestamp) -> AvcReceiptExternalTimestampSource {
let signer: AvcReceiptSigner =
Arc::new(|payload: &[u8]| timestamp_authority_keypair().sign(payload));
AvcReceiptExternalTimestampSource::Fixed {
authority_did: timestamp_authority_did(),
authority_public_key: timestamp_authority_keypair().public,
issued_at: timestamp,
signer,
}
}
fn http_external_timestamp_source(endpoint: String) -> AvcReceiptExternalTimestampSource {
AvcReceiptExternalTimestampSource::HttpJson {
endpoint: Arc::new(endpoint),
authority_did: timestamp_authority_did(),
authority_public_key: timestamp_authority_keypair().public,
client: reqwest::Client::new(),
}
}
fn rfc3161_external_timestamp_source(endpoint: String) -> AvcReceiptExternalTimestampSource {
AvcReceiptExternalTimestampSource::Rfc3161 {
endpoint: Arc::new(endpoint),
authority_did: Did::new(MICROSOFT_PUBLIC_RSA_TSA_DID).unwrap(),
authority_public_key_spki_der_hexes: Arc::new(vec![
MICROSOFT_FIXTURE_SIGNER_SPKI_HEX.to_owned(),
]),
issuing_ca_spki_der_hexes: Arc::new(Vec::new()),
policy_oid: Arc::new(
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID.to_owned(),
),
client: reqwest::Client::new(),
}
}
fn fixed_rfc3161_external_timestamp_source() -> AvcReceiptExternalTimestampSource {
AvcReceiptExternalTimestampSource::FixedRfc3161 {
authority_did: Did::new(MICROSOFT_PUBLIC_RSA_TSA_DID).unwrap(),
issued_at: Timestamp::new(1_782_571_620_539, 0),
token_der_base64: crate::avc_rfc3161::microsoft_fixture_timestamp_token_der_base64()
.unwrap(),
policy_oid: crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID.to_owned(),
serial_number_hex: "6a1c57054080".to_owned(),
nonce_hex: "a173ce171bc853e8".to_owned(),
tsa_subject: MICROSOFT_FIXTURE_TSA_SUBJECT.to_owned(),
tsa_public_key_spki_der_hex: MICROSOFT_FIXTURE_SIGNER_SPKI_HEX.to_owned(),
}
}
fn fresh_state() -> Arc<AvcApiState> {
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let state = AvcApiState::new_with_external_timestamp_source(
validator_did(),
signer,
fixed_external_timestamp_source(Timestamp::new(1_600_000, 0)),
);
// Seed the issuer key so validate paths succeed.
seed_avc_trust_keys(&state);
Arc::new(state)
}
fn fresh_state_with_finality_store(
finality_store: Arc<Mutex<crate::store::SqliteDagStore>>,
) -> Arc<AvcApiState> {
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let state = AvcApiState::new_with_external_timestamp_source_and_finality_store(
validator_did(),
signer,
fixed_external_timestamp_source(Timestamp::new(1_600_000, 0)),
Some(finality_store),
);
seed_avc_trust_keys(&state);
Arc::new(state)
}
async fn fresh_durable_state(data_dir: &FsPath) -> Arc<AvcApiState> {
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let mut state =
AvcApiState::with_durable_registry(data_dir, validator_did(), signer, None, None)
.await
.expect("durable AVC state");
state.external_timestamp_source =
fixed_external_timestamp_source(Timestamp::new(1_600_000, 0));
seed_avc_trust_keys(&state);
Arc::new(state)
}
async fn clear_postgres_avc_registry_state(pool: &PgPool) -> Result<(), sqlx::Error> {
let statement =
format!("DELETE FROM {AVC_REGISTRY_POSTGRES_TABLE} WHERE registry_key = $1");
sqlx::query(&statement)
.bind(AVC_REGISTRY_POSTGRES_KEY)
.execute(pool)
.await?;
Ok(())
}
async fn postgres_avc_test_pool() -> Option<PgPool> {
if std::env::var("EXO_TEST_AVC_POSTGRES_DURABILITY")
.ok()
.as_deref()
!= Some("1")
{
return None;
}
let database_url = std::env::var("DATABASE_URL").ok()?;
let pool = exo_gateway::db::init_pool(&database_url).await.ok()?;
clear_postgres_avc_registry_state(&pool).await.ok()?;
Some(pool)
}
fn baseline_draft() -> AvcDraft {
AvcDraft {
schema_version: AVC_SCHEMA_VERSION,
issuer_did: Did::new("did:exo:issuer").unwrap(),
principal_did: Did::new("did:exo:issuer").unwrap(),
subject_did: Did::new("did:exo:agent").unwrap(),
holder_did: None,
subject_kind: AvcSubjectKind::AiAgent {
model_id: "alpha".into(),
agent_version: None,
},
created_at: Timestamp::new(1_000_000, 0),
expires_at: Some(Timestamp::new(2_000_000, 0)),
delegated_intent: DelegatedIntent {
intent_id: Hash256::from_bytes([0xAA; 32]),
purpose: "research".into(),
allowed_objectives: vec!["primary".into()],
prohibited_objectives: vec![],
autonomy_level: AutonomyLevel::Draft,
delegation_allowed: false,
},
authority_scope: AuthorityScope {
permissions: vec![Permission::Read],
tools: vec![],
data_classes: vec![],
counterparties: vec![],
jurisdictions: vec!["US".into()],
},
constraints: AvcConstraints::permissive(),
authority_chain: None,
consent_refs: vec![],
policy_refs: vec![],
parent_avc_id: None,
}
}
fn baseline_credential() -> AutonomousVolitionCredential {
let kp = issuer_keypair();
issue_avc(baseline_draft(), |bytes| kp.sign(bytes)).unwrap()
}
fn credential_for_subject(subject_did: Did) -> AutonomousVolitionCredential {
let mut draft = baseline_draft();
draft.subject_did = subject_did;
let kp = issuer_keypair();
issue_avc(draft, |bytes| kp.sign(bytes)).unwrap()
}
fn credential_with_purpose(purpose: &str) -> AutonomousVolitionCredential {
let mut draft = baseline_draft();
draft.delegated_intent.purpose = purpose.into();
let kp = issuer_keypair();
issue_avc(draft, |bytes| kp.sign(bytes)).unwrap()
}
fn credential_expiring_at(expires_at: Timestamp) -> AutonomousVolitionCredential {
let mut draft = baseline_draft();
draft.expires_at = Some(expires_at);
let kp = issuer_keypair();
issue_avc(draft, |bytes| kp.sign(bytes)).unwrap()
}
fn baseline_action(actor_did: Did) -> AvcActionRequest {
AvcActionRequest {
action_id: Hash256::from_bytes([0x55; 32]),
actor_did,
requested_permission: Permission::Read,
tool: None,
target_did: None,
data_class: None,
estimated_budget_minor_units: None,
estimated_risk_bp: None,
human_approval: None,
requires_human_approval: false,
action_name: None,
}
}
fn sign_action(request: &AvcValidationRequest, keypair: &KeyPair) -> Signature {
let action = request.action.as_ref().unwrap();
let payload =
exo_avc::avc_action_signature_payload(&request.credential, action, &request.now)
.unwrap();
keypair.sign(&payload)
}
async fn read_body(response: axum::response::Response) -> Vec<u8> {
body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap()
.to_vec()
}
async fn serve_test_timestamp_authority(
mode: TestTimestampAuthorityMode,
) -> (String, tokio::task::JoinHandle<()>) {
#[derive(Deserialize)]
struct CapturedExternalTimestampRequest {
schema_version: u16,
domain: String,
subject_hash: String,
}
async fn issue_timestamp(
State(mode): State<TestTimestampAuthorityMode>,
axum::Json(request): axum::Json<CapturedExternalTimestampRequest>,
) -> axum::response::Response {
assert_eq!(request.schema_version, AVC_SCHEMA_VERSION);
assert_eq!(request.domain, exo_avc::AVC_RECEIPT_EVIDENCE_SUBJECT_DOMAIN);
if matches!(mode, TestTimestampAuthorityMode::NonSuccess) {
return (StatusCode::BAD_GATEWAY, "timestamp authority unavailable")
.into_response();
}
if matches!(mode, TestTimestampAuthorityMode::InvalidJson) {
return (StatusCode::OK, "not-json").into_response();
}
let subject_hash =
parse_hash_anyhow(&request.subject_hash, "test timestamp subject hash").unwrap();
let signed_subject_hash = match mode {
TestTimestampAuthorityMode::WrongSubject => Hash256::from_bytes([0xAB; 32]),
_ => subject_hash,
};
let authority_did = match mode {
TestTimestampAuthorityMode::WrongAuthority => {
Did::new("did:exo:wrong-timestamp-authority").unwrap()
}
_ => timestamp_authority_did(),
};
let issued_at = Timestamp::new(1_700_000, 7);
let mut proof = AvcReceiptExternalTimestampProof::signed(
authority_did,
signed_subject_hash,
issued_at,
|bytes| timestamp_authority_keypair().sign(bytes),
)
.unwrap();
if matches!(mode, TestTimestampAuthorityMode::BadSignature) {
proof.signature = Signature::empty();
}
axum::Json(serde_json::json!({
"authority_did": proof.authority_did.to_string(),
"subject_hash": proof.subject_hash.to_string(),
"issued_at_physical_ms": proof.issued_at.physical_ms,
"issued_at_logical": proof.issued_at.logical,
"signature_hex": proof.signature.to_string(),
}))
.into_response()
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let app = Router::new()
.route("/", post(issue_timestamp))
.with_state(mode);
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{address}"), handle)
}
async fn serve_test_rfc3161_timestamp_authority(
status: StatusCode,
response_der: Vec<u8>,
) -> (String, tokio::task::JoinHandle<()>) {
async fn issue_timestamp(
State((status, response_der)): State<(StatusCode, Vec<u8>)>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> axum::response::Response {
assert_eq!(
headers
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("application/timestamp-query")
);
assert!(!body.is_empty(), "RFC 3161 request body must be DER");
(
status,
[("content-type", "application/timestamp-reply")],
response_der,
)
.into_response()
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let app = Router::new()
.route("/", post(issue_timestamp))
.with_state((status, response_der));
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{address}"), handle)
}
async fn serve_test_rfc3161_timestamp_authority_sequence(
responses: Vec<(StatusCode, Vec<u8>)>,
) -> (String, Arc<AtomicUsize>, tokio::task::JoinHandle<()>) {
async fn issue_timestamp(
State((responses, attempts)): State<(
Arc<Mutex<VecDeque<(StatusCode, Vec<u8>)>>>,
Arc<AtomicUsize>,
)>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> axum::response::Response {
assert_eq!(
headers
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("application/timestamp-query")
);
assert!(!body.is_empty(), "RFC 3161 request body must be DER");
attempts.fetch_add(1, Ordering::SeqCst);
let (status, response_der) = responses
.lock()
.unwrap()
.pop_front()
.unwrap_or((StatusCode::INTERNAL_SERVER_ERROR, Vec::new()));
(
status,
[("content-type", "application/timestamp-reply")],
response_der,
)
.into_response()
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let responses = Arc::new(Mutex::new(VecDeque::from(responses)));
let attempts = Arc::new(AtomicUsize::new(0));
let app = Router::new()
.route("/", post(issue_timestamp))
.with_state((responses, Arc::clone(&attempts)));
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{address}"), attempts, handle)
}
async fn emit_baseline_receipt_with_source(
external_timestamp_source: AvcReceiptExternalTimestampSource,
) -> (StatusCode, Arc<AvcApiState>, Vec<u8>) {
emit_baseline_receipt_with_source_and_strict(external_timestamp_source, false).await
}
async fn emit_baseline_receipt_with_source_and_strict(
external_timestamp_source: AvcReceiptExternalTimestampSource,
require_external_timestamp: bool,
) -> (StatusCode, Arc<AvcApiState>, Vec<u8>) {
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let mut state = AvcApiState::new_with_external_timestamp_source(
validator_did(),
signer,
external_timestamp_source,
);
state.require_external_timestamp = require_external_timestamp;
seed_avc_trust_keys(&state);
let state = Arc::new(state);
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let response = avc_router(Arc::clone(&state))
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let response_body = read_body(response).await;
(status, state, response_body)
}
fn unreachable_postgres_pool() -> PgPool {
sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.acquire_timeout(std::time::Duration::from_millis(100))
.connect_lazy("postgres://exochain:test@127.0.0.1:1/exochain_test")
.unwrap()
}
async fn issue_credential(app: Router, credential: AutonomousVolitionCredential) -> StatusCode {
let body = serde_json::to_vec(&IssueRequest { credential }).unwrap();
app.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/issue")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
.status()
}
#[test]
fn durable_state_runtime_record_detection_covers_each_record_kind() {
assert!(!durable_state_has_runtime_records(
&AvcRegistryDurableState::default()
));
let credential = baseline_credential();
let credential_id = credential.id().unwrap();
let mut credential_state = AvcRegistryDurableState::default();
credential_state
.credentials
.insert(credential_id, credential.clone());
assert!(durable_state_has_runtime_records(&credential_state));
let mut revocation_state = AvcRegistryDurableState::default();
revocation_state.revocations.insert(
credential_id,
AvcRevocation {
schema_version: AVC_SCHEMA_VERSION,
credential_id,
revoker_did: Did::new("did:exo:issuer").unwrap(),
reason: AvcRevocationReason::IssuerRevoked,
created_at: Timestamp::new(2, 0),
signature: Signature::from_bytes([0x44; 64]),
},
);
assert!(durable_state_has_runtime_records(&revocation_state));
let validation = AvcValidationResult {
credential_id,
decision: AvcDecision::Allow,
reason_codes: Vec::new(),
normalized_holder_did: credential.subject_did.clone(),
valid_until: credential.expires_at,
receipt: None,
};
let receipt = create_trust_receipt(
&validation,
None,
validator_did(),
Timestamp::new(1_500_000, 0),
|bytes| validator_keypair().sign(bytes),
)
.unwrap();
let mut receipt_state = AvcRegistryDurableState::default();
receipt_state.receipts.insert(receipt.receipt_id, receipt);
assert!(durable_state_has_runtime_records(&receipt_state));
}
#[test]
fn file_durable_registry_round_trips_records_and_rejects_bad_files() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(AVC_REGISTRY_DURABLE_STATE_FILE);
let mut registry = InMemoryAvcRegistry::new();
registry.put_public_key(Did::new("did:exo:issuer").unwrap(), issuer_keypair().public);
let credential = baseline_credential();
let credential_id = registry.put_credential(credential.clone()).unwrap();
let state = registry.durable_state();
persist_file_durable_registry_state(&state, &path).unwrap();
let loaded = load_file_durable_registry(&path).unwrap();
assert_eq!(loaded.credential_count(), 1);
assert_eq!(loaded.get_credential(&credential_id), Some(credential));
assert_eq!(
loaded.resolve_public_key(&Did::new("did:exo:issuer").unwrap()),
None,
"durable files must not persist trust anchors"
);
let empty_path = dir.path().join("empty.cbor");
std::fs::write(&empty_path, []).unwrap();
let empty_error = load_file_durable_registry(&empty_path)
.unwrap_err()
.to_string();
assert!(empty_error.contains("is empty"));
let directory_error = load_file_durable_registry(dir.path())
.unwrap_err()
.to_string();
assert!(directory_error.contains("failed to read AVC durable registry"));
let corrupt_path = dir.path().join("corrupt.cbor");
std::fs::write(&corrupt_path, b"not cbor").unwrap();
let corrupt_error = load_file_durable_registry(&corrupt_path)
.unwrap_err()
.to_string();
assert!(corrupt_error.contains("failed to decode AVC durable registry"));
let mut invalid_state = AvcRegistryDurableState::default();
invalid_state
.credentials
.insert(Hash256::from_bytes([0x99; 32]), baseline_credential());
let invalid_bytes = encode_durable_registry_state(&invalid_state).unwrap();
let invalid_error = decode_durable_registry_bytes(&invalid_bytes, "invalid")
.unwrap_err()
.to_string();
assert!(invalid_error.contains("failed to validate AVC durable registry"));
}
#[tokio::test]
async fn issue_registers_credential() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let credential = baseline_credential();
let cred_id = credential.id().unwrap();
let body = serde_json::to_vec(&IssueRequest { credential }).unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/issue")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let resp_bytes = read_body(response).await;
let parsed: IssueResponse = serde_json::from_slice(&resp_bytes).unwrap();
assert_eq!(parsed.credential_id, format!("{cred_id}"));
assert_eq!(parsed.status, "registered");
}
#[tokio::test]
async fn durable_registry_restores_issued_credentials_for_receipt_emit_after_restart() {
let dir = tempfile::tempdir().unwrap();
let state = fresh_durable_state(dir.path()).await;
let app = avc_router(Arc::clone(&state));
let credential = baseline_credential();
let credential_id = credential.id().unwrap();
let body = serde_json::to_vec(&IssueRequest {
credential: credential.clone(),
})
.unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/issue")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let restarted = fresh_durable_state(dir.path()).await;
assert_eq!(restarted.registry.lock().unwrap().credential_count(), 1);
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let action_id = request.action.as_ref().unwrap().action_id;
let body = serde_json::to_vec(&EmitReceiptRequest {
subject_signature: sign_action(&request, &subject_keypair()),
validation: request,
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&restarted));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: EmitReceiptResponse =
serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed.receipt.credential_id, credential_id);
assert_eq!(parsed.receipt.action_id, Some(action_id));
let restarted_again = fresh_durable_state(dir.path()).await;
assert_eq!(
restarted_again.registry.lock().unwrap().credential_count(),
1
);
assert_eq!(restarted_again.registry.lock().unwrap().receipt_count(), 1);
}
#[tokio::test]
async fn durable_registry_startup_rejects_empty_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(AVC_REGISTRY_DURABLE_STATE_FILE), []).unwrap();
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let error = match AvcApiState::with_durable_registry(
dir.path(),
validator_did(),
signer,
None,
None,
)
.await
{
Ok(_) => panic!("empty AVC durable registry file must fail closed at startup"),
Err(error) => error.to_string(),
};
assert!(error.contains("AVC durable registry"));
assert!(error.contains("is empty"));
}
#[tokio::test]
async fn durable_registry_file_wrapper_restores_successful_mutation() {
let dir = tempfile::tempdir().unwrap();
let state = fresh_durable_state(dir.path()).await;
let credential = baseline_credential();
let credential_id = credential.id().unwrap();
let stored_id = with_registry_blocking(Arc::clone(&state), true, move |registry| {
registry.put_credential(credential).map_err(map_avc_error)
})
.await
.unwrap();
assert_eq!(stored_id, credential_id);
let reloaded = fresh_durable_state(dir.path()).await;
let registry = reloaded.registry.lock().unwrap();
assert_eq!(registry.credential_count(), 1);
assert!(
registry.get_credential(&credential_id).is_some(),
"file durability wrapper must persist accepted registry mutations"
);
assert_eq!(
registry.resolve_public_key(&Did::new("did:exo:issuer").unwrap()),
Some(issuer_keypair().public),
"startup trust seeding remains separate from durable records"
);
}
#[tokio::test]
async fn durable_registry_read_only_access_does_not_create_runtime_file() {
let dir = tempfile::tempdir().unwrap();
let state = fresh_durable_state(dir.path()).await;
let file_path = dir.path().join(AVC_REGISTRY_DURABLE_STATE_FILE);
let count = with_registry_blocking(Arc::clone(&state), false, |registry| {
Ok(registry.credential_count())
})
.await
.unwrap();
assert_eq!(count, 0);
assert!(
!file_path.exists(),
"read-only registry access must not create an empty durable runtime file"
);
}
#[tokio::test]
async fn postgres_durable_registry_fails_closed_when_database_is_unreachable() {
let dir = tempfile::tempdir().unwrap();
let pool = unreachable_postgres_pool();
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let startup_error = match AvcApiState::with_durable_registry(
dir.path(),
validator_did(),
Arc::clone(&signer),
Some(pool.clone()),
None,
)
.await
{
Ok(_) => panic!("unreachable Postgres pool must fail closed at AVC startup"),
Err(error) => error.to_string(),
};
assert!(startup_error.contains("failed to begin AVC Postgres registry transaction"));
let state = AvcApiState {
registry: Arc::new(Mutex::new(InMemoryAvcRegistry::new())),
validator_did: validator_did(),
receipt_signer: signer,
external_timestamp_source: AvcReceiptExternalTimestampSource::Unconfigured,
receipt_clock: Arc::new(Mutex::new(HybridClock::new())),
require_external_timestamp: false,
finality_store: None,
durability: AvcRegistryDurability::Postgres(pool.clone()),
};
seed_avc_trust_keys(&state);
let state = Arc::new(state);
let app = avc_router(Arc::clone(&state));
assert_eq!(
issue_credential(app, baseline_credential()).await,
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(state.registry.lock().unwrap().credential_count(), 0);
let persist_error = tokio::task::spawn_blocking(move || {
persist_durable_registry(
&InMemoryAvcRegistry::new(),
&AvcRegistryDurability::Postgres(pool),
)
.unwrap_err()
.to_string()
})
.await
.unwrap();
assert!(persist_error.contains("failed to begin AVC Postgres registry transaction"));
}
#[tokio::test]
async fn postgres_durable_registry_preserves_trust_anchors_across_multiple_mutations() {
let pool = match postgres_avc_test_pool().await {
Some(pool) => pool,
None => return,
};
let dir = tempfile::tempdir().unwrap();
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let mut state = AvcApiState::with_durable_registry(
dir.path(),
validator_did(),
Arc::clone(&signer),
Some(pool.clone()),
None,
)
.await
.expect("Postgres AVC state");
let trusted_now = Timestamp::new(1_600_000, 0);
state.external_timestamp_source = fixed_external_timestamp_source(trusted_now);
seed_avc_trust_keys(&state);
let state = Arc::new(state);
let app = avc_router(Arc::clone(&state));
let mut first_draft = baseline_draft();
first_draft.delegated_intent.purpose = "postgres anchor persistence one".into();
first_draft.expires_at = Some(Timestamp::new(trusted_now.physical_ms + 1_000_000, 0));
let first = {
let kp = issuer_keypair();
issue_avc(first_draft, |bytes| kp.sign(bytes)).unwrap()
};
let mut second_draft = baseline_draft();
second_draft.delegated_intent.purpose = "postgres anchor persistence two".into();
second_draft.expires_at = Some(Timestamp::new(trusted_now.physical_ms + 1_000_000, 0));
let second = {
let kp = issuer_keypair();
issue_avc(second_draft, |bytes| kp.sign(bytes)).unwrap()
};
assert_eq!(
issue_credential(app.clone(), first).await,
StatusCode::OK,
"first Postgres-backed issue must persist"
);
assert_eq!(
issue_credential(app.clone(), second.clone()).await,
StatusCode::OK,
"second Postgres-backed issue must not wipe issuer trust anchors"
);
{
let registry = state.registry.lock().unwrap();
assert_eq!(registry.credential_count(), 2);
assert_eq!(
registry.resolve_public_key(&Did::new("did:exo:issuer").unwrap()),
Some(issuer_keypair().public)
);
assert_eq!(
registry.resolve_public_key(&Did::new("did:exo:agent").unwrap()),
Some(subject_keypair().public)
);
}
let request = AvcValidationRequest {
credential: second,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"receipt emission after the second Postgres mutation must still resolve subject keys"
);
let reloaded = AvcApiState::with_durable_registry(
dir.path(),
validator_did(),
signer,
Some(pool.clone()),
None,
)
.await
.expect("reloaded Postgres AVC state");
seed_avc_trust_keys(&reloaded);
assert_eq!(reloaded.registry.lock().unwrap().credential_count(), 2);
assert_eq!(reloaded.registry.lock().unwrap().receipt_count(), 1);
clear_postgres_avc_registry_state(&pool)
.await
.expect("clean up Postgres AVC state");
}
#[tokio::test]
async fn durable_registry_rolls_back_issue_when_persistence_fails() {
let dir = tempfile::tempdir().unwrap();
let state = fresh_durable_state(dir.path()).await;
std::fs::remove_dir_all(dir.path()).unwrap();
let app = avc_router(Arc::clone(&state));
let credential = baseline_credential();
let body = serde_json::to_vec(&IssueRequest { credential }).unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/issue")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
state.registry.lock().unwrap().credential_count(),
0,
"failed persistence must not leave an in-memory credential that callers think was rejected"
);
}
#[tokio::test]
async fn registry_operation_error_rolls_back_in_memory_mutation() {
let state = fresh_state();
let credential = baseline_credential();
let credential_id = credential.id().unwrap();
let error = with_registry_blocking(Arc::clone(&state), false, move |registry| {
registry.put_credential(credential).unwrap();
Err::<(), _>((StatusCode::BAD_REQUEST, "synthetic rejection".into()))
})
.await
.unwrap_err();
assert_eq!(error.0, StatusCode::BAD_REQUEST);
assert!(
state
.registry
.lock()
.unwrap()
.get_credential(&credential_id)
.is_none()
);
}
#[tokio::test]
async fn registry_task_fails_closed_when_mutex_is_poisoned() {
let state = fresh_state();
let registry = Arc::clone(&state.registry);
let _ = std::panic::catch_unwind(move || {
let _guard = registry.lock().unwrap();
panic!("poison AVC registry mutex for fail-closed test");
});
let error = with_registry_blocking(state, false, |_registry| Ok(()))
.await
.unwrap_err();
assert_eq!(error.0, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.1, "AVC registry unavailable");
}
#[tokio::test]
async fn registry_task_panic_fails_closed() {
let state = fresh_state();
let error = with_registry_blocking(state, false, |_registry| -> ApiResult<()> {
panic!("synthetic AVC registry worker panic")
})
.await
.unwrap_err();
assert_eq!(error.0, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.1, "AVC registry task failed");
}
#[tokio::test]
async fn issue_rejects_invalid_issuer_signature_without_storing_credential() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let mut credential = baseline_credential();
credential.delegated_intent.purpose = "tampered after signing".into();
let forged_id = credential.id().unwrap();
let body = serde_json::to_vec(&IssueRequest { credential }).unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/issue")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let registry = state.registry.lock().unwrap();
assert_eq!(
registry.credential_count(),
0,
"invalid issuer signatures must not be stored"
);
assert!(
registry.get_credential(&forged_id).is_none(),
"forged credential id must remain absent"
);
}
#[tokio::test]
async fn issue_rejects_unknown_issuer_without_storing_credential() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let mut credential = baseline_credential();
credential.issuer_did = Did::new("did:exo:unknown-issuer").unwrap();
let forged_id = credential.id().unwrap();
let body = serde_json::to_vec(&IssueRequest { credential }).unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/issue")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let registry = state.registry.lock().unwrap();
assert_eq!(
registry.credential_count(),
0,
"unresolved issuers must not be stored"
);
assert!(
registry.get_credential(&forged_id).is_none(),
"unknown-issuer credential id must remain absent"
);
}
#[tokio::test]
async fn delegate_rejects_invalid_child_signature_without_storing_credential() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let mut credential = baseline_credential();
credential.parent_avc_id = Some(Hash256::from_bytes([0x42; 32]));
credential.delegated_intent.purpose = "tampered delegated purpose".into();
let forged_id = credential.id().unwrap();
let body = serde_json::to_vec(&DelegateRequest {
child_credential: credential,
})
.unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/delegate")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let registry = state.registry.lock().unwrap();
assert_eq!(
registry.credential_count(),
0,
"invalid delegated credentials must not be stored"
);
assert!(
registry.get_credential(&forged_id).is_none(),
"forged delegated credential id must remain absent"
);
}
#[tokio::test]
async fn delegate_registers_valid_child_credential() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let mut draft = baseline_draft();
let parent_id = Hash256::from_bytes([0x42; 32]);
draft.parent_avc_id = Some(parent_id);
let credential = {
let kp = issuer_keypair();
issue_avc(draft, |bytes| kp.sign(bytes)).unwrap()
};
let credential_id = credential.id().unwrap();
let body = serde_json::to_vec(&DelegateRequest {
child_credential: credential,
})
.unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/delegate")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: DelegateResponse = serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed.credential_id, format!("{credential_id}"));
assert_eq!(parsed.parent_avc_id, Some(format!("{parent_id}")));
assert_eq!(parsed.status, "registered");
assert_eq!(state.registry.lock().unwrap().credential_count(), 1);
}
#[tokio::test]
async fn delegate_response_omits_parent_when_credential_is_root() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let credential = baseline_credential();
let credential_id = credential.id().unwrap();
let body = serde_json::to_vec(&DelegateRequest {
child_credential: credential,
})
.unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/delegate")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: DelegateResponse = serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed.credential_id, format!("{credential_id}"));
assert_eq!(parsed.parent_avc_id, None);
assert_eq!(parsed.status, "registered");
}
#[tokio::test]
async fn validate_returns_allow_for_valid_credential() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let credential = baseline_credential();
let request = AvcValidationRequest {
credential,
action: None,
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&request).unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/validate")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let resp_bytes = read_body(response).await;
let parsed: AvcValidationResult = serde_json::from_slice(&resp_bytes).unwrap();
assert_eq!(parsed.decision, AvcDecision::Allow);
}
#[tokio::test]
async fn validate_returns_structured_deny_for_unsigned_credential() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let mut credential = baseline_credential();
credential.signature = Signature::empty();
let request = AvcValidationRequest {
credential,
action: None,
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&request).unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/validate")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: AvcValidationResult =
serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed.decision, AvcDecision::Deny);
}
#[tokio::test]
async fn receipt_emit_mints_and_stores_node_signed_receipt_for_registered_credential() {
let state = fresh_state();
let credential = baseline_credential();
let credential_id = credential.id().unwrap();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let action_id = request.action.as_ref().unwrap().action_id;
let action_commitment_hash = avc_action_commitment_hash(
&request.credential,
request.action.as_ref().unwrap(),
&request.now,
)
.unwrap();
let subject_signature = sign_action(&request, &subject_keypair());
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature,
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: EmitReceiptResponse =
serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(
parsed.receipt_hash,
format!("{}", parsed.receipt.receipt_id)
);
assert_eq!(parsed.receipt.credential_id, credential_id);
assert_eq!(parsed.receipt.action_id, Some(action_id));
assert_eq!(
parsed.receipt.action_commitment_hash,
Some(action_commitment_hash)
);
assert_eq!(parsed.receipt.previous_receipt_hash, None);
assert_eq!(
parsed.receipt.timestamp_provenance,
Some(AvcReceiptTimestampProvenance::ExternalTimestampAuthority)
);
let action_descriptor =
AvcActionDescriptor::from_action(request.action.as_ref().expect("receipt action"));
let action_descriptor_hash = avc_action_descriptor_hash(&action_descriptor).unwrap();
assert_eq!(parsed.receipt.action_descriptor, Some(action_descriptor));
assert_eq!(
parsed.receipt.action_descriptor_hash,
Some(action_descriptor_hash)
);
let evidence_subject = AvcReceiptEvidenceSubject {
credential_id,
action_id,
action_commitment_hash,
action_descriptor_hash,
previous_receipt_hash: None,
};
let evidence_subject_hash = evidence_subject.hash().unwrap();
let external_timestamp_proof = parsed
.receipt
.external_timestamp_proof
.as_ref()
.expect("receipt must carry external timestamp proof");
assert_eq!(
external_timestamp_proof.authority_did,
timestamp_authority_did()
);
assert_eq!(external_timestamp_proof.subject_hash, evidence_subject_hash);
assert!(
external_timestamp_proof
.verify_signature(timestamp_authority_keypair().public_key())
.unwrap()
);
assert_eq!(
parsed.receipt.created_at,
external_timestamp_proof.issued_at
);
assert_eq!(parsed.receipt.validator_did, validator_did());
assert_eq!(parsed.validation.decision, AvcDecision::Allow);
assert!(parsed.receipt.verify_id().unwrap());
let signing_payload = parsed.receipt.signing_payload().unwrap();
assert!(crypto::verify(
&signing_payload,
&parsed.receipt.signature,
validator_keypair().public_key()
));
assert_eq!(state.registry.lock().unwrap().receipt_count(), 1);
}
#[tokio::test]
async fn receipt_emit_commits_exochain_finality_node_and_trust_receipt() {
let dir = tempfile::tempdir().unwrap();
let finality_store = Arc::new(Mutex::new(
crate::store::SqliteDagStore::open(dir.path()).unwrap(),
));
let state = fresh_state_with_finality_store(Arc::clone(&finality_store));
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body.clone()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: EmitReceiptResponse =
serde_json::from_slice(&read_body(response).await).unwrap();
let finality_hash = parse_hash_anyhow(
parsed
.exochain_finality_hash
.as_deref()
.expect("configured AVC receipt emission must return EXOCHAIN finality hash"),
"AVC EXOCHAIN finality hash",
)
.unwrap();
let finality_height = parsed
.exochain_finality_height
.expect("configured AVC receipt emission must return EXOCHAIN finality height");
let finality_receipt_hash = parse_hash_anyhow(
parsed.exochain_finality_receipt_hash.as_deref().expect(
"configured AVC receipt emission must return EXOCHAIN finality receipt hash",
),
"AVC EXOCHAIN finality receipt hash",
)
.unwrap();
{
let store = finality_store.lock().unwrap();
assert!(
store.contains_sync(&finality_hash).unwrap(),
"EXOCHAIN finality DAG node must be durably stored"
);
assert_eq!(store.committed_height_sync().unwrap(), finality_height);
let finality_receipt = store
.load_receipt(&finality_receipt_hash)
.unwrap()
.expect("EXOCHAIN finality trust receipt must be durably stored");
assert_eq!(finality_receipt.actor_did, validator_did());
assert_eq!(
finality_receipt.action_type,
"avc.receipt.exochain_finality"
);
assert_eq!(finality_receipt.action_hash, finality_hash);
assert!(finality_receipt.verify_hash().unwrap());
assert!(crypto::verify(
&finality_receipt.signing_payload().unwrap(),
&finality_receipt.signature,
validator_keypair().public_key()
));
}
let replay = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(replay.status(), StatusCode::OK);
let replayed: EmitReceiptResponse =
serde_json::from_slice(&read_body(replay).await).unwrap();
assert_eq!(replayed.receipt_hash, parsed.receipt_hash);
assert_eq!(
replayed.exochain_finality_hash,
parsed.exochain_finality_hash
);
assert_eq!(
replayed.exochain_finality_height,
parsed.exochain_finality_height
);
assert_eq!(
replayed.exochain_finality_receipt_hash,
parsed.exochain_finality_receipt_hash
);
assert_eq!(
finality_store
.lock()
.unwrap()
.committed_height_sync()
.unwrap(),
finality_height
);
}
#[tokio::test]
async fn receipt_emit_strict_rfc3161_attaches_microsoft_proof_action_meaning_and_finality() {
let dir = tempfile::tempdir().unwrap();
let finality_store = Arc::new(Mutex::new(
crate::store::SqliteDagStore::open(dir.path()).unwrap(),
));
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let mut state = AvcApiState::new_with_external_timestamp_source_and_finality_store(
validator_did(),
signer,
fixed_rfc3161_external_timestamp_source(),
Some(Arc::clone(&finality_store)),
);
state.require_external_timestamp = true;
seed_avc_trust_keys(&state);
let state = Arc::new(state);
let credential = credential_expiring_at(Timestamp::new(1_900_000_000_000, 0));
let credential_id = credential.id().unwrap();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let action = request.action.as_ref().unwrap();
let action_id = action.action_id;
let action_commitment_hash =
avc_action_commitment_hash(&request.credential, action, &request.now).unwrap();
let action_descriptor = AvcActionDescriptor::from_action(action);
let action_descriptor_hash = avc_action_descriptor_hash(&action_descriptor).unwrap();
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let response = avc_router(Arc::clone(&state))
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: EmitReceiptResponse =
serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(
parsed.receipt.timestamp_provenance,
Some(AvcReceiptTimestampProvenance::ExternalTimestampAuthority)
);
assert_eq!(parsed.receipt.action_descriptor, Some(action_descriptor));
assert_eq!(
parsed.receipt.action_descriptor_hash,
Some(action_descriptor_hash)
);
assert_eq!(
parsed.receipt.action_commitment_hash,
Some(action_commitment_hash)
);
assert_eq!(parsed.receipt.previous_receipt_hash, None);
let evidence_subject = AvcReceiptEvidenceSubject {
credential_id,
action_id,
action_commitment_hash,
action_descriptor_hash,
previous_receipt_hash: None,
};
let evidence_subject_hash = evidence_subject.hash().unwrap();
let external_timestamp_proof = parsed.receipt.external_timestamp_proof.as_ref().unwrap();
assert_eq!(
external_timestamp_proof.authority_did,
Did::new(MICROSOFT_PUBLIC_RSA_TSA_DID).unwrap()
);
assert_eq!(external_timestamp_proof.subject_hash, evidence_subject_hash);
assert_eq!(
external_timestamp_proof.proof_kind,
AvcReceiptExternalTimestampProofKind::Rfc3161
);
let rfc3161 = external_timestamp_proof.rfc3161.as_ref().unwrap();
assert_eq!(
rfc3161.message_imprint_sha256_hex,
hex::encode(evidence_subject.rfc3161_sha256_message_imprint().unwrap())
);
assert_eq!(
rfc3161.token_der_base64,
crate::avc_rfc3161::microsoft_fixture_timestamp_token_der_base64().unwrap()
);
assert_eq!(
rfc3161.policy_oid,
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID
);
assert_eq!(rfc3161.serial_number_hex, "6a1c57054080");
assert_eq!(rfc3161.nonce_hex, "a173ce171bc853e8");
assert_eq!(rfc3161.tsa_subject, MICROSOFT_FIXTURE_TSA_SUBJECT);
assert_eq!(
rfc3161.tsa_public_key_spki_der_hex,
MICROSOFT_FIXTURE_SIGNER_SPKI_HEX
);
assert_eq!(
parsed.receipt.created_at,
external_timestamp_proof.issued_at
);
assert!(parsed.exochain_finality_hash.is_some());
assert_eq!(parsed.exochain_finality_height, Some(1));
assert!(parsed.exochain_finality_receipt_hash.is_some());
let finality_hash = parse_hash_anyhow(
parsed.exochain_finality_hash.as_deref().unwrap(),
"strict RFC 3161 finality hash",
)
.unwrap();
assert!(
finality_store
.lock()
.unwrap()
.contains_sync(&finality_hash)
.unwrap()
);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 1);
}
#[tokio::test]
async fn receipt_emit_uses_local_hlc_finality_when_external_timestamp_not_configured() {
let dir = tempfile::tempdir().unwrap();
let finality_store = Arc::new(Mutex::new(
crate::store::SqliteDagStore::open(dir.path()).unwrap(),
));
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let state = AvcApiState {
registry: Arc::new(Mutex::new(InMemoryAvcRegistry::new())),
validator_did: validator_did(),
receipt_signer: signer,
external_timestamp_source: AvcReceiptExternalTimestampSource::Unconfigured,
receipt_clock: Arc::new(Mutex::new(HybridClock::new())),
require_external_timestamp: false,
finality_store: Some(Arc::clone(&finality_store)),
durability: AvcRegistryDurability::None,
};
seed_avc_trust_keys(&state);
let state = Arc::new(state);
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let response = avc_router(Arc::clone(&state))
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: EmitReceiptResponse =
serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(
parsed.receipt.timestamp_provenance,
Some(AvcReceiptTimestampProvenance::LocalHybridLogicalClock)
);
assert_eq!(parsed.receipt.external_timestamp_proof, None);
assert_eq!(parsed.receipt.created_at, Timestamp::new(1_000_000, 0));
let finality_hash = parse_hash_anyhow(
parsed
.exochain_finality_hash
.as_deref()
.expect("local EXOCHAIN finality receipt must return finality hash"),
"AVC EXOCHAIN finality hash",
)
.unwrap();
let finality_receipt_hash = parse_hash_anyhow(
parsed
.exochain_finality_receipt_hash
.as_deref()
.expect("local EXOCHAIN finality receipt must return finality receipt hash"),
"AVC EXOCHAIN finality receipt hash",
)
.unwrap();
let store = finality_store.lock().unwrap();
assert!(
store.contains_sync(&finality_hash).unwrap(),
"local-HLC AVC receipt must still commit an EXOCHAIN finality DAG node"
);
let finality_receipt = store
.load_receipt(&finality_receipt_hash)
.unwrap()
.expect("local-HLC AVC finality trust receipt must be durably stored");
assert_eq!(finality_receipt.action_hash, finality_hash);
assert!(finality_receipt.verify_hash().unwrap());
}
#[tokio::test]
async fn receipt_emit_links_sequential_receipts_to_previous_avc_receipt_hash() {
let state = fresh_state();
let first_credential = credential_with_purpose("first linked receipt");
let second_credential = credential_with_purpose("second linked receipt");
{
let mut registry = state.registry.lock().unwrap();
registry.put_credential(first_credential.clone()).unwrap();
registry.put_credential(second_credential.clone()).unwrap();
}
let first_request = AvcValidationRequest {
credential: first_credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let mut second_action = baseline_action(Did::new("did:exo:agent").unwrap());
second_action.action_id = Hash256::from_bytes([0x56; 32]);
let second_request = AvcValidationRequest {
credential: second_credential,
action: Some(second_action),
now: Timestamp::new(1_500_000, 0),
};
let first_body = serde_json::to_vec(&EmitReceiptRequest {
validation: first_request.clone(),
subject_signature: sign_action(&first_request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let second_body = serde_json::to_vec(&EmitReceiptRequest {
validation: second_request.clone(),
subject_signature: sign_action(&second_request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let first_response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(first_body))
.unwrap(),
)
.await
.unwrap();
let second_response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(second_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(first_response.status(), StatusCode::OK);
assert_eq!(second_response.status(), StatusCode::OK);
let first: EmitReceiptResponse =
serde_json::from_slice(&read_body(first_response).await).unwrap();
let second: EmitReceiptResponse =
serde_json::from_slice(&read_body(second_response).await).unwrap();
assert_eq!(first.receipt.previous_receipt_hash, None);
assert_eq!(
second.receipt.previous_receipt_hash,
Some(first.receipt.receipt_id)
);
assert_ne!(
first.receipt.action_commitment_hash,
second.receipt.action_commitment_hash
);
assert_eq!(
state.registry.lock().unwrap().receipt_chain_head(),
Some(second.receipt.receipt_id)
);
}
#[tokio::test]
async fn receipt_readback_returns_stored_avc_receipt_by_hash() {
let state = fresh_state();
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let emitted = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(emitted.status(), StatusCode::OK);
let emitted: EmitReceiptResponse =
serde_json::from_slice(&read_body(emitted).await).unwrap();
let read_back = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/v1/avc/receipts/{}", emitted.receipt_hash))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(read_back.status(), StatusCode::OK);
let parsed: AvcTrustReceipt = serde_json::from_slice(&read_body(read_back).await).unwrap();
assert_eq!(parsed, emitted.receipt);
let non_avc_receipt_route = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/v1/receipts/{}", emitted.receipt_hash))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
non_avc_receipt_route.status(),
StatusCode::NOT_FOUND,
"AVC receipt read-back must not fall through to the DAG receipt store route"
);
}
#[tokio::test]
async fn receipt_readback_rejects_invalid_and_unknown_hashes() {
let state = fresh_state();
let app = avc_router(state);
let invalid = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/avc/receipts/not-hex")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
let uppercase = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/v1/avc/receipts/{}", "AA".repeat(32)))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(uppercase.status(), StatusCode::BAD_REQUEST);
let unknown = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/v1/avc/receipts/{}", "11".repeat(32)))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn receipt_list_filters_by_actor_caps_limit_and_orders_deterministically() {
let state = fresh_state();
let first = credential_with_purpose("receipt list first");
let second = credential_with_purpose("receipt list second");
let other = credential_for_subject(Did::new("did:exo:other-agent").unwrap());
let first_id = first.id().unwrap();
let second_id = second.id().unwrap();
let other_id = other.id().unwrap();
let make_receipt = |credential_id: Hash256, action_byte: u8| {
let validation = AvcValidationResult {
credential_id,
decision: AvcDecision::Allow,
reason_codes: Vec::new(),
normalized_holder_did: Did::new("did:exo:agent").unwrap(),
valid_until: Some(Timestamp::new(2_000_000, 0)),
receipt: None,
};
create_trust_receipt(
&validation,
Some(Hash256::from_bytes([action_byte; 32])),
validator_did(),
Timestamp::new(1_600_000, 0),
|bytes| validator_keypair().sign(bytes),
)
.unwrap()
};
let first_receipt = make_receipt(first_id, 0x21);
let second_receipt = make_receipt(second_id, 0x22);
let other_receipt = make_receipt(other_id, 0x23);
{
let mut registry = state.registry.lock().unwrap();
registry.put_credential(second).unwrap();
registry.put_credential(other).unwrap();
registry.put_credential(first).unwrap();
registry.put_receipt(second_receipt.clone()).unwrap();
registry.put_receipt(other_receipt).unwrap();
registry.put_receipt(first_receipt.clone()).unwrap();
}
let app = avc_router(Arc::clone(&state));
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/avc/receipts?actor=did:exo:agent&limit=1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: ListAvcReceiptsResponse =
serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed.did, "did:exo:agent");
assert_eq!(parsed.receipts.len(), 1);
let mut expected = [first_receipt, second_receipt];
expected.sort_by_key(|receipt| receipt.receipt_id);
assert_eq!(parsed.receipts[0], expected[0]);
let missing_actor = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/avc/receipts")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing_actor.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn protocol_discovery_accepts_legacy_and_current_rejects_future_version() {
let state = fresh_state();
let app = avc_router(state);
let legacy = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/avc/protocol")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(legacy.status(), StatusCode::OK);
let legacy_info: AvcProtocolInfo =
serde_json::from_slice(&read_body(legacy).await).unwrap();
assert_eq!(legacy_info.protocol_version, AVC_PROTOCOL_VERSION);
assert_eq!(
legacy_info.min_supported_protocol_version,
AVC_MIN_SUPPORTED_PROTOCOL_VERSION
);
assert_eq!(legacy_info.wasm_package_name, WASM_PACKAGE_NAME);
let current = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!(
"/api/v1/avc/protocol?protocol_version={AVC_PROTOCOL_VERSION}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(current.status(), StatusCode::OK);
let future = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!(
"/api/v1/avc/protocol?protocol_version={}",
AVC_MAX_SUPPORTED_PROTOCOL_VERSION + 1
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(future.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn receipt_emit_does_not_stamp_receipt_with_caller_validation_time() {
let state = fresh_state();
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let caller_supplied_now = Timestamp::new(1_500_000, 0);
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: caller_supplied_now,
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: EmitReceiptResponse =
serde_json::from_slice(&read_body(response).await).unwrap();
assert!(
parsed.receipt.created_at > caller_supplied_now,
"node-signed receipts must use a trusted node HLC timestamp, not caller validation.now"
);
}
#[tokio::test]
async fn receipt_emit_rejects_credential_expired_at_trusted_node_time() {
let state = fresh_state();
let credential = credential_expiring_at(Timestamp::new(1_550_000, 0));
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let backdated_now = Timestamp::new(1_500_000, 0);
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: backdated_now,
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_accepts_http_external_timestamp_authority_proof() {
let (endpoint, timestamp_authority) =
serve_test_timestamp_authority(TestTimestampAuthorityMode::Valid).await;
let request = AvcValidationRequest {
credential: baseline_credential(),
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let credential_id = request.credential.id().unwrap();
let action_id = request.action.as_ref().unwrap().action_id;
let action_commitment_hash = avc_action_commitment_hash(
&request.credential,
request.action.as_ref().unwrap(),
&request.now,
)
.unwrap();
let action_descriptor = AvcActionDescriptor::from_action(request.action.as_ref().unwrap());
let action_descriptor_hash = avc_action_descriptor_hash(&action_descriptor).unwrap();
let (status, state, response_body) =
emit_baseline_receipt_with_source(http_external_timestamp_source(endpoint)).await;
assert_eq!(status, StatusCode::OK);
let parsed: EmitReceiptResponse = serde_json::from_slice(&response_body).unwrap();
let proof = parsed.receipt.external_timestamp_proof.as_ref().unwrap();
let evidence_subject_hash = AvcReceiptEvidenceSubject {
credential_id,
action_id,
action_commitment_hash,
action_descriptor_hash,
previous_receipt_hash: None,
}
.hash()
.unwrap();
assert_eq!(proof.authority_did, timestamp_authority_did());
assert_eq!(proof.subject_hash, evidence_subject_hash);
assert_eq!(parsed.receipt.created_at, Timestamp::new(1_700_000, 7));
assert_eq!(
parsed.receipt.timestamp_provenance,
Some(AvcReceiptTimestampProvenance::ExternalTimestampAuthority)
);
assert!(
proof
.verify_signature(timestamp_authority_keypair().public_key())
.unwrap()
);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 1);
timestamp_authority.abort();
}
#[tokio::test]
async fn receipt_emit_fails_closed_for_http_external_timestamp_non_success_status() {
let (endpoint, timestamp_authority) =
serve_test_timestamp_authority(TestTimestampAuthorityMode::NonSuccess).await;
let (status, state, _) =
emit_baseline_receipt_with_source(http_external_timestamp_source(endpoint)).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
timestamp_authority.abort();
}
#[tokio::test]
async fn receipt_emit_fails_closed_for_http_external_timestamp_invalid_json() {
let (endpoint, timestamp_authority) =
serve_test_timestamp_authority(TestTimestampAuthorityMode::InvalidJson).await;
let (status, state, _) =
emit_baseline_receipt_with_source(http_external_timestamp_source(endpoint)).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
timestamp_authority.abort();
}
#[tokio::test]
async fn receipt_emit_fails_closed_for_http_external_timestamp_wrong_subject() {
let (endpoint, timestamp_authority) =
serve_test_timestamp_authority(TestTimestampAuthorityMode::WrongSubject).await;
let (status, state, _) =
emit_baseline_receipt_with_source(http_external_timestamp_source(endpoint)).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
timestamp_authority.abort();
}
#[tokio::test]
async fn receipt_emit_fails_closed_for_http_external_timestamp_wrong_authority() {
let (endpoint, timestamp_authority) =
serve_test_timestamp_authority(TestTimestampAuthorityMode::WrongAuthority).await;
let (status, state, _) =
emit_baseline_receipt_with_source(http_external_timestamp_source(endpoint)).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
timestamp_authority.abort();
}
#[tokio::test]
async fn receipt_emit_fails_closed_for_http_external_timestamp_bad_signature() {
let (endpoint, timestamp_authority) =
serve_test_timestamp_authority(TestTimestampAuthorityMode::BadSignature).await;
let (status, state, _) =
emit_baseline_receipt_with_source(http_external_timestamp_source(endpoint)).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
timestamp_authority.abort();
}
#[tokio::test]
async fn receipt_emit_fails_closed_for_unreachable_http_external_timestamp_authority() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
drop(listener);
let (status, state, _) = emit_baseline_receipt_with_source(http_external_timestamp_source(
format!("http://{address}"),
))
.await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_fails_closed_for_strict_rfc3161_malformed_der_before_storage() {
let (endpoint, timestamp_authority) =
serve_test_rfc3161_timestamp_authority(StatusCode::OK, vec![0x30, 0x03, 0x02]).await;
let (status, state, _) = emit_baseline_receipt_with_source_and_strict(
rfc3161_external_timestamp_source(endpoint),
true,
)
.await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
timestamp_authority.abort();
}
#[tokio::test]
async fn rfc3161_timestamp_fetch_retries_transient_status_before_success() {
let (endpoint, attempts, timestamp_authority) =
serve_test_rfc3161_timestamp_authority_sequence(vec![
(StatusCode::SERVICE_UNAVAILABLE, Vec::new()),
(StatusCode::TOO_MANY_REQUESTS, Vec::new()),
(StatusCode::OK, vec![0x30, 0x00]),
])
.await;
let response_der = fetch_rfc3161_timestamp_response(
&reqwest::Client::new(),
endpoint.as_str(),
&[0x30, 0x01, 0x00],
)
.await
.unwrap();
assert_eq!(response_der, vec![0x30, 0x00]);
assert_eq!(attempts.load(Ordering::SeqCst), 3);
timestamp_authority.abort();
}
#[tokio::test]
async fn receipt_emit_does_not_retry_rfc3161_verification_failures() {
let (endpoint, attempts, timestamp_authority) =
serve_test_rfc3161_timestamp_authority_sequence(vec![(
StatusCode::OK,
vec![0x30, 0x03, 0x02],
)])
.await;
let (status, state, _) = emit_baseline_receipt_with_source_and_strict(
rfc3161_external_timestamp_source(endpoint),
true,
)
.await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
timestamp_authority.abort();
}
#[test]
fn external_timestamp_diagnostics_distinguish_unconfigured_from_unreachable() {
let source = include_str!("avc.rs");
let production = source
.split("\n// ---------------------------------------------------------------------------\n// Tests")
.next()
.unwrap();
assert!(
production.contains("AvcExternalTimestampFailure::Unconfigured"),
"operator diagnostics must keep missing TSA configuration distinct from runtime reachability failures"
);
assert!(
production.contains("AvcExternalTimestampFailure::Unreachable"),
"operator diagnostics must classify configured-but-unreachable TSA failures separately"
);
assert!(
production.contains("external_timestamp_error_class"),
"the 503 path must attach a stable operator-facing TSA error class before returning the generic public error"
);
}
#[test]
fn external_timestamp_error_surfaces_operator_class_in_public_message() {
// A signer-SPKI pin rejection (the production misdiagnosis) must not read
// as a generic "authority unavailable": the public 503 message carries the
// stable operator class so callers seeing only the response — not the node
// logs — can tell a verification/config failure from upstream downtime.
// Cover every failure class so each operator_class arm is exercised.
let cases = [
(AvcExternalTimestampFailure::Unconfigured, "unconfigured"),
(
AvcExternalTimestampFailure::Unreachable {
reason: "connection refused".to_owned(),
},
"unreachable",
),
(
AvcExternalTimestampFailure::Rejected {
status: "503 Service Unavailable".to_owned(),
},
"rejected",
),
(
AvcExternalTimestampFailure::InvalidResponse {
reason: "malformed DER".to_owned(),
},
"invalid_response",
),
(
AvcExternalTimestampFailure::InvalidProof {
reason: "RFC 3161 TSA signer public key did not match any pinned SPKI DER"
.to_owned(),
},
"invalid_proof",
),
];
for (failure, expected_class) in cases {
let err: anyhow::Error = failure.into();
let (status, message) = external_timestamp_error(err);
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(
message.contains(expected_class),
"public TSA error must carry operator class '{expected_class}', got: {message}"
);
}
}
#[tokio::test]
async fn receipt_emit_fails_closed_when_external_timestamp_authority_is_required_but_unconfigured()
{
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let state = AvcApiState {
registry: Arc::new(Mutex::new(InMemoryAvcRegistry::new())),
validator_did: validator_did(),
receipt_signer: signer,
external_timestamp_source: AvcReceiptExternalTimestampSource::Unconfigured,
receipt_clock: Arc::new(Mutex::new(HybridClock::new())),
require_external_timestamp: true,
finality_store: None,
durability: AvcRegistryDurability::None,
};
seed_avc_trust_keys(&state);
let state = Arc::new(state);
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_is_idempotent_for_identical_request() {
let state = fresh_state();
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let first = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body.clone()))
.unwrap(),
)
.await
.unwrap();
let second = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert_eq!(second.status(), StatusCode::OK);
assert_eq!(
state.registry.lock().unwrap().receipt_count(),
1,
"identical receipt emission requests must remain idempotent"
);
}
#[tokio::test]
async fn receipt_emit_rejects_conflicting_stored_action_commitment() {
let state = fresh_state();
let credential = baseline_credential();
let request = AvcValidationRequest {
credential: credential.clone(),
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let action_commitment_hash = avc_action_commitment_hash(
&request.credential,
request.action.as_ref().unwrap(),
&request.now,
)
.unwrap();
{
let mut registry = state.registry.lock().unwrap();
registry.put_credential(credential).unwrap();
let mut trusted_request = request.clone();
trusted_request.now = Timestamp::new(1_600_000, 0);
let validation = validate_avc(&trusted_request, &*registry).unwrap();
let conflicting_receipt = create_trust_receipt_with_evidence(
&validation,
Some(Hash256::from_bytes([0x99; 32])),
AvcTrustReceiptEvidence {
action_commitment_hash: Some(action_commitment_hash),
action_descriptor: None,
previous_receipt_hash: None,
timestamp_provenance: Some(AvcReceiptTimestampProvenance::FixedTestTimestamp),
external_timestamp_proof: None,
},
validator_did(),
Timestamp::new(1_600_000, 0),
|bytes| validator_keypair().sign(bytes),
)
.unwrap();
registry.put_receipt(conflicting_receipt).unwrap();
}
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 1);
}
#[tokio::test]
async fn receipt_emit_accepts_derived_subject_public_key_when_registry_key_absent() {
let state = fresh_state();
let subject = crate::identity::did_from_public_key(subject_keypair().public_key()).unwrap();
let credential = credential_for_subject(subject.clone());
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(subject)),
now: Timestamp::new(1_500_000, 0),
};
let subject_signature = sign_action(&request, &subject_keypair());
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request,
subject_signature,
subject_public_key: Some(*subject_keypair().public_key()),
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 1);
}
#[test]
fn duplicate_same_receipt_is_idempotent() {
let mut registry = InMemoryAvcRegistry::new();
registry.put_public_key(Did::new("did:exo:issuer").unwrap(), issuer_keypair().public);
registry.put_receipt_validator_public_key(validator_did(), validator_keypair().public);
let credential = baseline_credential();
let credential_id = registry.put_credential(credential).unwrap();
let validation = AvcValidationResult {
credential_id,
decision: AvcDecision::Allow,
reason_codes: Vec::new(),
normalized_holder_did: Did::new("did:exo:agent").unwrap(),
valid_until: Some(Timestamp::new(2_000_000, 0)),
receipt: None,
};
let receipt = create_trust_receipt(
&validation,
Some(Hash256::from_bytes([0x55; 32])),
validator_did(),
Timestamp::new(1_500_000, 0),
|bytes| validator_keypair().sign(bytes),
)
.unwrap();
store_receipt_idempotent(&mut registry, receipt.clone()).unwrap();
store_receipt_idempotent(&mut registry, receipt).unwrap();
assert_eq!(registry.receipt_count(), 1);
}
#[test]
fn avc_registry_validator_key_registration_does_not_create_issuer_trust() {
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let state = AvcApiState::new(validator_did(), signer);
state
.register_validator_public_keys([(validator_did(), validator_keypair().public)])
.unwrap();
assert_eq!(
state
.registry
.lock()
.unwrap()
.resolve_public_key(&validator_did()),
None
);
}
#[test]
fn avc_registry_validator_key_registration_errors_when_mutex_is_poisoned() {
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let state = AvcApiState::new(validator_did(), signer);
let registry = Arc::clone(&state.registry);
let _ = std::panic::catch_unwind(move || {
let _guard = registry.lock().unwrap();
panic!("poison AVC registry mutex for validator key registration test");
});
let error = state
.register_validator_public_keys([(validator_did(), validator_keypair().public)])
.unwrap_err();
assert_eq!(
error.to_string(),
"AVC registry unavailable while registering validator public key"
);
}
#[test]
fn avc_registry_validator_key_registration_rejects_loaded_receipt_without_swapping_state() {
let other_validator_keypair = KeyPair::from_secret_bytes([0x44; 32]).unwrap();
let credential = baseline_credential();
let credential_id = credential.id().unwrap();
let validation = AvcValidationResult {
credential_id,
decision: AvcDecision::Allow,
reason_codes: Vec::new(),
normalized_holder_did: Did::new("did:exo:agent").unwrap(),
valid_until: Some(Timestamp::new(2_000_000, 0)),
receipt: None,
};
let receipt = create_trust_receipt(
&validation,
None,
validator_did(),
Timestamp::new(1_500_000, 0),
|bytes| other_validator_keypair.sign(bytes),
)
.unwrap();
let mut durable_state = AvcRegistryDurableState::default();
durable_state.credentials.insert(credential_id, credential);
durable_state.receipts.insert(receipt.receipt_id, receipt);
let loaded_registry = InMemoryAvcRegistry::from_durable_state(durable_state).unwrap();
let signer: AvcReceiptSigner = Arc::new(|payload: &[u8]| validator_keypair().sign(payload));
let state = AvcApiState::new(validator_did(), signer);
*state.registry.lock().unwrap() = loaded_registry;
let error = state
.register_validator_public_keys([(validator_did(), validator_keypair().public)])
.unwrap_err();
let error = error.to_string();
assert!(error.contains("AVC durable receipt validation failed"));
assert!(error.contains("signature"));
let post_failure_error = {
let registry = state.registry.lock().unwrap();
registry.validate_loaded_receipts().unwrap_err()
};
assert_eq!(
post_failure_error.to_string(),
"AVC invalid input: receipt validator public key for did:exo:validator is unresolved",
"failed validator key registration must not replace the live registry with the candidate"
);
}
#[test]
fn avc_registry_registered_credential_mismatch_is_rejected() {
let credential = baseline_credential();
let credential_id = credential.id().unwrap();
let mut submitted_credential = credential.clone();
submitted_credential.signature = Signature::from_bytes([0x44; 64]);
assert_eq!(submitted_credential.id().unwrap(), credential_id);
let mut registry = InMemoryAvcRegistry::new();
registry.put_public_key(Did::new("did:exo:issuer").unwrap(), issuer_keypair().public);
registry.put_credential(credential).unwrap();
let request = AvcValidationRequest {
credential: submitted_credential,
action: None,
now: Timestamp::new(1_500_000, 0),
};
let error = require_registered_credential(®istry, &request).unwrap_err();
assert_eq!(
error,
(
StatusCode::BAD_REQUEST,
"credential does not match registered AVC".into()
)
);
}
#[test]
fn invalid_receipt_storage_maps_to_client_error_without_state_change() {
let validation = AvcValidationResult {
credential_id: Hash256::from_bytes([0x88; 32]),
decision: AvcDecision::Allow,
reason_codes: Vec::new(),
normalized_holder_did: Did::new("did:exo:agent").unwrap(),
valid_until: Some(Timestamp::new(2_000_000, 0)),
receipt: None,
};
let receipt = create_trust_receipt(
&validation,
None,
validator_did(),
Timestamp::new(1_500_000, 0),
|bytes| validator_keypair().sign(bytes),
)
.unwrap();
let mut registry = InMemoryAvcRegistry::new();
let error = store_receipt_idempotent(&mut registry, receipt).unwrap_err();
assert_eq!(error.0, StatusCode::BAD_REQUEST);
assert_eq!(registry.receipt_count(), 0);
}
#[test]
fn internal_avc_errors_are_redacted_for_clients() {
let registry_error = map_avc_error(exo_avc::AvcError::Registry {
reason: "duplicate".into(),
});
assert_eq!(
registry_error,
(StatusCode::INTERNAL_SERVER_ERROR, "AVC error".into())
);
let serialization_error = map_avc_error(exo_avc::AvcError::Serialization {
reason: "cbor".into(),
});
assert_eq!(
serialization_error,
(StatusCode::INTERNAL_SERVER_ERROR, "AVC error".into())
);
}
#[test]
fn client_avc_errors_preserve_rejection_context() {
let cases = vec![
exo_avc::AvcError::EmptyField { field: "purpose" },
exo_avc::AvcError::UnsupportedSchema {
got: 99,
supported: 1,
},
exo_avc::AvcError::UnsupportedProtocol {
got: 99,
min_supported: 1,
max_supported: 1,
},
exo_avc::AvcError::BasisPointOutOfRange {
field: "risk",
value: 10_001,
},
exo_avc::AvcError::InvalidTimestamp {
reason: "expired".into(),
},
exo_avc::AvcError::DelegationWidens {
dimension: "permissions",
},
exo_avc::AvcError::DelegationRejected {
reason: "missing parent".into(),
},
exo_avc::AvcError::InvalidInput {
reason: "malformed".into(),
},
];
for err in cases {
let (status, body) = map_avc_error(err);
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!body.is_empty());
}
}
#[test]
fn hash_parser_requires_canonical_lowercase_hex() {
let uppercase_id = "AA".repeat(32);
let uppercase_error = parse_hash(&uppercase_id).unwrap_err();
assert_eq!(
uppercase_error,
(
StatusCode::BAD_REQUEST,
"credential id must be lowercase hex".into()
)
);
let short_error = parse_hash("11").unwrap_err();
assert_eq!(
short_error,
(
StatusCode::BAD_REQUEST,
"credential id must be 32 bytes (64 hex chars)".into()
)
);
}
#[test]
fn receipt_signature_requires_action_before_key_resolution() {
let request = AvcValidationRequest {
credential: baseline_credential(),
action: None,
now: Timestamp::new(1_500_000, 0),
};
let registry = InMemoryAvcRegistry::new();
let error = verify_subject_action_signature(
®istry,
&request,
&Signature::from_bytes([0x44; 64]),
None,
)
.unwrap_err();
assert_eq!(
error,
(
StatusCode::BAD_REQUEST,
"receipt emission requires an action".into()
)
);
}
#[test]
fn api_error_helpers_are_deterministic_and_redacted() {
let credential = baseline_credential();
let id = credential.id().unwrap();
let summary = summary_of(&credential).unwrap();
assert_eq!(summary.credential_id, format!("{id}"));
assert_eq!(summary.subject_did, "did:exo:agent");
assert_eq!(summary.issuer_did, "did:exo:issuer");
assert_eq!(summary.principal_did, "did:exo:issuer");
let persistence = persistence_error(anyhow::anyhow!("database DSN"));
assert_eq!(
persistence,
(
StatusCode::INTERNAL_SERVER_ERROR,
"AVC registry persistence failed".into()
)
);
}
#[tokio::test]
async fn receipt_emit_registry_subject_key_wins_over_supplied_public_key() {
let state = fresh_state();
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let attacker_keypair = KeyPair::from_secret_bytes([0x44; 32]).unwrap();
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &attacker_keypair),
subject_public_key: Some(*attacker_keypair.public_key()),
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_rejects_missing_action_without_receipt() {
let state = fresh_state();
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: None,
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request,
subject_signature: Signature::from_bytes([0x77; 64]),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_rejects_missing_subject_public_key_without_receipt() {
let state = fresh_state();
let subject = crate::identity::did_from_public_key(subject_keypair().public_key()).unwrap();
let credential = credential_for_subject(subject.clone());
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(subject)),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request,
subject_signature: Signature::from_bytes([0x77; 64]),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_rejects_mismatched_supplied_subject_key_without_receipt() {
let state = fresh_state();
let subject = Did::new("did:exo:detached-agent").unwrap();
let credential = credential_for_subject(subject.clone());
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(subject)),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request.clone(),
subject_signature: sign_action(&request, &subject_keypair()),
subject_public_key: Some(*subject_keypair().public_key()),
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_rejects_unregistered_credential_without_receipt() {
let state = fresh_state();
let credential = baseline_credential();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
subject_signature: sign_action(&request, &subject_keypair()),
validation: request,
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_rejects_registered_credential_mismatch_without_receipt() {
let state = fresh_state();
let registered = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(registered.clone())
.unwrap();
let mut submitted = registered;
submitted.signature = Signature::empty();
let request = AvcValidationRequest {
credential: submitted,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
subject_signature: sign_action(&request, &subject_keypair()),
validation: request,
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_rejects_invalid_subject_signature_without_receipt() {
let state = fresh_state();
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request,
subject_signature: Signature::from_bytes([0x99; 64]),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_rejects_empty_subject_signature_without_receipt() {
let state = fresh_state();
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let request = AvcValidationRequest {
credential,
action: Some(baseline_action(Did::new("did:exo:agent").unwrap())),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
validation: request,
subject_signature: Signature::empty(),
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn receipt_emit_rejects_denied_validation_without_receipt() {
let state = fresh_state();
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let mut action = baseline_action(Did::new("did:exo:agent").unwrap());
action.requested_permission = Permission::Write;
let request = AvcValidationRequest {
credential,
action: Some(action),
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&EmitReceiptRequest {
subject_signature: sign_action(&request, &subject_keypair()),
validation: request,
subject_public_key: None,
})
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/receipts/emit")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(state.registry.lock().unwrap().receipt_count(), 0);
}
#[tokio::test]
async fn validate_rejects_caller_claimed_human_approval_without_evidence() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let mut draft = baseline_draft();
draft.constraints.human_approval_required = true;
let credential = {
let kp = issuer_keypair();
issue_avc(draft, |bytes| kp.sign(bytes)).unwrap()
};
let action = AvcActionRequest {
action_id: Hash256::from_bytes([0x55; 32]),
actor_did: credential.subject_did.clone(),
requested_permission: Permission::Read,
tool: None,
target_did: None,
data_class: None,
estimated_budget_minor_units: None,
estimated_risk_bp: None,
human_approval: None,
requires_human_approval: true,
action_name: None,
};
let request = AvcValidationRequest {
credential,
action: Some(action),
now: Timestamp::new(1_500_000, 0),
};
let mut body_value = serde_json::to_value(&request).unwrap();
body_value
.get_mut("action")
.and_then(serde_json::Value::as_object_mut)
.unwrap()
.remove("human_approval");
let body = serde_json::to_vec(&body_value).unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/validate")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: AvcValidationResult =
serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed.decision, AvcDecision::HumanApprovalRequired);
assert_eq!(
parsed.reason_codes,
vec![AvcReasonCode::HumanApprovalMissing]
);
}
#[tokio::test]
async fn validate_returns_200_with_deny_for_revoked() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let credential = baseline_credential();
let id = credential.id().unwrap();
// Pre-register and revoke.
{
let kp = issuer_keypair();
let mut reg = state.registry.lock().unwrap();
reg.put_credential(credential.clone()).unwrap();
let revocation = revoke_avc(
id,
Did::new("did:exo:issuer").unwrap(),
AvcRevocationReason::IssuerRevoked,
Timestamp::new(2, 0),
|bytes| kp.sign(bytes),
)
.unwrap();
reg.put_revocation(revocation).unwrap();
}
let request = AvcValidationRequest {
credential,
action: None,
now: Timestamp::new(1_500_000, 0),
};
let body = serde_json::to_vec(&request).unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/validate")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: AvcValidationResult =
serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed.decision, AvcDecision::Deny);
}
#[tokio::test]
async fn revoke_marks_credential_revoked() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let credential = baseline_credential();
let id = credential.id().unwrap();
// Pre-register so we can revoke.
state
.registry
.lock()
.unwrap()
.put_credential(credential)
.unwrap();
let kp = issuer_keypair();
let revocation = revoke_avc(
id,
Did::new("did:exo:issuer").unwrap(),
AvcRevocationReason::IssuerRevoked,
Timestamp::new(2, 0),
|bytes| kp.sign(bytes),
)
.unwrap();
let body = serde_json::to_vec(&RevokeRequest { revocation }).unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/revoke")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(state.registry.lock().unwrap().is_revoked(&id));
}
#[tokio::test]
async fn revoke_rejects_unsigned_revocation_without_marking_credential_revoked() {
let state = fresh_state();
let app = avc_router(Arc::clone(&state));
let credential = baseline_credential();
let id = credential.id().unwrap();
state
.registry
.lock()
.unwrap()
.put_credential(credential)
.unwrap();
let kp = issuer_keypair();
let mut revocation = revoke_avc(
id,
Did::new("did:exo:issuer").unwrap(),
AvcRevocationReason::IssuerRevoked,
Timestamp::new(2, 0),
|bytes| kp.sign(bytes),
)
.unwrap();
revocation.signature = Signature::empty();
let body = serde_json::to_vec(&RevokeRequest { revocation }).unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/avc/revoke")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(
!state.registry.lock().unwrap().is_revoked(&id),
"unsigned revocation must not create a tombstone"
);
}
#[tokio::test]
async fn get_returns_404_when_unknown() {
let state = fresh_state();
let app = avc_router(state);
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/v1/avc/{}", "11".repeat(32)))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_returns_400_for_short_hex() {
let state = fresh_state();
let app = avc_router(state);
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/avc/11")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn get_returns_credential_when_known() {
let state = fresh_state();
let credential = baseline_credential();
let id = credential.id().unwrap();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/v1/avc/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: AutonomousVolitionCredential =
serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed, credential);
}
#[tokio::test]
async fn get_returns_400_for_bad_hex() {
let state = fresh_state();
let app = avc_router(state);
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/avc/not-hex")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn get_returns_400_for_uppercase_hex() {
let state = fresh_state();
let app = avc_router(state);
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/v1/avc/{}", "AA".repeat(32)))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn list_returns_400_for_invalid_did() {
let state = fresh_state();
let app = avc_router(state);
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/agents/not-a-did/avcs")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn list_returns_empty_credentials_for_subject_without_records() {
let state = fresh_state();
let app = avc_router(state);
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/agents/did:exo:agent/avcs")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: ListAvcResponse = serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed.did, "did:exo:agent");
assert!(parsed.credentials.is_empty());
}
#[tokio::test]
async fn list_returns_credentials_for_subject() {
let state = fresh_state();
let credential = baseline_credential();
state
.registry
.lock()
.unwrap()
.put_credential(credential.clone())
.unwrap();
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/agents/did:exo:agent/avcs")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: ListAvcResponse = serde_json::from_slice(&read_body(response).await).unwrap();
assert_eq!(parsed.did, "did:exo:agent");
assert_eq!(parsed.credentials.len(), 1);
}
#[tokio::test]
async fn list_returns_credentials_in_deterministic_id_order() {
let state = fresh_state();
let first = credential_with_purpose("deterministic order first");
let second = credential_with_purpose("deterministic order second");
let first_id = first.id().unwrap();
let second_id = second.id().unwrap();
{
let mut registry = state.registry.lock().unwrap();
registry.put_credential(second).unwrap();
registry.put_credential(first).unwrap();
}
let app = avc_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/v1/agents/did:exo:agent/avcs")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let parsed: ListAvcResponse = serde_json::from_slice(&read_body(response).await).unwrap();
let mut expected = vec![format!("{first_id}"), format!("{second_id}")];
expected.sort();
let actual = parsed
.credentials
.into_iter()
.map(|summary| summary.credential_id)
.collect::<Vec<_>>();
assert_eq!(actual, expected);
}
#[test]
fn expected_root_trust_constants_reject_wrong_lengths() {
let hash_error = parse_expected_hash("00", "test hash")
.unwrap_err()
.to_string();
assert!(hash_error.contains("expected 32 bytes"));
let key_error = parse_expected_public_key("00", "test key")
.unwrap_err()
.to_string();
assert!(key_error.contains("expected 32 bytes"));
}
fn external_timestamp_source_from_pairs(
pairs: Vec<(&'static str, &str)>,
) -> anyhow::Result<AvcReceiptExternalTimestampSource> {
let values = pairs
.iter()
.copied()
.collect::<std::collections::BTreeMap<_, _>>();
configured_external_timestamp_source_from_reader(|name| {
Ok(values.get(name).map(|value| (*value).to_owned()))
})
}
#[test]
fn rfc3161_env_config_requires_kind_triplet_policy_and_pinned_spki() {
let err = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://timestamp.acs.microsoft.com",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:microsoft-public-rsa-tsa",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
"30820122300d06092a864886f70d01010105000382010f",
),
])
.unwrap_err()
.to_string();
assert!(err.contains(AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV));
let microsoft_tsa_pin_set = format!(
"{MICROSOFT_FIXTURE_SIGNER_SPKI_HEX},{MICROSOFT_LIVE_SIGNER_SPKI_HEX_20260627}"
);
let source = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://timestamp.acs.microsoft.com",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:microsoft-public-rsa-tsa",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
microsoft_tsa_pin_set.as_str(),
),
(
AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID,
),
])
.unwrap();
match source {
AvcReceiptExternalTimestampSource::Rfc3161 {
endpoint,
authority_did,
authority_public_key_spki_der_hexes,
policy_oid,
..
} => {
assert_eq!(endpoint.as_str(), "http://timestamp.acs.microsoft.com");
assert_eq!(
authority_did.to_string(),
"did:exo:microsoft-public-rsa-tsa"
);
assert_eq!(
authority_public_key_spki_der_hexes.as_slice(),
&[
MICROSOFT_FIXTURE_SIGNER_SPKI_HEX.to_owned(),
MICROSOFT_LIVE_SIGNER_SPKI_HEX_20260627.to_owned(),
]
);
assert_eq!(
policy_oid.as_str(),
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID
);
}
_ => panic!("rfc3161 kind must construct the RFC 3161 timestamp source"),
}
let duplicate_pin_set =
format!("{MICROSOFT_FIXTURE_SIGNER_SPKI_HEX},{MICROSOFT_FIXTURE_SIGNER_SPKI_HEX}");
let duplicate_pin_err = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://timestamp.acs.microsoft.com",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:microsoft-public-rsa-tsa",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
duplicate_pin_set.as_str(),
),
(
AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID,
),
])
.unwrap_err()
.to_string();
assert!(duplicate_pin_err.contains("duplicate member"));
let empty_pin_set = format!("{MICROSOFT_FIXTURE_SIGNER_SPKI_HEX},");
let empty_pin_err = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://timestamp.acs.microsoft.com",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:microsoft-public-rsa-tsa",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
empty_pin_set.as_str(),
),
(
AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID,
),
])
.unwrap_err()
.to_string();
assert!(empty_pin_err.contains("member 2 is empty"));
}
#[test]
fn rfc3161_env_config_accepts_explicit_pinned_issuing_ca_without_leaf_pin() {
let source = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://timestamp.acs.microsoft.com",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:microsoft-public-rsa-tsa",
),
(
AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV,
MICROSOFT_FIXTURE_CA_SPKI_HEX,
),
(
AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID,
),
])
.unwrap();
match source {
AvcReceiptExternalTimestampSource::Rfc3161 {
authority_public_key_spki_der_hexes,
issuing_ca_spki_der_hexes,
..
} => {
assert!(authority_public_key_spki_der_hexes.is_empty());
assert_eq!(
issuing_ca_spki_der_hexes.as_slice(),
&[MICROSOFT_FIXTURE_CA_SPKI_HEX.to_owned()]
);
}
_ => panic!("rfc3161 kind must construct the RFC 3161 timestamp source"),
}
}
#[test]
fn external_timestamp_source_debug_redacts_transport_clients_and_pin_material() {
let rfc3161 = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://timestamp.acs.microsoft.com",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:microsoft-public-rsa-tsa",
),
(
AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV,
MICROSOFT_FIXTURE_CA_SPKI_HEX,
),
(
AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID,
),
])
.unwrap();
let rfc3161_debug = format!("{rfc3161:?}");
assert!(rfc3161_debug.contains("AvcReceiptExternalTimestampSource::Rfc3161"));
assert!(rfc3161_debug.contains("http://timestamp.acs.microsoft.com"));
assert!(rfc3161_debug.contains("did:exo:microsoft-public-rsa-tsa"));
assert!(rfc3161_debug.contains(crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID));
assert!(!rfc3161_debug.contains(MICROSOFT_FIXTURE_CA_SPKI_HEX));
assert!(!rfc3161_debug.contains("client"));
let http = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://127.0.0.1:3000",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:timestamp-authority",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
×tamp_authority_keypair().public.to_string(),
),
])
.unwrap();
let http_debug = format!("{http:?}");
assert!(http_debug.contains("AvcReceiptExternalTimestampSource::HttpJson"));
assert!(http_debug.contains("http://127.0.0.1:3000"));
assert!(http_debug.contains("did:exo:timestamp-authority"));
assert!(!http_debug.contains(×tamp_authority_keypair().public.to_string()));
assert!(!http_debug.contains("client"));
assert_eq!(
format!("{:?}", AvcReceiptExternalTimestampSource::Unconfigured),
"AvcReceiptExternalTimestampSource::Unconfigured"
);
let fixed = fixed_external_timestamp_source(Timestamp::new(1_600_000, 0));
let fixed_debug = format!("{fixed:?}");
assert!(fixed_debug.contains("AvcReceiptExternalTimestampSource::Fixed"));
assert!(fixed_debug.contains("did:exo:timestamp-authority"));
assert!(fixed_debug.contains("issued_at"));
let fixed_rfc3161 = fixed_rfc3161_external_timestamp_source();
let fixed_rfc3161_debug = format!("{fixed_rfc3161:?}");
assert!(fixed_rfc3161_debug.contains("AvcReceiptExternalTimestampSource::FixedRfc3161"));
assert!(fixed_rfc3161_debug.contains("did:exo:microsoft-public-rsa-tsa"));
assert!(
fixed_rfc3161_debug.contains(crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID)
);
assert!(!fixed_rfc3161_debug.contains(MICROSOFT_FIXTURE_SIGNER_SPKI_HEX));
}
#[test]
fn rfc3161_env_config_rejects_missing_leaf_and_ca_trust_anchors() {
let err = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://timestamp.acs.microsoft.com",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:microsoft-public-rsa-tsa",
),
(
AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID,
),
])
.unwrap_err()
.to_string();
assert!(err.contains(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV));
assert!(err.contains(AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV));
}
#[test]
fn external_timestamp_env_config_rejects_cross_protocol_and_malformed_values() {
let json_ca_err = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_JSON_ED25519,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://127.0.0.1:3000",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:timestamp-authority",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
×tamp_authority_keypair().public.to_string(),
),
(
AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV,
MICROSOFT_FIXTURE_CA_SPKI_HEX,
),
])
.unwrap_err()
.to_string();
assert!(json_ca_err.contains(AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV));
assert!(json_ca_err.contains(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161));
let json_did_err = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://127.0.0.1:3000",
),
(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV, "not-a-did"),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
×tamp_authority_keypair().public.to_string(),
),
])
.unwrap_err()
.to_string();
assert!(json_did_err.contains(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV));
let rfc3161_missing_endpoint_err = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:microsoft-public-rsa-tsa",
),
(
AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV,
MICROSOFT_FIXTURE_CA_SPKI_HEX,
),
(
AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID,
),
])
.unwrap_err()
.to_string();
assert!(rfc3161_missing_endpoint_err.contains(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV));
let rfc3161_did_err = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161,
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://timestamp.acs.microsoft.com",
),
(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV, "not-a-did"),
(
AVC_RFC3161_TIMESTAMP_CA_SPKI_HEX_ENV,
MICROSOFT_FIXTURE_CA_SPKI_HEX,
),
(
AVC_RFC3161_TIMESTAMP_POLICY_OID_ENV,
crate::avc_rfc3161::MICROSOFT_ARTIFACT_SIGNING_POLICY_OID,
),
])
.unwrap_err()
.to_string();
assert!(rfc3161_did_err.contains(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV));
let unknown_kind_err = external_timestamp_source_from_pairs(vec![(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_ENV,
"not-a-supported-kind",
)])
.unwrap_err()
.to_string();
assert!(unknown_kind_err.contains(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_JSON_ED25519));
assert!(unknown_kind_err.contains(AVC_EXTERNAL_TIMESTAMP_AUTHORITY_KIND_RFC3161));
}
#[test]
fn json_ed25519_env_config_remains_backward_compatible_without_kind() {
let source = external_timestamp_source_from_pairs(vec![
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_URL_ENV,
"http://127.0.0.1:3000",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_DID_ENV,
"did:exo:timestamp-authority",
),
(
AVC_EXTERNAL_TIMESTAMP_AUTHORITY_PUBLIC_KEY_HEX_ENV,
×tamp_authority_keypair().public.to_string(),
),
])
.unwrap();
match source {
AvcReceiptExternalTimestampSource::HttpJson { .. } => {}
_ => panic!("missing kind must preserve the legacy JSON Ed25519 adapter"),
}
}
#[test]
fn router_uses_blocking_store_access() {
let source = include_str!("avc.rs");
let production = source
.split("\n// ---------------------------------------------------------------------------\n// Tests")
.next()
.unwrap();
assert!(
production.contains("tokio::task::spawn_blocking"),
"AVC handlers must isolate synchronous registry access from Tokio workers"
);
assert!(
production.contains("DefaultBodyLimit::max(MAX_AVC_API_BODY_BYTES)"),
"AVC router must cap request body size locally"
);
assert!(
production.contains("ConcurrencyLimitLayer::new(MAX_AVC_API_CONCURRENT_REQUESTS)"),
"AVC router must apply local request admission control"
);
}
#[test]
fn durable_registry_prefers_postgres_and_keeps_file_fallback() {
let source = include_str!("avc.rs");
let production = source
.split("\n// ---------------------------------------------------------------------------\n// Tests")
.next()
.unwrap();
assert!(
production.contains("database_pool: Option<PgPool>"),
"AVC durable registry must accept the existing gateway Postgres pool"
);
assert!(
production.contains("AvcRegistryDurability::Postgres(pool.clone())"),
"AVC durable registry must persist to Postgres when DATABASE_URL is configured"
);
assert!(
production.contains("configured_external_timestamp_source_from_env()?"),
"AVC runtime receipt emission must load the external timestamp authority from explicit environment configuration"
);
assert!(
production.contains("validate_external_timestamp_proof(")
&& production.contains("AVC_RECEIPT_EVIDENCE_SUBJECT_DOMAIN"),
"AVC production receipt timestamps must verify a signed external evidence-subject proof"
);
assert!(
!production.contains("SystemTime::now") && !production.contains("Instant::now"),
"AVC production receipt emission must not read Rust system time directly"
);
assert!(
production.contains("pg_advisory_xact_lock"),
"AVC Postgres mutations must use a database transaction lock"
);
assert!(
production.contains("load_postgres_durable_registry_in_transaction"),
"AVC Postgres mutations must reload current durable state inside the lock"
);
assert!(
production.contains(".apply_durable_state(fresh_registry.durable_state())"),
"AVC Postgres reload must merge durable records without wiping trust anchors"
);
assert!(
!production.contains("*guard = fresh_registry"),
"AVC Postgres reload must not replace the full registry and drop startup trust anchors"
);
assert!(
production.contains("load_postgres_durable_registry_or_import_file"),
"AVC startup must import any existing file-backed state into Postgres"
);
let file_fallback = production
.split("None => {")
.nth(1)
.and_then(|section| section.split("Ok(Self").next())
.expect("AVC file fallback branch present");
assert!(
file_fallback.contains("tracing::warn!"),
"AVC file fallback startup must warn operators that Postgres durability is unavailable"
);
assert!(
production.contains("AvcRegistryDurability::File"),
"AVC durable registry must keep a no-DATABASE_URL file fallback for local nodes"
);
}
}
#[cfg(test)]
mod avc_root_trust_tests {
use exo_authority::permission::Permission;
use exo_avc::{
AVC_SCHEMA_VERSION, AuthorityScope, AutonomyLevel, AvcConstraints, AvcDraft,
AvcRegistryDurableState, AvcRegistryRead, AvcRevocationReason, AvcSubjectKind,
DelegatedIntent, issue_avc, revoke_avc,
};
use super::*;
fn avc_state_for_root_trust_test() -> AvcApiState {
let signer: AvcReceiptSigner = Arc::new(|_| Signature::empty());
AvcApiState::new(
Did::new("did:exo:test-validator").expect("test DID"),
signer,
)
}
fn avc_state_with_registry(registry: InMemoryAvcRegistry) -> AvcApiState {
let signer: AvcReceiptSigner = Arc::new(|_| Signature::empty());
AvcApiState {
registry: Arc::new(Mutex::new(registry)),
validator_did: Did::new("did:exo:test-validator").expect("test DID"),
receipt_signer: signer,
external_timestamp_source: AvcReceiptExternalTimestampSource::Unconfigured,
receipt_clock: Arc::new(Mutex::new(HybridClock::new())),
require_external_timestamp: false,
finality_store: None,
durability: AvcRegistryDurability::None,
}
}
fn avc_state_with_unreachable_postgres() -> AvcApiState {
let signer: AvcReceiptSigner = Arc::new(|_| Signature::empty());
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.acquire_timeout(std::time::Duration::from_millis(100))
.connect_lazy("postgres://exochain:test@127.0.0.1:1/exochain_test")
.expect("unreachable Postgres pool");
AvcApiState {
registry: Arc::new(Mutex::new(InMemoryAvcRegistry::new())),
validator_did: Did::new("did:exo:test-validator").expect("test DID"),
receipt_signer: signer,
external_timestamp_source: AvcReceiptExternalTimestampSource::Unconfigured,
receipt_clock: Arc::new(Mutex::new(HybridClock::new())),
require_external_timestamp: false,
finality_store: None,
durability: AvcRegistryDurability::Postgres(pool),
}
}
fn repo_root() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("crate has workspace root")
.to_path_buf()
}
fn installed_bundle_path() -> std::path::PathBuf {
repo_root().join("artifacts/trust/avc-exo-ceremony-2026/root-trust-bundle.canonical.json")
}
fn root_issuer_did() -> Did {
Did::new(AVC_ROOT_TRUST_ISSUER_DID).expect("expected issuer DID")
}
fn forged_root_issuer_revocation_registry() -> InMemoryAvcRegistry {
let issuer_did = root_issuer_did();
let credential = issue_avc(
AvcDraft {
schema_version: AVC_SCHEMA_VERSION,
issuer_did: issuer_did.clone(),
principal_did: issuer_did.clone(),
subject_did: Did::new("did:exo:agent").expect("agent DID"),
holder_did: None,
subject_kind: AvcSubjectKind::AiAgent {
model_id: "alpha".into(),
agent_version: None,
},
created_at: Timestamp::new(1_000_000, 0),
expires_at: Some(Timestamp::new(2_000_000, 0)),
delegated_intent: DelegatedIntent {
intent_id: Hash256::from_bytes([0xAA; 32]),
purpose: "research".into(),
allowed_objectives: vec!["primary".into()],
prohibited_objectives: vec![],
autonomy_level: AutonomyLevel::Draft,
delegation_allowed: false,
},
authority_scope: AuthorityScope {
permissions: vec![Permission::Read],
tools: vec![],
data_classes: vec![],
counterparties: vec![],
jurisdictions: vec!["US".into()],
},
constraints: AvcConstraints::permissive(),
authority_chain: None,
consent_refs: vec![],
policy_refs: vec![],
parent_avc_id: None,
},
|_| Signature::from_bytes([0x41; 64]),
)
.expect("test credential");
let credential_id = credential.id().expect("credential id");
let forged_revocation = revoke_avc(
credential_id,
issuer_did,
AvcRevocationReason::IssuerRevoked,
Timestamp::new(1_100_000, 0),
|_| Signature::from_bytes([0x42; 64]),
)
.expect("forged revocation");
let mut durable_state = AvcRegistryDurableState::default();
durable_state
.credentials
.insert(credential_id, credential.clone());
durable_state
.revocations
.insert(credential_id, forged_revocation);
InMemoryAvcRegistry::from_durable_state(durable_state)
.expect("current durable structural checks accept forged signature")
}
#[test]
fn avc_root_trust_bundle_loader_registers_expected_issuer() {
let state = avc_state_for_root_trust_test();
let registration = load_root_trust_bundle_from_path(&state, &installed_bundle_path())
.expect("load bundle");
let expected_did = Did::new(AVC_ROOT_TRUST_ISSUER_DID).expect("expected issuer DID");
let expected_public_key =
parse_expected_public_key(AVC_ROOT_TRUST_ISSUER_PUBLIC_KEY_HEX, "expected issuer key")
.expect("expected issuer key");
let expected_permissions = vec![
Permission::Read,
Permission::Write,
Permission::Execute,
Permission::Delegate,
];
assert_eq!(registration.ceremony_id, AVC_ROOT_TRUST_CEREMONY_ID);
assert_eq!(registration.issuer_did, expected_did);
assert_eq!(registration.issuer_public_key, expected_public_key);
assert_eq!(registration.granted_permissions, expected_permissions);
let registry = state.registry.lock().expect("registry lock");
assert_eq!(
registry.resolve_public_key(&expected_did),
Some(expected_public_key)
);
assert_eq!(
registry.resolve_issuer_permission_grant(&expected_did),
Some(expected_permissions)
);
}
#[tokio::test]
async fn avc_root_trust_valid_bundle_requires_dagdb_receipt_before_registering_issuer() {
let state = avc_state_with_unreachable_postgres();
let error = load_root_trust_bundle_from_path(&state, &installed_bundle_path())
.expect_err("valid bundle must not register without durable DAG DB receipt");
assert!(
error
.to_string()
.contains("failed to persist AVC root bundle DAG DB receipt"),
"expected DAG DB receipt persistence failure, got: {error}"
);
let registry = state.registry.lock().expect("registry lock");
assert_eq!(registry.resolve_public_key(&root_issuer_did()), None);
assert_eq!(
registry.resolve_issuer_permission_grant(&root_issuer_did()),
None
);
}
#[test]
fn avc_root_trust_loader_records_dagdb_receipt_after_verification_before_registry_commit() {
let source = include_str!("avc.rs");
let function_start = source
.find("pub fn load_root_trust_bundle_from_path(")
.expect("root bundle loader function present");
let function_tail = &source[function_start..];
let function_end = function_tail
.find(
"\n// ---------------------------------------------------------------------------",
)
.expect("root bundle loader section ends before response shapes");
let loader_source = &function_tail[..function_end];
let verification_position = loader_source
.find("verify_current_or_pinned_legacy_avc_root_bundle(&bundle, expected_bundle_id)")
.expect("root bundle verification call present");
let receipt_position = loader_source
.find("persist_verified_root_bundle_receipt(state, &bundle)")
.expect("DAG DB root bundle receipt persistence call present");
let registry_commit_position = loader_source
.find("*registry = candidate;")
.expect("root issuer registry commit present");
assert!(
verification_position < receipt_position,
"root bundle DAG DB receipt must be recorded only after verification"
);
assert!(
receipt_position < registry_commit_position,
"root bundle DAG DB receipt must be recorded before the issuer registry commit"
);
}
#[test]
fn avc_root_trust_bundle_tamper_fails_closed_without_registering_issuer() {
let mut bundle: RootTrustBundle =
serde_json::from_slice(&std::fs::read(installed_bundle_path()).expect("read bundle"))
.expect("parse bundle");
bundle.transcript_hash = Hash256::from_bytes([42u8; 32]);
let temp = tempfile::NamedTempFile::new().expect("temp file");
serde_json::to_writer(temp.as_file(), &bundle).expect("write tampered bundle");
let state = avc_state_for_root_trust_test();
let error = load_root_trust_bundle_from_path(&state, temp.path())
.expect_err("tampered bundle must fail");
assert!(
error
.to_string()
.contains("AVC root trust bundle verification failed")
);
let expected_did = Did::new(AVC_ROOT_TRUST_ISSUER_DID).expect("expected issuer DID");
let registry = state.registry.lock().expect("registry lock");
assert_eq!(registry.resolve_public_key(&expected_did), None);
assert_eq!(
registry.resolve_issuer_permission_grant(&expected_did),
None
);
}
#[tokio::test]
async fn avc_root_trust_tamper_fails_before_dagdb_receipt_insert() {
let mut bundle: RootTrustBundle =
serde_json::from_slice(&std::fs::read(installed_bundle_path()).expect("read bundle"))
.expect("parse bundle");
bundle.transcript_hash = Hash256::from_bytes([42u8; 32]);
let temp = tempfile::NamedTempFile::new().expect("temp file");
serde_json::to_writer(temp.as_file(), &bundle).expect("write tampered bundle");
let state = avc_state_with_unreachable_postgres();
let error = load_root_trust_bundle_from_path(&state, temp.path())
.expect_err("tampered bundle must fail before DAG DB receipt insert");
let message = error.to_string();
assert!(
message.contains("AVC root trust bundle verification failed"),
"expected verification failure, got: {error}"
);
assert!(
!message.contains("failed to persist AVC root bundle DAG DB receipt"),
"tampered bundle must not reach DAG DB receipt persistence: {error}"
);
let registry = state.registry.lock().expect("registry lock");
assert_eq!(registry.resolve_public_key(&root_issuer_did()), None);
assert_eq!(
registry.resolve_issuer_permission_grant(&root_issuer_did()),
None
);
}
#[test]
fn avc_root_trust_legacy_bundle_rejects_relabelled_signer_metadata() {
let mut bundle: RootTrustBundle =
serde_json::from_slice(&std::fs::read(installed_bundle_path()).expect("read bundle"))
.expect("parse bundle");
bundle.root_signature.signer_ids = vec![1, 2, 3, 4, 5, 6, 8];
let temp = tempfile::NamedTempFile::new().expect("temp file");
serde_json::to_writer(temp.as_file(), &bundle).expect("write relabelled bundle");
let state = avc_state_for_root_trust_test();
let error = load_root_trust_bundle_from_path(&state, temp.path())
.expect_err("legacy compatibility must not accept relabelled signer metadata");
assert!(
error.to_string().contains("signer metadata"),
"expected signer metadata rejection, got: {error}"
);
let registry = state.registry.lock().expect("registry lock");
assert_eq!(registry.resolve_public_key(&root_issuer_did()), None);
assert_eq!(
registry.resolve_issuer_permission_grant(&root_issuer_did()),
None
);
}
#[test]
fn avc_root_trust_bundle_loader_revalidates_loaded_durable_revocations() {
let state = avc_state_with_registry(forged_root_issuer_revocation_registry());
let error = load_root_trust_bundle_from_path(&state, &installed_bundle_path())
.expect_err("forged durable revocation must fail closed after issuer key registration");
assert!(
error.to_string().contains("revocation signature"),
"root trust startup must surface durable revocation signature validation failure: {error}"
);
let registry = state.registry.lock().expect("registry lock");
assert_eq!(
registry.resolve_public_key(&root_issuer_did()),
None,
"root trust issuer key registration must roll back when durable revocation validation fails"
);
assert_eq!(
registry.resolve_issuer_permission_grant(&root_issuer_did()),
None,
"root trust issuer permission grant must roll back when durable revocation validation fails"
);
}
#[test]
fn avc_root_trust_bundle_missing_required_path_fails_closed() {
let state = avc_state_for_root_trust_test();
let missing = repo_root().join("artifacts/trust/avc-exo-ceremony-2026/missing.json");
let error = load_root_trust_bundle_from_path(&state, &missing)
.expect_err("missing configured bundle path must fail");
assert!(
error
.to_string()
.contains("failed to read AVC root trust bundle")
);
}
#[test]
fn avc_root_trust_bundle_malformed_json_fails_closed() {
let temp = tempfile::NamedTempFile::new().expect("temp file");
std::fs::write(temp.path(), b"not json").expect("write malformed bundle");
let state = avc_state_for_root_trust_test();
let error = load_root_trust_bundle_from_path(&state, temp.path())
.expect_err("malformed configured bundle must fail");
assert!(
error
.to_string()
.contains("failed to parse AVC root trust bundle")
);
}
}