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, fingerprint_for_key_id_since};
20use acdp_did::WebResolver;
21use acdp_primitives::error::AcdpError;
22use acdp_types::body::{Body, FullContext};
23use acdp_types::keyless::is_keyless_key_id;
24use acdp_types::primitives::{AgentDid, CtxId, LineageId};
25use acdp_types::receipt::ReceiptKeyStatus;
26use acdp_types::revocation::{KeyRevocation, RevocationTrustClass};
27
28use crate::revocation_verdict::{RevocationEffect, RevocationVerdict};
29use acdp_types::search::SearchParamsBuilder;
30use acdp_verify::Verifier;
31use chrono::{DateTime, Utc};
32
33use super::registry::RegistryClient;
34use super::verified::KeyAuthorization;
35
36/// Pagination safety cap for [`find_revocations`] — a hostile registry
37/// must not be able to hold the helper in an endless cursor loop.
38const MAX_SEARCH_PAGES: usize = 10;
39
40/// Lineage-walk safety cap for [`find_revocations`] and
41/// [`find_registry_attested_revocations`] — a hostile registry must not
42/// be able to name an unbounded number of distinct `lineage_id`s across
43/// a `MAX_SEARCH_PAGES`-bounded search (3 statuses × 2 type-forms × 10
44/// pages × limit 100 = up to ~6000 candidates) and force one
45/// `GET /lineages/{id}` fetch each (each capped at 1 MB by the client —
46/// ~6 GB of forced fetches with no cap at all). 100 is generous for any
47/// legitimate producer's revocation history and bounded against a
48/// hostile one; exceeding it is treated exactly like exhausting
49/// `MAX_SEARCH_PAGES` — a hard [`AcdpError::SearchTruncated`], never a
50/// silently partial set (issue #226 Phase 4).
51const MAX_LINEAGE_WALKS: usize = 100;
52
53/// Apply the RFC-ACDP-0014 §7 compromise-boundary rule.
54///
55/// Inputs:
56///
57/// - `revocations` — **verified** revocations the consumer has decided
58/// to act on (see [`RevocationPolicy`](crate::RevocationPolicy) for
59/// the §6 trust-class guidance). The §4 earliest-`compromised_since`
60/// rule is applied across every entry naming the fingerprint.
61/// - `signing_key_fingerprint` — the RFC-ACDP-0010 §6 fingerprint of
62/// the key that signed the context under verification.
63/// - `receipt_attested_created_at` — `created_at` from a registry
64/// receipt **verified per RFC-ACDP-0010 §8** (whose step 5 confirms
65/// the receipt attests this same fingerprint), or `None` when there
66/// is no verified receipt. The bare body `created_at` MUST NOT be
67/// passed here — it is registry-assigned, unsigned by the producer,
68/// and attacker-backdatable (§7 step 1).
69///
70/// Verdicts:
71///
72/// - `Ok(None)` — no supplied revocation names this key; the ordinary
73/// verification rules apply unchanged.
74/// - `Ok(Some(`[`KeyAuthorization::HistoricallyAuthorizedPreCompromise`]`))`
75/// — publish time strictly before the boundary (§7 step 2). The
76/// caller must still verify the signature itself, under the
77/// RFC-ACDP-0010 §10 historical rule.
78/// - `Err(`[`AcdpError::KeyRevoked`]`)` — fail closed (wire code
79/// `key_not_authorized`; [`AcdpError::is_key_not_authorized`]): publish
80/// time at/after the boundary (§7 step 3), or no verifiable publish
81/// time at all (§7 step 4). Per RFC-ACDP-0014 §10 this is a
82/// verification verdict, not a wire condition — there is no new wire
83/// error code; the key is simply not authorized to speak for the
84/// producer in (or without placement relative to) the compromise
85/// window.
86///
87/// `receipt_attested_created_at` is taken as a placement from a
88/// *current* receipt key; use [`classify_under_revocation_placed`] to
89/// apply the 0.5.0 receipt-key-status rule.
90pub fn classify_under_revocation(
91 revocations: &[KeyRevocation],
92 signing_key_fingerprint: &str,
93 receipt_attested_created_at: Option<DateTime<Utc>>,
94) -> Result<Option<KeyAuthorization>, AcdpError> {
95 classify_under_revocation_placed(
96 revocations,
97 signing_key_fingerprint,
98 receipt_attested_created_at.map(|t| (t, ReceiptKeyStatus::Current)),
99 false,
100 )
101}
102
103/// [`classify_under_revocation`] with the receipt-key status of the
104/// placing receipt (RFC-ACDP-0014 §7 step 1, 0.5.0). Under the strict
105/// profile (`accept_historical_receipt_key == false`) a receipt verified
106/// under a [`ReceiptKeyStatus::Historical`] key cannot place the context:
107/// it is unplaceable (§7 step 4) and fails closed. `true` is the named
108/// non-strict mode
109/// ([`RevocationPolicy::accept_historical_receipt_key_for_pre_t`](crate::RevocationPolicy::accept_historical_receipt_key_for_pre_t)).
110pub fn classify_under_revocation_placed(
111 revocations: &[KeyRevocation],
112 signing_key_fingerprint: &str,
113 receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
114 accept_historical_receipt_key: bool,
115) -> Result<Option<KeyAuthorization>, AcdpError> {
116 let assessment = crate::revocation_verdict::decide_known(
117 revocations,
118 signing_key_fingerprint,
119 receipt_placement,
120 accept_historical_receipt_key,
121 );
122 match assessment.verdict {
123 RevocationVerdict::Revoked {
124 effect: RevocationEffect::PreCompromise,
125 ..
126 } => Ok(Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)),
127 RevocationVerdict::NotRevoked { .. } => Ok(None),
128 _ => assessment.enforce().map(|_| None),
129 }
130}
131
132/// Verify a `key-revocation` context body per RFC-ACDP-0014 §5 and
133/// return its typed, trust-classified form.
134///
135/// Pipeline:
136///
137/// 1. Strict RFC-ACDP-0001 §5.11 body verification (schema, hash
138/// recomputation, DID resolution, `assertionMethod` authorization,
139/// signature) — §5 step 1's "signed by a currently authorized key".
140/// 2. §4 shape parse + §5/§6 trust-class derivation
141/// ([`KeyRevocation::from_body`]).
142/// 3. §5 step 2 — the resolved signing key's fingerprint MUST NOT
143/// equal `revoked_key_fingerprint`
144/// ([`KeyRevocation::check_not_self_signed`]).
145///
146/// Note the §5 step 1 nuance this strict form does not cover: a
147/// revocation whose own signing key was later rotated out *cleanly*
148/// remains acceptable via the RFC-ACDP-0010 §10 receipt-attested
149/// historical rule — fetch it through
150/// [`VerifiedContext::fetch_with_policy`](crate::VerifiedContext::fetch_with_policy)
151/// (default policy) and parse the body afterwards for that case.
152///
153/// The returned trust class MUST be honored per §6: act on
154/// producer-signed revocations unconditionally; treat registry-attested
155/// ones as the weaker class (confirm `publisher` is in fact the DID of
156/// the registry involved via
157/// [`KeyRevocation::cross_check_registry_binding`], and corroborate
158/// before global use).
159pub async fn verify_revocation_body(
160 body: &Body,
161 resolver: &WebResolver,
162) -> Result<KeyRevocation, AcdpError> {
163 Verifier::new(resolver).verify_body(body).await?;
164 let revocation = KeyRevocation::from_body(body)?;
165 let signer_fingerprint =
166 fingerprint_for_key_id(&body.signature.key_id, &body.signature.algorithm, resolver).await?;
167 revocation.check_not_self_signed(&signer_fingerprint)?;
168 Ok(revocation)
169}
170
171/// [`verify_revocation_body`] for a retrieved [`FullContext`]: the
172/// `content_hash` is recomputed over the raw received body
173/// (RFC-ACDP-0001 §5.7), so a revocation whose producer spelled a
174/// timestamp differently from this crate's typed rendering still
175/// verifies. Prefer this whenever the whole context is at hand.
176pub async fn verify_revocation_context(
177 ctx: &FullContext,
178 resolver: &WebResolver,
179) -> Result<KeyRevocation, AcdpError> {
180 let body = &ctx.body;
181 Verifier::new(resolver).verify_context(ctx).await?;
182 let revocation = KeyRevocation::from_body(body)?;
183 let signer_fingerprint =
184 fingerprint_for_key_id(&body.signature.key_id, &body.signature.algorithm, resolver).await?;
185 revocation.check_not_self_signed(&signer_fingerprint)?;
186 Ok(revocation)
187}
188
189/// Verify one revocation **candidate** found by auto-discovery
190/// (RFC-ACDP-0014 §8, 0.5.0 "Discovery outcome classes").
191///
192/// Differs from [`verify_revocation_context`] in two ways the discovery
193/// outcome classes require:
194///
195/// 1. **DID-cache freshness.** Every DID lookup is scoped to `since`
196/// (the discovery run's start): a key absent from — or not yet in
197/// `assertionMethod` of — a cached document older than `since` is
198/// re-fetched once before any verdict (RFC-ACDP-0001 §5.11
199/// "Caching"; rev-005 D). One forced fetch per DID serves every
200/// candidate in the run.
201/// 2. **The mandatory historical arm.** A candidate whose signing key is
202/// in the producer's `verificationMethod` but no longer in
203/// `assertionMethod` — the producer rotated after publishing the
204/// revocation — is evaluated under the RFC-ACDP-0010 §10 historical
205/// rule: its `registry_receipt` (re-retrieved by `ctx_id` when the
206/// candidate came from a lineage walk without one) is verified and,
207/// if it verifies, the signature is checked against the retained key
208/// (rev-005 A). No receipt, or one that does not verify, makes the
209/// candidate **unverifiable** —
210/// [`AcdpError::RevocationCandidateUnverifiable`], which discovery
211/// propagates (rev-005 B) — never a silent drop.
212///
213/// Return classes, as the discovery loops consume them:
214///
215/// - `Ok(rev)` — a verified revocation.
216/// - `Err(e)` with `e.is_transient()` or
217/// [`AcdpError::RevocationCandidateUnverifiable`] — could not decide;
218/// discovery fails (outcome *unknown*).
219/// - any other `Err` — **permanently invalid** (hash failure, bad
220/// signature, §4 shape, self-signed, key absent from a fresh
221/// `verificationMethod`, foreign `key_id`): the candidate is dropped.
222pub async fn verify_revocation_candidate(
223 ctx: &FullContext,
224 client: &RegistryClient,
225 resolver: &WebResolver,
226 since: std::time::Instant,
227) -> Result<KeyRevocation, AcdpError> {
228 let body = &ctx.body;
229 if is_keyless_key_id(&body.signature.key_id) {
230 return Err(keyless_unverifiable(body));
231 }
232 let verifier = Verifier::new(resolver).fresh_since(since);
233 match verifier.verify_context(ctx).await {
234 Ok(()) => {}
235 Err(AcdpError::KeyNotAuthorized(why)) => {
236 verify_candidate_historically(ctx, client, resolver, since, why).await?;
237 }
238 Err(e) => return Err(e),
239 }
240 let revocation = KeyRevocation::from_body(body)?;
241 let signer_fingerprint = fingerprint_for_key_id_since(
242 &body.signature.key_id,
243 &body.signature.algorithm,
244 resolver,
245 since,
246 )
247 .await?;
248 revocation.check_not_self_signed(&signer_fingerprint)?;
249 Ok(revocation)
250}
251
252/// The RFC-ACDP-0014 §8 historical arm for a candidate whose strict
253/// verification ended in `key_not_authorized` (`why`). Schema and hash
254/// already passed (strict verification checks them first).
255async fn verify_candidate_historically(
256 ctx: &FullContext,
257 client: &RegistryClient,
258 resolver: &WebResolver,
259 since: std::time::Instant,
260 why: String,
261) -> Result<(), AcdpError> {
262 let body = &ctx.body;
263 let key_id = body.signature.key_id.as_str();
264 // Only a did:web key of the producer itself can be a rotated-out
265 // historical key; a foreign key_id or an unsupported DID method is
266 // permanently not this producer's signature.
267 let same_producer = key_id
268 .split_once('#')
269 .is_some_and(|(did, _)| did == body.agent_id.as_str() && did.starts_with("did:web:"));
270 if !same_producer {
271 return Err(AcdpError::KeyNotAuthorized(why));
272 }
273
274 // The receipt: carried on the candidate, or — for a lineage-walk
275 // member served without one — re-retrieved by ctx_id (charged to the
276 // discovery budget like every other discovery request).
277 let refetched;
278 let receipt_value = match &ctx.registry_receipt {
279 Some(v) => v,
280 None => {
281 // RFC-ACDP-0014 §8 P1: the FULL-retrieval endpoint (the
282 // body-only one never carries a receipt). A transient failure
283 // or a budget refusal keeps its identity (both → unknown);
284 // any other failure leaves the candidate unverifiable.
285 refetched = match client.retrieve(&body.ctx_id).await {
286 Ok(c) => c,
287 Err(e) if is_discovery_fatal(&e) => return Err(e),
288 Err(e) => {
289 return Err(AcdpError::RevocationCandidateUnverifiable(format!(
290 "revocation candidate {} could not be re-retrieved for its \
291 registry receipt: {e}",
292 body.ctx_id.as_str()
293 )))
294 }
295 };
296 if refetched.body.ctx_id != body.ctx_id
297 || refetched.body.content_hash != body.content_hash
298 {
299 return Err(AcdpError::RevocationCandidateUnverifiable(format!(
300 "revocation candidate {} re-retrieved for its registry receipt came back as a different body — cannot place its rotated-out signing key",
301 body.ctx_id.as_str()
302 )));
303 }
304 match &refetched.registry_receipt {
305 Some(v) => v,
306 None => {
307 return Err(AcdpError::RevocationCandidateUnverifiable(format!(
308 "revocation candidate {} is signed by {key_id}, which is no longer in assertionMethod, and carries no registry_receipt — the RFC-ACDP-0010 §10 historical rule cannot place it ({why})",
309 body.ctx_id.as_str()
310 )))
311 }
312 }
313 }
314 };
315
316 let serving_authority = client
317 .authority()
318 .unwrap_or_else(|| body.ctx_id.authority().to_string());
319 let producer_fp =
320 fingerprint_for_key_id_since(key_id, &body.signature.algorithm, resolver, since).await?;
321 match super::receipt::verify_receipt_value_since(
322 receipt_value,
323 &body.ctx_id,
324 body,
325 &body.content_hash,
326 &producer_fp,
327 &serving_authority,
328 resolver,
329 since,
330 )
331 .await
332 {
333 Ok(_) => {}
334 Err(e) if e.is_transient() => return Err(e),
335 Err(e) => {
336 return Err(AcdpError::RevocationCandidateUnverifiable(format!(
337 "revocation candidate {} is signed by rotated-out key {key_id} and its registry_receipt does not verify: {e}",
338 body.ctx_id.as_str()
339 )))
340 }
341 }
342 acdp_verify::verify_body_signature_historical_since(body, resolver, since).await
343}
344
345/// A revocation signed by a keyless key (RFC-ACDP-0021 §9.3) cannot be
346/// verified through RFC-ACDP-0001 §5.11. Discovery sets such candidates
347/// aside for the keyless procedure; standalone callers get this error,
348/// which makes the outcome *unknown*, never *absent*.
349fn keyless_unverifiable(body: &Body) -> AcdpError {
350 AcdpError::RevocationCandidateUnverifiable(format!(
351 "revocation candidate {} is signed by the keyless key {}; it verifies only through \
352 its key attestation (RFC-ACDP-0021 §8.1), not through the DID document",
353 body.ctx_id.as_str(),
354 body.signature.key_id
355 ))
356}
357
358/// Fail closed on keyless candidates a standalone discovery call could
359/// not evaluate (see [`keyless_unverifiable`]).
360fn refuse_deferred(deferred: &[FullContext]) -> Result<(), AcdpError> {
361 match deferred.first() {
362 Some(ctx) => Err(keyless_unverifiable(&ctx.body)),
363 None => Ok(()),
364 }
365}
366
367/// A candidate-verification error that makes the discovery outcome
368/// *unknown* rather than dropping the candidate: transient lookups and
369/// unverifiable candidates, and a discovery-budget refusal met while
370/// verifying a candidate (RFC-ACDP-0014 §8, P1/P2).
371fn is_discovery_fatal(e: &AcdpError) -> bool {
372 e.is_transient()
373 || matches!(
374 e,
375 AcdpError::RevocationCandidateUnverifiable(_)
376 | AcdpError::RevocationDiscoveryBudgetExceeded(_)
377 )
378}
379
380/// Walk every member of a revocation lineage and return the ones that
381/// verify per §5 — **regardless of `registry_state.status`** and with
382/// **no trust-class or publisher scope filter**.
383///
384/// **Why walk a lineage at all, when [`find_revocations`] already
385/// re-queries `status=superseded`:** a merely-superseded member is
386/// already visible to a `status=superseded` search pass — the walk buys
387/// nothing there. What the walk buys is robustness to any lineage whose
388/// members carry a *mixed* set of statuses a search pass does not (or
389/// cannot) enumerate: `expired` members, a future [`acdp_types::primitives::Status::Other`]
390/// value (`Status` is an open enum — RFC-ACDP-0004 §4), or a member a
391/// search pass has been made structurally blind to. RFC-ACDP-0013
392/// §8.1 makes `GET /lineages/{id}` an **obligation** to include the
393/// full lineage including retracted versions — so one lineage member
394/// that IS visible to search exposes every other member through this
395/// endpoint, independent of what search itself would ever return for
396/// them. A registry excluding a retracted context from `status=superseded`
397/// search results (RFC-ACDP-0013 §8.2 — normative) is exactly the seed
398/// case this closes; the complementary case — where **every** member of
399/// a lineage is invisible to search (e.g. the lineage head itself was
400/// retracted) — has no live search match to discover the lineage id
401/// from in the first place, and is closed separately by an explicit
402/// `status=retracted` search pass, not by this walk.
403///
404/// Fetches `client.lineage(lineage_id)` (RFC-ACDP-0004 §5.1 defines no
405/// pagination for this endpoint, so a pathological lineage fails loudly
406/// at the client's `MAX_CONTEXT_BYTES` cap rather than truncating) and
407/// verifies every returned member via [`verify_revocation_body`],
408/// pairing each verified member with its `ctx_id` — read from
409/// `ctx.body.ctx_id`, since the lineage linkage travels via the
410/// [`acdp_types::body::FullContext`]/[`Body`] shape, never via a new
411/// [`KeyRevocation`] field (`KeyRevocation` carries no `ctx_id` and
412/// derives no `Hash`, so its public return shape alone cannot feed a
413/// `ctx_id`-keyed dedupe set).
414///
415/// A member is only skipped when its §5 verification failure is
416/// **permanent** (`AcdpError::is_transient() == false` — a broken
417/// signature, a hash mismatch, a schema violation, a DID that resolves
418/// but denies the key) — attacker-injected garbage in a lineage must
419/// not poison the set — logged via `tracing::warn!` when the `tracing`
420/// feature is enabled. Note the asymmetry with the two fail-closed
421/// cases below: a cryptographic-verification failure *inside* an
422/// otherwise well-formed, correctly-membered lineage is actually a
423/// **stronger** signal of registry (or upstream producer) misbehavior
424/// than an empty or mismatched lineage response is — a well-behaved
425/// registry simply does not have a signature-broken member to serve in
426/// the first place — yet it is the empty/mismatched cases that fail
427/// loudly here, not this one. That is a deliberate, asymmetric choice,
428/// not an oversight: treating every verification failure as fatal
429/// would let one injected garbage member suppress every other genuine
430/// revocation in the lineage (the same "one bad apple poisons
431/// discovery" DoS this whole function exists to avoid), whereas
432/// dropping a **permanently** un-verifiable member and continuing
433/// costs nothing an attacker can turn into a false *authorization* —
434/// such a member was never going to contribute a valid
435/// `compromised_since` to [`effective_boundary`](acdp_types::revocation::effective_boundary)'s fold, dropped or
436/// not. It is currently invisible without the `tracing` feature; a
437/// future caller auditing registry health should not assume "no
438/// revocations found" means "no suspicious members were seen."
439///
440/// **A transient failure (`is_transient() == true` — the DID host is
441/// unreachable, rate-limited, or otherwise could not be asked, as
442/// opposed to having answered and denied) is a different case and is
443/// NOT dropped: it propagates as `Err` instead (issue #248 Phase 1,
444/// "D5").** An earlier revision of this doc claimed a dropped member
445/// is "simply absent from the result, never wrongly present" — true
446/// only of the permanent case above, and false in general: a dropped
447/// member that *would* have named an earlier `compromised_since` than
448/// any survivor moves [`effective_boundary`](acdp_types::revocation::effective_boundary)'s `.min()` fold **later**
449/// (`acdp_types::revocation::effective_boundary`), which silently
450/// authorizes activity that should have been inside the compromise
451/// window — a genuine false authorization, not a mere omission. A
452/// transient failure gives no information about what that member would
453/// have said, so silently continuing past it could produce exactly
454/// that outcome. Propagating instead tells the caller "this set is
455/// incomplete, do not trust it," which — per D5 — adds no new denial-
456/// of-service lever: this function already aborts unconditionally on
457/// `client.lineage` failing outright, so a hostile or merely-unlucky
458/// network path already had an abort lever before this change; this
459/// closes the one case that used to bypass it by masquerading as a
460/// clean, complete result.
461///
462/// **Fails closed, rather than silently continuing, in two cases:**
463///
464/// 1. The lineage has no members at all. The reference registry returns
465/// `Ok(vec![])` for an unrecognized `lineage_id`
466/// (`crates/acdp-server/src/registry/store.rs`), so an empty response
467/// for a `lineage_id` a live search match just named is never an
468/// honest "nothing here."
469/// 2. `expect_member` is `Some` and is not among the **pre-verification**
470/// members the registry served (see the parameter doc below) — the
471/// registry answered the lineage-walk request, but not with the
472/// lineage the search match actually pointed at.
473///
474/// Both cases return [`AcdpError::IncompleteLineage`]. A genuine 404 or
475/// transport error from `client.lineage` propagates unchanged (a
476/// different variant, not this one) — all the way out to
477/// [`find_revocations`] and [`find_registry_attested_revocations`],
478/// neither of which catches an `Err` from this function.
479///
480/// **This does not contradict RFC-ACDP-0004 §5.4** ("if the lineage
481/// exists but the requester is authorized to see zero versions, the
482/// response MUST be an empty array `[]`, not `not_found`" — corroborated
483/// by fixture `vis-008`): that rule is about visibility scoping hiding
484/// versions from a requester who is not authorized to see them, and it
485/// is real for ordinary lineages. It cannot legitimately fire *here*,
486/// specifically, because RFC-ACDP-0014 §4 requires every revocation
487/// context to be published `visibility: public` — there is no
488/// authorized-to-see-zero-versions state a public-only lineage can be
489/// in. An empty response to a revocation-lineage walk therefore means
490/// the registry is inconsistent (eventually-consistent lag) or hostile,
491/// not that §5.4 visibility scoping legitimately applied — reword from
492/// an earlier draft that called this "the realistic hostile shape,"
493/// which conflated a hostile registry with a benign eventual-consistency
494/// race; both are covered by the same fail-closed response here, but
495/// only because both are equally unexplainable for a lineage that MUST
496/// be all-public. Callers relying on this function for a
497/// **non**-revocation, mixed-visibility lineage would need a different
498/// rule.
499///
500/// **Round-trip cost.** Every distinct `lineage_id` a search match names
501/// (deduped by the callers below) costs one `client.lineage` fetch
502/// (RFC-ACDP-0004 §5.1 defines no pagination for this endpoint, so a
503/// pathological lineage fails loudly at `MAX_CONTEXT_BYTES` rather than
504/// truncating) plus one DID resolution per member returned — including
505/// members belonging to producers other than the one a caller queried
506/// for, since a lineage walk has no producer filter. Worst case this
507/// roughly doubles the per-call work a hostile registry can impose,
508/// against the already-existing per-search-match `retrieve` cost — not a
509/// new exposure *class*, since [`acdp_did::WebResolver`]'s
510/// [`acdp_safe_http::SsrfPolicy`] still bounds which hosts any of those
511/// DID fetches can reach, but real added work per call.
512///
513/// This is the neutral building block: no trust-class or publisher
514/// filter is applied here. [`find_revocations_in_lineage`] is a thin
515/// wrapper for a caller that wants every verified member regardless of
516/// scope; [`find_revocations`] and [`find_registry_attested_revocations`]
517/// each apply their own (different) scope filter to the members this
518/// returns, and each propagates a failure from this function via `?`,
519/// aborting the whole discovery call rather than scoping the failure to
520/// one lineage — see their docs.
521///
522/// `expect_member`, when `Some`, is the `ctx_id` a live search match
523/// named for `lineage_id` — checked against the lineage's
524/// **pre-verification** member list, not the post-verification
525/// survivors. The two differ whenever a member fails §5 verification,
526/// and the choice matters: checking survivors instead would let a
527/// hostile registry serve a corrupted (signature-broken) copy of the
528/// named member specifically to manufacture this same failure, making
529/// "fails §5" and "isn't the lineage that was searched" indistinguishable.
530/// The question this check answers is "did the registry serve the
531/// lineage it claimed to," which is answerable from the raw member list
532/// alone, before any per-member verification is attempted.
533async fn walk_revocation_lineage(
534 client: &RegistryClient,
535 resolver: &WebResolver,
536 lineage_id: &LineageId,
537 expect_member: Option<&CtxId>,
538 since: std::time::Instant,
539) -> Result<Walked, AcdpError> {
540 let members = client.lineage(lineage_id).await?;
541 if members.is_empty() {
542 return Err(AcdpError::IncompleteLineage {
543 lineage_id: lineage_id.as_str().to_string(),
544 ctx_id: expect_member.map(|c| c.as_str().to_string()),
545 });
546 }
547 // GAP-A (issue #226 Phase 3): confirm the registry actually served
548 // the lineage a live search match named, checked against the
549 // PRE-verification member list — see the parameter doc above for
550 // why pre- rather than post-verification.
551 if let Some(expected) = expect_member {
552 if !members.iter().any(|ctx| &ctx.body.ctx_id == expected) {
553 return Err(AcdpError::IncompleteLineage {
554 lineage_id: lineage_id.as_str().to_string(),
555 ctx_id: Some(expected.as_str().to_string()),
556 });
557 }
558 }
559 let mut out = Vec::with_capacity(members.len());
560 let mut deferred = Vec::new();
561 for ctx in members {
562 if is_keyless_key_id(&ctx.body.signature.key_id) {
563 deferred.push(ctx);
564 continue;
565 }
566 match verify_revocation_candidate(&ctx, client, resolver, since).await {
567 Ok(rev) => out.push((ctx.body.ctx_id.clone(), rev)),
568 // D5 (issue #248 Phase 1): a transient failure means "could
569 // not check this member," not "this member is bad" — propagate
570 // it rather than silently treating it as if it had never
571 // existed. An unverifiable member (RFC-ACDP-0014 §8 historical
572 // arm without a usable receipt) is likewise "unknown", never
573 // "absent". See the rustdoc block above for why the permanent
574 // case still drops and warns.
575 Err(e) if is_discovery_fatal(&e) => return Err(e),
576 Err(_e) => {
577 #[cfg(feature = "tracing")]
578 tracing::warn!(
579 lineage_id = %lineage_id.as_str(),
580 ctx_id = %ctx.body.ctx_id.as_str(),
581 error = %_e,
582 "walk_revocation_lineage: dropped lineage member failing §5 verification"
583 );
584 }
585 }
586 }
587 Ok(Walked {
588 verified: out,
589 deferred,
590 })
591}
592
593/// A lineage walk's members: those that verified per §5, and those
594/// signed by a keyless key, set aside unverified (RFC-ACDP-0021 §9.3).
595struct Walked {
596 verified: Vec<(CtxId, KeyRevocation)>,
597 deferred: Vec<FullContext>,
598}
599
600/// Discover every verified member of a revocation lineage
601/// (RFC-ACDP-0014 §4, RFC-ACDP-0013 §8.1), regardless of
602/// `registry_state.status` and with no trust-class or publisher scope
603/// filter applied.
604///
605/// A thin wrapper over the crate-private lineage walk
606/// (`walk_revocation_lineage`) — see the walk-vs-search rationale
607/// below (in short: `GET /lineages/{id}` is normatively obliged to
608/// include every member, including ones a `status=`-scoped search pass
609/// cannot or does not enumerate, so one search-visible member exposes
610/// the whole lineage through this call). [`find_revocations`] and
611/// [`find_registry_attested_revocations`] both call this internally
612/// (via the same private walk) to widen their own search-driven
613/// discovery; call this function directly when a caller already has a
614/// `lineage_id` in hand (e.g. from a [`acdp_types::search::SearchResult`]
615/// or a previously-verified [`Body`]) and wants every verified member
616/// without those functions' scope filters.
617pub async fn find_revocations_in_lineage(
618 client: &RegistryClient,
619 resolver: &WebResolver,
620 lineage_id: &LineageId,
621) -> Result<Vec<KeyRevocation>, AcdpError> {
622 let walked = walk_revocation_lineage(
623 client,
624 resolver,
625 lineage_id,
626 None,
627 std::time::Instant::now(),
628 )
629 .await?;
630 refuse_deferred(&walked.deferred)?;
631 Ok(walked.verified.into_iter().map(|(_, rev)| rev).collect())
632}
633
634/// The data-only differences between [`find_revocations`] and
635/// [`find_registry_attested_revocations`] that [`discover_revocations`]
636/// needs but cannot compute itself (issue #264: "five parameters, not
637/// three" — this struct is three of the five; `keep` and `on_drop` below
638/// are the other two).
639struct DiscoveryParams<'a> {
640 /// Value passed to `SearchParamsBuilder::agent_id` for every search
641 /// pass — the producer's own DID for [`find_revocations`], the
642 /// registry's own DID (`capabilities.registry_did`) for the attested
643 /// form (registry-attested revocations are published under the
644 /// *registry's* `agent_id`, never the producer's).
645 search_agent_id: &'a str,
646 /// Diagnostic prefix shared by both `AcdpError::SearchTruncated`
647 /// messages this engine can raise — `"find_revocations"` or
648 /// `"find_registry_attested_revocations"`.
649 fn_name: &'a str,
650 /// Identity label used in the `MAX_LINEAGE_WALKS` message —
651 /// `"agent_id"` or `"controller"`. Genuinely different from a mere
652 /// value substitution: the label itself differs, not only what fills
653 /// it (issue #264 point 5).
654 identity_label: &'a str,
655 /// Identity value formatted after `identity_label` in the same
656 /// message — `agent_id.as_str()` or `controller.as_str()`.
657 identity_value: &'a str,
658}
659
660/// The discovery engine shared by [`find_revocations`] and
661/// [`find_registry_attested_revocations`]: the type-form × status ×
662/// page search loop, the `MAX_LINEAGE_WALKS` bound, and the lineage-walk
663/// loop that follows it (issue #264). Everything upstream of the search
664/// loop — DID parsing, the revocation-cache marker check/record, and
665/// (for the attested form) the one-time `client.capabilities()` fetch —
666/// stays in the two public callers: the marker check must run before
667/// `capabilities()` in the attested form, and `record_success` must fire
668/// only once this engine returns `Ok`, so neither belongs inside a
669/// shared loop body.
670///
671/// `params.search_agent_id` scopes every `SearchParamsBuilder` pass.
672/// `keep` re-checks the verified body against the caller's own scope
673/// invariants (publisher + trust class for the producer-signed form;
674/// controller + registry-binding for the attested form) — a candidate
675/// `keep` rejects is dropped, never surfaced as an error, exactly as
676/// before. `on_drop` is called for every candidate `keep` rejects, with
677/// a [`DropSite`] naming which loop dropped it (the two forms differ
678/// in `tracing::warn!` payload shape — `trust_class`/computed `filter`
679/// vs `publisher`/`controller` — which a single
680/// `&(dyn Fn(..) -> bool + Sync)` predicate cannot carry).
681///
682/// Every other behavior — the transient-propagate/permanent-drop split
683/// (issue #248 Phase 1), `MAX_SEARCH_PAGES` being fresh per
684/// `(type_form, status)` pair, the `MAX_LINEAGE_WALKS` check running
685/// after the retrieves for that pass have already gone out, and the
686/// `SearchTruncated` fail-closed contract — is unchanged from the two
687/// functions' original bodies; see their docs for the full rationale.
688/// Which loop inside [`discover_revocations`] dropped a candidate.
689///
690/// Deliberately an enum rather than a `bool`: the value is chosen ~70
691/// lines away from the `tracing::warn!` it selects, and swapping it
692/// silently mislabels a search-loop drop as a lineage-walk drop (and
693/// vice versa) while every test still passes — verified by mutation
694/// during review of issue #264. A named variant makes the call site
695/// self-describing so the mistake is visible in the diff rather than
696/// only in production log wording.
697#[derive(Debug, Clone, Copy, PartialEq, Eq)]
698enum DropSite {
699 /// The `(type_form, status)` search/retrieve loop.
700 Search,
701 /// The `MAX_LINEAGE_WALKS`-bounded lineage-walk loop.
702 LineageWalk,
703}
704
705async fn discover_revocations(
706 client: &RegistryClient,
707 resolver: &WebResolver,
708 params: DiscoveryParams<'_>,
709 keep: &(dyn Fn(&KeyRevocation) -> bool + Sync),
710 on_drop: &(dyn Fn(&KeyRevocation, &CtxId, DropSite) + Sync),
711) -> Result<(Vec<KeyRevocation>, Vec<FullContext>), AcdpError> {
712 let DiscoveryParams {
713 search_agent_id,
714 fn_name,
715 identity_label,
716 identity_value,
717 } = params;
718 // One DID-freshness scope for the whole run: a document fetched at
719 // or after this instant is fresh, so a forced re-fetch triggered by
720 // one candidate serves every later candidate (RFC-ACDP-0001 §5.11).
721 let since = std::time::Instant::now();
722
723 let mut revocations = Vec::new();
724 // Keyless-signed candidates, set aside for the keyless procedure.
725 let mut deferred = Vec::new();
726 let mut seen = std::collections::HashSet::new();
727 // Discovery-order list of distinct lineage ids (walked below) plus
728 // a plain `HashSet<String>` for the dedupe check — `LineageId`
729 // derives `Hash` but not `Ord`, so a `BTreeSet` is not an option
730 // without a wider change than GAP-B needs. Iterating a `Vec` here
731 // (rather than a `HashSet<LineageId>`) keeps output order — and
732 // which lineage's walk failure surfaces first — deterministic
733 // across runs, matching the pre-existing search-order determinism.
734 let mut lineage_order: Vec<LineageId> = Vec::new();
735 let mut lineage_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
736 // First search match naming each lineage_id — threaded into the
737 // walk below as the expected member (GAP-A). First match wins: once
738 // a lineage_id has been seen, later matches naming the same lineage
739 // do not overwrite the recorded ctx_id.
740 let mut lineage_expect: std::collections::HashMap<String, CtxId> =
741 std::collections::HashMap::new();
742
743 for type_form in ["key-revocation", "acdp:key-revocation"] {
744 // Revocations are permanent but supersedable; the registry
745 // defaults search to status=active, so ask for the other
746 // statuses explicitly too. `retracted` closes the seed hole
747 // Phase 3's lineage walk cannot reach on its own: if every
748 // member of a lineage is retracted, no `active`/`superseded`
749 // pass returns anything to walk from in the first place
750 // (RFC-ACDP-0013 §8.2, issue #226 Phase 4).
751 for status in ["active", "superseded", "retracted"] {
752 let mut search_params = SearchParamsBuilder::new()
753 .context_type(type_form)
754 .agent_id(search_agent_id)
755 .status(status)
756 .limit(100)
757 .build();
758 // Set only when the loop exhausts `MAX_SEARCH_PAGES` while a
759 // cursor still remains — i.e. genuine truncation, not
760 // completion. See the boundary note on `AcdpError::SearchTruncated`.
761 let mut truncated = false;
762 for _page in 0..MAX_SEARCH_PAGES {
763 let resp = client.search(&search_params).await?;
764 for m in &resp.matches {
765 // Free — no extra round trip: every match names its
766 // own lineage, walked below regardless of whether
767 // this particular ctx_id turns out to be new.
768 let lid_key = m.lineage_id.as_str().to_string();
769 if lineage_seen.insert(lid_key.clone()) {
770 lineage_order.push(m.lineage_id.clone());
771 lineage_expect.insert(lid_key, m.ctx_id.clone());
772 }
773 if !seen.insert(m.ctx_id.as_str().to_string()) {
774 continue;
775 }
776 let ctx = client.retrieve(&m.ctx_id).await?;
777 if is_keyless_key_id(&ctx.body.signature.key_id) {
778 deferred.push(ctx);
779 continue;
780 }
781 match verify_revocation_candidate(&ctx, client, resolver, since).await {
782 Ok(rev) => {
783 if keep(&rev) {
784 revocations.push(rev);
785 } else {
786 on_drop(&rev, &m.ctx_id, DropSite::Search);
787 }
788 }
789 // D5 (issue #248 Phase 1): transient means "could
790 // not look," not "clean" — propagate rather than
791 // let the candidate vanish into an empty Vec that
792 // `classify_under_revocation` reads as "proceed."
793 // Same for an unverifiable candidate (§8 historical
794 // arm, rev-005 B).
795 Err(e) if is_discovery_fatal(&e) => return Err(e),
796 Err(_e) => {
797 #[cfg(feature = "tracing")]
798 tracing::warn!(
799 ctx_id = %m.ctx_id,
800 error = %_e,
801 "{fn_name}: dropped candidate failing §5 verification"
802 );
803 }
804 }
805 }
806 match resp.next_cursor {
807 Some(cursor) => {
808 search_params.cursor = Some(cursor);
809 truncated = true;
810 }
811 None => {
812 truncated = false;
813 break;
814 }
815 }
816 }
817 if truncated {
818 return Err(AcdpError::SearchTruncated(format!(
819 "{fn_name}: exhausted MAX_SEARCH_PAGES={MAX_SEARCH_PAGES} pages \
820 for (type_form={type_form}, status={status}) with results still \
821 remaining on the registry (next_cursor was still Some) — refusing to \
822 return a silently partial revocation set"
823 )));
824 }
825 }
826 }
827
828 // Bound the lineage walk the same way the search pages are bounded:
829 // a hostile registry must not be able to name more distinct
830 // lineage ids than this client is willing to fetch one-by-one via
831 // `GET /lineages/{id}` (issue #226 Phase 4).
832 if lineage_order.len() > MAX_LINEAGE_WALKS {
833 return Err(AcdpError::SearchTruncated(format!(
834 "{fn_name}: {} candidate lineage ids for {identity_label}={identity_value} exceed \
835 MAX_LINEAGE_WALKS={MAX_LINEAGE_WALKS} — refusing to fetch a partial set",
836 lineage_order.len(),
837 )));
838 }
839
840 // The lineage walk: recovers members a search pass cannot or does
841 // not enumerate. Each candidate lineage is fetched at most once
842 // (deduped above), in discovery order (GAP-B). A walk failure for
843 // one lineage — empty response, or a served member set missing the
844 // search-named `ctx_id` (GAP-A) — aborts this whole call via `?`:
845 // a partial `Vec` here is indistinguishable from a complete one to
846 // the caller, and feeds `effective_boundary`'s `.min()` over
847 // `compromised_since` (RFC-ACDP-0014 §4), so silently dropping a
848 // lineage could only ever move the effective compromise boundary
849 // later (or, if the whole set is dropped, make enforcement inert) —
850 // never safer to omit than to fail closed on. A member failing §5
851 // verification transiently (issue #248 Phase 1, "D5") also aborts
852 // the walk this way, rather than being skipped like a permanent
853 // failure.
854 for lineage_id in &lineage_order {
855 let expect = lineage_expect.get(lineage_id.as_str());
856 let walked = walk_revocation_lineage(client, resolver, lineage_id, expect, since).await?;
857 for ctx in walked.deferred {
858 if seen.insert(ctx.body.ctx_id.as_str().to_string()) {
859 deferred.push(ctx);
860 }
861 }
862 for (ctx_id, rev) in walked.verified {
863 if !seen.insert(ctx_id.as_str().to_string()) {
864 continue;
865 }
866 if keep(&rev) {
867 revocations.push(rev);
868 } else {
869 on_drop(&rev, &ctx_id, DropSite::LineageWalk);
870 }
871 }
872 }
873
874 Ok((revocations, deferred))
875}
876
877/// Discover a producer's key revocations on a registry
878/// (RFC-ACDP-0014 §8): search `type=key-revocation` (and the §10
879/// interim `acdp:key-revocation`) with `agent_id=<producer>`, retrieve
880/// each match, and return the ones that verify per §5
881/// ([`verify_revocation_body`]) **and** that satisfy two additional
882/// invariants enforced client-side, neither of which the registry can
883/// be trusted to have applied:
884///
885/// 1. `KeyRevocation::publisher == agent_id` (exact byte match — see
886/// below) — the query's own scope. `resp.matches` is registry-
887/// supplied and is re-checked here rather than trusted: a hostile
888/// registry could otherwise return a context belonging to a
889/// different producer entirely, and it would verify (§5 covers
890/// signature validity, not query relevance). This is a genuine
891/// cryptographic binding, not merely a registry-trusted one:
892/// `Body.agent_id` is a producer-controlled field, not part of the
893/// RFC-ACDP-0001 §5.7 exclusion set, so it sits inside
894/// ProducerContent — covered by `content_hash` and the producer's
895/// signature. Because [`verify_revocation_body`] runs the full
896/// §5.11 pipeline first, `rev.publisher` read here has already been
897/// verified against that signature, so a hostile registry cannot
898/// forge it — stronger than the Phase 1 `ctx_id` check, which binds
899/// only a registry-assigned field.
900/// 2. `trust_class == RevocationTrustClass::ProducerSigned` — with (1)
901/// enforced, a `RegistryAttested` result here would mean a producer
902/// published a revocation *claiming to speak as the registry*
903/// (`agent_id == publisher == <the queried producer>` while
904/// `revoked_key_controller` differs). RFC-ACDP-0014 §4 makes
905/// `revoked_key_controller` REQUIRED on registry-attested
906/// revocations and, when present, requires it equal `body.agent_id`
907/// on producer-signed ones — so this shape (`agent_id` fixed as the
908/// queried producer, `revoked_key_controller` differing) is illegal
909/// only in the producer-signed case; it is the *mandatory* shape
910/// when `body.agent_id` is genuinely a registry. RFC-ACDP-0014 §13
911/// documents this exact cross-producer forgery and endorses
912/// consumer-local, operational mitigations generally — this
913/// implementation's choice is client-local filtering; §13's own
914/// first-named suggestion is surfacing which DID issued each
915/// acted-upon revocation, which the `tracing` diagnostics below
916/// partially adopt.
917///
918/// Both are required together: (1) alone still admits a producer's own
919/// spec-illegal self-published "registry attestation" (`agent_id` and
920/// `publisher` are the same field, so that check alone can't tell honest
921/// producer-signed apart from self-claimed registry-attested); (2) alone
922/// still admits *another* producer's genuine revocation served by a
923/// hostile or buggy registry that ignored the `agent_id` search filter.
924///
925/// Candidates dropped by either check, or that fail §5 verification
926/// outright with a **permanent** error (`AcdpError::is_transient() ==
927/// false`) — including self-signed "revocations", which are at most a
928/// hint (§5 step 2) — are omitted from the return value: returning a
929/// typed error for one bad or off-scope body would let anyone poison
930/// the whole discovery result for every caller — a cheap DoS on the
931/// very helper meant to defend against DoS. Dropped candidates are not
932/// silent, though: with the `tracing` feature enabled, each one is
933/// surfaced via a `tracing::warn!` recording the publisher, trust
934/// class, and `ctx_id` of the dropped candidate and which filter
935/// dropped it — RFC-ACDP-0014 §13's first-named mitigation,
936/// "surfacing which DID issued each acted-upon revocation." A caller
937/// wanting them in-band, or building without `tracing`, has the
938/// composable primitives directly:
939/// [`RegistryClient::search`](crate::RegistryClient::search),
940/// [`verify_revocation_body`], and [`KeyRevocation::from_body`].
941///
942/// Superseded revocations are queried too: the §4 earliest-boundary
943/// rule needs the whole lineage. **Retracted revocations are queried as
944/// a third status pass** (RFC-ACDP-0013 §8.2 makes `status=retracted`
945/// available for exactly this): if every member of a revocation lineage
946/// has been retracted, no `active`/`superseded` search pass returns
947/// anything to seed the lineage walk from at all — the `retracted` pass
948/// is the only way to discover the `lineage_id` in that case (issue
949/// #226 Phase 4). **A retracted revocation is not merely a seed for
950/// finding the rest of the lineage**: once verified it is pushed into
951/// the returned `Vec` exactly like any other member, so it still
952/// permanently constrains [`effective_boundary`](acdp_types::revocation::effective_boundary)'s earliest-`T` fold.
953/// This is deliberate, fail-closed behavior, not an oversight — a
954/// registry cannot erase a revocation's effect merely by retracting it
955/// — and it matches the private lineage walk underneath, which never
956/// filtered on `registry_state.status` in the first place.
957///
958/// **Error contract.** In addition to the errors documented per-step
959/// above, this function returns
960/// [`AcdpError::SearchTruncated`] in two cases, both hard fail-closed —
961/// never a silently partial `Vec`: (1) any one `(type_form, status)`
962/// search pass exhausts `MAX_SEARCH_PAGES` while a `next_cursor` still
963/// remains, or (2) the total number of distinct candidate
964/// `lineage_id`s discovered across every search pass exceeds
965/// `MAX_LINEAGE_WALKS` before the lineage walk begins. Exactly
966/// `MAX_SEARCH_PAGES` full pages whose last page's `next_cursor` is
967/// `None` is a *complete* result, not truncated, and returns `Ok`
968/// normally.
969///
970/// **`agent_id` is matched by exact bytes, not normalized.**
971/// [`AgentDid`] derives `PartialEq` as plain string equality, and
972/// [`AgentDid::parse`] allows uppercase in the method-specific id —
973/// only the DID *method* is constrained to lowercase (a mismatched
974/// case there is rejected with `SchemaViolation`, not normalized) — so
975/// `did:web:Agents.example.com` and `did:web:agents.example.com` are
976/// both schema-valid and unequal here.
977/// Passing an `agent_id` that differs only in case from the one a body
978/// was actually published under means every candidate is dropped by
979/// filter (1) and this function returns `Ok(vec![])` — a silent "no
980/// revocations found", indistinguishable from the honest empty case.
981/// Pass the exact DID bytes the producer publishes under (e.g. from a
982/// verified body's `agent_id`, not a hand-typed or config-sourced
983/// variant). `agent_id` is schema-parsed at entry so a malformed DID
984/// fails loudly instead of silently returning empty.
985///
986/// **The honest caveat (§8), unchanged by the above:** search is served
987/// by the registry, and a malicious registry can hide a revocation
988/// exactly as it can hide any context — an empty result is *not*
989/// evidence of absence, and a registry colluding with a key thief can
990/// serve the stolen key's contexts while suppressing this signal.
991/// Within the protocol the systemic mitigation is the RFC-ACDP-0009
992/// §2.11 append-only transparency log (RFC-ACDP-0012); until it is
993/// deployed, query more than one vantage where the stakes warrant it,
994/// and remember that revocations are self-contained signed contexts —
995/// out-of-band delivery verifies identically and is the one channel a
996/// registry cannot suppress.
997///
998/// **No independent binding of the returned `ctx_id`.** Each result is
999/// retrieved by the `ctx_id` the search response named; nothing here
1000/// re-derives or independently confirms that id. The publisher filter
1001/// above catches a registry substituting a *different producer's*
1002/// revocation, but a registry that returns `agent_id`'s own *wrong*
1003/// revocation body for a listed `ctx_id` is not detected by this
1004/// function. That is benign today only because the §4 earliest-`T`
1005/// rule makes any genuine revocation of `agent_id` conservative to
1006/// apply regardless of which one is returned — a future reader must
1007/// not assume the id-to-body binding is actually checked.
1008///
1009/// Registry-attested revocations (§6) are published under the
1010/// *registry's* DID, not the producer's, so this producer-scoped query
1011/// never returns them (filter 2 above, in addition to the search scope
1012/// itself) — call [`find_registry_attested_revocations`] for those.
1013///
1014/// **Beyond the search passes above, every distinct `lineage_id` named
1015/// by a search match is also walked** via the private lineage-walking
1016/// helper behind [`find_revocations_in_lineage`] — see that function's
1017/// doc for the full rationale. This recovers a lineage member a
1018/// `status=`-scoped search pass cannot or does not enumerate (mixed
1019/// `expired`/`Other` statuses, or a member a search pass has been made
1020/// structurally blind to) as long as at least one member of the same
1021/// lineage is still visible to search. Walked members are deduped
1022/// against search-found ones by `ctx_id` and pass through the same
1023/// publisher/trust-class filter as above.
1024///
1025/// **A failure walking any one lineage aborts the whole call.** If
1026/// `walk_revocation_lineage` errors for a given `lineage_id` — empty
1027/// response, or (per issue #226 Phase 3) a registry-served member set
1028/// that does not include the `ctx_id` a search match actually named
1029/// for it — that error propagates via `?` and this function returns
1030/// `Err` rather than a partial `Vec`. A caller cannot distinguish a
1031/// `Vec` that omits a compromise window from one that is complete, and
1032/// that `Vec` feeds straight into [`effective_boundary`](acdp_types::revocation::effective_boundary)'s `.min()`
1033/// over `compromised_since` (RFC-ACDP-0014 §4): silently dropping a
1034/// member with an earlier boundary would move the effective boundary
1035/// later, and dropping an entire lineage would make revocation
1036/// enforcement inert for it — exactly the "quietly shrink a compromise
1037/// window" outcome §4 forbids, and exactly what a hostile registry
1038/// gets for free by failing one lineage walk if this were scoped
1039/// instead of fatal. This is not a new abort lever either: the same
1040/// call already aborts unconditionally on `client.search` and
1041/// `client.retrieve` a few lines above, so failing closed here adds no
1042/// availability exposure this function does not already have.
1043///
1044/// **Extended by issue #248 Phase 1 ("D5") to the per-candidate
1045/// verification calls this function makes directly**, not only to
1046/// `walk_revocation_lineage`'s own errors above: a *transient*
1047/// [`verify_revocation_body`] failure (the candidate's DID host is
1048/// unreachable, rate-limited, or otherwise could not be asked) now
1049/// propagates via the same reasoning — an unresolved candidate could
1050/// have named an earlier `compromised_since` than anything already
1051/// found, so silently continuing is the same "quietly shrink a
1052/// compromise window" outcome this paragraph already rejects for a
1053/// failed lineage walk. Only a *permanent* verification failure (bad
1054/// signature, hash mismatch, schema violation, or a DID that resolves
1055/// but denies the key) is still dropped with a `tracing::warn!` — see
1056/// `walk_revocation_lineage`'s doc above for the full DoS argument for
1057/// why that narrower case remains safe to drop.
1058pub async fn find_revocations(
1059 client: &RegistryClient,
1060 resolver: &WebResolver,
1061 agent_id: &AgentDid,
1062) -> Result<Vec<KeyRevocation>, AcdpError> {
1063 let lookup = find_revocations_lookup(client, resolver, agent_id).await?;
1064 refuse_deferred(&lookup.deferred)?;
1065 Ok(lookup.revocations)
1066}
1067
1068/// One discovery lookup's result: the verified revocations found and, when
1069/// a fresh cache marker answered the lookup instead of the network, the
1070/// wall-clock time that cached complete absence was observed.
1071#[derive(Debug)]
1072pub(crate) struct Lookup {
1073 pub(crate) revocations: Vec<KeyRevocation>,
1074 /// Candidates signed by a keyless key, not yet verified (RFC-ACDP-0021
1075 /// §9.3): the caller evaluates them under its keyless policy.
1076 pub(crate) deferred: Vec<FullContext>,
1077 pub(crate) cached_since: Option<std::time::SystemTime>,
1078}
1079
1080impl Lookup {
1081 fn live(revocations: Vec<KeyRevocation>, deferred: Vec<FullContext>) -> Self {
1082 Self {
1083 revocations,
1084 deferred,
1085 cached_since: None,
1086 }
1087 }
1088
1089 fn cached(observed: std::time::SystemTime) -> Self {
1090 Self {
1091 revocations: Vec::new(),
1092 deferred: Vec::new(),
1093 cached_since: Some(observed),
1094 }
1095 }
1096}
1097
1098/// [`find_revocations`] reporting whether a cached absence answered it.
1099pub(crate) async fn find_revocations_lookup(
1100 client: &RegistryClient,
1101 resolver: &WebResolver,
1102 agent_id: &AgentDid,
1103) -> Result<Lookup, AcdpError> {
1104 // Schema-validate the caller's DID before it is promoted from an
1105 // opaque search-filter string into an equality operand (filter 1
1106 // below) — see the exact-byte-match caveat in the doc above. This
1107 // does not normalize case; it only rejects a malformed DID loudly
1108 // instead of silently yielding an empty result.
1109 let agent_id = AgentDid::parse(agent_id.as_str())?;
1110
1111 // Issue #257: a per-vantage freshness marker, when a cache is attached
1112 // AND still fresh, skips this entire lookup — zero requests issued.
1113 // Keyed by `(vantage, agent_id, ProducerSigned)`, never by anything
1114 // registry-specific: this function's scope is the producer itself.
1115 // `fresh_marker` is a lock-free fast path when no cache is attached or
1116 // `freshness == Duration::ZERO` (the default from both
1117 // `RevocationDiscovery` named constructors), so this costs nothing in
1118 // the common case. Facts are handled separately, downstream, at
1119 // `verify_retrieved`'s `effective` merge — never folded in here (see
1120 // the wave plan's B1: this function's own failure paths below set
1121 // nothing but `Err`, and if facts rode in this return value they would
1122 // vanish on exactly the failure an attacker can induce).
1123 let vantage = client.authority();
1124 // N7 (fresh-Opus review of Phase 2): both the marker check and the
1125 // fact record below are gated on `Some(vantage)`, so a client whose
1126 // base URL has no resolvable host (`authority()` returns `None`)
1127 // silently makes caching a no-op for this call — unreachable in
1128 // practice (`RegistryClient` is always built from a parsed URL), but
1129 // worth signaling rather than leaving unsignalled.
1130 #[cfg(feature = "tracing")]
1131 if vantage.is_none() && client.revocation_cache().is_some() {
1132 tracing::warn!(
1133 "find_revocations: a RevocationCache is attached but client.authority() is None \
1134 — caching is silently inert for this call"
1135 );
1136 }
1137 if let (Some((cache, freshness)), Some(vantage)) =
1138 (client.revocation_cache(), vantage.as_deref())
1139 {
1140 if let Some(observed) = cache.fresh_marker(
1141 vantage,
1142 agent_id.as_str(),
1143 RevocationTrustClass::ProducerSigned,
1144 freshness,
1145 ) {
1146 return Ok(Lookup::cached(observed));
1147 }
1148 }
1149
1150 let keep = |rev: &KeyRevocation| {
1151 // Re-check query scope and trust class on the verified body — do
1152 // not trust `resp.matches`, and do not accept a producer claiming
1153 // to be a registry (RFC-ACDP-0014 §4, §13).
1154 rev.publisher == agent_id && rev.trust_class == RevocationTrustClass::ProducerSigned
1155 };
1156 let on_drop = |_rev: &KeyRevocation, _ctx_id: &CtxId, _site: DropSite| {
1157 #[cfg(feature = "tracing")]
1158 if _site == DropSite::LineageWalk {
1159 tracing::warn!(
1160 publisher = %_rev.publisher,
1161 trust_class = ?_rev.trust_class,
1162 ctx_id = %_ctx_id,
1163 filter = if _rev.publisher != agent_id {
1164 "publisher_scope"
1165 } else {
1166 "trust_class"
1167 },
1168 "find_revocations: dropped lineage-walk candidate outside query scope/trust class"
1169 );
1170 } else {
1171 tracing::warn!(
1172 publisher = %_rev.publisher,
1173 trust_class = ?_rev.trust_class,
1174 ctx_id = %_ctx_id,
1175 filter = if _rev.publisher != agent_id {
1176 "publisher_scope"
1177 } else {
1178 "trust_class"
1179 },
1180 "find_revocations: dropped candidate outside query scope/trust class"
1181 );
1182 }
1183 };
1184
1185 let (revocations, deferred) = discover_revocations(
1186 client,
1187 resolver,
1188 DiscoveryParams {
1189 search_agent_id: agent_id.as_str(),
1190 fn_name: "find_revocations",
1191 identity_label: "agent_id",
1192 identity_value: agent_id.as_str(),
1193 },
1194 &keep,
1195 &on_drop,
1196 )
1197 .await?;
1198
1199 // Issue #257: this point is reached only on a fully successful,
1200 // untruncated discovery (every early-return above is an `Err`), so
1201 // recording here is exactly "mint a marker only on full success" by
1202 // construction. `record_success` dedups `revocations` into the cached
1203 // fact set via `KeyRevocation`'s `Eq` and caps growth per entry — see
1204 // `crate::revocation_cache`.
1205 //
1206 // A lookup that set keyless candidates aside is not a complete answer
1207 // on its own: no marker, so a later lookup re-fetches them rather than
1208 // reading an absence from the cache.
1209 if let (Some((cache, _freshness)), Some(vantage), true) = (
1210 client.revocation_cache(),
1211 vantage.as_deref(),
1212 deferred.is_empty(),
1213 ) {
1214 cache.record_success(
1215 vantage,
1216 agent_id.as_str(),
1217 RevocationTrustClass::ProducerSigned,
1218 &revocations,
1219 );
1220 }
1221 Ok(Lookup::live(revocations, deferred))
1222}
1223
1224/// Discover a producer's **registry-attested** (§6) key revocations —
1225/// the RFC-ACDP-0014 §8 second query that [`find_revocations`]'s own
1226/// doc points callers at, since a producer-scoped search structurally
1227/// cannot return them (they are published under the *registry's*
1228/// `agent_id`, not the producer's).
1229///
1230/// Fetches the registry's own capabilities document (**exactly once**,
1231/// before the search loop — [`RegistryClient::capabilities`] issues a
1232/// fresh network round-trip on every call, so hoisting it above the
1233/// type-form × status loop bounds total cost to one capabilities fetch,
1234/// plus up to `6 * MAX_SEARCH_PAGES` search round-trips (three statuses
1235/// — `active`, `superseded`, `retracted` — × two type-forms = six
1236/// distinct `(type_form, status)` pairs, each independently bounded by
1237/// its own `MAX_SEARCH_PAGES` page cap), plus up to
1238/// `MAX_LINEAGE_WALKS` lineage fetches from the walk below, each capped
1239/// at 1 MB — rather than one capabilities fetch per candidate), then searches
1240/// `agent_id=<capabilities.registry_did>` for `key-revocation` (and the
1241/// §10 interim `acdp:key-revocation`) contexts, retrieves each match,
1242/// and keeps the ones that:
1243///
1244/// 1. Verify per §5 ([`verify_revocation_body`]) — schema, hash
1245/// recomputation, DID resolution, signature, and the §5 step 2
1246/// not-self-signed check;
1247/// 2. Name `controller` in `revoked_key_controller` (exact
1248/// [`AgentDid`] equality — see [`find_revocations`]'s exact-byte-match
1249/// caveat, which applies here identically); and
1250/// 3. Pass [`KeyRevocation::cross_check_registry_binding`] against the
1251/// authority this client actually talks to and the capabilities
1252/// document just fetched — RFC-ACDP-0014 §6 step 2 (`publisher`
1253/// must equal `capabilities.registry_did`) plus the RFC-ACDP-0011
1254/// §7 step 3 / RFC-ACDP-0012 §9.3 step 3 house binding (`publisher`
1255/// must equal `did:web:<serving_authority>`), confirming that
1256/// `publisher` really is the specific registry this client is
1257/// talking to, not merely *some* identity that appears in the
1258/// search response claiming registry standing over `controller`'s
1259/// key.
1260///
1261/// This function is what closes the *discovery* gap
1262/// [`find_revocations`]'s trust-class filter opened: that filter
1263/// excludes registry-attested revocations from its results
1264/// entirely (by design — a producer-scoped search cannot
1265/// distinguish a genuine one from a forgery), so without a
1266/// dedicated registry-scoped query a caller would never see a
1267/// genuine one at all. Filter (3) here then narrows *this*
1268/// function's own results — and narrows a different thing than it
1269/// might look like: it does **not** stop a producer forging a
1270/// registry attestation of its own key by setting `agent_id` to the
1271/// registry's DID. That forgery is already impossible one step
1272/// earlier — [`verify_revocation_body`] runs `Verifier::verify_body`,
1273/// which resolves `body.agent_id`'s DID document and verifies the
1274/// signature against it, so a body claiming `agent_id = <registry
1275/// DID>` cannot exist unless the registry's own key actually signed
1276/// it. What filter (3) actually rejects is a **genuinely signed
1277/// body published under some third DID** — another registry, or a
1278/// producer emitting the §4-illegal `agent_id=Q,
1279/// revoked_key_controller=P` shape — that a hostile or compromised
1280/// registry lists in the `agent_id=<registry_did>` search response
1281/// it serves to this client. That is the exact analog of
1282/// [`find_revocations`]'s filter 1, and it is real and load-bearing:
1283/// without it, this function would trust `publisher` merely because
1284/// *some* validly-signed body appeared among the search results,
1285/// rather than confirming it is signed by the one registry this
1286/// client actually talks to.
1287///
1288/// As with [`find_revocations`], candidates dropped by (2) or (3), or
1289/// that fail §5 outright with a **permanent** error
1290/// (`AcdpError::is_transient() == false`), are omitted rather than
1291/// surfaced as errors — a single bad or off-scope body must not poison
1292/// the whole discovery result. With the `tracing` feature enabled,
1293/// each drop is logged via `tracing::warn!` naming the publisher,
1294/// controller, and `ctx_id`.
1295///
1296/// **Propagates, rather than swallows, a `capabilities()` error.**
1297/// `CapabilitiesDocument.registry_did` is a required, non-`Option`
1298/// `String` with no `#[serde(default)]`
1299/// (`crates/acdp-types/src/capabilities.rs`), and
1300/// [`RegistryClient::capabilities`] runs
1301/// `acdp_validation::validate_capabilities` — which parses it with
1302/// [`acdp_types::primitives::AgentDid::parse_web`] — before returning.
1303/// A registry that omits `registry_did` fails deserialization; one
1304/// sending `""` or a non-`did:web` value fails `parse_web`. Either way
1305/// `capabilities()` already returns `Err`, so there is no "missing
1306/// `registry_did`" state for this function to special-case — it simply
1307/// propagates whatever `capabilities()` returns via `?`, rather than
1308/// mapping a failure into a silent empty vec.
1309///
1310/// **The same §8 honest caveat as [`find_revocations`] applies**: search
1311/// is registry-served, so an empty result is not evidence of absence.
1312///
1313/// **Retracted revocations are queried as a third status pass**, and
1314/// **the same [`AcdpError::SearchTruncated`] error contract applies**,
1315/// as [`find_revocations`] — see that function's doc for the exact
1316/// truncation and completeness boundary (issue #226 Phase 4). As with
1317/// [`find_revocations`], a retracted revocation is not seed-only: once
1318/// verified it is pushed into the returned `Vec` like any other member
1319/// and still permanently constrains [`effective_boundary`](acdp_types::revocation::effective_boundary)'s
1320/// earliest-`T` fold — a registry cannot erase its effect by retracting
1321/// it.
1322///
1323/// **Beyond the search passes above, every distinct `lineage_id` named
1324/// by a search match is also walked** via the private lineage-walking
1325/// helper behind [`find_revocations_in_lineage`] — see that function's
1326/// doc for the full rationale. Walked members are deduped against
1327/// search-found ones by `ctx_id` and pass through the same
1328/// controller/registry-binding filter as above.
1329///
1330/// **Cost note for callers verifying many contexts.** The single
1331/// capabilities fetch above is hoisted *within* one call, but
1332/// [`RegistryClient::capabilities`] issues a fresh network round-trip
1333/// on every call to *this* function too — nothing here caches it across
1334/// calls. A caller verifying many contexts against the same registry in
1335/// a loop should hoist its own call to this function (or to
1336/// `capabilities()` directly) above that loop rather than calling it
1337/// once per context. An overload taking a pre-fetched capabilities
1338/// document can be added additively later if that turns out to matter
1339/// in practice.
1340///
1341/// **A failure walking any one lineage aborts the whole call** — see
1342/// [`find_revocations`]'s doc for the identical rationale (issue #226
1343/// Phase 3): a partial `Vec` is indistinguishable from a complete one
1344/// to the caller, and feeds the same `effective_boundary` `.min()` this
1345/// crate uses to enforce RFC-ACDP-0014 §4, so a dropped lineage must
1346/// fail the call rather than silently narrow the result. The same doc's
1347/// issue #248 Phase 1 ("D5") extension applies here identically: a
1348/// *transient* per-candidate [`verify_revocation_body`] failure in the
1349/// search loop below also propagates as `Err` rather than being
1350/// silently dropped — only a *permanent* one is dropped with a
1351/// `tracing::warn!`.
1352pub async fn find_registry_attested_revocations(
1353 client: &RegistryClient,
1354 resolver: &WebResolver,
1355 controller: &AgentDid,
1356) -> Result<Vec<KeyRevocation>, AcdpError> {
1357 find_registry_attested_lookup(client, resolver, controller)
1358 .await
1359 .map(|l| l.revocations)
1360}
1361
1362/// [`find_registry_attested_revocations`] reporting whether a cached
1363/// absence answered it.
1364pub(crate) async fn find_registry_attested_lookup(
1365 client: &RegistryClient,
1366 resolver: &WebResolver,
1367 controller: &AgentDid,
1368) -> Result<Lookup, AcdpError> {
1369 let controller = AgentDid::parse(controller.as_str())?;
1370
1371 // Issue #257: the marker check MUST precede `client.capabilities()`
1372 // below — that fetch is unconditional and otherwise costs one request
1373 // on every "suppressed" call. Keyed by `(vantage, controller,
1374 // RegistryAttested)` — `controller` is the producer/controller DID
1375 // this call was asked about, deliberately NEVER the registry's own DID
1376 // (`capabilities.registry_did`, not yet even fetched at this point):
1377 // keying by the search identity instead would let one marker suppress
1378 // discovery for every producer at this registry, not just this one.
1379 // See `find_revocations` above for the shared rationale (fast path
1380 // when unattached or `freshness == Duration::ZERO`; facts are handled
1381 // downstream in `verify_retrieved`, never folded into this return
1382 // value).
1383 let vantage = client.authority();
1384 // N7 (fresh-Opus review of Phase 2): see `find_revocations`'s identical
1385 // note — both the marker check and the fact record below are gated on
1386 // `Some(vantage)`, so a client with no resolvable authority silently
1387 // makes caching a no-op for this call.
1388 #[cfg(feature = "tracing")]
1389 if vantage.is_none() && client.revocation_cache().is_some() {
1390 tracing::warn!(
1391 "find_registry_attested_revocations: a RevocationCache is attached but \
1392 client.authority() is None — caching is silently inert for this call"
1393 );
1394 }
1395 if let (Some((cache, freshness)), Some(vantage)) =
1396 (client.revocation_cache(), vantage.as_deref())
1397 {
1398 if let Some(observed) = cache.fresh_marker(
1399 vantage,
1400 controller.as_str(),
1401 RevocationTrustClass::RegistryAttested,
1402 freshness,
1403 ) {
1404 return Ok(Lookup::cached(observed));
1405 }
1406 }
1407
1408 // Fetched exactly once, outside the type-form × status loop below —
1409 // see the doc above for why hoisting this is required, not
1410 // stylistic.
1411 let caps = client.capabilities().await?;
1412 let registry_agent_id = AgentDid::parse(caps.registry_did.as_str())?;
1413 let serving_authority = client
1414 .authority()
1415 .ok_or_else(|| AcdpError::SchemaViolation("registry client base URL has no host".into()))?;
1416
1417 let keep = |rev: &KeyRevocation| {
1418 rev.revoked_key_controller == controller
1419 && rev
1420 .cross_check_registry_binding(&serving_authority, &caps.registry_did)
1421 .is_ok()
1422 };
1423 let on_drop = |_rev: &KeyRevocation, _ctx_id: &CtxId, _site: DropSite| {
1424 #[cfg(feature = "tracing")]
1425 if _site == DropSite::LineageWalk {
1426 tracing::warn!(
1427 publisher = %_rev.publisher,
1428 controller = %_rev.revoked_key_controller,
1429 ctx_id = %_ctx_id,
1430 "find_registry_attested_revocations: dropped lineage-walk \
1431 candidate outside controller scope or failing \
1432 registry-binding check"
1433 );
1434 } else {
1435 tracing::warn!(
1436 publisher = %_rev.publisher,
1437 controller = %_rev.revoked_key_controller,
1438 ctx_id = %_ctx_id,
1439 "find_registry_attested_revocations: dropped candidate \
1440 outside controller scope or failing registry-binding check"
1441 );
1442 }
1443 };
1444
1445 // A keyless key is a producer's key, never a registry's: keyless
1446 // candidates here are outside the registry-attested class.
1447 let (revocations, _keyless) = discover_revocations(
1448 client,
1449 resolver,
1450 DiscoveryParams {
1451 search_agent_id: registry_agent_id.as_str(),
1452 fn_name: "find_registry_attested_revocations",
1453 identity_label: "controller",
1454 identity_value: controller.as_str(),
1455 },
1456 &keep,
1457 &on_drop,
1458 )
1459 .await?;
1460
1461 // Issue #257: reached only on full, untruncated success — see
1462 // `find_revocations`'s identical note above. Keyed by `controller`,
1463 // matching the marker check at the top of this function.
1464 if let (Some((cache, _freshness)), Some(vantage)) =
1465 (client.revocation_cache(), vantage.as_deref())
1466 {
1467 cache.record_success(
1468 vantage,
1469 controller.as_str(),
1470 RevocationTrustClass::RegistryAttested,
1471 &revocations,
1472 );
1473 }
1474 Ok(Lookup::live(revocations, Vec::new()))
1475}
1476
1477#[cfg(test)]
1478mod tests {
1479 use super::*;
1480 use acdp_types::revocation::RevocationTrustClass;
1481 use chrono::TimeZone;
1482
1483 fn rev(fp: &str, t: DateTime<Utc>) -> KeyRevocation {
1484 KeyRevocation {
1485 revoked_key_fingerprint: fp.into(),
1486 compromised_since: t,
1487 reason: None,
1488 revoked_key_id: None,
1489 revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
1490 publisher: AgentDid::new("did:web:agents.example.com:p"),
1491 trust_class: RevocationTrustClass::ProducerSigned,
1492 ctx_id: None,
1493 }
1494 }
1495
1496 fn at(s: &str) -> DateTime<Utc> {
1497 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
1498 }
1499
1500 const F: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
1501
1502 /// §7 step 2: strictly-before-T verifies with the distinguishable
1503 /// pre-compromise status; §7 step 3: equal-to-T already fails.
1504 #[test]
1505 fn boundary_is_strict() {
1506 let t = at("2026-05-01T00:00:00.000Z");
1507 let revs = [rev(F, t)];
1508 assert_eq!(
1509 classify_under_revocation(&revs, F, Some(at("2026-04-30T23:59:59.999Z"))).unwrap(),
1510 Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
1511 );
1512 assert!(matches!(
1513 classify_under_revocation(&revs, F, Some(t)),
1514 Err(AcdpError::KeyRevoked(_))
1515 ));
1516 }
1517
1518 /// §7 step 4: no receipt-attested time → fail closed.
1519 #[test]
1520 fn no_receipt_fails_closed() {
1521 let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
1522 assert!(matches!(
1523 classify_under_revocation(&revs, F, None),
1524 Err(AcdpError::KeyRevoked(_))
1525 ));
1526 }
1527
1528 /// A revocation of some OTHER key changes nothing.
1529 #[test]
1530 fn unrelated_fingerprint_is_inert() {
1531 let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
1532 let other = "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
1533 assert_eq!(classify_under_revocation(&revs, other, None).unwrap(), None);
1534 assert_eq!(
1535 classify_under_revocation(&[], F, None).unwrap(),
1536 None,
1537 "no known revocations ⇒ inert"
1538 );
1539 }
1540
1541 /// §4 monotonicity: the earliest T across a revocation lineage is
1542 /// effective — a later supersession cannot quietly shrink the
1543 /// window.
1544 #[test]
1545 fn earliest_boundary_wins() {
1546 let early = at("2026-04-01T00:00:00.000Z");
1547 let late = at("2026-05-01T00:00:00.000Z");
1548 let revs = [rev(F, late), rev(F, early)];
1549 // Between the two boundaries: inside the (earliest-T) window.
1550 assert!(matches!(
1551 classify_under_revocation(&revs, F, Some(at("2026-04-15T00:00:00.000Z"))),
1552 Err(AcdpError::KeyRevoked(_))
1553 ));
1554 // Before both: pre-compromise.
1555 assert_eq!(
1556 classify_under_revocation(&revs, F, Some(at("2026-03-01T00:00:00.000Z"))).unwrap(),
1557 Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
1558 );
1559 }
1560
1561 #[test]
1562 fn pre_compromise_uses_millis() {
1563 // Sub-second boundaries compare at millisecond precision — the
1564 // canonical wire precision (RFC-ACDP-0001 §5.3).
1565 let t = Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap()
1566 + chrono::Duration::milliseconds(500);
1567 let revs = [rev(F, t)];
1568 let just_before = t - chrono::Duration::milliseconds(1);
1569 assert_eq!(
1570 classify_under_revocation(&revs, F, Some(just_before)).unwrap(),
1571 Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
1572 );
1573 }
1574}