1use std::time::{Duration, SystemTime};
23
24use acdp_did::WebResolver;
25use acdp_primitives::error::AcdpError;
26use acdp_types::primitives::AgentDid;
27use acdp_types::receipt::ReceiptKeyStatus;
28use acdp_types::revocation::{KeyRevocation, RevocationTrustClass};
29use chrono::{DateTime, Utc};
30
31pub use acdp_types::revocation::{
32 RevocationEffect, RevocationRefusal, RevocationSource, SourcedRevocation, UnknownReason,
33};
34
35use crate::registry::{DiscoveryBudget, RegistryClient};
36use crate::revocation::{find_registry_attested_lookup, find_revocations_lookup, Lookup};
37use crate::verified::{DiscoveryFailurePolicy, DiscoveryOutcome, RevocationPolicy};
38
39pub const RECOMMENDED_NEGATIVE_FRESHNESS: Duration = Duration::from_secs(300);
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum RevocationVerdict {
49 Revoked {
51 effect: RevocationEffect,
55 boundary: DateTime<Utc>,
57 revocation: SourcedRevocation,
59 placement: Option<DateTime<Utc>>,
61 },
62 NotRevoked {
64 complete: bool,
69 registry_attested_checked: bool,
72 observed_at: Option<SystemTime>,
76 },
77 Unknown {
79 reason: UnknownReason,
81 detail: String,
83 },
84}
85
86impl RevocationVerdict {
87 #[must_use]
89 pub fn is_refusal(&self) -> bool {
90 matches!(
91 self,
92 RevocationVerdict::Revoked {
93 effect: RevocationEffect::Revoked | RevocationEffect::Unplaceable,
94 ..
95 }
96 )
97 }
98
99 #[must_use]
101 pub fn is_unknown(&self) -> bool {
102 matches!(self, RevocationVerdict::Unknown { .. })
103 }
104
105 #[must_use]
107 pub fn unknown_reason(&self) -> Option<UnknownReason> {
108 match self {
109 RevocationVerdict::Unknown { reason, .. } => Some(*reason),
110 _ => None,
111 }
112 }
113}
114
115#[derive(Debug, Clone)]
118#[non_exhaustive]
119pub struct RevocationAssessment {
120 pub verdict: RevocationVerdict,
122 pub hints: Vec<SourcedRevocation>,
125 pub discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
129 pub(crate) pre_compromise: bool,
133 pub(crate) on_failure: DiscoveryFailurePolicy,
134}
135
136impl RevocationAssessment {
137 pub fn enforce(self) -> Result<Self, AcdpError> {
142 if let RevocationVerdict::Revoked {
143 effect: effect @ (RevocationEffect::Revoked | RevocationEffect::Unplaceable),
144 boundary,
145 revocation,
146 placement,
147 } = &self.verdict
148 {
149 return Err(AcdpError::KeyRevoked(Box::new(RevocationRefusal::new(
150 *effect,
151 revocation.revoked_key_fingerprint.clone(),
152 *boundary,
153 revocation.clone(),
154 *placement,
155 refusal_detail(*effect, *boundary, *placement),
156 ))));
157 }
158 if self.verdict.is_unknown() && self.on_failure == DiscoveryFailurePolicy::FailClosed {
159 if let Some(Err(e)) = &self.discovery {
160 return Err(e.clone());
161 }
162 }
163 Ok(self)
164 }
165
166 #[must_use]
170 pub fn pre_compromise(&self) -> bool {
171 self.pre_compromise
172 }
173}
174
175fn fmt_ms(t: DateTime<Utc>) -> String {
176 t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
177}
178
179fn refusal_detail(
180 effect: RevocationEffect,
181 boundary: DateTime<Utc>,
182 placement: Option<DateTime<Utc>>,
183) -> String {
184 match (effect, placement) {
185 (RevocationEffect::Revoked, Some(t)) => format!(
186 "the receipt-attested publish time {} is at/after the compromise boundary {}, so \
187 the signature is not attributable to the producer — fail closed regardless of \
188 DID-document state or receipt validity (RFC-ACDP-0014 §7 step 3)",
189 fmt_ms(t),
190 fmt_ms(boundary),
191 ),
192 (RevocationEffect::Unplaceable, Some(_)) => format!(
193 "the context's receipt is signed by a historical receipt key (verificationMethod \
194 only), which cannot place a revoked-key context before the compromise boundary \
195 {} under the strict profile — unplaceable, fail closed (RFC-ACDP-0014 §7 steps 1 \
196 and 4)",
197 fmt_ms(boundary),
198 ),
199 _ => format!(
200 "the context has no verified registry receipt, so its publish time cannot be \
201 placed relative to the compromise boundary {} — an unplaceable revoked-key \
202 signature is exactly the artifact an attacker mints freely; the strict profile \
203 fails closed (RFC-ACDP-0014 §7 step 4)",
204 fmt_ms(boundary),
205 ),
206 }
207}
208
209#[derive(Debug)]
211pub(crate) struct Gathered {
212 in_scope: Vec<SourcedRevocation>,
213 hints: Vec<SourcedRevocation>,
214 discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
215 complete: bool,
216 registry_attested_checked: bool,
217 observed_at: Option<SystemTime>,
218 on_failure: DiscoveryFailurePolicy,
219 unevaluated: Vec<crate::keyless::Unevaluated>,
221}
222
223impl Gathered {
224 pub(crate) fn has_in_scope(&self) -> bool {
227 !self.in_scope.is_empty() || !self.unevaluated.is_empty()
228 }
229}
230
231fn source_rank(s: &RevocationSource) -> u8 {
232 match s {
233 RevocationSource::Known => 0,
234 RevocationSource::Discovered { .. } => 1,
235 _ => 2,
236 }
237}
238
239pub(crate) async fn gather(
243 client: &RegistryClient,
244 resolver: &WebResolver,
245 agent_id: &AgentDid,
246 policy: &RevocationPolicy,
247 keyless: &crate::keyless::KeylessPolicy,
248) -> Gathered {
249 let vantage = client.authority();
250 let include_attested = policy.discover.is_some_and(|d| d.include_registry_attested);
251 let on_failure = policy
252 .discover
253 .map_or(DiscoveryFailurePolicy::FailClosed, |d| d.on_failure);
254
255 let mut all: Vec<SourcedRevocation> = policy
256 .known
257 .iter()
258 .map(|r| r.sourced(RevocationSource::Known))
259 .collect();
260 if let Some((cache, _)) = client.revocation_cache() {
262 all.extend(
263 cache
264 .facts_for(agent_id.as_str(), include_attested, vantage.as_deref())
265 .into_iter()
266 .map(|(r, origin)| r.sourced(RevocationSource::Cached { origin })),
267 );
268 }
269
270 let mut gathered = Gathered {
271 in_scope: Vec::new(),
272 hints: Vec::new(),
273 discovery: None,
274 complete: false,
275 registry_attested_checked: false,
276 observed_at: None,
277 on_failure,
278 unevaluated: Vec::new(),
279 };
280 let mut keyless_hints = Vec::new();
281
282 if let Some(discovery) = &policy.discover {
283 let budget = DiscoveryBudget::new(discovery.max_requests, discovery.max_bytes);
286 let dclient = client
287 .with_discovery_budget(budget)
288 .with_revocation_freshness(discovery.freshness);
289 let fut = async {
290 let (signed, attested) = tokio::try_join!(
291 find_revocations_lookup(&dclient, resolver, agent_id),
292 async {
293 if include_attested {
294 find_registry_attested_lookup(&dclient, resolver, agent_id)
295 .await
296 .map(Some)
297 } else {
298 Ok(None)
299 }
300 },
301 )?;
302 let keyless = crate::keyless::evaluate_revocations(
305 &dclient,
306 resolver,
307 agent_id,
308 &signed.deferred,
309 keyless,
310 )
311 .await;
312 Ok::<_, AcdpError>((signed, attested, keyless))
313 };
314 let failure = match tokio::time::timeout(discovery.total_timeout, fut).await {
315 Ok(Ok((signed, attested, keyless))) => {
316 let outcome = DiscoveryOutcome {
317 producer_signed: signed.revocations.len(),
318 registry_attested: attested.as_ref().map(|l| l.revocations.len()),
319 };
320 gathered.observed_at = [Some(&signed), attested.as_ref()]
321 .into_iter()
322 .flatten()
323 .filter_map(|l: &Lookup| l.cached_since)
324 .min();
325 gathered.complete = true;
326 gathered.registry_attested_checked = attested.is_some();
327 let authority = vantage.clone().unwrap_or_default();
328 for l in std::iter::once(signed).chain(attested) {
329 all.extend(l.revocations.iter().map(|r: &KeyRevocation| {
330 r.sourced(RevocationSource::Discovered {
331 authority: authority.clone(),
332 })
333 }));
334 }
335 all.extend(keyless.applied.iter().map(|r| {
336 r.sourced(RevocationSource::Discovered {
337 authority: authority.clone(),
338 })
339 }));
340 keyless_hints.extend(keyless.hints.iter().map(|r| {
341 r.sourced(RevocationSource::Discovered {
342 authority: authority.clone(),
343 })
344 }));
345 gathered.unevaluated = keyless.unevaluated;
346 gathered.discovery = Some(Ok(outcome));
347 None
348 }
349 Ok(Err(e)) => Some(e),
350 Err(_elapsed) => Some(AcdpError::CrossRegistryResolutionFailed(format!(
351 "revocation auto-discovery exceeded total_timeout={:?}",
352 discovery.total_timeout
353 ))),
354 };
355 if let Some(source) = failure {
356 let reason = source.unknown_reason();
357 let detail = format!(
358 "RFC-ACDP-0014 §8 discovery for {agent_id} at {} could not finish: {source}",
359 vantage.as_deref().unwrap_or("<no authority>")
360 );
361 gathered.discovery = Some(Err(AcdpError::RevocationUnknown {
362 reason,
363 source: Box::new(source),
364 detail,
365 }));
366 }
367 }
368
369 (gathered.in_scope, gathered.hints) = scope(all, agent_id);
370 for h in keyless_hints {
371 if !gathered.hints.contains(&h) {
372 gathered.hints.push(h);
373 }
374 }
375 gathered
376}
377
378fn scope(
381 all: Vec<SourcedRevocation>,
382 agent_id: &AgentDid,
383) -> (Vec<SourcedRevocation>, Vec<SourcedRevocation>) {
384 let (mut in_scope, mut hints) = (Vec::new(), Vec::new());
385 for r in all {
386 if r.speaks_for(agent_id) {
387 in_scope.push(r);
388 } else if !hints.contains(&r) {
389 hints.push(r);
390 }
391 }
392 (in_scope, hints)
393}
394
395pub(crate) fn decide(
398 gathered: Gathered,
399 signing_key_fingerprint: Option<&str>,
400 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
401 accept_historical_receipt_key: bool,
402) -> RevocationAssessment {
403 let fixing = signing_key_fingerprint.and_then(|fp| {
406 gathered
407 .in_scope
408 .iter()
409 .filter(|r| r.revoked_key_fingerprint == fp)
410 .min_by_key(|r| {
411 (
412 r.compromised_since,
413 r.trust_class != RevocationTrustClass::ProducerSigned,
414 source_rank(&r.source),
415 )
416 })
417 .cloned()
418 });
419 let named = fixing.map(|revocation| {
420 let boundary = revocation.compromised_since;
421 let (effect, placement) = match receipt_placement {
422 Some((t, ReceiptKeyStatus::Historical)) if !accept_historical_receipt_key => {
423 (RevocationEffect::Unplaceable, Some(t))
424 }
425 Some((t, _)) if t < boundary => (RevocationEffect::PreCompromise, Some(t)),
426 Some((t, _)) => (RevocationEffect::Revoked, Some(t)),
427 None => (RevocationEffect::Unplaceable, None),
428 };
429 RevocationVerdict::Revoked {
430 effect,
431 boundary,
432 revocation,
433 placement,
434 }
435 });
436
437 let pre_compromise = matches!(
438 named,
439 Some(RevocationVerdict::Revoked {
440 effect: RevocationEffect::PreCompromise,
441 ..
442 })
443 );
444 let mut discovery = gathered.discovery;
447 if !matches!(discovery, Some(Err(_))) {
448 if let Some(u) = signing_key_fingerprint
449 .and_then(|fp| gathered.unevaluated.iter().find(|u| u.fingerprint == fp))
450 {
451 discovery = Some(Err(AcdpError::RevocationUnknown {
452 reason: UnknownReason::UnverifiableCandidate,
453 source: Box::new(AcdpError::RevocationCandidateUnverifiable(u.detail.clone())),
454 detail: u.detail.clone(),
455 }));
456 }
457 }
458 let unknown = match &discovery {
459 Some(Err(AcdpError::RevocationUnknown { reason, detail, .. })) => {
460 Some(RevocationVerdict::Unknown {
461 reason: *reason,
462 detail: detail.clone(),
463 })
464 }
465 _ => None,
466 };
467 let verdict = match (named, unknown) {
470 (Some(v), _) if v.is_refusal() => v,
471 (_, Some(u)) => u,
472 (Some(v), None) => v,
473 (None, None) => RevocationVerdict::NotRevoked {
474 complete: gathered.complete,
475 registry_attested_checked: gathered.registry_attested_checked,
476 observed_at: gathered.observed_at,
477 },
478 };
479 RevocationAssessment {
480 verdict,
481 hints: gathered.hints,
482 discovery,
483 pre_compromise,
484 on_failure: gathered.on_failure,
485 }
486}
487
488pub async fn find_revocations_verdict(
507 client: &RegistryClient,
508 resolver: &WebResolver,
509 agent_id: &AgentDid,
510 signing_key_fingerprint: &str,
511 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
512 policy: &RevocationPolicy,
513) -> Result<RevocationAssessment, AcdpError> {
514 let agent_id = AgentDid::parse(agent_id.as_str())?;
515 let gathered = gather(
519 client,
520 resolver,
521 &agent_id,
522 policy,
523 &crate::keyless::KeylessPolicy::default(),
524 )
525 .await;
526 Ok(decide(
527 gathered,
528 Some(signing_key_fingerprint),
529 receipt_placement,
530 policy.accept_historical_receipt_key_for_pre_t,
531 ))
532}
533
534pub(crate) fn decide_known(
539 revocations: &[KeyRevocation],
540 signing_key_fingerprint: &str,
541 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
542 accept_historical_receipt_key: bool,
543) -> RevocationAssessment {
544 let gathered = Gathered {
545 in_scope: revocations
546 .iter()
547 .map(|r| r.sourced(RevocationSource::Known))
548 .collect(),
549 hints: Vec::new(),
550 discovery: None,
551 complete: false,
552 registry_attested_checked: false,
553 observed_at: None,
554 on_failure: DiscoveryFailurePolicy::FailClosed,
555 unevaluated: Vec::new(),
556 };
557 decide(
558 gathered,
559 Some(signing_key_fingerprint),
560 receipt_placement,
561 accept_historical_receipt_key,
562 )
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568 use acdp_types::primitives::{ContextType, CtxId, Visibility};
569 use serde_json::{json, Value};
570
571 fn spec_dir() -> Option<std::path::PathBuf> {
572 let candidates = [
573 std::env::var("ACDP_SPEC_DIR")
574 .ok()
575 .map(std::path::PathBuf::from),
576 Some(
577 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
578 .join("../../../agentcontextdistributionprotocol"),
579 ),
580 ];
581 candidates
582 .into_iter()
583 .flatten()
584 .find(|p| p.join("schemas/conformance").exists())
585 }
586
587 fn member(m: &Value) -> Option<SourcedRevocation> {
592 let agent = AgentDid::new(m["agent_id"].as_str().unwrap());
593 let mut meta = json!({ "revoked_key_fingerprint": m["revoked_key_fingerprint"] });
594 for k in ["compromised_since", "revoked_key_controller"] {
595 if let Some(v) = m.get(k) {
596 meta[k] = v.clone();
597 }
598 }
599 let req = acdp_producer::Producer::new(
600 acdp_crypto::SigningKey::from_bytes(&[0x42; 32]),
601 agent.clone(),
602 format!("{agent}#key-9"),
603 )
604 .publish_request()
605 .title("rev-007 member")
606 .context_type(serde_json::from_value::<ContextType>(m["type"].clone()).ok()?)
607 .visibility(Visibility::Public)
608 .metadata(meta)
609 .build()
610 .ok()?;
611 let mut r = KeyRevocation::from_publish_request(&req).ok()?;
612 r.ctx_id = Some(CtxId(m["ctx_id"].as_str().unwrap().to_owned()));
613 Some(r.sourced(RevocationSource::Discovered {
614 authority: "registry.example.com".into(),
615 }))
616 }
617
618 #[test]
622 fn rev_007_classification_known_answers() {
623 let Some(spec) = spec_dir() else {
624 assert!(
625 std::env::var("ACDP_REQUIRE_CONFORMANCE").is_err(),
626 "ACDP_REQUIRE_CONFORMANCE is set but the spec checkout was not found"
627 );
628 eprintln!("ACDP spec not found; skipping rev-007");
629 return;
630 };
631 let path = spec.join("schemas/conformance/rev-007-classification-known-answers.json");
632 let fixture: Value =
633 serde_json::from_slice(&std::fs::read(&path).expect("rev-007 fixture")).unwrap();
634 let mut disagreements = Vec::new();
635 for v in fixture["vectors"].as_array().unwrap() {
636 let i = &v["input"];
637 let agent = AgentDid::new(i["context"]["agent_id"].as_str().unwrap());
638 let fp = i["context"]["key_fingerprint"].as_str().unwrap();
639 let all = i["revocations"]
640 .as_array()
641 .unwrap()
642 .iter()
643 .filter_map(member)
644 .collect();
645 let (in_scope, hints) = scope(all, &agent);
646 let unknown = i["unknown_lookups"].as_u64().unwrap() > 0;
647 let discovery = if unknown {
648 Err(AcdpError::RevocationUnknown {
649 reason: UnknownReason::Transient,
650 source: Box::new(AcdpError::Http("lookup failed".into())),
651 detail: "rev-007 unknown lookup".into(),
652 })
653 } else {
654 Ok(DiscoveryOutcome {
655 producer_signed: in_scope.len(),
656 registry_attested: Some(0),
657 })
658 };
659 let placement = match &i["receipt"] {
660 r if r.is_null() || r["verified"] == false => None,
661 r => Some((
662 r["created_at"].as_str().unwrap().parse().unwrap(),
663 match r["key_status"].as_str().unwrap() {
664 "current" => ReceiptKeyStatus::Current,
665 _ => ReceiptKeyStatus::Historical,
666 },
667 )),
668 };
669 let gathered = Gathered {
670 in_scope,
671 hints,
672 complete: !unknown,
673 registry_attested_checked: !unknown,
674 discovery: Some(discovery),
675 observed_at: None,
676 on_failure: DiscoveryFailurePolicy::FailClosed,
677 unevaluated: Vec::new(),
678 };
679 let got = match decide(gathered, Some(fp), placement, false).verdict {
680 RevocationVerdict::Revoked {
681 effect,
682 boundary,
683 revocation,
684 ..
685 } => json!({
686 "key_revocation": match effect {
687 RevocationEffect::PreCompromise => "pre-compromise",
688 RevocationEffect::Revoked => "revoked",
689 _ => "unplaceable",
690 },
691 "boundary": fmt_ms(boundary),
692 "revocation_ctx_id": revocation.ctx_id.map(|c| c.0),
693 }),
694 RevocationVerdict::NotRevoked { .. } => json!({
695 "key_revocation": "not-revoked", "boundary": null, "revocation_ctx_id": null
696 }),
697 RevocationVerdict::Unknown { .. } => json!({
698 "key_revocation": "unknown", "boundary": null, "revocation_ctx_id": null
699 }),
700 };
701 if got != v["expected"] {
702 disagreements.push(format!(
703 "{}: acdp {got}, expected {}",
704 v["name"], v["expected"]
705 ));
706 }
707 }
708 assert!(disagreements.is_empty(), "{}", disagreements.join("\n"));
709 }
710}