Skip to main content

RevocationDiscovery

Struct RevocationDiscovery 

Source
#[non_exhaustive]
pub struct RevocationDiscovery { pub include_registry_attested: bool, pub on_failure: DiscoveryFailurePolicy, pub total_timeout: Duration, pub max_requests: Option<NonZeroUsize>, pub max_bytes: Option<u64>, pub freshness: Duration, }
Expand description

RFC-ACDP-0014 §8 revocation auto-discovery configuration.

When set on RevocationPolicy::discover, this instructs verification to look up revocations itself — via find_revocations and, when Self::include_registry_attested is true, additionally find_registry_attested_revocations — instead of relying solely on RevocationPolicy::known.

§Cost

Discovery is expensive, and every request it issues is serial. MAX_SEARCH_PAGES = 10 (crate::revocation) bounds search round-trips per (type_form, status) pair, and there are 6 such pairs (2 type forms × 3 statuses) — so up to 60 search requests, each of which can name up to 100 per-candidate context retrieves (GET /contexts/{id}, capped at 1 MB apiece), for up to 6,000 + 60 + 100 = 6,160 requests / ~6.1 GB in the worst case for one of the two discovery functions. The retrieve fan-out is not bounded by MAX_LINEAGE_WALKS = 100 — that cap is only checked after the retrieves have already gone out. With Self::include_registry_attested set, both functions run: ≈12,321 requests / ~12.2 GB worst case for the pair (the extra 1 is the unconditional client.capabilities() fetch find_registry_attested_revocations makes).

Self::total_timeout is an availability bound, not a bytes or memory bound: it stops verification from hanging forever against a slow or hostile registry, but a hostile registry on a fast link can still serve gigabytes of legitimate-looking traffic inside the window — the 1 MB cap applies per request, not in aggregate, and verified revocations accumulate in a Vec for the call’s duration. Self::max_requests and Self::max_bytes (issue #258) close that gap: set either (or both) to bound the two lookups combined — enabling Self::include_registry_attested does not double the ceiling — checked before each request is issued, so a would-be request that would exceed the budget is never sent. Exhaustion raises AcdpError::RevocationDiscoveryBudgetExceeded through the same Self::on_failure path as AcdpError::SearchTruncated — it is permanent for the same request shape and is never transient. Both knobs bound registry traffic only: DID-document fetches issued via WebResolver are not counted. Self::max_bytes counts only successfully-parsed response bodies — an error-envelope read on a non-success response is not charged. Self::max_requests has no such exemption: the request slot is reserved before the request is issued, so a 503, a parse failure, or a PayloadTooLarge still consumes it. Leaving both None (as both Self::producer_signed_only and Self::all_trust_classes do) preserves pre-#258 behavior exactly: unbounded requests and bytes, bounded only by Self::total_timeout.

Issue #257 adds an opt-in cache (crate::RevocationCache, attached to the crate::RegistryClient passed in via crate::RegistryClient::with_revocation_cache) — but attaching one does NOT, by itself, turn “re-discovers from scratch” into “sometimes skips discovery.” It is two independent things: verified revocations (“facts”) are always unioned into classification, indefinitely, regardless of Self::freshness — a caller verifying many contexts against the same producer benefits from this immediately, with zero extra configuration, simply by attaching a cache and reusing the client. Whether a repeat lookup is skipped entirely (saving requests) is governed separately by Self::freshness, which defaults to Duration::ZERO — i.e. off. See Self::freshness’s own doc and crate::revocation_cache for the full model. Without a cache attached at all, this crate behaves exactly as before #257: every call re-discovers from scratch, budgeted or not. A caller that does not want to manage a RevocationCache can still discover once itself and pass the results via RevocationPolicy::known instead of setting discover on every call — the same hoisting guidance crate::revocation’s find_registry_attested_revocations doc already gives callers of that function directly (see its “Cost note for callers verifying many contexts”).

§Reentrancy

Discovery calls back into verification, and that reentrancy has two consequences worth stating explicitly rather than leaving implicit:

  1. Each candidate body find_revocations turns up is verified via Verifier::new(resolver).verify_body — not verify_retrieved — so discovered revocation bodies are themselves checked without revocation checking of their own. That is defensible under RFC-ACDP-0014 §5 step 1’s “currently authorized key,” but it is an assumption this type is making on the caller’s behalf, not an accident.
  2. Verifying a key-revocation context now also triggers discovery against the same producer, so the revocation-fetch path itself becomes fragile under DiscoveryFailurePolicy::FailClosed: a producer whose revocation search is briefly unreachable can no longer be verified as revoked, either.

§No Default

This type deliberately has no Default impl — construct it via Self::producer_signed_only or Self::all_trust_classes. RFC-ACDP-0014 §6’s “lost-everything” fallback means a producer that has lost every key it could sign a revocation with can only be revoked registry-attested — so the catastrophic case is exactly the one a silently-defaulted-off trust class would skip. A quiet Default::default() that leaves include_registry_attested: false would make that skip invisible at every call site; forcing a named constructor puts the choice at the type level instead, where a reviewer (and git grep) can see it. Callers protecting against key loss, or otherwise unwilling to assume a producer always retains signing capacity, MUST use Self::all_trust_classes.

Fields (Non-exhaustive)§

This struct is marked as non-exhaustive
Non-exhaustive structs could have additional fields added in future. Therefore, non-exhaustive structs cannot be constructed in external crates using the traditional Struct { .. } syntax; cannot be matched against without a wildcard ..; and struct update syntax will not work.
§include_registry_attested: bool

Whether to also run find_registry_attested_revocations (the §6 registry-attested trust class), in addition to the producer-signed search every discovery configuration runs. Requires the registry to serve /.well-known/acdp.json; under DiscoveryFailurePolicy::FailClosed a registry that serves no capabilities document fails verification when this is true.

§on_failure: DiscoveryFailurePolicy

What to do when discovery itself fails (a transient transport error from either search, or the search-safety-cap error AcdpError::SearchTruncated). Default DiscoveryFailurePolicy::FailClosed.

SearchTruncated and a transport error (e.g. a 503) are NOT equivalent, even though both take this same on_failure path. SearchTruncated means “this producer has more revocations than we will page through” (MAX_SEARCH_PAGES) — an attacker-inducible security downgrade, since a hostile producer or registry can pad the result set specifically to exhaust the page cap and hide a real revocation from discovery. A 503 is an ordinary availability blip. A third case is attacker-inducible the same way SearchTruncated is (issue #258): AcdpError::RevocationDiscoveryBudgetExceeded, raised when Self::max_requests or Self::max_bytes is exhausted — a hostile registry that learns a caller’s budget can pad harmless-looking traffic specifically to exhaust it before a real revocation is found, just as padding the result set exhausts MAX_SEARCH_PAGES. Under DiscoveryFailurePolicy::ProceedWithKnown all three are treated the same way (proceed on RevocationPolicy::known alone, record the failure) — choose ProceedWithKnown knowing it also waives truncation and budget exhaustion, not just transient unavailability.

§total_timeout: Duration

Wall-clock budget for the whole discovery step (both searches, if Self::include_registry_attested is set). An availability bound only — see the type-level cost section above. Matches this crate’s existing ResolverOptions::total_timeout precedent (crate::cross_registry), defaulting to the same 30 s rather than exceeding it on the core verify path.

Enforced via tokio::time::timeout, which requires the executing Tokio runtime to have its time driver enabled (#[tokio::main] and #[tokio::test] enable it by default; a hand-built Builder::new_current_thread() runtime does not unless .enable_time() or .enable_all() is called). Calling verify_retrieved with discover: Some(..) from a runtime without the time driver panics — it does not return Err — the same requirement ResolverOptions::total_timeout (crate::cross_registry) already carries on its opt-in walk, but here it sits on the core verify path whenever discovery is configured, not just on an explicit cross-registry walk.

§max_requests: Option<NonZeroUsize>

Issue #258: cap on the total number of registry requests the discovery step may issue, combined across both lookups (the producer-signed search always, plus the registry-attested search when Self::include_registry_attested is set) — not a ceiling per lookup, so turning on the second trust class does not double the allowance. None (the default from both named constructors) is unbounded, matching every version before #258. Checked before each request is issued (RegistryClient::capabilities, ::retrieve, ::lineage, ::search); exceeding it raises AcdpError::RevocationDiscoveryBudgetExceeded through Self::on_failure, the same path AcdpError::SearchTruncated already takes. Counts registry requests only — DID-document fetches via WebResolver are not counted.

§max_bytes: Option<u64>

Issue #258: cap on the cumulative bytes of successfully-parsed response bodies the discovery step may read, combined across both lookups, same combination rule as Self::max_requests. None (the default from both named constructors) is unbounded, matching every version before #258. Checked before each request is issued, using the running total from requests that already completed — the size of an in-flight request cannot be known (and therefore reserved) in advance, so up to two requests can push the total past max_bytes before the next check observes the overrun: the two trust-class lookups run concurrently under tokio::try_join!, and both can pass a not-yet-updated check before either’s response is recorded — see Self::max_requests’s doc for the same race on the request count, where it is closed by an atomic reservation; there is no equivalent reservation for bytes, since a response’s size is not known until after it is read. Counts a non-success response’s error-envelope read not at all — only bytes read on the success path are charged.

Some(0) is representable (unlike Self::max_requests, which is guarded by NonZeroUsize) and is not special-cased: it trips the >= check on the very first request of either lookup, before that request is ever issued, so discovery fails immediately with zero registry traffic. This is a deliberate consequence of keeping this field a plain u64 (matching the wave plan’s chosen types) rather than a reason to reach for NonZeroU64.

§freshness: Duration

Issue #257: how long a discovery-freshness marker stays valid on a crate::RevocationCache attached to the crate::RegistryClient passed to verify_retrieved (via crate::RegistryClient::with_revocation_cache). A marker records “vantage V completed a full, untruncated discovery for this producer/trust-class at time T”; while one is within freshness of T, that lookup is skipped entirely (zero registry requests) rather than merely supplemented.

Default Duration::ZERO from both named constructors — markers never suppress a lookup unless a caller explicitly raises this above zero. This is the safe default per RFC-ACDP-0014 §8’s own warning (“absence of search results is not evidence of absence”): a cached absence is not licensed the way a cached, verified revocation is (§7:114 licenses only the latter, indefinitely). No cache attached makes this field inert regardless of its value.

Distinct from — and orthogonal to — caching verified revocations themselves (“facts”), which a crate::RevocationCache does unconditionally and indefinitely whenever one is attached, independent of this field: facts are always unioned into classification (never gated behind freshness), because a revocation is monotone (more revocations ⇒ an earlier effective boundary ⇒ strictly more fail-closed verdicts), so seeding from them can only tighten a verdict, never loosen one. freshness governs only whether a lookup that would otherwise re-confirm “no NEW revocation” is skipped. See crate::revocation_cache for the full two-object model.

That “unconditionally” is exact for a producer-signed fact (§8: self-contained, applies at any vantage) but is scoped for a registry-attested one (§6): the cache additionally filters those to the vantage that minted them, so attaching one cache to clients for two different registries does not let registry A’s attestation apply to a context served by registry B.

A recommended ceiling, not enforced: RFC-ACDP-0006 §4.2 caps WebResolver’s own DID-document cache TTL at 3600 s, and that is a reasonable order-of-magnitude anchor for this field too — this crate already accepts bounded key-material staleness at that order. Left unenforced deliberately: a hard cap on a knob whose safe default is ZERO would add a failure mode without adding safety.

Implementations§

Source§

impl RevocationDiscovery

Source

pub fn producer_signed_only() -> Self

Discover producer-signed revocations only (include_registry_attested: false). Cheapest of the two constructors, and the default choice for callers that are not specifically defending against a producer that has lost every signing key — see the “No Default” section above for who must NOT stop here.

Source

pub fn all_trust_classes() -> Self

Discover both trust classes: producer-signed AND registry-attested (include_registry_attested: true). Required to catch RFC-ACDP-0014 §6’s “lost-everything” fallback, where a producer with no signing key left can only be revoked registry-attested. Requires the registry to serve a capabilities document.

Trait Implementations§

Source§

impl Clone for RevocationDiscovery

Source§

fn clone(&self) -> Self

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Copy for RevocationDiscovery

Source§

impl Debug for RevocationDiscovery

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Eq for RevocationDiscovery

Source§

impl PartialEq for RevocationDiscovery

Source§

fn eq(&self, other: &Self) -> bool

Equality operator ==. Read more
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Inequality operator !=. Read more
Source§

impl StructuralPartialEq for RevocationDiscovery

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<Q, K> Equivalent<K> for Q
where Q: Eq + ?Sized, K: Borrow<Q> + ?Sized,

Source§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self> ⓘ

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self> ⓘ

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> PolicyExt for T
where T: ?Sized,

Source§

fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow only if self and other return Action::Follow. Read more
Source§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow if either self or other returns Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self> ⓘ
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self> ⓘ

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more