Skip to main content

acdp_client/
revocation_verdict.rs

1//! Typed, attributable revocation verdicts (RFC-ACDP-0014 §7/§8, 0.5.0
2//! "Attribution", "Producer scope", "Discovery outcome classes",
3//! "Caching").
4//!
5//! One assessment runs for every verification that applies a
6//! [`RevocationPolicy`]: [`VerifiedContext`](crate::VerifiedContext)'s
7//! `fetch*` family and [`find_revocations_verdict`] share it, so a re-check
8//! sees the same known revocations, cache, discovery budget, and failure
9//! policy as the original verification.
10//!
11//! The assessment has three outcomes, never collapsed:
12//!
13//! - [`RevocationVerdict::Revoked`] — a verified, in-scope revocation names
14//!   the signing key; [`RevocationEffect`] says which §7 step applied.
15//! - [`RevocationVerdict::NotRevoked`] — none does. `complete` says whether
16//!   every configured discovery lookup finished; `observed_at` reports a
17//!   cached absence's age.
18//! - [`RevocationVerdict::Unknown`] — discovery could not finish, for a
19//!   typed [`UnknownReason`]. `unknown` is never treated as not-revoked and
20//!   is never cached.
21
22use 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
39/// RFC-ACDP-0014 §8 "Caching" RECOMMENDED bound on how long a cached
40/// complete absence (a negative) may suppress a discovery lookup: 300 s.
41/// Both [`RevocationDiscovery`](crate::RevocationDiscovery) named
42/// constructors use it.
43pub const RECOMMENDED_NEGATIVE_FRESHNESS: Duration = Duration::from_secs(300);
44
45/// The typed revocation verdict for one context's signing key.
46#[derive(Debug, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum RevocationVerdict {
49    /// A verified, in-scope revocation names the signing key.
50    Revoked {
51        /// §7 step 2 ([`RevocationEffect::PreCompromise`], accepted), step 3
52        /// ([`RevocationEffect::Revoked`]) or step 4
53        /// ([`RevocationEffect::Unplaceable`]).
54        effect: RevocationEffect,
55        /// Effective boundary T (the §4 earliest `compromised_since`).
56        boundary: DateTime<Utc>,
57        /// The revocation that fixes `boundary`, attributed.
58        revocation: SourcedRevocation,
59        /// The receipt-attested publish time compared, if any.
60        placement: Option<DateTime<Utc>>,
61    },
62    /// No verified, in-scope revocation names the signing key.
63    NotRevoked {
64        /// `true` when §8 discovery was configured and every configured
65        /// lookup completed untruncated (live or from a fresh cached
66        /// absence). `false` when discovery was off: only the caller's
67        /// known revocations and cached facts were consulted.
68        complete: bool,
69        /// Whether the registry-attested class was among the completed
70        /// lookups.
71        registry_attested_checked: bool,
72        /// When a cached complete absence answered at least one lookup,
73        /// the earliest time such an absence was observed; `None` when
74        /// every lookup ran live.
75        observed_at: Option<SystemTime>,
76    },
77    /// Discovery could not establish the key's status.
78    Unknown {
79        /// Why.
80        reason: UnknownReason,
81        /// The failure, rendered.
82        detail: String,
83    },
84}
85
86impl RevocationVerdict {
87    /// Whether the verdict refuses the context (§7 step 3 or 4).
88    #[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    /// Whether the verdict is `unknown`.
100    #[must_use]
101    pub fn is_unknown(&self) -> bool {
102        matches!(self, RevocationVerdict::Unknown { .. })
103    }
104
105    /// The `unknown` reason, if any.
106    #[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/// A complete revocation assessment: the verdict, out-of-scope hints, and
116/// what discovery did.
117#[derive(Debug, Clone)]
118#[non_exhaustive]
119pub struct RevocationAssessment {
120    /// The verdict.
121    pub verdict: RevocationVerdict,
122    /// Verified revocations that do NOT speak for the producer (RFC-ACDP-0014
123    /// §7 "Producer scope", 0.5.0) — reported, never applied.
124    pub hints: Vec<SourcedRevocation>,
125    /// §8 discovery: `None` when not configured; `Some(Ok)` with what each
126    /// trust class found; `Some(Err(AcdpError::RevocationUnknown { .. }))`
127    /// when discovery could not finish.
128    pub discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
129    /// An in-scope revocation names the key and the context is placed
130    /// before T — even when the verdict is `Unknown` (discovery could not
131    /// rule out an earlier T). Drives the historical signature rule.
132    pub(crate) pre_compromise: bool,
133    pub(crate) on_failure: DiscoveryFailurePolicy,
134}
135
136impl RevocationAssessment {
137    /// Apply the policy: a refusal becomes [`AcdpError::KeyRevoked`]; an
138    /// `unknown` verdict becomes [`AcdpError::RevocationUnknown`] under
139    /// [`DiscoveryFailurePolicy::FailClosed`] and is returned as `Ok` under
140    /// [`DiscoveryFailurePolicy::ProceedWithKnown`].
141    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    /// Whether the signing key is placed before an in-scope compromise
167    /// boundary (§7 step 2): the signature must be verified under the
168    /// RFC-ACDP-0010 §10 historical rule.
169    #[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/// Revocations gathered for one producer, before the signing key is known.
210#[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    /// Whether any in-scope revocation exists — the caller resolves the
223    /// signing key's fingerprint only then.
224    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
237/// Collect known, cached, and (when configured) discovered revocations for
238/// `agent_id`, scope them, and record what discovery did. Never fails: a
239/// discovery failure is captured as `unknown`.
240pub(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    // Facts survive every failure below: read before discovery runs.
258    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        // One combined budget for both lookups, on a client clone, so
279        // unrelated traffic on `client` is never charged to it.
280        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
352/// Decide the verdict for `signing_key_fingerprint` (`None` when there is
353/// no in-scope revocation, so no fingerprint was resolved).
354pub(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    // §4: the earliest boundary across every in-scope revocation naming the
361    // key; ties prefer producer-signed, then the most direct source.
362    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    // A refusal is definitive; `unknown` outranks an acceptance (an
411    // unfinished discovery could hide an earlier boundary).
412    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
431/// Assess `signing_key_fingerprint`'s revocation status for `agent_id`
432/// under `policy` — the same assessment
433/// [`VerifiedContext::fetch_with_policy`](crate::VerifiedContext::fetch_with_policy)
434/// runs, for re-checking an already-verified context without refetching
435/// it. Shares `policy.known`, `client`'s attached
436/// [`RevocationCache`](crate::RevocationCache), and `policy.discover`'s
437/// budget, deadline, freshness, and failure policy.
438///
439/// `receipt_placement` is the context's verified receipt `created_at` and
440/// receipt-key status (see
441/// [`VerifiedContext::verified_receipt`](crate::VerifiedContext::verified_receipt)
442/// and [`VerifiedContext::receipt_key_status`](crate::VerifiedContext::receipt_key_status)),
443/// or `None` when it has no verified receipt.
444///
445/// Returns the typed assessment and never fails on a verdict: call
446/// [`RevocationAssessment::enforce`] to turn a refusal into
447/// [`AcdpError::KeyRevoked`] and an `unknown` (under `FailClosed`) into
448/// [`AcdpError::RevocationUnknown`]. `Err` only for a malformed `agent_id`.
449pub 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
467/// Classify `revocations` (all treated as caller-supplied) for
468/// `signing_key_fingerprint` — the synchronous core of
469/// [`classify_under_revocation_placed`](crate::classify_under_revocation_placed).
470/// No producer-scope filter: the caller chose these revocations.
471pub(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}