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