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, RevocationTrustClass};
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 (confirm `publisher` is in fact the DID of
123/// the registry involved via
124/// [`KeyRevocation::cross_check_registry_binding`], and corroborate
125/// before global use).
126pub async fn verify_revocation_body(
127 body: &Body,
128 resolver: &WebResolver,
129) -> Result<KeyRevocation, AcdpError> {
130 Verifier::new(resolver).verify_body(body).await?;
131 let revocation = KeyRevocation::from_body(body)?;
132 let signer_fingerprint =
133 fingerprint_for_key_id(&body.signature.key_id, &body.signature.algorithm, resolver).await?;
134 revocation.check_not_self_signed(&signer_fingerprint)?;
135 Ok(revocation)
136}
137
138/// Discover a producer's key revocations on a registry
139/// (RFC-ACDP-0014 §8): search `type=key-revocation` (and the §10
140/// interim `acdp:key-revocation`) with `agent_id=<producer>`, retrieve
141/// each match, and return the ones that verify per §5
142/// ([`verify_revocation_body`]) **and** that satisfy two additional
143/// invariants enforced client-side, neither of which the registry can
144/// be trusted to have applied:
145///
146/// 1. `KeyRevocation::publisher == agent_id` (exact byte match — see
147/// below) — the query's own scope. `resp.matches` is registry-
148/// supplied and is re-checked here rather than trusted: a hostile
149/// registry could otherwise return a context belonging to a
150/// different producer entirely, and it would verify (§5 covers
151/// signature validity, not query relevance). This is a genuine
152/// cryptographic binding, not merely a registry-trusted one:
153/// `Body.agent_id` is a producer-controlled field, not part of the
154/// RFC-ACDP-0001 §5.7 exclusion set, so it sits inside
155/// ProducerContent — covered by `content_hash` and the producer's
156/// signature. Because [`verify_revocation_body`] runs the full
157/// §5.11 pipeline first, `rev.publisher` read here has already been
158/// verified against that signature, so a hostile registry cannot
159/// forge it — stronger than the Phase 1 `ctx_id` check, which binds
160/// only a registry-assigned field.
161/// 2. `trust_class == RevocationTrustClass::ProducerSigned` — with (1)
162/// enforced, a `RegistryAttested` result here would mean a producer
163/// published a revocation *claiming to speak as the registry*
164/// (`agent_id == publisher == <the queried producer>` while
165/// `revoked_key_controller` differs). RFC-ACDP-0014 §4 makes
166/// `revoked_key_controller` REQUIRED on registry-attested
167/// revocations and, when present, requires it equal `body.agent_id`
168/// on producer-signed ones — so this shape (`agent_id` fixed as the
169/// queried producer, `revoked_key_controller` differing) is illegal
170/// only in the producer-signed case; it is the *mandatory* shape
171/// when `body.agent_id` is genuinely a registry. RFC-ACDP-0014 §13
172/// documents this exact cross-producer forgery and endorses
173/// consumer-local, operational mitigations generally — this
174/// implementation's choice is client-local filtering; §13's own
175/// first-named suggestion is surfacing which DID issued each
176/// acted-upon revocation, which the `tracing` diagnostics below
177/// partially adopt.
178///
179/// Both are required together: (1) alone still admits a producer's own
180/// spec-illegal self-published "registry attestation" (`agent_id` and
181/// `publisher` are the same field, so that check alone can't tell honest
182/// producer-signed apart from self-claimed registry-attested); (2) alone
183/// still admits *another* producer's genuine revocation served by a
184/// hostile or buggy registry that ignored the `agent_id` search filter.
185///
186/// Candidates dropped by either check, or that fail §5 verification
187/// outright — including self-signed "revocations", which are at most a
188/// hint (§5 step 2) — are omitted from the return value: returning a
189/// typed error for one bad or off-scope body would let anyone poison
190/// the whole discovery result for every caller — a cheap DoS on the
191/// very helper meant to defend against DoS. Dropped candidates are not
192/// silent, though: with the `tracing` feature enabled, each one is
193/// surfaced via a `tracing::warn!` recording the publisher, trust
194/// class, and `ctx_id` of the dropped candidate and which filter
195/// dropped it — RFC-ACDP-0014 §13's first-named mitigation,
196/// "surfacing which DID issued each acted-upon revocation." A caller
197/// wanting them in-band, or building without `tracing`, has the
198/// composable primitives directly:
199/// [`RegistryClient::search`](crate::RegistryClient::search),
200/// [`verify_revocation_body`], and [`KeyRevocation::from_body`].
201///
202/// Superseded revocations are queried too: the §4 earliest-boundary
203/// rule needs the whole lineage.
204///
205/// **`agent_id` is matched by exact bytes, not normalized.**
206/// [`AgentDid`] derives `PartialEq` as plain string equality, and
207/// [`AgentDid::parse`] allows uppercase in the method-specific id —
208/// only the DID *method* is constrained to lowercase (a mismatched
209/// case there is rejected with `SchemaViolation`, not normalized) — so
210/// `did:web:Agents.example.com` and `did:web:agents.example.com` are
211/// both schema-valid and unequal here.
212/// Passing an `agent_id` that differs only in case from the one a body
213/// was actually published under means every candidate is dropped by
214/// filter (1) and this function returns `Ok(vec![])` — a silent "no
215/// revocations found", indistinguishable from the honest empty case.
216/// Pass the exact DID bytes the producer publishes under (e.g. from a
217/// verified body's `agent_id`, not a hand-typed or config-sourced
218/// variant). `agent_id` is schema-parsed at entry so a malformed DID
219/// fails loudly instead of silently returning empty.
220///
221/// **The honest caveat (§8), unchanged by the above:** search is served
222/// by the registry, and a malicious registry can hide a revocation
223/// exactly as it can hide any context — an empty result is *not*
224/// evidence of absence, and a registry colluding with a key thief can
225/// serve the stolen key's contexts while suppressing this signal.
226/// Within the protocol the systemic mitigation is the RFC-ACDP-0009
227/// §2.11 append-only transparency log (RFC-ACDP-0012); until it is
228/// deployed, query more than one vantage where the stakes warrant it,
229/// and remember that revocations are self-contained signed contexts —
230/// out-of-band delivery verifies identically and is the one channel a
231/// registry cannot suppress.
232///
233/// **No independent binding of the returned `ctx_id`.** Each result is
234/// retrieved by the `ctx_id` the search response named; nothing here
235/// re-derives or independently confirms that id. The publisher filter
236/// above catches a registry substituting a *different producer's*
237/// revocation, but a registry that returns `agent_id`'s own *wrong*
238/// revocation body for a listed `ctx_id` is not detected by this
239/// function. That is benign today only because the §4 earliest-`T`
240/// rule makes any genuine revocation of `agent_id` conservative to
241/// apply regardless of which one is returned — a future reader must
242/// not assume the id-to-body binding is actually checked.
243///
244/// Registry-attested revocations (§6) are published under the
245/// *registry's* DID, not the producer's, so this producer-scoped query
246/// never returns them (filter 2 above, in addition to the search scope
247/// itself) — call [`find_registry_attested_revocations`] for those.
248pub async fn find_revocations(
249 client: &RegistryClient,
250 resolver: &WebResolver,
251 agent_id: &AgentDid,
252) -> Result<Vec<KeyRevocation>, AcdpError> {
253 // Schema-validate the caller's DID before it is promoted from an
254 // opaque search-filter string into an equality operand (filter 1
255 // below) — see the exact-byte-match caveat in the doc above. This
256 // does not normalize case; it only rejects a malformed DID loudly
257 // instead of silently yielding an empty result.
258 let agent_id = AgentDid::parse(agent_id.as_str())?;
259
260 let mut revocations = Vec::new();
261 let mut seen = std::collections::HashSet::new();
262
263 for type_form in ["key-revocation", "acdp:key-revocation"] {
264 // Revocations are permanent but supersedable; the registry
265 // defaults search to status=active, so ask for both explicitly.
266 for status in ["active", "superseded"] {
267 let mut params = SearchParamsBuilder::new()
268 .context_type(type_form)
269 .agent_id(agent_id.as_str())
270 .status(status)
271 .limit(100)
272 .build();
273 for _page in 0..MAX_SEARCH_PAGES {
274 let resp = client.search(¶ms).await?;
275 for m in &resp.matches {
276 if !seen.insert(m.ctx_id.as_str().to_string()) {
277 continue;
278 }
279 let ctx = client.retrieve(&m.ctx_id).await?;
280 if let Ok(rev) = verify_revocation_body(&ctx.body, resolver).await {
281 // Re-check query scope and trust class on the
282 // verified body — do not trust `resp.matches`,
283 // and do not accept a producer claiming to be
284 // a registry (RFC-ACDP-0014 §4, §13).
285 if rev.publisher == agent_id
286 && rev.trust_class == RevocationTrustClass::ProducerSigned
287 {
288 revocations.push(rev);
289 } else {
290 #[cfg(feature = "tracing")]
291 tracing::warn!(
292 publisher = %rev.publisher,
293 trust_class = ?rev.trust_class,
294 ctx_id = %m.ctx_id,
295 filter = if rev.publisher != agent_id {
296 "publisher_scope"
297 } else {
298 "trust_class"
299 },
300 "find_revocations: dropped candidate outside query scope/trust class"
301 );
302 }
303 }
304 }
305 match resp.next_cursor {
306 Some(cursor) => params.cursor = Some(cursor),
307 None => break,
308 }
309 }
310 }
311 }
312 Ok(revocations)
313}
314
315/// Discover a producer's **registry-attested** (§6) key revocations —
316/// the RFC-ACDP-0014 §8 second query that [`find_revocations`]'s own
317/// doc points callers at, since a producer-scoped search structurally
318/// cannot return them (they are published under the *registry's*
319/// `agent_id`, not the producer's).
320///
321/// Fetches the registry's own capabilities document (**exactly once**,
322/// before the search loop — [`RegistryClient::capabilities`] issues a
323/// fresh network round-trip on every call, so hoisting it above the
324/// type-form × status loop bounds total cost to one capabilities fetch
325/// plus up to `2 * MAX_SEARCH_PAGES` search round-trips rather than
326/// one capabilities fetch per candidate), then searches
327/// `agent_id=<capabilities.registry_did>` for `key-revocation` (and the
328/// §10 interim `acdp:key-revocation`) contexts, retrieves each match,
329/// and keeps the ones that:
330///
331/// 1. Verify per §5 ([`verify_revocation_body`]) — schema, hash
332/// recomputation, DID resolution, signature, and the §5 step 2
333/// not-self-signed check;
334/// 2. Name `controller` in `revoked_key_controller` (exact
335/// [`AgentDid`] equality — see [`find_revocations`]'s exact-byte-match
336/// caveat, which applies here identically); and
337/// 3. Pass [`KeyRevocation::cross_check_registry_binding`] against the
338/// authority this client actually talks to and the capabilities
339/// document just fetched — RFC-ACDP-0014 §6 step 2 (`publisher`
340/// must equal `capabilities.registry_did`) plus the RFC-ACDP-0011
341/// §7 step 3 / RFC-ACDP-0012 §9.3 step 3 house binding (`publisher`
342/// must equal `did:web:<serving_authority>`), confirming that
343/// `publisher` really is the specific registry this client is
344/// talking to, not merely *some* identity that appears in the
345/// search response claiming registry standing over `controller`'s
346/// key.
347///
348/// This function is what closes the *discovery* gap
349/// [`find_revocations`]'s trust-class filter opened: that filter
350/// excludes registry-attested revocations from its results
351/// entirely (by design — a producer-scoped search cannot
352/// distinguish a genuine one from a forgery), so without a
353/// dedicated registry-scoped query a caller would never see a
354/// genuine one at all. Filter (3) here then narrows *this*
355/// function's own results — and narrows a different thing than it
356/// might look like: it does **not** stop a producer forging a
357/// registry attestation of its own key by setting `agent_id` to the
358/// registry's DID. That forgery is already impossible one step
359/// earlier — [`verify_revocation_body`] runs `Verifier::verify_body`,
360/// which resolves `body.agent_id`'s DID document and verifies the
361/// signature against it, so a body claiming `agent_id = <registry
362/// DID>` cannot exist unless the registry's own key actually signed
363/// it. What filter (3) actually rejects is a **genuinely signed
364/// body published under some third DID** — another registry, or a
365/// producer emitting the §4-illegal `agent_id=Q,
366/// revoked_key_controller=P` shape — that a hostile or compromised
367/// registry lists in the `agent_id=<registry_did>` search response
368/// it serves to this client. That is the exact analog of
369/// [`find_revocations`]'s filter 1, and it is real and load-bearing:
370/// without it, this function would trust `publisher` merely because
371/// *some* validly-signed body appeared among the search results,
372/// rather than confirming it is signed by the one registry this
373/// client actually talks to.
374///
375/// As with [`find_revocations`], candidates dropped by (2) or (3), or
376/// that fail §5 outright, are omitted rather than surfaced as errors —
377/// a single bad or off-scope body must not poison the whole discovery
378/// result. With the `tracing` feature enabled, each drop is logged via
379/// `tracing::warn!` naming the publisher, controller, and `ctx_id`.
380///
381/// **Propagates, rather than swallows, a `capabilities()` error.**
382/// `CapabilitiesDocument.registry_did` is a required, non-`Option`
383/// `String` with no `#[serde(default)]`
384/// (`crates/acdp-types/src/capabilities.rs`), and
385/// [`RegistryClient::capabilities`] runs
386/// `acdp_validation::validate_capabilities` — which parses it with
387/// [`acdp_types::primitives::AgentDid::parse_web`] — before returning.
388/// A registry that omits `registry_did` fails deserialization; one
389/// sending `""` or a non-`did:web` value fails `parse_web`. Either way
390/// `capabilities()` already returns `Err`, so there is no "missing
391/// `registry_did`" state for this function to special-case — it simply
392/// propagates whatever `capabilities()` returns via `?`, rather than
393/// mapping a failure into a silent empty vec.
394///
395/// **The same §8 honest caveat as [`find_revocations`] applies**: search
396/// is registry-served, so an empty result is not evidence of absence.
397///
398/// **Cost note for callers verifying many contexts.** The single
399/// capabilities fetch above is hoisted *within* one call, but
400/// [`RegistryClient::capabilities`] issues a fresh network round-trip
401/// on every call to *this* function too — nothing here caches it across
402/// calls. A caller verifying many contexts against the same registry in
403/// a loop should hoist its own call to this function (or to
404/// `capabilities()` directly) above that loop rather than calling it
405/// once per context. An overload taking a pre-fetched capabilities
406/// document can be added additively later if that turns out to matter
407/// in practice.
408pub async fn find_registry_attested_revocations(
409 client: &RegistryClient,
410 resolver: &WebResolver,
411 controller: &AgentDid,
412) -> Result<Vec<KeyRevocation>, AcdpError> {
413 let controller = AgentDid::parse(controller.as_str())?;
414
415 // Fetched exactly once, outside the type-form × status loop below —
416 // see the doc above for why hoisting this is required, not
417 // stylistic.
418 let caps = client.capabilities().await?;
419 let registry_agent_id = AgentDid::parse(caps.registry_did.as_str())?;
420 let serving_authority = client
421 .authority()
422 .ok_or_else(|| AcdpError::SchemaViolation("registry client base URL has no host".into()))?;
423
424 let mut revocations = Vec::new();
425 let mut seen = std::collections::HashSet::new();
426
427 for type_form in ["key-revocation", "acdp:key-revocation"] {
428 // Revocations are permanent but supersedable; the registry
429 // defaults search to status=active, so ask for both explicitly.
430 for status in ["active", "superseded"] {
431 let mut params = SearchParamsBuilder::new()
432 .context_type(type_form)
433 .agent_id(registry_agent_id.as_str())
434 .status(status)
435 .limit(100)
436 .build();
437 for _page in 0..MAX_SEARCH_PAGES {
438 let resp = client.search(¶ms).await?;
439 for m in &resp.matches {
440 if !seen.insert(m.ctx_id.as_str().to_string()) {
441 continue;
442 }
443 let ctx = client.retrieve(&m.ctx_id).await?;
444 if let Ok(rev) = verify_revocation_body(&ctx.body, resolver).await {
445 if rev.revoked_key_controller == controller
446 && rev
447 .cross_check_registry_binding(
448 &serving_authority,
449 &caps.registry_did,
450 )
451 .is_ok()
452 {
453 revocations.push(rev);
454 } else {
455 #[cfg(feature = "tracing")]
456 tracing::warn!(
457 publisher = %rev.publisher,
458 controller = %rev.revoked_key_controller,
459 ctx_id = %m.ctx_id,
460 "find_registry_attested_revocations: dropped candidate \
461 outside controller scope or failing registry-binding check"
462 );
463 }
464 }
465 }
466 match resp.next_cursor {
467 Some(cursor) => params.cursor = Some(cursor),
468 None => break,
469 }
470 }
471 }
472 }
473 Ok(revocations)
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use acdp_types::revocation::RevocationTrustClass;
480 use chrono::TimeZone;
481
482 fn rev(fp: &str, t: DateTime<Utc>) -> KeyRevocation {
483 KeyRevocation {
484 revoked_key_fingerprint: fp.into(),
485 compromised_since: t,
486 reason: None,
487 revoked_key_id: None,
488 revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
489 publisher: AgentDid::new("did:web:agents.example.com:p"),
490 trust_class: RevocationTrustClass::ProducerSigned,
491 }
492 }
493
494 fn at(s: &str) -> DateTime<Utc> {
495 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
496 }
497
498 const F: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
499
500 /// §7 step 2: strictly-before-T verifies with the distinguishable
501 /// pre-compromise status; §7 step 3: equal-to-T already fails.
502 #[test]
503 fn boundary_is_strict() {
504 let t = at("2026-05-01T00:00:00.000Z");
505 let revs = [rev(F, t)];
506 assert_eq!(
507 classify_under_revocation(&revs, F, Some(at("2026-04-30T23:59:59.999Z"))).unwrap(),
508 Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
509 );
510 assert!(matches!(
511 classify_under_revocation(&revs, F, Some(t)),
512 Err(AcdpError::KeyNotAuthorized(_))
513 ));
514 }
515
516 /// §7 step 4: no receipt-attested time → fail closed.
517 #[test]
518 fn no_receipt_fails_closed() {
519 let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
520 assert!(matches!(
521 classify_under_revocation(&revs, F, None),
522 Err(AcdpError::KeyNotAuthorized(_))
523 ));
524 }
525
526 /// A revocation of some OTHER key changes nothing.
527 #[test]
528 fn unrelated_fingerprint_is_inert() {
529 let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
530 let other = "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
531 assert_eq!(classify_under_revocation(&revs, other, None).unwrap(), None);
532 assert_eq!(
533 classify_under_revocation(&[], F, None).unwrap(),
534 None,
535 "no known revocations ⇒ inert"
536 );
537 }
538
539 /// §4 monotonicity: the earliest T across a revocation lineage is
540 /// effective — a later supersession cannot quietly shrink the
541 /// window.
542 #[test]
543 fn earliest_boundary_wins() {
544 let early = at("2026-04-01T00:00:00.000Z");
545 let late = at("2026-05-01T00:00:00.000Z");
546 let revs = [rev(F, late), rev(F, early)];
547 // Between the two boundaries: inside the (earliest-T) window.
548 assert!(matches!(
549 classify_under_revocation(&revs, F, Some(at("2026-04-15T00:00:00.000Z"))),
550 Err(AcdpError::KeyNotAuthorized(_))
551 ));
552 // Before both: pre-compromise.
553 assert_eq!(
554 classify_under_revocation(&revs, F, Some(at("2026-03-01T00:00:00.000Z"))).unwrap(),
555 Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
556 );
557 }
558
559 #[test]
560 fn pre_compromise_uses_millis() {
561 // Sub-second boundaries compare at millisecond precision — the
562 // canonical wire precision (RFC-ACDP-0001 §5.3).
563 let t = Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap()
564 + chrono::Duration::milliseconds(500);
565 let revs = [rev(F, t)];
566 let just_before = t - chrono::Duration::milliseconds(1);
567 assert_eq!(
568 classify_under_revocation(&revs, F, Some(just_before)).unwrap(),
569 Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
570 );
571 }
572}