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 for r in all {
343 if r.speaks_for(agent_id) {
344 gathered.in_scope.push(r);
345 } else if !gathered.hints.contains(&r) {
346 gathered.hints.push(r);
347 }
348 }
349 gathered
350}
351
352pub(crate) fn decide(
355 gathered: Gathered,
356 signing_key_fingerprint: Option<&str>,
357 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
358 accept_historical_receipt_key: bool,
359) -> RevocationAssessment {
360 let fixing = signing_key_fingerprint.and_then(|fp| {
363 gathered
364 .in_scope
365 .iter()
366 .filter(|r| r.revoked_key_fingerprint == fp)
367 .min_by_key(|r| {
368 (
369 r.compromised_since,
370 r.trust_class != RevocationTrustClass::ProducerSigned,
371 source_rank(&r.source),
372 )
373 })
374 .cloned()
375 });
376 let named = fixing.map(|revocation| {
377 let boundary = revocation.compromised_since;
378 let (effect, placement) = match receipt_placement {
379 Some((t, ReceiptKeyStatus::Historical)) if !accept_historical_receipt_key => {
380 (RevocationEffect::Unplaceable, Some(t))
381 }
382 Some((t, _)) if t < boundary => (RevocationEffect::PreCompromise, Some(t)),
383 Some((t, _)) => (RevocationEffect::Revoked, Some(t)),
384 None => (RevocationEffect::Unplaceable, None),
385 };
386 RevocationVerdict::Revoked {
387 effect,
388 boundary,
389 revocation,
390 placement,
391 }
392 });
393
394 let pre_compromise = matches!(
395 named,
396 Some(RevocationVerdict::Revoked {
397 effect: RevocationEffect::PreCompromise,
398 ..
399 })
400 );
401 let unknown = match &gathered.discovery {
402 Some(Err(AcdpError::RevocationUnknown { reason, detail, .. })) => {
403 Some(RevocationVerdict::Unknown {
404 reason: *reason,
405 detail: detail.clone(),
406 })
407 }
408 _ => None,
409 };
410 let verdict = match (named, unknown) {
413 (Some(v), _) if v.is_refusal() => v,
414 (_, Some(u)) => u,
415 (Some(v), None) => v,
416 (None, None) => RevocationVerdict::NotRevoked {
417 complete: gathered.complete,
418 registry_attested_checked: gathered.registry_attested_checked,
419 observed_at: gathered.observed_at,
420 },
421 };
422 RevocationAssessment {
423 verdict,
424 hints: gathered.hints,
425 discovery: gathered.discovery,
426 pre_compromise,
427 on_failure: gathered.on_failure,
428 }
429}
430
431pub async fn find_revocations_verdict(
450 client: &RegistryClient,
451 resolver: &WebResolver,
452 agent_id: &AgentDid,
453 signing_key_fingerprint: &str,
454 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
455 policy: &RevocationPolicy,
456) -> Result<RevocationAssessment, AcdpError> {
457 let agent_id = AgentDid::parse(agent_id.as_str())?;
458 let gathered = gather(client, resolver, &agent_id, policy).await;
459 Ok(decide(
460 gathered,
461 Some(signing_key_fingerprint),
462 receipt_placement,
463 policy.accept_historical_receipt_key_for_pre_t,
464 ))
465}
466
467pub(crate) fn decide_known(
472 revocations: &[KeyRevocation],
473 signing_key_fingerprint: &str,
474 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
475 accept_historical_receipt_key: bool,
476) -> RevocationAssessment {
477 let gathered = Gathered {
478 in_scope: revocations
479 .iter()
480 .map(|r| r.sourced(RevocationSource::Known))
481 .collect(),
482 hints: Vec::new(),
483 discovery: None,
484 complete: false,
485 registry_attested_checked: false,
486 observed_at: None,
487 on_failure: DiscoveryFailurePolicy::FailClosed,
488 };
489 decide(
490 gathered,
491 Some(signing_key_fingerprint),
492 receipt_placement,
493 accept_historical_receipt_key,
494 )
495}