use acdp_crypto::fingerprint::fingerprint_for_key_id;
use acdp_did::WebResolver;
use acdp_primitives::error::AcdpError;
use acdp_types::body::Body;
use acdp_types::primitives::AgentDid;
use acdp_types::revocation::{effective_boundary, KeyRevocation};
use acdp_types::search::SearchParamsBuilder;
use acdp_verify::Verifier;
use chrono::{DateTime, Utc};
use super::registry::RegistryClient;
use super::verified::KeyAuthorization;
const MAX_SEARCH_PAGES: usize = 10;
pub fn classify_under_revocation(
revocations: &[KeyRevocation],
signing_key_fingerprint: &str,
receipt_attested_created_at: Option<DateTime<Utc>>,
) -> Result<Option<KeyAuthorization>, AcdpError> {
let Some(boundary) = effective_boundary(revocations, signing_key_fingerprint) else {
return Ok(None);
};
match receipt_attested_created_at {
Some(created_at) if created_at < boundary => {
Ok(Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise))
}
Some(created_at) => Err(AcdpError::KeyNotAuthorized(format!(
"signing key {signing_key_fingerprint} is revoked with compromise boundary \
{}; the receipt-attested publish time {} is at/after the 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)",
boundary.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
))),
None => Err(AcdpError::KeyNotAuthorized(format!(
"signing key {signing_key_fingerprint} is revoked (compromise boundary {}) \
and the context has no verified registry receipt, so its publish time \
cannot be placed relative to the 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)",
boundary.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
))),
}
}
pub async fn verify_revocation_body(
body: &Body,
resolver: &WebResolver,
) -> Result<KeyRevocation, AcdpError> {
Verifier::new(resolver).verify_body(body).await?;
let revocation = KeyRevocation::from_body(body)?;
let signer_fingerprint =
fingerprint_for_key_id(&body.signature.key_id, &body.signature.algorithm, resolver).await?;
revocation.check_not_self_signed(&signer_fingerprint)?;
Ok(revocation)
}
pub async fn find_revocations(
client: &RegistryClient,
resolver: &WebResolver,
agent_id: &AgentDid,
) -> Result<Vec<KeyRevocation>, AcdpError> {
let mut revocations = Vec::new();
let mut seen = std::collections::HashSet::new();
for type_form in ["key-revocation", "acdp:key-revocation"] {
for status in ["active", "superseded"] {
let mut params = SearchParamsBuilder::new()
.context_type(type_form)
.agent_id(agent_id.as_str())
.status(status)
.limit(100)
.build();
for _page in 0..MAX_SEARCH_PAGES {
let resp = client.search(¶ms).await?;
for m in &resp.matches {
if !seen.insert(m.ctx_id.as_str().to_string()) {
continue;
}
let ctx = client.retrieve(&m.ctx_id).await?;
if let Ok(rev) = verify_revocation_body(&ctx.body, resolver).await {
revocations.push(rev);
}
}
match resp.next_cursor {
Some(cursor) => params.cursor = Some(cursor),
None => break,
}
}
}
}
Ok(revocations)
}
#[cfg(test)]
mod tests {
use super::*;
use acdp_types::revocation::RevocationTrustClass;
use chrono::TimeZone;
fn rev(fp: &str, t: DateTime<Utc>) -> KeyRevocation {
KeyRevocation {
revoked_key_fingerprint: fp.into(),
compromised_since: t,
reason: None,
revoked_key_id: None,
revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
publisher: AgentDid::new("did:web:agents.example.com:p"),
trust_class: RevocationTrustClass::ProducerSigned,
}
}
fn at(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
const F: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
#[test]
fn boundary_is_strict() {
let t = at("2026-05-01T00:00:00.000Z");
let revs = [rev(F, t)];
assert_eq!(
classify_under_revocation(&revs, F, Some(at("2026-04-30T23:59:59.999Z"))).unwrap(),
Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
);
assert!(matches!(
classify_under_revocation(&revs, F, Some(t)),
Err(AcdpError::KeyNotAuthorized(_))
));
}
#[test]
fn no_receipt_fails_closed() {
let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
assert!(matches!(
classify_under_revocation(&revs, F, None),
Err(AcdpError::KeyNotAuthorized(_))
));
}
#[test]
fn unrelated_fingerprint_is_inert() {
let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
let other = "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
assert_eq!(classify_under_revocation(&revs, other, None).unwrap(), None);
assert_eq!(
classify_under_revocation(&[], F, None).unwrap(),
None,
"no known revocations ⇒ inert"
);
}
#[test]
fn earliest_boundary_wins() {
let early = at("2026-04-01T00:00:00.000Z");
let late = at("2026-05-01T00:00:00.000Z");
let revs = [rev(F, late), rev(F, early)];
assert!(matches!(
classify_under_revocation(&revs, F, Some(at("2026-04-15T00:00:00.000Z"))),
Err(AcdpError::KeyNotAuthorized(_))
));
assert_eq!(
classify_under_revocation(&revs, F, Some(at("2026-03-01T00:00:00.000Z"))).unwrap(),
Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
);
}
#[test]
fn pre_compromise_uses_millis() {
let t = Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap()
+ chrono::Duration::milliseconds(500);
let revs = [rev(F, t)];
let just_before = t - chrono::Duration::milliseconds(1);
assert_eq!(
classify_under_revocation(&revs, F, Some(just_before)).unwrap(),
Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
);
}
}