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