use super::data_ref::{fetch_and_verify_data_ref, DataRefFetcher};
use super::registry::RegistryClient;
use acdp_did::WebResolver;
use acdp_primitives::error::AcdpError;
use acdp_types::{body::FullContext, primitives::CtxId};
use acdp_verify::Verifier;
use std::time::Duration;
use super::revocation_verdict::{RevocationAssessment, RevocationVerdict, SourcedRevocation};
use super::transparency::{
CannotVerifyReason, LogInclusionPolicy, LogInclusionVerdict, TransparencyVerdicts,
WitnessQuorumPolicy,
};
use super::witness::WitnessQuorumReport;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerificationPolicy {
pub validate_body_schema: bool,
pub allow_unknown_status: bool,
pub receipts: ReceiptPolicy,
pub historical_keys: HistoricalKeyPolicy,
pub lineage_head: LineageHeadPolicy,
pub revocations: RevocationPolicy,
pub require_did_web_for_v0_1_0: bool,
pub log_inclusion: LogInclusionPolicy,
pub witness_quorum: Option<WitnessQuorumPolicy>,
}
impl Default for VerificationPolicy {
fn default() -> Self {
Self {
validate_body_schema: true,
allow_unknown_status: true,
receipts: ReceiptPolicy::VerifyIfPresent,
historical_keys: HistoricalKeyPolicy::AcceptWithReceipt,
lineage_head: LineageHeadPolicy::default(),
revocations: RevocationPolicy::default(),
require_did_web_for_v0_1_0: true,
log_inclusion: LogInclusionPolicy::Ignore,
witness_quorum: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct RevocationPolicy {
pub known: Vec<acdp_types::revocation::KeyRevocation>,
pub discover: Option<RevocationDiscovery>,
pub accept_historical_receipt_key_for_pre_t: bool,
}
impl RevocationPolicy {
#[must_use]
pub fn new(known: Vec<acdp_types::revocation::KeyRevocation>) -> Self {
Self {
known,
discover: None,
accept_historical_receipt_key_for_pre_t: false,
}
}
#[must_use]
pub fn accept_historical_receipt_key_for_pre_t(mut self) -> Self {
self.accept_historical_receipt_key_for_pre_t = true;
self
}
#[must_use]
pub fn with_discovery(mut self, discovery: RevocationDiscovery) -> Self {
self.discover = Some(discovery);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct RevocationDiscovery {
pub include_registry_attested: bool,
pub on_failure: DiscoveryFailurePolicy,
pub total_timeout: Duration,
pub max_requests: Option<std::num::NonZeroUsize>,
pub max_bytes: Option<u64>,
pub freshness: Duration,
}
impl RevocationDiscovery {
#[must_use]
pub fn producer_signed_only() -> Self {
Self {
include_registry_attested: false,
on_failure: DiscoveryFailurePolicy::FailClosed,
total_timeout: Duration::from_secs(30),
max_requests: None,
max_bytes: None,
freshness: super::revocation_verdict::RECOMMENDED_NEGATIVE_FRESHNESS,
}
}
#[must_use]
pub fn all_trust_classes() -> Self {
Self {
include_registry_attested: true,
on_failure: DiscoveryFailurePolicy::FailClosed,
total_timeout: Duration::from_secs(30),
max_requests: None,
max_bytes: None,
freshness: super::revocation_verdict::RECOMMENDED_NEGATIVE_FRESHNESS,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum DiscoveryFailurePolicy {
#[default]
FailClosed,
ProceedWithKnown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct DiscoveryOutcome {
pub producer_signed: usize,
pub registry_attested: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ReceiptPolicy {
Ignore,
#[default]
VerifyIfPresent,
Require,
VerifyAndReport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineageHeadPolicy {
pub receipts: ReceiptPolicy,
pub max_clock_skew_seconds: u32,
pub max_age_seconds: Option<u32>,
}
impl Default for LineageHeadPolicy {
fn default() -> Self {
Self {
receipts: ReceiptPolicy::VerifyIfPresent,
max_clock_skew_seconds: 120,
max_age_seconds: Some(300),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HistoricalKeyPolicy {
Reject,
#[default]
AcceptWithReceipt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyAuthorization {
CurrentlyAuthorized,
HistoricallyAuthorized,
HistoricallyAuthorizedPreCompromise,
}
fn require_did_web_for_v0_1_0(body: &acdp_types::body::Body) -> Result<(), AcdpError> {
if !matches!(body.acdp_version.as_deref(), None | Some("0.1.0")) {
return Ok(());
}
let key_did = body
.signature
.key_id
.split_once('#')
.map_or(body.signature.key_id.as_str(), |(d, _)| d);
for (field, did) in [
("agent_id", body.agent_id.as_str()),
("signature.key_id", key_did),
] {
if !did.starts_with("did:web:") {
return Err(AcdpError::SchemaViolation(format!(
"v0.1.0 context (acdp_version {}) requires a did:web {field}; got '{did}' \
(RFC-ACDP-0001 §5.11 strict profile)",
body.acdp_version.as_deref().unwrap_or("absent"),
)));
}
}
Ok(())
}
impl VerificationPolicy {
pub fn strict_v0_1_0() -> Self {
Self {
validate_body_schema: true,
allow_unknown_status: true,
receipts: ReceiptPolicy::Ignore,
historical_keys: HistoricalKeyPolicy::Reject,
lineage_head: LineageHeadPolicy {
receipts: ReceiptPolicy::Ignore,
..LineageHeadPolicy::default()
},
revocations: RevocationPolicy::default(),
require_did_web_for_v0_1_0: true,
log_inclusion: LogInclusionPolicy::Ignore,
witness_quorum: None,
}
}
fn derived_for_report(&self) -> Self {
Self {
validate_body_schema: false,
..self.clone()
}
}
}
#[derive(Debug)]
pub struct VerifiedContext {
inner: FullContext,
key_status: KeyAuthorization,
verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
receipt_key_status: Option<acdp_types::receipt::ReceiptKeyStatus>,
receipt_verdict: Option<Result<(), AcdpError>>,
lineage_binding: acdp_verify::LineageBinding,
verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
head_receipt_stale: Option<bool>,
head_receipt_verdict: Option<Result<(), AcdpError>>,
revocation_discovery_failure: Option<AcdpError>,
revocation_verdict: RevocationVerdict,
revocation_hints: Vec<SourcedRevocation>,
transparency: TransparencyVerdicts,
}
struct Retrieved {
key_status: KeyAuthorization,
verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
receipt_key_status: Option<acdp_types::receipt::ReceiptKeyStatus>,
receipt_verdict: Option<Result<(), AcdpError>>,
lineage_binding: acdp_verify::LineageBinding,
revocation: RevocationAssessment,
transparency: TransparencyVerdicts,
}
type VerifyRetrievedResult = Result<Retrieved, AcdpError>;
impl Retrieved {
fn record(&self, report: &mut VerificationReport) {
report.key_status = Some(self.key_status);
report.receipt_key_status = self.receipt_key_status;
report.receipt_verdict = self.receipt_verdict.clone();
report.lineage_binding = Some(self.lineage_binding);
report.revocation_discovery = self.revocation.discovery.clone();
report.revocation_verdict = Some(self.revocation.verdict.clone());
report.revocation_hints = self.revocation.hints.clone();
report.log_inclusion_verdict = self.transparency.log.clone();
report.witness_quorum = self.transparency.witness.clone();
}
fn into_context(self, inner: FullContext) -> VerifiedContext {
let RevocationAssessment {
verdict,
hints,
discovery,
..
} = self.revocation;
let revocation_discovery_failure = match discovery {
Some(Err(e)) => Some(e),
_ => None,
};
VerifiedContext {
inner,
key_status: self.key_status,
verified_receipt: self.verified_receipt,
receipt_key_status: self.receipt_key_status,
receipt_verdict: self.receipt_verdict,
lineage_binding: self.lineage_binding,
verified_head_receipt: None,
head_receipt_stale: None,
head_receipt_verdict: None,
revocation_discovery_failure,
revocation_verdict: verdict,
revocation_hints: hints,
transparency: self.transparency,
}
}
}
impl VerifiedContext {
pub async fn fetch(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
) -> Result<Self, AcdpError> {
Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
}
pub async fn fetch_with_policy(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
) -> Result<Self, AcdpError> {
let ctx = client.retrieve(ctx_id).await?;
let retrieved = Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
Ok(retrieved.into_context(ctx))
}
pub async fn fetch_current(
client: &RegistryClient,
resolver: &WebResolver,
lineage_id: &acdp_types::primitives::LineageId,
) -> Result<Self, AcdpError> {
Self::fetch_current_with_policy(
client,
resolver,
lineage_id,
&VerificationPolicy::default(),
)
.await
}
pub async fn fetch_current_with_policy(
client: &RegistryClient,
resolver: &WebResolver,
lineage_id: &acdp_types::primitives::LineageId,
policy: &VerificationPolicy,
) -> Result<Self, AcdpError> {
let ctx = client.current(lineage_id).await?;
let served_ctx_id = ctx.body.ctx_id.clone();
let retrieved =
Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
let head_policy = policy.lineage_head.receipts;
let head = match (head_policy, &ctx.lineage_head_receipt) {
(ReceiptPolicy::Ignore, _)
| (ReceiptPolicy::VerifyIfPresent | ReceiptPolicy::VerifyAndReport, None) => None,
(ReceiptPolicy::Require, None) => {
return Err(AcdpError::InvalidReceipt(
"policy requires a lineage-head receipt but the /current response \
carries none (registry without the acdp-registry-head-receipts \
profile?)"
.into(),
));
}
(_, Some(value)) => {
let serving_authority = client
.authority()
.unwrap_or_else(|| served_ctx_id.authority().to_string());
let caps = client.capabilities().await?;
let verdict = super::receipt::verify_lineage_head_receipt_value(
value,
lineage_id,
&served_ctx_id,
ctx.body.version,
&ctx.registry_state.status,
true, &serving_authority,
&caps.registry_did,
chrono::Duration::seconds(policy.lineage_head.max_clock_skew_seconds as i64),
resolver,
)
.await;
match verdict {
Ok(receipt) => Some(Ok(receipt)),
Err(e) if head_policy == ReceiptPolicy::VerifyAndReport => Some(Err(e)),
Err(e) => return Err(e),
}
}
};
let (verified_head_receipt, head_receipt_verdict) = match head {
None => (None, None),
Some(Ok(receipt)) => (Some(receipt), Some(Ok(()))),
Some(Err(e)) => (None, Some(Err(e))),
};
let head_receipt_stale = verified_head_receipt.as_ref().and_then(
|receipt: &acdp_types::receipt::LineageHeadReceipt| {
policy.lineage_head.max_age_seconds.map(|max| {
receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
})
},
);
let mut verified = retrieved.into_context(ctx);
verified.verified_head_receipt = verified_head_receipt;
verified.head_receipt_stale = head_receipt_stale;
verified.head_receipt_verdict = head_receipt_verdict;
Ok(verified)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
name = "acdp.verify_retrieved",
skip_all,
fields(ctx_id = %expected_ctx_id),
err(Display)
)
)]
async fn verify_retrieved(
client: &RegistryClient,
resolver: &WebResolver,
ctx: &FullContext,
expected_ctx_id: &CtxId,
policy: &VerificationPolicy,
) -> VerifyRetrievedResult {
if ctx.body.ctx_id != *expected_ctx_id {
return Err(AcdpError::ContextIdMismatch {
requested: expected_ctx_id.as_str().to_string(),
served: ctx.body.ctx_id.as_str().to_string(),
});
}
acdp_verify::verify_registry_assigned_bindings(&ctx.body)?;
if policy.validate_body_schema {
acdp_validation::validate_body(&ctx.body)?;
}
if policy.require_did_web_for_v0_1_0 {
require_did_web_for_v0_1_0(&ctx.body)?;
}
let since = std::time::Instant::now();
let verifier = Verifier::new(resolver).fresh_since(since);
verifier.verify_context_hash(ctx)?;
let serving_authority = client
.authority()
.unwrap_or_else(|| expected_ctx_id.authority().to_string());
let receipt_outcome = match (policy.receipts, &ctx.registry_receipt) {
(ReceiptPolicy::Ignore, _)
| (ReceiptPolicy::VerifyIfPresent | ReceiptPolicy::VerifyAndReport, None) => None,
(ReceiptPolicy::Require, None) => {
return Err(AcdpError::InvalidReceipt(
"policy requires a registry receipt but the response carries none \
(registry without the acdp-registry-receipts profile, or a \
pre-receipts context)"
.into(),
));
}
(_, Some(value)) => {
let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id_since(
&ctx.body.signature.key_id,
&ctx.body.signature.algorithm,
resolver,
since,
)
.await?;
let verdict = super::receipt::verify_receipt_value_with_status_since(
value,
expected_ctx_id,
&ctx.body,
&ctx.body.content_hash,
&fingerprint,
&serving_authority,
resolver,
since,
)
.await;
match verdict {
Ok(placed) => Some(Ok(placed)),
Err(e) if policy.receipts == ReceiptPolicy::VerifyAndReport => Some(Err(e)),
Err(e) => return Err(e),
}
}
};
let (verified_receipt, receipt_key_status, receipt_verdict) = match receipt_outcome {
None => (None, None, None),
Some(Ok((receipt, status))) => (Some(receipt), Some(status), Some(Ok(()))),
Some(Err(e)) => (None, None, Some(Err(e))),
};
let gathered = super::revocation_verdict::gather(
client,
resolver,
&ctx.body.agent_id,
&policy.revocations,
)
.await;
let fingerprint = if gathered.has_in_scope() {
Some(
acdp_crypto::fingerprint::fingerprint_for_key_id_since(
&ctx.body.signature.key_id,
&ctx.body.signature.algorithm,
resolver,
since,
)
.await?,
)
} else {
None
};
let revocation = super::revocation_verdict::decide(
gathered,
fingerprint.as_deref(),
verified_receipt
.as_ref()
.zip(receipt_key_status)
.map(|(r, status)| (r.created_at, status)),
policy.revocations.accept_historical_receipt_key_for_pre_t,
)
.enforce()?;
let key_status = match revocation.pre_compromise() {
true => {
if ctx.body.agent_id.as_str().starts_with("did:key:") {
verifier.verify_body_signature(&ctx.body).await?;
} else {
verifier.verify_body_signature_historical(&ctx.body).await?;
}
KeyAuthorization::HistoricallyAuthorizedPreCompromise
}
false => match verifier.verify_body_signature(&ctx.body).await {
Ok(()) => KeyAuthorization::CurrentlyAuthorized,
Err(AcdpError::KeyNotAuthorized(_))
if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
&& verified_receipt.is_some() =>
{
verifier.verify_body_signature_historical(&ctx.body).await?;
KeyAuthorization::HistoricallyAuthorized
}
Err(e) => return Err(e),
},
};
if !policy.allow_unknown_status {
if let Some(other) = ctx.registry_state.status.as_other() {
return Err(AcdpError::SchemaViolation(format!(
"policy.allow_unknown_status=false; registry returned '{other}'"
)));
}
}
let material = match (&verified_receipt, &ctx.registry_receipt, &receipt_verdict) {
(Some(receipt), Some(receipt_value), _) => Ok(super::transparency::LeafMaterial {
receipt,
receipt_value,
}),
(_, None, _) => Err(CannotVerifyReason::ReceiptAbsent),
(_, Some(_), Some(Err(_))) => Err(CannotVerifyReason::ReceiptInvalid),
(_, Some(_), _) => Err(CannotVerifyReason::ReceiptNotVerified),
};
let transparency = super::transparency::evaluate(
client,
ctx,
material,
&serving_authority,
policy.log_inclusion,
policy.witness_quorum.as_ref(),
resolver,
)
.await?;
let lineage_binding = acdp_verify::lineage_binding(&ctx.body, verified_receipt.is_some());
Ok(Retrieved {
key_status,
verified_receipt,
receipt_key_status,
receipt_verdict,
lineage_binding,
revocation,
transparency,
})
}
pub async fn fetch_report(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
) -> Result<(Self, VerificationReport), AcdpError> {
Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
}
pub async fn fetch_report_diagnose(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
) -> Result<(Option<Self>, VerificationReport), AcdpError> {
let ctx = client.retrieve(ctx_id).await?;
let mut report = VerificationReport {
body_hash_ok: false,
signature_ok: false,
schema_ok: false,
data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
ctx_id_ok: ctx.body.ctx_id == *ctx_id,
key_status: None,
policy_phase_error: None,
revocation_discovery: None,
receipt_key_status: None,
receipt_verdict: None,
lineage_binding: None,
revocation_verdict: None,
revocation_hints: Vec::new(),
log_inclusion_verdict: None,
witness_quorum: None,
};
if policy.validate_body_schema {
match acdp_validation::validate_body_structural(&ctx.body) {
Ok(()) => report.schema_ok = true,
Err(_) => { }
}
} else {
report.schema_ok = true;
}
for dr in &ctx.body.data_refs {
if let Some(emb) = &dr.embedded {
if dr.content_hash.is_some() || emb.content_hash.is_some() {
let outcome = acdp_validation::verify_embedded_hash(dr).and_then(|()| {
acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len())
});
report.data_ref_embedded.push(outcome);
} else {
report.data_ref_embedded.push(Ok(0));
}
} else {
report.data_ref_embedded.push(Ok(0));
}
}
let verifier = Verifier::new(resolver);
report.body_hash_ok = verifier.verify_context_hash(&ctx).is_ok();
report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();
for _ in &ctx.body.data_refs {
report.data_ref_external.push(None);
}
let all_top_level_pass =
report.schema_ok && report.body_hash_ok && report.signature_ok && report.ctx_id_ok;
let verified = if all_top_level_pass {
let outcome = Self::verify_retrieved(
client,
resolver,
&ctx,
ctx_id,
&policy.derived_for_report(),
)
.await; match outcome {
Ok(retrieved) => {
retrieved.record(&mut report);
Some(retrieved.into_context(ctx))
}
Err(e) => {
report.policy_phase_error = Some(e);
None
}
}
} else {
None
};
Ok((verified, report))
}
pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
fetcher: &F,
) -> Result<(Self, VerificationReport), AcdpError> {
Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
}
async fn fetch_report_inner<F: DataRefFetcher>(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
fetcher: Option<&F>,
) -> Result<(Self, VerificationReport), AcdpError> {
let ctx = client.retrieve(ctx_id).await?;
if ctx.body.ctx_id != *ctx_id {
return Err(AcdpError::ContextIdMismatch {
requested: ctx_id.as_str().to_string(),
served: ctx.body.ctx_id.as_str().to_string(),
});
}
let mut report = VerificationReport {
body_hash_ok: false,
signature_ok: false,
schema_ok: false,
data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
ctx_id_ok: true,
key_status: None,
policy_phase_error: None,
revocation_discovery: None,
receipt_key_status: None,
receipt_verdict: None,
lineage_binding: None,
revocation_verdict: None,
revocation_hints: Vec::new(),
log_inclusion_verdict: None,
witness_quorum: None,
};
if policy.validate_body_schema {
acdp_validation::validate_body_structural(&ctx.body)?;
}
report.schema_ok = true;
for dr in &ctx.body.data_refs {
if let Some(emb) = &dr.embedded {
if dr.content_hash.is_some() || emb.content_hash.is_some() {
let outcome = acdp_validation::verify_embedded_hash(dr).and_then(|()| {
acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len())
});
report.data_ref_embedded.push(outcome);
} else {
report.data_ref_embedded.push(Ok(0));
}
} else {
report.data_ref_embedded.push(Ok(0));
}
}
let retrieved =
Self::verify_retrieved(client, resolver, &ctx, ctx_id, &policy.derived_for_report())
.await?;
report.body_hash_ok = true;
report.signature_ok = true;
retrieved.record(&mut report);
for dr in &ctx.body.data_refs {
let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
(Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
_ => None,
};
report.data_ref_external.push(slot);
}
Ok((retrieved.into_context(ctx), report))
}
pub fn body(&self) -> &acdp_types::body::Body {
&self.inner.body
}
pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
&self.inner.registry_state
}
pub fn full_context(&self) -> &FullContext {
&self.inner
}
pub fn key_status(&self) -> KeyAuthorization {
self.key_status
}
pub fn verified_receipt(&self) -> Option<&acdp_types::receipt::RegistryReceipt> {
self.verified_receipt.as_ref()
}
pub fn receipt_key_status(&self) -> Option<acdp_types::receipt::ReceiptKeyStatus> {
self.receipt_key_status
}
pub fn receipt_verdict(&self) -> Option<&Result<(), AcdpError>> {
self.receipt_verdict.as_ref()
}
pub fn lineage_binding(&self) -> acdp_verify::LineageBinding {
self.lineage_binding
}
pub fn head_receipt_verdict(&self) -> Option<&Result<(), AcdpError>> {
self.head_receipt_verdict.as_ref()
}
pub fn verified_head_receipt(&self) -> Option<&acdp_types::receipt::LineageHeadReceipt> {
self.verified_head_receipt.as_ref()
}
pub fn head_receipt_stale(&self) -> Option<bool> {
self.head_receipt_stale
}
pub fn revocation_discovery_failure(&self) -> Option<&AcdpError> {
self.revocation_discovery_failure.as_ref()
}
pub fn revocation_verdict(&self) -> &RevocationVerdict {
&self.revocation_verdict
}
pub fn revocation_hints(&self) -> &[SourcedRevocation] {
&self.revocation_hints
}
#[must_use]
pub fn log_inclusion_verdict(&self) -> Option<&LogInclusionVerdict> {
self.transparency.log.as_ref()
}
#[must_use]
pub fn verified_log_inclusion(&self) -> Option<&acdp_types::log::LogInclusion> {
self.transparency
.log
.as_ref()
.and_then(LogInclusionVerdict::inclusion)
}
#[must_use]
pub fn witness_quorum(&self) -> Option<&Result<WitnessQuorumReport, AcdpError>> {
self.transparency.witness.as_ref()
}
pub fn receipt(&self) -> Option<&serde_json::Value> {
self.inner.registry_receipt.as_ref()
}
pub fn lineage_head_receipt(&self) -> Option<&serde_json::Value> {
self.inner.lineage_head_receipt.as_ref()
}
pub async fn verify_receipt(
&self,
resolver: &WebResolver,
) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
let Some(value) = &self.inner.registry_receipt else {
return Ok(None);
};
let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
&self.inner.body.signature.key_id,
&self.inner.body.signature.algorithm,
resolver,
)
.await?;
let receipt = super::receipt::verify_receipt_value(
value,
&self.inner.body.ctx_id,
&self.inner.body,
&self.inner.body.content_hash,
&fingerprint,
self.inner.body.ctx_id.authority(),
resolver,
)
.await?;
Ok(Some(receipt))
}
pub async fn recheck_revocation(
&self,
client: &RegistryClient,
resolver: &WebResolver,
policy: &RevocationPolicy,
) -> Result<crate::RevocationAssessment, AcdpError> {
let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
&self.inner.body.signature.key_id,
&self.inner.body.signature.algorithm,
resolver,
)
.await?;
let placement = self
.verified_receipt
.as_ref()
.zip(self.receipt_key_status)
.map(|(r, status)| (r.created_at, status));
crate::find_revocations_verdict(
client,
resolver,
&self.inner.body.agent_id,
&fingerprint,
placement,
policy,
)
.await
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct VerificationReport {
pub body_hash_ok: bool,
pub signature_ok: bool,
pub schema_ok: bool,
pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
pub ctx_id_ok: bool,
pub key_status: Option<KeyAuthorization>,
pub policy_phase_error: Option<AcdpError>,
pub revocation_discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
pub receipt_key_status: Option<acdp_types::receipt::ReceiptKeyStatus>,
pub receipt_verdict: Option<Result<(), AcdpError>>,
pub lineage_binding: Option<acdp_verify::LineageBinding>,
pub revocation_verdict: Option<RevocationVerdict>,
pub revocation_hints: Vec<SourcedRevocation>,
pub log_inclusion_verdict: Option<LogInclusionVerdict>,
pub witness_quorum: Option<Result<WitnessQuorumReport, AcdpError>>,
}
struct NoFetcher;
impl DataRefFetcher for NoFetcher {
async fn fetch(
&self,
_location: &acdp_types::data_ref::Location,
) -> Result<Vec<u8>, AcdpError> {
Err(AcdpError::NotImplemented(
"NoFetcher should never be called — this is a fetch_report sentinel".into(),
))
}
}
#[cfg(test)]
mod tests {
use super::{
DiscoveryFailurePolicy, HistoricalKeyPolicy, ReceiptPolicy, RevocationDiscovery,
RevocationPolicy, VerificationPolicy,
};
use acdp_primitives::error::AcdpError;
use std::time::Duration;
#[test]
fn v0_1_0_bodies_require_did_web() {
use acdp_types::Body;
const DK: &str = "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp";
const WEB: &str = "did:web:agents.example.com:p";
let body_for = |agent: &str, version: Option<&str>| -> Body {
let mut v = serde_json::json!({
"ctx_id": "acdp://registry.example.com/9f1e2d3c-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"lineage_id": "lin:sha256:6af6229c1c6a4a119695c77e47f6554941aebce3d25ba8567e2ae6ffbb6059cb",
"origin_registry": "registry.example.com",
"created_at": "2026-05-02T08:00:00.000Z",
"version": 1,
"supersedes": null,
"agent_id": agent,
"contributors": [],
"content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"signature": {"algorithm": "ed25519", "key_id": format!("{agent}#key-1"), "value": "AA=="},
"title": "strict v0.1.0",
"type": "data_snapshot",
"data_refs": [],
"derived_from": [],
"visibility": "public",
});
if let Some(ver) = version {
v["acdp_version"] = serde_json::json!(ver);
}
serde_json::from_value(v).unwrap()
};
for v in [None, Some("0.1.0")] {
let err = crate::verified::require_did_web_for_v0_1_0(&body_for(DK, v)).unwrap_err();
assert!(
matches!(err, AcdpError::SchemaViolation(_)),
"{v:?}: {err:?}"
);
crate::verified::require_did_web_for_v0_1_0(&body_for(WEB, v)).unwrap();
}
for v in ["0.2.0", "0.4.0"] {
crate::verified::require_did_web_for_v0_1_0(&body_for(DK, Some(v))).unwrap();
}
let mut mixed = body_for(WEB, None);
mixed.signature.key_id = format!("{DK}#k");
assert!(matches!(
crate::verified::require_did_web_for_v0_1_0(&mixed),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn strict_v0_1_0_preserves_v0_1_0_semantics() {
let strict = VerificationPolicy::strict_v0_1_0();
assert!(strict.validate_body_schema);
assert!(strict.allow_unknown_status);
assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
assert!(strict.require_did_web_for_v0_1_0);
assert!(
VerificationPolicy::default().require_did_web_for_v0_1_0,
"RFC-ACDP-0001 §5.11: a relaxed mode MUST NOT be the default"
);
assert!(
strict.revocations.known.is_empty(),
"a 0.1.0-pinned consumer is unaffected by RFC-ACDP-0014"
);
assert_ne!(
strict,
VerificationPolicy::default(),
"the 0.2 default is receipt-aware; the v0.1.0 profile is not"
);
}
#[test]
fn verify_retrieved_is_sole_reader_of_authorization_policy_fields() {
const SRC: &str = include_str!("verified.rs");
let policy_prefix = "policy.";
let fields = [
"receipts",
"revocations",
"historical_keys",
"allow_unknown_status",
"require_did_web_for_v0_1_0",
"log_inclusion",
"witness_quorum",
];
let patterns: Vec<String> = fields
.iter()
.map(|f| format!("{policy_prefix}{f}"))
.collect();
let fn_start = SRC
.find("async fn verify_retrieved(")
.expect("verify_retrieved must exist in verified.rs");
let body_open = fn_start
+ SRC[fn_start..]
.find('{')
.expect("verify_retrieved must have a body");
let mut depth = 0i32;
let mut body_close = None;
for (i, ch) in SRC[body_open..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
body_close = Some(body_open + i);
break;
}
}
_ => {}
}
}
let body_close =
body_close.expect("verify_retrieved's matching closing brace must be found");
assert!(
body_close > body_open,
"sanity: verify_retrieved's body must be non-empty"
);
let mut offset = 0usize;
let mut checked_any = false;
for line in SRC.split_inclusive('\n') {
let trimmed = line.trim_start();
let is_comment_line = trimmed.starts_with("//");
if !is_comment_line {
for pattern in &patterns {
let mut search_from = 0usize;
while let Some(rel) = line[search_from..].find(pattern.as_str()) {
let match_col = search_from + rel;
let match_start = offset + match_col;
let before = &line[..match_col];
let in_string_literal = before.matches('"').count() % 2 == 1;
if !in_string_literal {
checked_any = true;
assert!(
match_start >= body_open && match_start < body_close,
"found `{pattern}` outside verify_retrieved's body \
(byte offset {match_start}, line: {line:?}) — every \
authorization-policy-field read must live inside \
verify_retrieved, the sole reader"
);
}
search_from = match_col + pattern.len();
}
}
}
offset += line.len();
}
let mut normalized = String::with_capacity(SRC.len());
let mut orig_offsets: Vec<usize> = Vec::with_capacity(SRC.len());
let mut after_dot = false;
for (i, ch) in SRC.char_indices() {
if ch == '.' {
while let Some(last) = normalized.chars().last() {
if !last.is_whitespace() {
break;
}
normalized.pop();
let new_len = orig_offsets.len() - last.len_utf8();
orig_offsets.truncate(new_len);
}
normalized.push(ch);
orig_offsets.push(i);
after_dot = true;
continue;
}
if after_dot && ch.is_whitespace() {
continue; }
after_dot = false;
normalized.push(ch);
for _ in 0..ch.len_utf8() {
orig_offsets.push(i);
}
}
debug_assert_eq!(normalized.len(), orig_offsets.len());
let is_comment_line_at = |pos: usize| -> bool {
let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
let line_end = SRC[pos..].find('\n').map_or(SRC.len(), |i| pos + i);
SRC[line_start..line_end].trim_start().starts_with("//")
};
let in_string_literal_at = |pos: usize| -> bool {
let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
SRC[line_start..pos].matches('"').count() % 2 == 1
};
for pattern in &patterns {
let mut search_from = 0usize;
while let Some(rel) = normalized[search_from..].find(pattern.as_str()) {
let match_col = search_from + rel;
let orig_start = orig_offsets[match_col];
if !is_comment_line_at(orig_start) && !in_string_literal_at(orig_start) {
checked_any = true;
assert!(
orig_start >= body_open && orig_start < body_close,
"found `{pattern}` (whitespace-normalized) outside \
verify_retrieved's body (original byte offset {orig_start}) \
— every authorization-policy-field read must live inside \
verify_retrieved, the sole reader, even when rustfmt has \
wrapped the field-access chain across multiple lines"
);
}
search_from = match_col + pattern.len();
}
}
assert!(
checked_any,
"sanity: the scan must find at least one non-comment, non-string-literal \
match for at least one pattern (verify_retrieved itself reads these \
fields) — zero hits would mean the patterns are miscomputed, not that \
the invariant holds"
);
}
#[test]
fn revocation_policy_default_is_unchanged() {
let policy = RevocationPolicy::default();
assert!(policy.known.is_empty());
assert!(policy.discover.is_none());
}
#[test]
fn revocation_policy_new_leaves_discovery_off() {
let policy = RevocationPolicy::new(vec![]);
assert!(policy.known.is_empty());
assert!(policy.discover.is_none());
}
#[test]
fn discovery_failure_policy_defaults_to_fail_closed() {
assert_eq!(
DiscoveryFailurePolicy::default(),
DiscoveryFailurePolicy::FailClosed
);
}
#[test]
fn revocation_discovery_constructors_set_the_right_trust_classes() {
let producer_only = RevocationDiscovery::producer_signed_only();
assert!(!producer_only.include_registry_attested);
assert_eq!(producer_only.on_failure, DiscoveryFailurePolicy::FailClosed);
assert_eq!(producer_only.total_timeout, Duration::from_secs(30));
assert_eq!(
producer_only.freshness,
Duration::from_secs(300),
"RFC-ACDP-0014 §8 (0.5.0): negative freshness defaults to the RECOMMENDED 300 s"
);
let all = RevocationDiscovery::all_trust_classes();
assert!(all.include_registry_attested);
assert_eq!(all.on_failure, DiscoveryFailurePolicy::FailClosed);
assert_eq!(all.total_timeout, Duration::from_secs(30));
assert_eq!(all.freshness, Duration::from_secs(300));
}
#[test]
fn revocation_discovery_has_no_default_by_design() {
}
#[test]
fn revocation_unknown_is_transient_only_for_transient_reason() {
let unknown = |source: AcdpError| {
let reason = source.unknown_reason();
AcdpError::RevocationUnknown {
reason,
source: Box::new(source),
detail: String::new(),
}
};
let transient = unknown(AcdpError::KeyResolutionUnreachable("x".into()));
assert!(transient.is_transient());
for permanent in [
AcdpError::InvalidSignature("x".into()),
AcdpError::SearchTruncated("x".into()),
AcdpError::NotAuthorized("x".into()),
AcdpError::RevocationCandidateUnverifiable("x".into()),
] {
assert!(!unknown(permanent).is_transient());
}
}
}