Skip to main content

acdp_client/
revocation.rs

1//! Consumer-side key-revocation semantics (ACDP 0.3, RFC-ACDP-0014).
2//!
3//! Three layers:
4//!
5//! - [`classify_under_revocation`] — the pure §7 boundary rule: given
6//!   verified revocations and a (receipt-attested) publish time,
7//!   decide *historically authorized (pre-compromise, receipt-attested)*
8//!   vs fail-closed. This is what
9//!   [`VerificationPolicy::revocations`](crate::VerificationPolicy)
10//!   drives inside the fetch pipeline.
11//! - [`verify_revocation_body`] — the §5 verification pipeline for a
12//!   revocation context itself: strict RFC-ACDP-0001 §5.11 body
13//!   verification, §4 shape parse, and the §5 step 2 not-self-signed
14//!   check against the resolved signing key's fingerprint.
15//! - [`find_revocations`] — the §8 discovery SHOULD: search a registry
16//!   for a producer's revocation contexts and return the ones that
17//!   verify.
18
19use acdp_crypto::fingerprint::{fingerprint_for_key_id, 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}