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}
220
221impl Gathered {
222 pub(crate) fn has_in_scope(&self) -> bool {
225 !self.in_scope.is_empty()
226 }
227}
228
229fn source_rank(s: &RevocationSource) -> u8 {
230 match s {
231 RevocationSource::Known => 0,
232 RevocationSource::Discovered { .. } => 1,
233 _ => 2,
234 }
235}
236
237pub(crate) async fn gather(
241 client: &RegistryClient,
242 resolver: &WebResolver,
243 agent_id: &AgentDid,
244 policy: &RevocationPolicy,
245) -> Gathered {
246 let vantage = client.authority();
247 let include_attested = policy.discover.is_some_and(|d| d.include_registry_attested);
248 let on_failure = policy
249 .discover
250 .map_or(DiscoveryFailurePolicy::FailClosed, |d| d.on_failure);
251
252 let mut all: Vec<SourcedRevocation> = policy
253 .known
254 .iter()
255 .map(|r| r.sourced(RevocationSource::Known))
256 .collect();
257 if let Some((cache, _)) = client.revocation_cache() {
259 all.extend(
260 cache
261 .facts_for(agent_id.as_str(), include_attested, vantage.as_deref())
262 .into_iter()
263 .map(|(r, origin)| r.sourced(RevocationSource::Cached { origin })),
264 );
265 }
266
267 let mut gathered = Gathered {
268 in_scope: Vec::new(),
269 hints: Vec::new(),
270 discovery: None,
271 complete: false,
272 registry_attested_checked: false,
273 observed_at: None,
274 on_failure,
275 };
276
277 if let Some(discovery) = &policy.discover {
278 let budget = DiscoveryBudget::new(discovery.max_requests, discovery.max_bytes);
281 let dclient = client
282 .with_discovery_budget(budget)
283 .with_revocation_freshness(discovery.freshness);
284 let fut = async {
285 tokio::try_join!(
286 find_revocations_lookup(&dclient, resolver, agent_id),
287 async {
288 if include_attested {
289 find_registry_attested_lookup(&dclient, resolver, agent_id)
290 .await
291 .map(Some)
292 } else {
293 Ok(None)
294 }
295 },
296 )
297 };
298 let failure = match tokio::time::timeout(discovery.total_timeout, fut).await {
299 Ok(Ok((signed, attested))) => {
300 let outcome = DiscoveryOutcome {
301 producer_signed: signed.revocations.len(),
302 registry_attested: attested.as_ref().map(|l| l.revocations.len()),
303 };
304 gathered.observed_at = [Some(&signed), attested.as_ref()]
305 .into_iter()
306 .flatten()
307 .filter_map(|l: &Lookup| l.cached_since)
308 .min();
309 gathered.complete = true;
310 gathered.registry_attested_checked = attested.is_some();
311 let authority = vantage.clone().unwrap_or_default();
312 for l in std::iter::once(signed).chain(attested) {
313 all.extend(l.revocations.iter().map(|r: &KeyRevocation| {
314 r.sourced(RevocationSource::Discovered {
315 authority: authority.clone(),
316 })
317 }));
318 }
319 gathered.discovery = Some(Ok(outcome));
320 None
321 }
322 Ok(Err(e)) => Some(e),
323 Err(_elapsed) => Some(AcdpError::CrossRegistryResolutionFailed(format!(
324 "revocation auto-discovery exceeded total_timeout={:?}",
325 discovery.total_timeout
326 ))),
327 };
328 if let Some(source) = failure {
329 let reason = source.unknown_reason();
330 let detail = format!(
331 "RFC-ACDP-0014 §8 discovery for {agent_id} at {} could not finish: {source}",
332 vantage.as_deref().unwrap_or("<no authority>")
333 );
334 gathered.discovery = Some(Err(AcdpError::RevocationUnknown {
335 reason,
336 source: Box::new(source),
337 detail,
338 }));
339 }
340 }
341
342 (gathered.in_scope, gathered.hints) = scope(all, agent_id);
343 gathered
344}
345
346fn scope(
349 all: Vec<SourcedRevocation>,
350 agent_id: &AgentDid,
351) -> (Vec<SourcedRevocation>, Vec<SourcedRevocation>) {
352 let (mut in_scope, mut hints) = (Vec::new(), Vec::new());
353 for r in all {
354 if r.speaks_for(agent_id) {
355 in_scope.push(r);
356 } else if !hints.contains(&r) {
357 hints.push(r);
358 }
359 }
360 (in_scope, hints)
361}
362
363pub(crate) fn decide(
366 gathered: Gathered,
367 signing_key_fingerprint: Option<&str>,
368 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
369 accept_historical_receipt_key: bool,
370) -> RevocationAssessment {
371 let fixing = signing_key_fingerprint.and_then(|fp| {
374 gathered
375 .in_scope
376 .iter()
377 .filter(|r| r.revoked_key_fingerprint == fp)
378 .min_by_key(|r| {
379 (
380 r.compromised_since,
381 r.trust_class != RevocationTrustClass::ProducerSigned,
382 source_rank(&r.source),
383 )
384 })
385 .cloned()
386 });
387 let named = fixing.map(|revocation| {
388 let boundary = revocation.compromised_since;
389 let (effect, placement) = match receipt_placement {
390 Some((t, ReceiptKeyStatus::Historical)) if !accept_historical_receipt_key => {
391 (RevocationEffect::Unplaceable, Some(t))
392 }
393 Some((t, _)) if t < boundary => (RevocationEffect::PreCompromise, Some(t)),
394 Some((t, _)) => (RevocationEffect::Revoked, Some(t)),
395 None => (RevocationEffect::Unplaceable, None),
396 };
397 RevocationVerdict::Revoked {
398 effect,
399 boundary,
400 revocation,
401 placement,
402 }
403 });
404
405 let pre_compromise = matches!(
406 named,
407 Some(RevocationVerdict::Revoked {
408 effect: RevocationEffect::PreCompromise,
409 ..
410 })
411 );
412 let unknown = match &gathered.discovery {
413 Some(Err(AcdpError::RevocationUnknown { reason, detail, .. })) => {
414 Some(RevocationVerdict::Unknown {
415 reason: *reason,
416 detail: detail.clone(),
417 })
418 }
419 _ => None,
420 };
421 let verdict = match (named, unknown) {
424 (Some(v), _) if v.is_refusal() => v,
425 (_, Some(u)) => u,
426 (Some(v), None) => v,
427 (None, None) => RevocationVerdict::NotRevoked {
428 complete: gathered.complete,
429 registry_attested_checked: gathered.registry_attested_checked,
430 observed_at: gathered.observed_at,
431 },
432 };
433 RevocationAssessment {
434 verdict,
435 hints: gathered.hints,
436 discovery: gathered.discovery,
437 pre_compromise,
438 on_failure: gathered.on_failure,
439 }
440}
441
442pub async fn find_revocations_verdict(
461 client: &RegistryClient,
462 resolver: &WebResolver,
463 agent_id: &AgentDid,
464 signing_key_fingerprint: &str,
465 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
466 policy: &RevocationPolicy,
467) -> Result<RevocationAssessment, AcdpError> {
468 let agent_id = AgentDid::parse(agent_id.as_str())?;
469 let gathered = gather(client, resolver, &agent_id, policy).await;
470 Ok(decide(
471 gathered,
472 Some(signing_key_fingerprint),
473 receipt_placement,
474 policy.accept_historical_receipt_key_for_pre_t,
475 ))
476}
477
478pub(crate) fn decide_known(
483 revocations: &[KeyRevocation],
484 signing_key_fingerprint: &str,
485 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
486 accept_historical_receipt_key: bool,
487) -> RevocationAssessment {
488 let gathered = Gathered {
489 in_scope: revocations
490 .iter()
491 .map(|r| r.sourced(RevocationSource::Known))
492 .collect(),
493 hints: Vec::new(),
494 discovery: None,
495 complete: false,
496 registry_attested_checked: false,
497 observed_at: None,
498 on_failure: DiscoveryFailurePolicy::FailClosed,
499 };
500 decide(
501 gathered,
502 Some(signing_key_fingerprint),
503 receipt_placement,
504 accept_historical_receipt_key,
505 )
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use acdp_types::primitives::{ContextType, CtxId, Visibility};
512 use serde_json::{json, Value};
513
514 fn spec_dir() -> Option<std::path::PathBuf> {
515 let candidates = [
516 std::env::var("ACDP_SPEC_DIR")
517 .ok()
518 .map(std::path::PathBuf::from),
519 Some(
520 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
521 .join("../../../agentcontextdistributionprotocol"),
522 ),
523 ];
524 candidates
525 .into_iter()
526 .flatten()
527 .find(|p| p.join("schemas/conformance").exists())
528 }
529
530 fn member(m: &Value) -> Option<SourcedRevocation> {
535 let agent = AgentDid::new(m["agent_id"].as_str().unwrap());
536 let mut meta = json!({ "revoked_key_fingerprint": m["revoked_key_fingerprint"] });
537 for k in ["compromised_since", "revoked_key_controller"] {
538 if let Some(v) = m.get(k) {
539 meta[k] = v.clone();
540 }
541 }
542 let req = acdp_producer::Producer::new(
543 acdp_crypto::SigningKey::from_bytes(&[0x42; 32]),
544 agent.clone(),
545 format!("{agent}#key-9"),
546 )
547 .publish_request()
548 .title("rev-007 member")
549 .context_type(serde_json::from_value::<ContextType>(m["type"].clone()).ok()?)
550 .visibility(Visibility::Public)
551 .metadata(meta)
552 .build()
553 .ok()?;
554 let mut r = KeyRevocation::from_publish_request(&req).ok()?;
555 r.ctx_id = Some(CtxId(m["ctx_id"].as_str().unwrap().to_owned()));
556 Some(r.sourced(RevocationSource::Discovered {
557 authority: "registry.example.com".into(),
558 }))
559 }
560
561 #[test]
565 fn rev_007_classification_known_answers() {
566 let Some(spec) = spec_dir() else {
567 assert!(
568 std::env::var("ACDP_REQUIRE_CONFORMANCE").is_err(),
569 "ACDP_REQUIRE_CONFORMANCE is set but the spec checkout was not found"
570 );
571 eprintln!("ACDP spec not found; skipping rev-007");
572 return;
573 };
574 let path = spec.join("schemas/conformance/rev-007-classification-known-answers.json");
575 let fixture: Value =
576 serde_json::from_slice(&std::fs::read(&path).expect("rev-007 fixture")).unwrap();
577 let mut disagreements = Vec::new();
578 for v in fixture["vectors"].as_array().unwrap() {
579 let i = &v["input"];
580 let agent = AgentDid::new(i["context"]["agent_id"].as_str().unwrap());
581 let fp = i["context"]["key_fingerprint"].as_str().unwrap();
582 let all = i["revocations"]
583 .as_array()
584 .unwrap()
585 .iter()
586 .filter_map(member)
587 .collect();
588 let (in_scope, hints) = scope(all, &agent);
589 let unknown = i["unknown_lookups"].as_u64().unwrap() > 0;
590 let discovery = if unknown {
591 Err(AcdpError::RevocationUnknown {
592 reason: UnknownReason::Transient,
593 source: Box::new(AcdpError::Http("lookup failed".into())),
594 detail: "rev-007 unknown lookup".into(),
595 })
596 } else {
597 Ok(DiscoveryOutcome {
598 producer_signed: in_scope.len(),
599 registry_attested: Some(0),
600 })
601 };
602 let placement = match &i["receipt"] {
603 r if r.is_null() || r["verified"] == false => None,
604 r => Some((
605 r["created_at"].as_str().unwrap().parse().unwrap(),
606 match r["key_status"].as_str().unwrap() {
607 "current" => ReceiptKeyStatus::Current,
608 _ => ReceiptKeyStatus::Historical,
609 },
610 )),
611 };
612 let gathered = Gathered {
613 in_scope,
614 hints,
615 complete: !unknown,
616 registry_attested_checked: !unknown,
617 discovery: Some(discovery),
618 observed_at: None,
619 on_failure: DiscoveryFailurePolicy::FailClosed,
620 };
621 let got = match decide(gathered, Some(fp), placement, false).verdict {
622 RevocationVerdict::Revoked {
623 effect,
624 boundary,
625 revocation,
626 ..
627 } => json!({
628 "key_revocation": match effect {
629 RevocationEffect::PreCompromise => "pre-compromise",
630 RevocationEffect::Revoked => "revoked",
631 _ => "unplaceable",
632 },
633 "boundary": fmt_ms(boundary),
634 "revocation_ctx_id": revocation.ctx_id.map(|c| c.0),
635 }),
636 RevocationVerdict::NotRevoked { .. } => json!({
637 "key_revocation": "not-revoked", "boundary": null, "revocation_ctx_id": null
638 }),
639 RevocationVerdict::Unknown { .. } => json!({
640 "key_revocation": "unknown", "boundary": null, "revocation_ctx_id": null
641 }),
642 };
643 if got != v["expected"] {
644 disagreements.push(format!(
645 "{}: acdp {got}, expected {}",
646 v["name"], v["expected"]
647 ));
648 }
649 }
650 assert!(disagreements.is_empty(), "{}", disagreements.join("\n"));
651 }
652}