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, 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(&params).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(&params).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}