Skip to main content

acdp_client/
revocation.rs

1//! Consumer-side key-revocation semantics (ACDP 0.3, RFC-ACDP-0014).
2//!
3//! Three layers:
4//!
5//! - [`classify_under_revocation`] — the pure §7 boundary rule: given
6//!   verified revocations and a (receipt-attested) publish time,
7//!   decide *historically authorized (pre-compromise, receipt-attested)*
8//!   vs fail-closed. This is what
9//!   [`VerificationPolicy::revocations`](crate::VerificationPolicy)
10//!   drives inside the fetch pipeline.
11//! - [`verify_revocation_body`] — the §5 verification pipeline for a
12//!   revocation context itself: strict RFC-ACDP-0001 §5.11 body
13//!   verification, §4 shape parse, and the §5 step 2 not-self-signed
14//!   check against the resolved signing key's fingerprint.
15//! - [`find_revocations`] — the §8 discovery SHOULD: search a registry
16//!   for a producer's revocation contexts and return the ones that
17//!   verify.
18
19use acdp_crypto::fingerprint::fingerprint_for_key_id;
20use acdp_did::WebResolver;
21use acdp_primitives::error::AcdpError;
22use acdp_types::body::Body;
23use acdp_types::primitives::AgentDid;
24use acdp_types::revocation::{effective_boundary, KeyRevocation};
25use acdp_types::search::SearchParamsBuilder;
26use acdp_verify::Verifier;
27use chrono::{DateTime, Utc};
28
29use super::registry::RegistryClient;
30use super::verified::KeyAuthorization;
31
32/// Pagination safety cap for [`find_revocations`] — a hostile registry
33/// must not be able to hold the helper in an endless cursor loop.
34const MAX_SEARCH_PAGES: usize = 10;
35
36/// Apply the RFC-ACDP-0014 §7 compromise-boundary rule.
37///
38/// Inputs:
39///
40/// - `revocations` — **verified** revocations the consumer has decided
41///   to act on (see [`RevocationPolicy`](crate::RevocationPolicy) for
42///   the §6 trust-class guidance). The §4 earliest-`compromised_since`
43///   rule is applied across every entry naming the fingerprint.
44/// - `signing_key_fingerprint` — the RFC-ACDP-0010 §6 fingerprint of
45///   the key that signed the context under verification.
46/// - `receipt_attested_created_at` — `created_at` from a registry
47///   receipt **verified per RFC-ACDP-0010 §8** (whose step 5 confirms
48///   the receipt attests this same fingerprint), or `None` when there
49///   is no verified receipt. The bare body `created_at` MUST NOT be
50///   passed here — it is registry-assigned, unsigned by the producer,
51///   and attacker-backdatable (§7 step 1).
52///
53/// Verdicts:
54///
55/// - `Ok(None)` — no supplied revocation names this key; the ordinary
56///   verification rules apply unchanged.
57/// - `Ok(Some(`[`KeyAuthorization::HistoricallyAuthorizedPreCompromise`]`))`
58///   — publish time strictly before the boundary (§7 step 2). The
59///   caller must still verify the signature itself, under the
60///   RFC-ACDP-0010 §10 historical rule.
61/// - `Err(`[`AcdpError::KeyNotAuthorized`]`)` — fail closed: publish
62///   time at/after the boundary (§7 step 3), or no verifiable publish
63///   time at all (§7 step 4). Per RFC-ACDP-0014 §10 this is a
64///   verification verdict, not a wire condition — there is no new wire
65///   error code; the key is simply not authorized to speak for the
66///   producer in (or without placement relative to) the compromise
67///   window.
68pub fn classify_under_revocation(
69    revocations: &[KeyRevocation],
70    signing_key_fingerprint: &str,
71    receipt_attested_created_at: Option<DateTime<Utc>>,
72) -> Result<Option<KeyAuthorization>, AcdpError> {
73    let Some(boundary) = effective_boundary(revocations, signing_key_fingerprint) else {
74        return Ok(None);
75    };
76    match receipt_attested_created_at {
77        Some(created_at) if created_at < boundary => {
78            Ok(Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise))
79        }
80        Some(created_at) => Err(AcdpError::KeyNotAuthorized(format!(
81            "signing key {signing_key_fingerprint} is revoked with compromise boundary \
82             {}; the receipt-attested publish time {} is at/after the boundary, so the \
83             signature is not attributable to the producer — fail closed regardless of \
84             DID-document state or receipt validity (RFC-ACDP-0014 §7 step 3)",
85            boundary.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
86            created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
87        ))),
88        None => Err(AcdpError::KeyNotAuthorized(format!(
89            "signing key {signing_key_fingerprint} is revoked (compromise boundary {}) \
90             and the context has no verified registry receipt, so its publish time \
91             cannot be placed relative to the boundary — an unplaceable revoked-key \
92             signature is exactly the artifact an attacker mints freely; the strict \
93             profile fails closed (RFC-ACDP-0014 §7 step 4)",
94            boundary.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
95        ))),
96    }
97}
98
99/// Verify a `key-revocation` context body per RFC-ACDP-0014 §5 and
100/// return its typed, trust-classified form.
101///
102/// Pipeline:
103///
104/// 1. Strict RFC-ACDP-0001 §5.11 body verification (schema, hash
105///    recomputation, DID resolution, `assertionMethod` authorization,
106///    signature) — §5 step 1's "signed by a currently authorized key".
107/// 2. §4 shape parse + §5/§6 trust-class derivation
108///    ([`KeyRevocation::from_body`]).
109/// 3. §5 step 2 — the resolved signing key's fingerprint MUST NOT
110///    equal `revoked_key_fingerprint`
111///    ([`KeyRevocation::check_not_self_signed`]).
112///
113/// Note the §5 step 1 nuance this strict form does not cover: a
114/// revocation whose own signing key was later rotated out *cleanly*
115/// remains acceptable via the RFC-ACDP-0010 §10 receipt-attested
116/// historical rule — fetch it through
117/// [`VerifiedContext::fetch_with_policy`](crate::VerifiedContext::fetch_with_policy)
118/// (default policy) and parse the body afterwards for that case.
119///
120/// The returned trust class MUST be honored per §6: act on
121/// producer-signed revocations unconditionally; treat registry-attested
122/// ones as the weaker class (verify that `publisher` is in fact the DID
123/// of the registry involved, and corroborate before global use).
124pub async fn verify_revocation_body(
125    body: &Body,
126    resolver: &WebResolver,
127) -> Result<KeyRevocation, AcdpError> {
128    Verifier::new(resolver).verify_body(body).await?;
129    let revocation = KeyRevocation::from_body(body)?;
130    let signer_fingerprint =
131        fingerprint_for_key_id(&body.signature.key_id, &body.signature.algorithm, resolver).await?;
132    revocation.check_not_self_signed(&signer_fingerprint)?;
133    Ok(revocation)
134}
135
136/// Discover a producer's key revocations on a registry
137/// (RFC-ACDP-0014 §8): search `type=key-revocation` (and the §10
138/// interim `acdp:key-revocation`) with `agent_id=<producer>`, retrieve
139/// each match, and return the ones that verify per §5
140/// ([`verify_revocation_body`]). Candidates that fail verification —
141/// including self-signed "revocations", which are at most a hint (§5
142/// step 2) — are skipped, not errors. Superseded revocations are
143/// queried too: the §4 earliest-boundary rule needs the whole lineage.
144///
145/// **The honest caveat (§8):** search is served by the registry, and a
146/// malicious registry can hide a revocation exactly as it can hide any
147/// context — an empty result is *not* evidence of absence, and a
148/// registry colluding with a key thief can serve the stolen key's
149/// contexts while suppressing this signal. Within the protocol the
150/// systemic mitigation is the RFC-ACDP-0009 §2.11 append-only
151/// transparency log (RFC-ACDP-0012); until it is deployed, query more
152/// than one vantage where the stakes warrant it, and remember that
153/// revocations are self-contained signed contexts — out-of-band
154/// delivery verifies identically and is the one channel a registry
155/// cannot suppress.
156///
157/// Registry-attested revocations (§6) are published under the
158/// *registry's* DID, not the producer's, so this producer-scoped query
159/// does not find them — search the registry's own `agent_id` and match
160/// `revoked_key_controller` client-side for those.
161pub async fn find_revocations(
162    client: &RegistryClient,
163    resolver: &WebResolver,
164    agent_id: &AgentDid,
165) -> Result<Vec<KeyRevocation>, AcdpError> {
166    let mut revocations = Vec::new();
167    let mut seen = std::collections::HashSet::new();
168
169    for type_form in ["key-revocation", "acdp:key-revocation"] {
170        // Revocations are permanent but supersedable; the registry
171        // defaults search to status=active, so ask for both explicitly.
172        for status in ["active", "superseded"] {
173            let mut params = SearchParamsBuilder::new()
174                .context_type(type_form)
175                .agent_id(agent_id.as_str())
176                .status(status)
177                .limit(100)
178                .build();
179            for _page in 0..MAX_SEARCH_PAGES {
180                let resp = client.search(&params).await?;
181                for m in &resp.matches {
182                    if !seen.insert(m.ctx_id.as_str().to_string()) {
183                        continue;
184                    }
185                    let ctx = client.retrieve(&m.ctx_id).await?;
186                    if let Ok(rev) = verify_revocation_body(&ctx.body, resolver).await {
187                        revocations.push(rev);
188                    }
189                }
190                match resp.next_cursor {
191                    Some(cursor) => params.cursor = Some(cursor),
192                    None => break,
193                }
194            }
195        }
196    }
197    Ok(revocations)
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use acdp_types::revocation::RevocationTrustClass;
204    use chrono::TimeZone;
205
206    fn rev(fp: &str, t: DateTime<Utc>) -> KeyRevocation {
207        KeyRevocation {
208            revoked_key_fingerprint: fp.into(),
209            compromised_since: t,
210            reason: None,
211            revoked_key_id: None,
212            revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
213            publisher: AgentDid::new("did:web:agents.example.com:p"),
214            trust_class: RevocationTrustClass::ProducerSigned,
215        }
216    }
217
218    fn at(s: &str) -> DateTime<Utc> {
219        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
220    }
221
222    const F: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
223
224    /// §7 step 2: strictly-before-T verifies with the distinguishable
225    /// pre-compromise status; §7 step 3: equal-to-T already fails.
226    #[test]
227    fn boundary_is_strict() {
228        let t = at("2026-05-01T00:00:00.000Z");
229        let revs = [rev(F, t)];
230        assert_eq!(
231            classify_under_revocation(&revs, F, Some(at("2026-04-30T23:59:59.999Z"))).unwrap(),
232            Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
233        );
234        assert!(matches!(
235            classify_under_revocation(&revs, F, Some(t)),
236            Err(AcdpError::KeyNotAuthorized(_))
237        ));
238    }
239
240    /// §7 step 4: no receipt-attested time → fail closed.
241    #[test]
242    fn no_receipt_fails_closed() {
243        let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
244        assert!(matches!(
245            classify_under_revocation(&revs, F, None),
246            Err(AcdpError::KeyNotAuthorized(_))
247        ));
248    }
249
250    /// A revocation of some OTHER key changes nothing.
251    #[test]
252    fn unrelated_fingerprint_is_inert() {
253        let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
254        let other = "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
255        assert_eq!(classify_under_revocation(&revs, other, None).unwrap(), None);
256        assert_eq!(
257            classify_under_revocation(&[], F, None).unwrap(),
258            None,
259            "no known revocations ⇒ inert"
260        );
261    }
262
263    /// §4 monotonicity: the earliest T across a revocation lineage is
264    /// effective — a later supersession cannot quietly shrink the
265    /// window.
266    #[test]
267    fn earliest_boundary_wins() {
268        let early = at("2026-04-01T00:00:00.000Z");
269        let late = at("2026-05-01T00:00:00.000Z");
270        let revs = [rev(F, late), rev(F, early)];
271        // Between the two boundaries: inside the (earliest-T) window.
272        assert!(matches!(
273            classify_under_revocation(&revs, F, Some(at("2026-04-15T00:00:00.000Z"))),
274            Err(AcdpError::KeyNotAuthorized(_))
275        ));
276        // Before both: pre-compromise.
277        assert_eq!(
278            classify_under_revocation(&revs, F, Some(at("2026-03-01T00:00:00.000Z"))).unwrap(),
279            Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
280        );
281    }
282
283    #[test]
284    fn pre_compromise_uses_millis() {
285        // Sub-second boundaries compare at millisecond precision — the
286        // canonical wire precision (RFC-ACDP-0001 §5.3).
287        let t = Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap()
288            + chrono::Duration::milliseconds(500);
289        let revs = [rev(F, t)];
290        let just_before = t - chrono::Duration::milliseconds(1);
291        assert_eq!(
292            classify_under_revocation(&revs, F, Some(just_before)).unwrap(),
293            Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
294        );
295    }
296}