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(¶ms).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}