use std::time::{Duration, SystemTime};
use acdp_did::WebResolver;
use acdp_primitives::error::AcdpError;
use acdp_types::primitives::AgentDid;
use acdp_types::receipt::ReceiptKeyStatus;
use acdp_types::revocation::{KeyRevocation, RevocationTrustClass};
use chrono::{DateTime, Utc};
pub use acdp_types::revocation::{
RevocationEffect, RevocationRefusal, RevocationSource, SourcedRevocation, UnknownReason,
};
use crate::registry::{DiscoveryBudget, RegistryClient};
use crate::revocation::{find_registry_attested_lookup, find_revocations_lookup, Lookup};
use crate::verified::{DiscoveryFailurePolicy, DiscoveryOutcome, RevocationPolicy};
pub const RECOMMENDED_NEGATIVE_FRESHNESS: Duration = Duration::from_secs(300);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RevocationVerdict {
Revoked {
effect: RevocationEffect,
boundary: DateTime<Utc>,
revocation: SourcedRevocation,
placement: Option<DateTime<Utc>>,
},
NotRevoked {
complete: bool,
registry_attested_checked: bool,
observed_at: Option<SystemTime>,
},
Unknown {
reason: UnknownReason,
detail: String,
},
}
impl RevocationVerdict {
#[must_use]
pub fn is_refusal(&self) -> bool {
matches!(
self,
RevocationVerdict::Revoked {
effect: RevocationEffect::Revoked | RevocationEffect::Unplaceable,
..
}
)
}
#[must_use]
pub fn is_unknown(&self) -> bool {
matches!(self, RevocationVerdict::Unknown { .. })
}
#[must_use]
pub fn unknown_reason(&self) -> Option<UnknownReason> {
match self {
RevocationVerdict::Unknown { reason, .. } => Some(*reason),
_ => None,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RevocationAssessment {
pub verdict: RevocationVerdict,
pub hints: Vec<SourcedRevocation>,
pub discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
pub(crate) pre_compromise: bool,
pub(crate) on_failure: DiscoveryFailurePolicy,
}
impl RevocationAssessment {
pub fn enforce(self) -> Result<Self, AcdpError> {
if let RevocationVerdict::Revoked {
effect: effect @ (RevocationEffect::Revoked | RevocationEffect::Unplaceable),
boundary,
revocation,
placement,
} = &self.verdict
{
return Err(AcdpError::KeyRevoked(Box::new(RevocationRefusal::new(
*effect,
revocation.revoked_key_fingerprint.clone(),
*boundary,
revocation.clone(),
*placement,
refusal_detail(*effect, *boundary, *placement),
))));
}
if self.verdict.is_unknown() && self.on_failure == DiscoveryFailurePolicy::FailClosed {
if let Some(Err(e)) = &self.discovery {
return Err(e.clone());
}
}
Ok(self)
}
#[must_use]
pub fn pre_compromise(&self) -> bool {
self.pre_compromise
}
}
fn fmt_ms(t: DateTime<Utc>) -> String {
t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
fn refusal_detail(
effect: RevocationEffect,
boundary: DateTime<Utc>,
placement: Option<DateTime<Utc>>,
) -> String {
match (effect, placement) {
(RevocationEffect::Revoked, Some(t)) => format!(
"the receipt-attested publish time {} is at/after the compromise boundary {}, so \
the signature is not attributable to the producer — fail closed regardless of \
DID-document state or receipt validity (RFC-ACDP-0014 §7 step 3)",
fmt_ms(t),
fmt_ms(boundary),
),
(RevocationEffect::Unplaceable, Some(_)) => format!(
"the context's receipt is signed by a historical receipt key (verificationMethod \
only), which cannot place a revoked-key context before the compromise boundary \
{} under the strict profile — unplaceable, fail closed (RFC-ACDP-0014 §7 steps 1 \
and 4)",
fmt_ms(boundary),
),
_ => format!(
"the context has no verified registry receipt, so its publish time cannot be \
placed relative to the compromise boundary {} — an unplaceable revoked-key \
signature is exactly the artifact an attacker mints freely; the strict profile \
fails closed (RFC-ACDP-0014 §7 step 4)",
fmt_ms(boundary),
),
}
}
#[derive(Debug)]
pub(crate) struct Gathered {
in_scope: Vec<SourcedRevocation>,
hints: Vec<SourcedRevocation>,
discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
complete: bool,
registry_attested_checked: bool,
observed_at: Option<SystemTime>,
on_failure: DiscoveryFailurePolicy,
}
impl Gathered {
pub(crate) fn has_in_scope(&self) -> bool {
!self.in_scope.is_empty()
}
}
fn source_rank(s: &RevocationSource) -> u8 {
match s {
RevocationSource::Known => 0,
RevocationSource::Discovered { .. } => 1,
_ => 2,
}
}
pub(crate) async fn gather(
client: &RegistryClient,
resolver: &WebResolver,
agent_id: &AgentDid,
policy: &RevocationPolicy,
) -> Gathered {
let vantage = client.authority();
let include_attested = policy.discover.is_some_and(|d| d.include_registry_attested);
let on_failure = policy
.discover
.map_or(DiscoveryFailurePolicy::FailClosed, |d| d.on_failure);
let mut all: Vec<SourcedRevocation> = policy
.known
.iter()
.map(|r| r.sourced(RevocationSource::Known))
.collect();
if let Some((cache, _)) = client.revocation_cache() {
all.extend(
cache
.facts_for(agent_id.as_str(), include_attested, vantage.as_deref())
.into_iter()
.map(|(r, origin)| r.sourced(RevocationSource::Cached { origin })),
);
}
let mut gathered = Gathered {
in_scope: Vec::new(),
hints: Vec::new(),
discovery: None,
complete: false,
registry_attested_checked: false,
observed_at: None,
on_failure,
};
if let Some(discovery) = &policy.discover {
let budget = DiscoveryBudget::new(discovery.max_requests, discovery.max_bytes);
let dclient = client
.with_discovery_budget(budget)
.with_revocation_freshness(discovery.freshness);
let fut = async {
tokio::try_join!(
find_revocations_lookup(&dclient, resolver, agent_id),
async {
if include_attested {
find_registry_attested_lookup(&dclient, resolver, agent_id)
.await
.map(Some)
} else {
Ok(None)
}
},
)
};
let failure = match tokio::time::timeout(discovery.total_timeout, fut).await {
Ok(Ok((signed, attested))) => {
let outcome = DiscoveryOutcome {
producer_signed: signed.revocations.len(),
registry_attested: attested.as_ref().map(|l| l.revocations.len()),
};
gathered.observed_at = [Some(&signed), attested.as_ref()]
.into_iter()
.flatten()
.filter_map(|l: &Lookup| l.cached_since)
.min();
gathered.complete = true;
gathered.registry_attested_checked = attested.is_some();
let authority = vantage.clone().unwrap_or_default();
for l in std::iter::once(signed).chain(attested) {
all.extend(l.revocations.iter().map(|r: &KeyRevocation| {
r.sourced(RevocationSource::Discovered {
authority: authority.clone(),
})
}));
}
gathered.discovery = Some(Ok(outcome));
None
}
Ok(Err(e)) => Some(e),
Err(_elapsed) => Some(AcdpError::CrossRegistryResolutionFailed(format!(
"revocation auto-discovery exceeded total_timeout={:?}",
discovery.total_timeout
))),
};
if let Some(source) = failure {
let reason = source.unknown_reason();
let detail = format!(
"RFC-ACDP-0014 §8 discovery for {agent_id} at {} could not finish: {source}",
vantage.as_deref().unwrap_or("<no authority>")
);
gathered.discovery = Some(Err(AcdpError::RevocationUnknown {
reason,
source: Box::new(source),
detail,
}));
}
}
for r in all {
if r.speaks_for(agent_id) {
gathered.in_scope.push(r);
} else if !gathered.hints.contains(&r) {
gathered.hints.push(r);
}
}
gathered
}
pub(crate) fn decide(
gathered: Gathered,
signing_key_fingerprint: Option<&str>,
receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
accept_historical_receipt_key: bool,
) -> RevocationAssessment {
let fixing = signing_key_fingerprint.and_then(|fp| {
gathered
.in_scope
.iter()
.filter(|r| r.revoked_key_fingerprint == fp)
.min_by_key(|r| {
(
r.compromised_since,
r.trust_class != RevocationTrustClass::ProducerSigned,
source_rank(&r.source),
)
})
.cloned()
});
let named = fixing.map(|revocation| {
let boundary = revocation.compromised_since;
let (effect, placement) = match receipt_placement {
Some((t, ReceiptKeyStatus::Historical)) if !accept_historical_receipt_key => {
(RevocationEffect::Unplaceable, Some(t))
}
Some((t, _)) if t < boundary => (RevocationEffect::PreCompromise, Some(t)),
Some((t, _)) => (RevocationEffect::Revoked, Some(t)),
None => (RevocationEffect::Unplaceable, None),
};
RevocationVerdict::Revoked {
effect,
boundary,
revocation,
placement,
}
});
let pre_compromise = matches!(
named,
Some(RevocationVerdict::Revoked {
effect: RevocationEffect::PreCompromise,
..
})
);
let unknown = match &gathered.discovery {
Some(Err(AcdpError::RevocationUnknown { reason, detail, .. })) => {
Some(RevocationVerdict::Unknown {
reason: *reason,
detail: detail.clone(),
})
}
_ => None,
};
let verdict = match (named, unknown) {
(Some(v), _) if v.is_refusal() => v,
(_, Some(u)) => u,
(Some(v), None) => v,
(None, None) => RevocationVerdict::NotRevoked {
complete: gathered.complete,
registry_attested_checked: gathered.registry_attested_checked,
observed_at: gathered.observed_at,
},
};
RevocationAssessment {
verdict,
hints: gathered.hints,
discovery: gathered.discovery,
pre_compromise,
on_failure: gathered.on_failure,
}
}
pub async fn find_revocations_verdict(
client: &RegistryClient,
resolver: &WebResolver,
agent_id: &AgentDid,
signing_key_fingerprint: &str,
receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
policy: &RevocationPolicy,
) -> Result<RevocationAssessment, AcdpError> {
let agent_id = AgentDid::parse(agent_id.as_str())?;
let gathered = gather(client, resolver, &agent_id, policy).await;
Ok(decide(
gathered,
Some(signing_key_fingerprint),
receipt_placement,
policy.accept_historical_receipt_key_for_pre_t,
))
}
pub(crate) fn decide_known(
revocations: &[KeyRevocation],
signing_key_fingerprint: &str,
receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
accept_historical_receipt_key: bool,
) -> RevocationAssessment {
let gathered = Gathered {
in_scope: revocations
.iter()
.map(|r| r.sourced(RevocationSource::Known))
.collect(),
hints: Vec::new(),
discovery: None,
complete: false,
registry_attested_checked: false,
observed_at: None,
on_failure: DiscoveryFailurePolicy::FailClosed,
};
decide(
gathered,
Some(signing_key_fingerprint),
receipt_placement,
accept_historical_receipt_key,
)
}