Skip to main content

aph_core/
credential_status.rs

1//! Revocation status transport — the parsing and decision half (spec §6.3.3).
2//!
3//! §6.3.1 makes revocation normative and §6.3.2 makes expiry normative, but
4//! only expiry is readable from an envelope alone. This module is the
5//! machinery a third-party recipient uses to learn, at verification time,
6//! that the parent Delegation Mandate was withdrawn: the status entry that
7//! travels on the envelope (§6.3.3.1), the endpoint the verifier DERIVES
8//! from the notary's own `did:web` (§6.3.3.2), the status list credential
9//! served there (§6.3.3.3), and the three-outcome decision of §6.3.3.4.
10//!
11//! **The whole security argument is one sentence: the envelope does not get
12//! to choose which host answers "is this mandate revoked".** The origin is
13//! derived from `credentialSubject.notarization.notaryService.id`; a
14//! `statusListCredential` carried in the envelope is bound same-origin
15//! against that derivation and REFUSED WITHOUT BEING FETCHED when it is not
16//! (§6.3.3.2). Whoever holds an old envelope would otherwise also choose the
17//! host, and a host of the attacker's choosing always answers *not revoked*.
18//!
19//! **Fetching still happens elsewhere.** As with [`crate::discovery`], this
20//! crate parses and decides; the one HTTPS GET arrives through
21//! [`crate::discovery::ports::StatusCredentialFetch`], the sibling port
22//! declared beside the `did:web` document port. That is why every rule below
23//! is exercised against fixed strings with no network.
24//!
25//! **One producer rule lives here too**, against the "parsing and decision"
26//! framing above: [`STATUS_REPUBLISH_INTERVAL_SECONDS`] is §6.3.3.3's cadence
27//! MUST, and it is held beside the verifier's two bounds because publisher
28//! and verifier have to agree on those three numbers to the second. A
29//! publisher that re-derives the interval from prose is a second expression
30//! of a normative value, which is how the two sides drift apart.
31//!
32//! ## Two obligations this module deliberately does NOT discharge
33//!
34//! 1. **⛔ The status list credential's own proof is NOT verified here.**
35//!    §6.3.3.3 requires it, and it is load-bearing rather than decorative:
36//!    same-origin permits a DIFFERENT PATH (that is how a notary points at a
37//!    second list, §6.3.3.6), so a host with any writable path — an upload
38//!    directory, a user-content route — lets an attacker publish a forged
39//!    list that satisfies same-origin AND simply writes the notary's DID
40//!    into its own `issuer`. Only the signature closes that. Verifying it
41//!    needs the notary's key, and resolving a key needs the two §8.4
42//!    discovery ports and the host's cache — state this crate does not hold,
43//!    and a key this crate must not GUESS (the envelope's key is the one
44//!    valid at `decisionTimestamp` per §8.4.7, which after a rotation is not
45//!    the key the CURRENT list is signed with). The caller therefore owns
46//!    the step, and [`status_list_signing_base`] plus
47//!    [`StatusListCredential::proof_verification_method`] are provided so it
48//!    is a few lines rather than a re-derivation.
49//! 2. **The GZIP expansion of `encodedList` is supplied by the caller.**
50//!    `aph-core`'s eight dependencies are serde, serde_json, thiserror,
51//!    chrono, two signature primitives (`ed25519-dalek`, `p256`) and two
52//!    encoders (`bs58`, `base64`) — there is no compression codec among
53//!    them, while the pinned vintage (§14.1, W3C Bitstring Status List
54//!    v1.0) mandates GZIP. Rather than vendor a decompressor into a
55//!    security-critical parse path or mint a second port for what is a pure
56//!    codec and not a domain seam, [`check_credential_status`] takes the
57//!    expansion as a plain function argument. It is called LAST — after
58//!    same-origin binding, issuer binding, purpose and freshness have all
59//!    passed — so a caller's decompressor never sees bytes from an origin
60//!    the verifier did not derive.
61
62/// `credentialStatus.type` — the pinned entry type of §6.3.3.1.
63pub const STATUS_ENTRY_TYPE: &str = "BitstringStatusListEntry";
64
65/// The only `statusPurpose` APH defines (§6.3.3.5).
66pub const STATUS_PURPOSE_REVOCATION: &str = "revocation";
67
68/// The `type` a status list credential must carry (§6.3.3.3).
69pub const STATUS_LIST_CREDENTIAL_TYPE: &str = "BitstringStatusListCredential";
70
71/// The `type` the status list credential's subject carries, when it carries
72/// one (§6.3.3.3 leaves the member to the W3C profile).
73pub const STATUS_LIST_SUBJECT_TYPE: &str = "BitstringStatusList";
74
75/// Last path segment of the derived status endpoint (§6.3.3.2 step 2), the
76/// counterpart of `did.json` in §8.4.4 step 2.
77pub const STATUS_ENDPOINT_LEAF: &str = "aph-status.json";
78
79/// §6.3.3.3 freshness bound: a status list credential issued more than this
80/// many seconds before `now` MUST NOT be accepted.
81pub const STATUS_MAX_AGE_SECONDS: i64 = 300;
82
83/// §6.3.3.3 clock-skew tolerance, the same 60 seconds §8.3 step 6 allows.
84/// Applied in BOTH directions: it widens the staleness bound and it is the
85/// only amount by which a credential may be dated into the future.
86pub const STATUS_CLOCK_SKEW_SECONDS: i64 = 60;
87
88/// §6.3.3.3 producer cadence: a notary that publishes status MUST re-issue
89/// and republish at an interval no greater than this.
90///
91/// The verifier's two bounds above were already constants here; this one was
92/// only ever prose, which left every publisher to copy the number out of the
93/// specification — a second expression of a normative value, in the one
94/// mechanism where publisher and verifier must agree to the second.
95///
96/// It is deliberately less than half of [`STATUS_MAX_AGE_SECONDS`], and that
97/// margin is what makes the pair a system rather than a coincidence: a
98/// publisher that misses a cycle is LATE, not down, and a verifier still
99/// finds a credential inside the bound. [`StatusListCredential::seconds_until_republish_due`]
100/// is the margin read as a number an operator can watch.
101pub const STATUS_REPUBLISH_INTERVAL_SECONDS: i64 = 120;
102
103/// Multibase prefix for base64url-no-pad, the encoding `encodedList` uses.
104pub const MULTIBASE_BASE64URL_PREFIX: char = 'u';
105
106/// Hard cap on the expanded bitstring this module will index into.
107///
108/// 1 MiB addresses 8,388,608 mandates — orders of magnitude past any notary
109/// this specification contemplates — so a list larger than this is a
110/// malformed or hostile document rather than a big deployment.
111///
112/// ⛔ WHERE THIS IS AND IS NOT A BOMB DEFENCE. The expansion happens in a
113/// caller-supplied decompressor ([`ExpandEncodedList`]), so this constant can
114/// only be a real memory bound at the place that produces the bytes: the
115/// contract on that alias makes stopping at this cap a MUST on the expander.
116/// The check inside [`revocation_bit`] runs on a `Vec` that already exists
117/// and therefore refuses an over-long list AFTER the allocation — a
118/// correctness backstop, not protection against a decompression bomb. Stating
119/// the split matters because the opposite claim was written here first, and a
120/// verifier that believed it would run an unbounded codec over unbounded
121/// network bytes on the strength of a comment.
122pub const MAX_EXPANDED_LIST_BYTES: usize = 1024 * 1024;
123
124/// `credentialStatus.type`, a closed set of exactly one member (§6.3.3.1).
125///
126/// Modelled as an enum rather than a `String` so an unrecognized value is a
127/// hard deserialization error — which is precisely where §6.3.3.1 routes it,
128/// §7.1's strict parse at §8.3 step 1.
129#[derive(
130  std::fmt::Debug,
131  std::clone::Clone,
132  std::marker::Copy,
133  std::cmp::PartialEq,
134  std::cmp::Eq,
135  serde::Serialize,
136  serde::Deserialize,
137)]
138pub enum StatusEntryType {
139  /// The W3C Bitstring Status List v1.0 entry type, the vintage §14.1 pins.
140  #[serde(rename = "BitstringStatusListEntry")]
141  BitstringStatusListEntry,
142}
143
144/// `credentialStatus.statusPurpose`, a closed set of exactly one member
145/// (§6.3.3.5).
146///
147/// `"suspension"` is deliberately absent: suspension is reversible and
148/// §6.3.2 forbids re-activation, so admitting it would put a lifecycle in
149/// the transport that the mandate itself does not have.
150///
151/// Modelled as an enum for a security reason §6.3.3.5 states outright: a
152/// verifier MUST NOT read a purpose it does not recognize as "no status
153/// claim was made", because a producer could then disable the check on any
154/// verifier by writing a word that verifier has never seen. An enum makes
155/// that opt-out unconstructible — the envelope fails to parse at all.
156#[derive(
157  std::fmt::Debug,
158  std::clone::Clone,
159  std::marker::Copy,
160  std::cmp::PartialEq,
161  std::cmp::Eq,
162  serde::Serialize,
163  serde::Deserialize,
164)]
165pub enum StatusPurpose {
166  /// The mandate's authority was withdrawn by the human who granted it.
167  #[serde(rename = "revocation")]
168  Revocation,
169}
170
171/// The status reference an envelope carries at top-level `credentialStatus`
172/// (spec §6.3.3.1, §7.1.1).
173///
174/// **Whose status this is.** In W3C VC 2.0 `credentialStatus` describes the
175/// credential carrying it. In APH it describes the **parent Delegation
176/// Mandate** named by `credentialSubject.policy.delegationMandateId`: an
177/// issued envelope stays cryptographically valid whatever happens
178/// afterwards, and revocation applies to Delegation Mandates only.
179#[derive(
180  std::fmt::Debug,
181  std::clone::Clone,
182  std::cmp::PartialEq,
183  std::cmp::Eq,
184  serde::Serialize,
185  serde::Deserialize,
186)]
187#[serde(rename_all = "camelCase", deny_unknown_fields)]
188pub struct CredentialStatusEntry {
189  /// Identifier for this status entry. Omitted when absent.
190  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
191  pub id: std::option::Option<String>,
192  /// Always [`StatusEntryType::BitstringStatusListEntry`].
193  #[serde(rename = "type")]
194  pub r#type: StatusEntryType,
195  /// Always [`StatusPurpose::Revocation`].
196  pub status_purpose: StatusPurpose,
197  /// This mandate's position in the list, a base-10 integer **in a JSON
198  /// string** (§6.3.3.6).
199  ///
200  /// A `String` on the wire AND in Rust, never a numeric type. In runtimes
201  /// where every JSON number is an IEEE-754 double, an index past 2^53 is
202  /// silently rounded — and a rounded index does not raise a parse error, it
203  /// reads a DIFFERENT BIT, so the verifier answers a question about some
204  /// other mandate with full confidence. Typing the field `String` makes a
205  /// JSON number a deserialization failure instead of a wrong answer.
206  /// [`Self::index`] is the only way to a number, and it goes through
207  /// `u64`.
208  pub status_list_index: String,
209  /// Absolute `https:` URL of the status list credential. Bound same-origin
210  /// against the derived endpoint before it is ever fetched (§6.3.3.2).
211  pub status_list_credential: String,
212}
213
214impl CredentialStatusEntry {
215  /// Parses [`Self::status_list_index`] as a base-10 `u64` (§6.3.3.6).
216  ///
217  /// # Errors
218  ///
219  /// `APH_E008` for anything that is not a base-10 integer inside `u64`.
220  /// §6.3.3.1 routes a malformed status reference to §8.3 step 1, but the
221  /// §11 taxonomy is a CLOSED set of seventeen with no "malformed envelope"
222  /// code; `APH_E008` is §6.3.3.4 case 2 — "the verifier could not establish
223  /// the status" — which is literally what an unreadable index leaves. The
224  /// envelope is refused either way; only the reported code differs.
225  pub fn index(&self) -> std::result::Result<u64, crate::errors::AphError> {
226    // Rejected explicitly rather than left to `from_str`: `u64::from_str`
227    // accepts a leading `+`, and a sign on a bit position is not a base-10
228    // integer in the sense §6.3.3.6 means.
229    if self.status_list_index.is_empty()
230      || !self.status_list_index.bytes().all(|b| b.is_ascii_digit())
231    {
232      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
233    }
234    match self.status_list_index.parse::<u64>() {
235      std::result::Result::Ok(index) => std::result::Result::Ok(index),
236      // Overflow past u64 is unreachable for any real list but must not
237      // wrap or panic: it is simply an index no published list contains.
238      std::result::Result::Err(_) => {
239        std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
240      }
241    }
242  }
243}
244
245/// A status list credential fetched from the notary's own origin
246/// (spec §6.3.3.3).
247///
248/// Deliberately NOT `deny_unknown_fields`, for the same reason
249/// [`crate::discovery::did_document::DidDocument`] is not: this is a general
250/// W3C artifact that legitimately carries more than APH reads (`name`,
251/// `description`, `ttl`, `validUntil`, `renderMethod`). Rejecting those
252/// would make APH unable to consume ordinary status list credentials. The
253/// ENVELOPE's own [`CredentialStatusEntry`] is strict, because that one is
254/// APH's wire shape and is covered by a signature.
255#[derive(std::fmt::Debug, std::clone::Clone, serde::Serialize, serde::Deserialize)]
256pub struct StatusListCredential {
257  /// JSON-LD contexts the document declares.
258  #[serde(default, rename = "@context")]
259  pub context: std::vec::Vec<serde_json::Value>,
260  /// The credential's own identifier, when it carries one.
261  #[serde(default)]
262  pub id: std::option::Option<String>,
263  /// JSON-LD type array; MUST include [`STATUS_LIST_CREDENTIAL_TYPE`].
264  #[serde(default, rename = "type")]
265  pub r#type: std::vec::Vec<String>,
266  /// The issuing notary. W3C permits a bare DID string or an object with an
267  /// `id`, so both are kept verbatim and read through
268  /// [`Self::issuer_did`].
269  #[serde(default)]
270  pub issuer: serde_json::Value,
271  /// RFC 3339 issuance instant. The freshness bound of §6.3.3.3 is measured
272  /// from this, so a document without it cannot be accepted.
273  #[serde(default, rename = "validFrom")]
274  pub valid_from: std::option::Option<String>,
275  /// The subject carrying the purpose and the bitstring.
276  #[serde(rename = "credentialSubject")]
277  pub credential_subject: StatusListSubject,
278  /// The notary's proof over this document. Kept verbatim: this module does
279  /// not verify it (see the module preamble), it only surfaces the
280  /// verification method the caller must resolve.
281  #[serde(default)]
282  pub proof: serde_json::Value,
283}
284
285/// The `credentialSubject` of a status list credential (spec §6.3.3.3).
286#[derive(std::fmt::Debug, std::clone::Clone, serde::Serialize, serde::Deserialize)]
287pub struct StatusListSubject {
288  /// Subject identifier, when present.
289  #[serde(default)]
290  pub id: std::option::Option<String>,
291  /// Subject type; [`STATUS_LIST_SUBJECT_TYPE`] when present.
292  #[serde(default, rename = "type")]
293  pub r#type: std::option::Option<String>,
294  /// The purpose this list expresses. A `String` here, NOT the
295  /// [`StatusPurpose`] enum the envelope entry uses, and the asymmetry is
296  /// the spec's: an unrecognized purpose on the ENVELOPE is malformed wire
297  /// (§6.3.3.5, refused at §8.3 step 1), while an unrecognized purpose in
298  /// the fetched DOCUMENT is §6.3.3.4 case 2 — the status could not be
299  /// established — and carries `APH_E008`. Parsing it as an enum here would
300  /// collapse the second case into the first.
301  #[serde(rename = "statusPurpose")]
302  pub status_purpose: String,
303  /// Multibase base64url of the GZIP-compressed bitstring.
304  #[serde(rename = "encodedList")]
305  pub encoded_list: String,
306}
307
308impl StatusListCredential {
309  /// The issuing DID, whether `issuer` is a string or an object with an
310  /// `id` — both are W3C-legal and a verifier that read only one form would
311  /// refuse conformant notaries.
312  pub fn issuer_did(&self) -> std::option::Option<&str> {
313    match &self.issuer {
314      serde_json::Value::String(did) => std::option::Option::Some(did.as_str()),
315      serde_json::Value::Object(object) => object.get("id").and_then(|v| v.as_str()),
316      _ => std::option::Option::None,
317    }
318  }
319
320  /// The `proof.verificationMethod` the caller must resolve through §8.4 to
321  /// check this document's signature — the step named in the module
322  /// preamble as NOT performed here.
323  ///
324  /// `None` when the document carries no proof or no method, which the
325  /// caller MUST treat as §6.3.3.4 case 2 rather than as "nothing to check".
326  pub fn proof_verification_method(&self) -> std::option::Option<&str> {
327    match &self.proof {
328      serde_json::Value::Object(object) => {
329        object.get("verificationMethod").and_then(|v| v.as_str())
330      }
331      // A proof chain on a status list is not a shape §6.3.3.3 defines, so
332      // an array is reported as "no single method" rather than guessed at.
333      _ => std::option::Option::None,
334    }
335  }
336
337  /// Applies every §6.3.3.3 check this crate can make without a key: the
338  /// credential type, the issuer binding, the purpose, and the freshness
339  /// bound.
340  ///
341  /// `notary_did` is the DID the endpoint was DERIVED from (§6.3.3.2 step
342  /// 1), never a value read out of the document — same-origin alone does not
343  /// make a document authoritative, because one host serves many documents.
344  ///
345  /// # Errors
346  ///
347  /// `APH_E008` for every failure, per §6.3.3.4 case 2's single-code rule:
348  /// the verifier's action (reject) and the operator's remediation (repair
349  /// the notary's status surface) are identical for all of them.
350  pub fn validate(
351    &self,
352    notary_did: &str,
353    now_rfc3339: &str,
354  ) -> std::result::Result<(), crate::errors::AphError> {
355    if !self.r#type.iter().any(|t| t == STATUS_LIST_CREDENTIAL_TYPE) {
356      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
357    }
358    // Checked only when present: §6.3.3.3 names the purpose, the issuer, the
359    // proof and the freshness bound as APH's normative checks and leaves the
360    // subject's own `type` to the W3C profile, so REQUIRING it here would
361    // refuse documents that are conformant as far as APH ever said.
362    if self
363      .credential_subject
364      .r#type
365      .as_deref()
366      .is_some_and(|subject_type| subject_type != STATUS_LIST_SUBJECT_TYPE)
367    {
368      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
369    }
370    if self.credential_subject.status_purpose != STATUS_PURPOSE_REVOCATION {
371      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
372    }
373    if self.issuer_did() != std::option::Option::Some(notary_did) {
374      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
375    }
376    self.check_freshness(now_rfc3339)
377  }
378
379  /// The §6.3.3.3 freshness bound, split out so it can be pinned on its own.
380  ///
381  /// Fails CLOSED in every direction: a missing or unparseable `validFrom`,
382  /// an unparseable `now`, a credential older than
383  /// [`STATUS_MAX_AGE_SECONDS`] plus the skew allowance, and a credential
384  /// dated further into the future than the skew allowance all refuse. A
385  /// future-dated document is refused because the alternative is a notary
386  /// (or an attacker who reached its publishing pipeline) buying unbounded
387  /// staleness by writing tomorrow's date.
388  pub fn check_freshness(
389    &self,
390    now_rfc3339: &str,
391  ) -> std::result::Result<(), crate::errors::AphError> {
392    let age_seconds = self.age_seconds(now_rfc3339)?;
393    if age_seconds > STATUS_MAX_AGE_SECONDS + STATUS_CLOCK_SKEW_SECONDS {
394      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
395    }
396    if age_seconds < -STATUS_CLOCK_SKEW_SECONDS {
397      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
398    }
399    std::result::Result::Ok(())
400  }
401
402  /// Seconds remaining before this document must have been REPLACED by a
403  /// freshly issued one, per [`STATUS_REPUBLISH_INTERVAL_SECONDS`]. Negative
404  /// once the producer is late.
405  ///
406  /// This is the PUBLISHER's line, and it is the one worth alarming on. A
407  /// publication pipeline that stops fails closed — correct, and silent: the
408  /// first symptom is a stranger's verifier refusing an envelope, which is
409  /// the far end of the outage to learn at. The deadline is exposed as a
410  /// distance rather than as a verdict so an operator can watch it approach
411  /// instead of discovering it has passed.
412  ///
413  /// # Errors
414  ///
415  /// `APH_E008` for the same timestamp failures [`Self::check_freshness`]
416  /// refuses on — a missing or unparseable `validFrom`, or an unparseable
417  /// `now`. A distance is not reported from a clock that could not be read,
418  /// because a monitor would display it as time remaining.
419  pub fn seconds_until_republish_due(
420    &self,
421    now_rfc3339: &str,
422  ) -> std::result::Result<i64, crate::errors::AphError> {
423    std::result::Result::Ok(STATUS_REPUBLISH_INTERVAL_SECONDS - self.age_seconds(now_rfc3339)?)
424  }
425
426  /// Seconds remaining before a conformant verifier begins REFUSING this
427  /// document under §6.3.3.3's freshness bound. Negative once they do.
428  ///
429  /// The same bound [`Self::check_freshness`] applies, read as a distance
430  /// rather than as a verdict — the outage line, where
431  /// [`Self::seconds_until_republish_due`] is the alarm line.
432  ///
433  /// # Errors
434  ///
435  /// `APH_E008`, on the same inputs and for the same reason as
436  /// [`Self::seconds_until_republish_due`].
437  pub fn seconds_until_stale(
438    &self,
439    now_rfc3339: &str,
440  ) -> std::result::Result<i64, crate::errors::AphError> {
441    std::result::Result::Ok(
442      STATUS_MAX_AGE_SECONDS + STATUS_CLOCK_SKEW_SECONDS - self.age_seconds(now_rfc3339)?,
443    )
444  }
445
446  /// Age of this document at `now_rfc3339`, in seconds; negative when the
447  /// document is dated into the future.
448  ///
449  /// Every timestamp failure is `APH_E008` because §6.3.3.4 case 2 gives the
450  /// whole mechanism one code. Shared by the freshness verdict and the two
451  /// deadline distances so those three can never disagree about how old a
452  /// document is.
453  fn age_seconds(
454    &self,
455    now_rfc3339: &str,
456  ) -> std::result::Result<i64, crate::errors::AphError> {
457    let issued = match self.valid_from.as_deref() {
458      std::option::Option::Some(value) => value,
459      std::option::Option::None => {
460        return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
461      }
462    };
463    let issued = match chrono::DateTime::parse_from_rfc3339(issued) {
464      std::result::Result::Ok(t) => t,
465      std::result::Result::Err(_) => {
466        return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
467      }
468    };
469    let now = match chrono::DateTime::parse_from_rfc3339(now_rfc3339) {
470      std::result::Result::Ok(t) => t,
471      std::result::Result::Err(_) => {
472        return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
473      }
474    };
475    std::result::Result::Ok((now - issued).num_seconds())
476  }
477}
478
479/// Parses a status list credential from the bytes an adapter fetched.
480///
481/// # Errors
482///
483/// `APH_E008` — a document that will not parse is indistinguishable, from a
484/// verifier's seat, from a status surface that could not be reached
485/// (§6.3.3.4 case 2). This mirrors
486/// [`crate::discovery::did_document::parse_did_document`].
487pub fn parse_status_list_credential(
488  json: &str,
489) -> std::result::Result<StatusListCredential, crate::errors::AphError> {
490  match serde_json::from_str::<StatusListCredential>(json) {
491    std::result::Result::Ok(credential) => std::result::Result::Ok(credential),
492    std::result::Result::Err(_) => {
493      std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
494    }
495  }
496}
497
498/// The canonical bytes a status list credential's proof covers: the document
499/// with its `proof` member removed, JCS-canonicalized.
500///
501/// Provided because this module does not verify that proof (module
502/// preamble): the caller resolves the notary key through §8.4 and checks the
503/// signature over exactly these bytes. Deriving the base a second time in
504/// the host is how signer and verifier drift apart, which is the reason
505/// [`crate::crypto::proof_base`] exists for envelopes.
506///
507/// # Errors
508///
509/// `APH_E008` when the body is not a JSON object.
510pub fn status_list_signing_base(
511  raw_json: &str,
512) -> std::result::Result<String, crate::errors::AphError> {
513  let mut value = match serde_json::from_str::<serde_json::Value>(raw_json) {
514    std::result::Result::Ok(v) => v,
515    std::result::Result::Err(_) => {
516      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
517    }
518  };
519  match value.as_object_mut() {
520    std::option::Option::Some(object) => {
521      object.remove("proof");
522    }
523    std::option::Option::None => {
524      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
525    }
526  }
527  std::result::Result::Ok(crate::crypto::jcs::canonicalize_rfc8785(&value))
528}
529
530/// §6.3.3.3 — verifies the status list's OWN proof against a known key.
531///
532/// ⛔ WHY THIS IS NOT OPTIONAL, AND WHY THE KEY IS A REQUIRED ARGUMENT RATHER
533/// THAN A DOC COMMENT ASKING NICELY: a status list that is not signature-checked
534/// is an unauthenticated assertion about whether somebody's authority is still
535/// valid. An attacker who can write to the status endpoint — or who can answer
536/// for it — would otherwise flip a revoked mandate back to live, and the
537/// revocation transport would become the mechanism by which a revoked agent
538/// proves it is fine. Earlier drafts documented this as "the caller's step";
539/// documentation is prose, and prose is the weakest rung of
540/// `type > sealed port > tripwire > prose`. Taking the key by value makes the
541/// check unskippable: there is no way to reach a verdict without supplying the
542/// key that authenticates it.
543///
544/// The caller already HAS this key. It must resolve the issuer's key through
545/// §8.4 to verify the envelope itself before a status question is even
546/// meaningful, so this consumes that resolution rather than performing a second
547/// one (§3.4) — and no new port is introduced to fetch it.
548///
549/// # Errors
550///
551/// `APH_E008` when the document carries no proof, when the proof value is
552/// malformed, or when the signature does not verify — all §6.3.3.4 case-2
553/// outcomes, which the caller REFUSES rather than admits.
554pub fn verify_status_list_proof(
555  raw_json: &str,
556  credential: &StatusListCredential,
557  key: &ed25519_dalek::VerifyingKey,
558) -> std::result::Result<(), crate::errors::AphError> {
559  let proof_value = match &credential.proof {
560    serde_json::Value::Object(object) => match object.get("proofValue").and_then(|v| v.as_str()) {
561      std::option::Option::Some(value) if !value.is_empty() => value,
562      // No proof, or an empty one, is an UNSIGNED document. §6.3.3.4 case 2
563      // names it explicitly; it is never "nothing to check".
564      _ => return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable),
565    },
566    // A proof chain is not a shape §6.3.3.3 defines for a status list. Refusing
567    // beats guessing which link would have been authoritative.
568    _ => return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable),
569  };
570  let raw = match crate::crypto::multibase::base58btc_decode(proof_value) {
571    std::result::Result::Ok(bytes) => bytes,
572    std::result::Result::Err(_) => {
573      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
574    }
575  };
576  let bytes: [u8; 64] = match raw.as_slice().try_into() {
577    std::result::Result::Ok(b) => b,
578    std::result::Result::Err(_) => {
579      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
580    }
581  };
582  let signature = ed25519_dalek::Signature::from_bytes(&bytes);
583  // Signed over the SAME canonical base the issuer signed: the document with
584  // its own `proof` member removed, JCS-canonicalised.
585  let canonical = status_list_signing_base(raw_json)?;
586  match ed25519_dalek::Verifier::verify(key, canonical.as_bytes(), &signature) {
587    std::result::Result::Ok(()) => std::result::Result::Ok(()),
588    std::result::Result::Err(_) => {
589      std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
590    }
591  }
592}
593
594/// Decodes `encodedList` to the GZIP stream it wraps (§6.3.3.3).
595///
596/// The value is MULTIBASE — a `u` prefix naming base64url-no-pad — and the
597/// prefix is REQUIRED rather than tolerated as optional. §14.1 pins the
598/// vintage precisely because a value that two vintages read differently
599/// looks conformant to both and interoperates with neither; the earlier
600/// Status List 2021 shape wrote bare base64url at this position, and quietly
601/// accepting it would mean silently reading a different specification's
602/// document.
603///
604/// The GZIP magic is checked here, before the bytes are handed to a
605/// caller-supplied decompressor, so that codec is never asked to read a
606/// format nobody agreed on.
607///
608/// # Errors
609///
610/// `APH_E008` for a missing prefix, an undecodable body, or bytes that are
611/// not a GZIP stream.
612pub fn decode_encoded_list(
613  encoded: &str,
614) -> std::result::Result<std::vec::Vec<u8>, crate::errors::AphError> {
615  let body = match encoded.strip_prefix(MULTIBASE_BASE64URL_PREFIX) {
616    std::option::Option::Some(body) => body,
617    std::option::Option::None => {
618      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
619    }
620  };
621  let bytes = match crate::crypto::base64url::decode(body) {
622    std::result::Result::Ok(bytes) => bytes,
623    std::result::Result::Err(_) => {
624      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
625    }
626  };
627  // RFC 1952 §2.3.1: every GZIP member begins 0x1f 0x8b.
628  if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
629    return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
630  }
631  std::result::Result::Ok(bytes)
632}
633
634/// Reads the revocation bit at `index` from an EXPANDED bitstring
635/// (§6.3.3.4 case 3).
636///
637/// Bit order is the W3C profile's: index 0 is the MOST significant bit of
638/// the first byte. Getting this backwards reads a real bit belonging to a
639/// different mandate rather than failing, so it is pinned by test.
640///
641/// # Errors
642///
643/// `APH_E008` when the list is too short to contain `index`, or larger than
644/// [`MAX_EXPANDED_LIST_BYTES`]. §6.3.3.4 case 2 names "its list is too short
645/// to contain `statusListIndex`" explicitly: a verifier that read a missing
646/// bit as `0` would treat a truncated list as a blanket "nothing is
647/// revoked".
648///
649/// The over-long half of that is a BACKSTOP and nothing more: `expanded_list`
650/// is already allocated by the time this function sees it, so the memory
651/// bound has to be honoured by the expander ([`ExpandEncodedList`]) and this
652/// check only stops an implausible list from being indexed.
653pub fn revocation_bit(
654  expanded_list: &[u8],
655  index: u64,
656) -> std::result::Result<bool, crate::errors::AphError> {
657  if expanded_list.len() > MAX_EXPANDED_LIST_BYTES {
658    return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
659  }
660  let byte_offset = index / 8;
661  // `u64 -> usize` is lossy on a 32-bit target, and a wrapped offset would
662  // index the WRONG mandate rather than fail, so the conversion is checked.
663  let byte_offset = match usize::try_from(byte_offset) {
664    std::result::Result::Ok(offset) => offset,
665    std::result::Result::Err(_) => {
666      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
667    }
668  };
669  let byte = match expanded_list.get(byte_offset) {
670    std::option::Option::Some(byte) => *byte,
671    std::option::Option::None => {
672      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
673    }
674  };
675  let bit_in_byte = (index % 8) as u32;
676  let mask = 0x80u8 >> bit_in_byte;
677  std::result::Result::Ok(byte & mask != 0)
678}
679
680/// The two non-refusing outcomes of §6.3.3.4. The third outcome — revoked —
681/// is an `APH_E015` error, not a value, because a verifier that could hold
682/// "revoked" as a success value could forget to act on it.
683#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
684pub enum StatusCheck {
685  /// §6.3.3.4 case 1 — the envelope carried no `credentialStatus`, so no
686  /// claim was offered and none was checked. NOT the same as "not revoked".
687  Skipped,
688  /// §6.3.3.4 case 3, negative — the bit at `statusListIndex` was `0` **in a
689  /// document whose proof this crate did NOT check**.
690  ///
691  /// ⛔ The qualifier is part of the value, not a footnote, because this is
692  /// the exact variant a caller branches on to ADMIT an envelope. §6.3.3.3's
693  /// proof check is the only thing standing between "a bit was clear" and
694  /// "the notary says this mandate is live": same-origin deliberately permits
695  /// a DIFFERENT PATH (§6.3.3.2), so any writable path on the notary's origin
696  /// can serve a forged list that satisfies same-origin and writes the
697  /// notary's DID into its own `issuer`. Verifying the proof is the caller's
698  /// step for the reasons in the module preamble; until it has run,
699  /// `NotRevoked` means only that the document the caller was handed said so.
700  /// [`check_envelope_status`] carries the obligation in full.
701  NotRevoked,
702}
703
704/// The GZIP-then-base64url expansion of a status list's `encodedList`,
705/// supplied by the caller rather than performed here.
706///
707/// WHY A CALLER-SUPPLIED CLOSURE AND NOT A DEPENDENCY: this crate has no
708/// compression dependency and deliberately keeps none — it is linked into a
709/// wasm binding and into a kernel that both pay for every byte. The expansion
710/// is a pure `&[u8] -> Vec<u8>` transform with no protocol content, so it
711/// belongs to whoever already has an inflate implementation.
712///
713/// WHY AN ALIAS: the same signature appeared inline in two public functions,
714/// which is the duplication `clippy::type_complexity` exists to catch —
715/// spelling one contract twice invites the two spellings to drift (§3.4).
716///
717/// ⛔ THE CALLER'S OBLIGATION — the byte cap lives HERE, not downstream. An
718/// implementation of this contract MUST stop and return `APH_E008` as soon
719/// as the output it would produce exceeds [`MAX_EXPANDED_LIST_BYTES`],
720/// rather than expanding fully and letting a caller measure afterwards. This
721/// is the only place the cap can actually bound anything: by the time
722/// [`check_credential_status`] holds a `Vec`, the allocation has already
723/// happened, so the length check inside [`revocation_bit`] is a backstop
724/// against an over-long list and NOT a defence against a decompression bomb.
725/// The shape that satisfies this is an inflate reader with a budget on the
726/// DECOMPRESSED side (`std::io::Read::take` over the decoder, never over the
727/// compressed input, whose length says nothing about the expansion).
728///
729/// `Send + Sync` for exactly the reason
730/// [`crate::discovery::ports::DiscoveryFuture`] carries `+ Send`: adapters
731/// run under a host executor that moves tasks between threads and
732/// `tokio::spawn` demands it. A bare `dyn Fn` has neither auto trait, and a
733/// `&ExpandEncodedList<'_>` without them makes the futures of BOTH public
734/// entry points below `!Send` — unspawnable by every real caller, and broken
735/// in the host crate far from the line that caused it.
736pub type ExpandEncodedList<'a> =
737  dyn std::ops::Fn(&[u8]) -> std::result::Result<std::vec::Vec<u8>, crate::errors::AphError>
738    + std::marker::Send
739    + std::marker::Sync
740    + 'a;
741
742/// §8.3 step 8a over a whole envelope: the §6.3.3.4 trichotomy MINUS the
743/// §6.3.3.3 proof check, which is the caller's — read the ⛔ below BEFORE
744/// wiring this into a verifier.
745///
746/// Absent ⇒ [`StatusCheck::Skipped`] with no error and no I/O. Present ⇒ the
747/// derived endpoint is computed from the envelope's OWN notary DID, the
748/// carried `statusListCredential` is bound same-origin against it, the
749/// document is fetched, checked as far as a KEYLESS check reaches
750/// ([`StatusListCredential::validate`]) and indexed, and a set bit is
751/// `APH_E015`. Every case-2 outcome reachable without a key — no derivable
752/// origin, an unreachable, oversized or unparseable document, a wrong
753/// `issuer`, `statusPurpose`, freshness or index — is `APH_E008`.
754///
755/// ⛔ THE §6.3.3.3 PROOF CHECK IS NOT PERFORMED HERE. This function never
756/// reads `proof`: an entirely UNSIGNED status list whose other members are
757/// well formed returns `Ok(StatusCheck::NotRevoked)`, and the test
758/// `a_clear_bit_admits_the_envelope` pins exactly that. §6.3.3.4 case 2
759/// enumerates "its proof did not verify" among its `APH_E008` outcomes, so
760/// **a caller that admits an envelope on this function's `Ok` without
761/// verifying that proof has NOT implemented §6.3.3.4 case 2.** The
762/// gap is exploitable rather than theoretical: same-origin deliberately
763/// permits a DIFFERENT PATH (§6.3.3.2), so any writable path on the notary's
764/// origin serves a forged list that satisfies same-origin and writes the
765/// notary's DID into its own `issuer`, and only the signature closes it. The
766/// module preamble states why the step lives in the caller (it needs a §8.4
767/// key resolution and a rotation-aware cache this crate must not hold);
768/// [`status_list_signing_base`] yields the exact bytes the signature covers
769/// and [`StatusListCredential::proof_verification_method`] the method to
770/// resolve, so discharging it is a few lines rather than a re-derivation.
771/// Note what this arm hands back: a decision, never the document, so the
772/// check cannot be bolted onto this signature after the fact — the caller
773/// needs the body in its own hands. Nor may it be hidden inside the
774/// fetch adapter: [`crate::discovery::ports::StatusCredentialFetch`]
775/// contracts an adapter to form no opinion about which document is
776/// authoritative, and a proof check is exactly that opinion.
777///
778/// `expand_encoded_list` decompresses the GZIP stream — see the module
779/// preamble for why the codec is the caller's, and [`ExpandEncodedList`] for
780/// the byte cap the caller owes along with it. It is invoked only after
781/// every other check has passed.
782///
783/// # Errors
784///
785/// `APH_E015` when the parent mandate's bit is set; `APH_E008` for every
786/// §6.3.3.4 case-2 outcome this function can detect WITHOUT a key — the
787/// proof is NOT among them, per the ⛔ above — including an envelope that
788/// carries a status reference but no `delegationMandateId` for it to be the
789/// status OF (§6.3.3.1).
790pub async fn check_envelope_status(
791  envelope: &crate::envelope::NotarizationEnvelope,
792  fetch: &dyn crate::discovery::ports::StatusCredentialFetch,
793  expand_encoded_list: &ExpandEncodedList<'_>,
794  issuer_key: &ed25519_dalek::VerifyingKey,
795  now_rfc3339: &str,
796) -> std::result::Result<StatusCheck, crate::errors::AphError> {
797  let entry = match envelope.credential_status.as_ref() {
798    std::option::Option::Some(entry) => entry,
799    // §6.3.3.4 case 1. Returning BEFORE touching the port is the whole
800    // point: enforcing a claim nobody made is not fail-closed, it is
801    // fail-arbitrary, and it would refuse every conformant pre-revision
802    // envelope.
803    std::option::Option::None => return std::result::Result::Ok(StatusCheck::Skipped),
804  };
805  let mandate_id = match envelope
806    .credential_subject
807    .policy
808    .delegation_mandate_id
809    .as_deref()
810  {
811    std::option::Option::Some(id) => id,
812    // §6.3.3.1: an envelope carrying `credentialStatus` MUST also carry a
813    // non-null `delegationMandateId`. The spec routes this to §8.3 step 1 as
814    // malformed wire; the §11 taxonomy has no malformed-envelope code, so it
815    // lands as case 2 — a status reference with no subject leaves the status
816    // unestablished, which is what `APH_E008` says.
817    std::option::Option::None => {
818      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
819    }
820  };
821  check_credential_status(
822    entry,
823    mandate_id,
824    envelope
825      .credential_subject
826      .notarization
827      .notary_service
828      .id
829      .as_str(),
830    fetch,
831    expand_encoded_list,
832    issuer_key,
833    now_rfc3339,
834  )
835  .await
836}
837
838/// [`check_envelope_status`] with the three envelope-derived inputs passed
839/// explicitly, for a caller that holds them without holding an envelope.
840///
841/// `notary_service_id` MUST be the notary DID from
842/// `credentialSubject.notarization.notaryService.id` (§6.3.3.2 step 1) — the
843/// party whose key signed the mandate. Passing anything the envelope's
844/// status entry influenced would hand the attacker back the origin choice
845/// this whole module exists to take away.
846///
847/// # Errors
848///
849/// As [`check_envelope_status`] — INCLUDING its ⛔: the status list's
850/// §6.3.3.3 proof is not verified here either, so a caller that admits on
851/// this function's `Ok` without checking that proof has not implemented
852/// §6.3.3.4 case 2.
853pub async fn check_credential_status(
854  entry: &CredentialStatusEntry,
855  delegation_mandate_id: &str,
856  notary_service_id: &str,
857  fetch: &dyn crate::discovery::ports::StatusCredentialFetch,
858  expand_encoded_list: &ExpandEncodedList<'_>,
859  issuer_key: &ed25519_dalek::VerifyingKey,
860  now_rfc3339: &str,
861) -> std::result::Result<StatusCheck, crate::errors::AphError> {
862  // Read the index BEFORE any network work: an unreadable index makes the
863  // fetch pointless, and §6.3.3.6's whole argument is that a bad index must
864  // never reach a bit lookup.
865  let index = entry.index()?;
866
867  // §6.3.3.2 step 1-2. A notary whose id is not a `did:web` derives no
868  // origin, so the same-origin rule cannot be satisfied and case 2 applies.
869  let derived = match crate::discovery::DidUrl::parse(notary_service_id).web_status_url() {
870    std::option::Option::Some(url) => url,
871    std::option::Option::None => {
872      return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
873    }
874  };
875
876  // §6.3.3.2 same-origin binding. This runs BEFORE the port is touched and
877  // the function returns without touching it — "MUST reject the envelope and
878  // MUST NOT fetch the named URL". `same_origin` also refuses a non-`https:`
879  // URL, which is the other half of the same sentence.
880  if !crate::discovery::same_origin(&derived, &entry.status_list_credential) {
881    return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
882  }
883
884  // The bound URL is fetched, not the derived one: §6.3.3.2 permits a
885  // DIFFERENT PATH on the same origin, which is how a notary with an
886  // exhausted list points at its successor (§6.3.3.6).
887  let body = fetch
888    .fetch_status_credential(&entry.status_list_credential)
889    .await?;
890  let credential = parse_status_list_credential(&body)?;
891  credential.validate(notary_service_id, now_rfc3339)?;
892  // ⛔ §6.3.3.3 — AUTHENTICATE THE DOCUMENT BEFORE BELIEVING ONE BIT OF IT.
893  // Everything above proves the bytes came from the right ORIGIN; nothing
894  // above proves the right PARTY wrote them. Without this step an attacker
895  // who can write to the status endpoint flips a revoked mandate back to
896  // live, and the transport built to enforce revocation becomes the way a
897  // revoked agent proves it is fine. It runs BEFORE the bit lookup so a
898  // forged document can never reach an admit.
899  verify_status_list_proof(&body, &credential, issuer_key)?;
900
901  let compressed = decode_encoded_list(&credential.credential_subject.encoded_list)?;
902  let expanded = expand_encoded_list(&compressed)?;
903  if revocation_bit(&expanded, index)? {
904    return std::result::Result::Err(crate::errors::AphError::mandate_revoked(
905      delegation_mandate_id,
906    ));
907  }
908  std::result::Result::Ok(StatusCheck::NotRevoked)
909}
910
911#[cfg(test)]
912mod tests {
913  /// The notary every fixture below is issued by, and the origin every
914  /// derived endpoint therefore lands on.
915  const NOTARY_DID: &str = "did:web:aph-notary.squillo.com";
916  /// The derived endpoint for [`NOTARY_DID`] under §6.3.3.2.
917  const DERIVED_ENDPOINT: &str =
918    "https://aph-notary.squillo.com/.well-known/aph-status.json";
919  /// `validFrom` of every fresh fixture, paired with [`NOW`].
920  const ISSUED_AT: &str = "2026-05-28T00:00:00Z";
921  /// The verification instant: 30 seconds after [`ISSUED_AT`], comfortably
922  /// inside the §6.3.3.3 bound.
923  const NOW: &str = "2026-05-28T00:00:30Z";
924
925  /// A fetch port double that records every URL it was asked for, so a test
926  /// can prove a refusal happened WITHOUT a fetch rather than merely
927  /// assert an error code.
928  struct RecordingFetch {
929    body: String,
930    urls: std::sync::Mutex<std::vec::Vec<String>>,
931  }
932
933  impl RecordingFetch {
934    fn new(body: &str) -> Self {
935      Self {
936        body: String::from(body),
937        urls: std::sync::Mutex::new(std::vec::Vec::new()),
938      }
939    }
940
941    fn calls(&self) -> usize {
942      self.urls.lock().expect("test mutex is uncontended").len()
943    }
944  }
945
946  impl crate::discovery::ports::StatusCredentialFetch for RecordingFetch {
947    fn fetch_status_credential<'a>(
948      &'a self,
949      url: &'a str,
950    ) -> crate::discovery::ports::DiscoveryFuture<'a, String> {
951      std::boxed::Box::pin(async move {
952        self
953          .urls
954          .lock()
955          .expect("test mutex is uncontended")
956          .push(String::from(url));
957        let out: std::result::Result<String, crate::errors::AphError> =
958          std::result::Result::Ok(self.body.clone());
959        out
960      })
961    }
962  }
963
964  /// A port that always fails the way a dead origin does.
965  struct UnreachableFetch {
966    calls: std::sync::atomic::AtomicUsize,
967  }
968
969  impl crate::discovery::ports::StatusCredentialFetch for UnreachableFetch {
970    fn fetch_status_credential<'a>(
971      &'a self,
972      _url: &'a str,
973    ) -> crate::discovery::ports::DiscoveryFuture<'a, String> {
974      std::boxed::Box::pin(async move {
975        self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
976        let out: std::result::Result<String, crate::errors::AphError> =
977          std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
978        out
979      })
980    }
981  }
982
983  /// The stand-in for the caller's GZIP decompressor.
984  ///
985  /// Tests do not compress anything: they hand the arm a "compressed" buffer
986  /// that is a GZIP header followed by the bitstring, and this expander
987  /// strips the header. That keeps every assertion about the §6.3.3.4
988  /// DECISION rather than about a codec `aph-core` does not link, and it is
989  /// exactly the seam a real caller fills with three lines over a gzip
990  /// reader.
991  fn test_expander(
992    compressed: &[u8],
993  ) -> std::result::Result<std::vec::Vec<u8>, crate::errors::AphError> {
994    // Sliced through `get` rather than `[..]`: a decompressor handed short
995    // input must report a failure, never panic a verifier's task.
996    match compressed.get(GZIP_HEADER.len()..) {
997      std::option::Option::Some(rest) => std::result::Result::Ok(std::vec::Vec::from(rest)),
998      std::option::Option::None => {
999        std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
1000      }
1001    }
1002  }
1003
1004  /// A ten-byte RFC 1952 header: magic, DEFLATE method, no flags, zero
1005  /// mtime, no extra flags, unknown OS. Enough to satisfy the magic check
1006  /// [`super::decode_encoded_list`] performs.
1007  const GZIP_HEADER: [u8; 10] = [0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0x00, 0xff];
1008
1009  /// Builds the multibase `encodedList` value for a bitstring, wrapping it
1010  /// in the header [`test_expander`] strips back off.
1011  fn encoded_list_for(bits: &[u8]) -> String {
1012    let mut bytes = std::vec::Vec::from(GZIP_HEADER);
1013    bytes.extend_from_slice(bits);
1014    std::format!(
1015      "{}{}",
1016      super::MULTIBASE_BASE64URL_PREFIX,
1017      crate::crypto::base64url::encode(&bytes)
1018    )
1019  }
1020
1021  /// A status list credential document with the given bitstring.
1022  /// The obviously-fake seed every fixture signs with, matching the
1023  /// `[9u8; 32]`-style convention already used in `crypto::eddsa_jcs`.
1024  /// NEVER a production-shaped key.
1025  const FIXTURE_SEED: [u8; 32] = [9u8; 32];
1026
1027  /// The key a fixture is signed with, and therefore the key a passing check
1028  /// must be given.
1029  fn fixture_key() -> ed25519_dalek::VerifyingKey {
1030    ed25519_dalek::SigningKey::from_bytes(&FIXTURE_SEED).verifying_key()
1031  }
1032
1033  /// Signs `unsigned_json` the way a notary does and returns the document
1034  /// WITH its `proof` member.
1035  ///
1036  /// WHY THE FIXTURES ARE REALLY SIGNED: the check refuses an unsigned status
1037  /// list, so a fixture without a proof would make every admit-path test fail
1038  /// for the wrong reason — and a fixture whose proof were faked past the
1039  /// verifier would prove nothing at all. Signing over
1040  /// `status_list_signing_base` is what makes these tests exercise the same
1041  /// bytes a real issuer commits to.
1042  fn sign_document(unsigned_json: &str) -> String {
1043    let base = super::status_list_signing_base(unsigned_json).expect("fixture is a JSON object");
1044    let sk = ed25519_dalek::SigningKey::from_bytes(&FIXTURE_SEED);
1045    let signature = ed25519_dalek::Signer::sign(&sk, base.as_bytes());
1046    let proof_value = crate::crypto::multibase::base58btc_encode(&signature.to_bytes());
1047    let mut value: serde_json::Value =
1048      serde_json::from_str(unsigned_json).expect("fixture is valid JSON");
1049    let object = value.as_object_mut().expect("fixture is a JSON object");
1050    object.insert(
1051      String::from("proof"),
1052      serde_json::json!({
1053        "type": "DataIntegrityProof",
1054        "cryptosuite": "eddsa-jcs-2022",
1055        "proofPurpose": "assertionMethod",
1056        "verificationMethod": std::format!("{NOTARY_DID}#k1"),
1057        "proofValue": proof_value,
1058      }),
1059    );
1060    serde_json::to_string(&value).expect("re-serialising a JSON object cannot fail")
1061  }
1062
1063  fn status_document(bits: &[u8]) -> String {
1064    sign_document(&status_document_unsigned(bits))
1065  }
1066
1067  /// The document BEFORE its proof is attached — the shape a signer starts
1068  /// from, and the shape the unsigned-document refusal test needs.
1069  fn status_document_unsigned(bits: &[u8]) -> String {
1070    std::format!(
1071      r#"{{
1072  "@context": ["https://www.w3.org/ns/credentials/v2"],
1073  "id": "{endpoint}",
1074  "type": ["VerifiableCredential", "BitstringStatusListCredential"],
1075  "issuer": "{issuer}",
1076  "validFrom": "{issued}",
1077  "credentialSubject": {{
1078    "id": "{endpoint}#list",
1079    "type": "BitstringStatusList",
1080    "statusPurpose": "revocation",
1081    "encodedList": "{encoded}"
1082  }}
1083}}"#,
1084      endpoint = DERIVED_ENDPOINT,
1085      issuer = NOTARY_DID,
1086      issued = ISSUED_AT,
1087      encoded = encoded_list_for(bits)
1088    )
1089  }
1090
1091  /// An entry pointing at `url` for bit `index`.
1092  fn entry_at(url: &str, index: &str) -> super::CredentialStatusEntry {
1093    super::CredentialStatusEntry {
1094      id: std::option::Option::None,
1095      r#type: super::StatusEntryType::BitstringStatusListEntry,
1096      status_purpose: super::StatusPurpose::Revocation,
1097      status_list_index: String::from(index),
1098      status_list_credential: String::from(url),
1099    }
1100  }
1101
1102  #[test]
1103  fn entry_round_trips_and_omits_an_absent_id() {
1104    // Pins the wire shape of §6.3.3.1 in both directions. The `id` member is
1105    // OPTIONAL and Pattern A, so an entry without one must serialize with no
1106    // `id` key at all — a `null` there would change the JCS bytes of every
1107    // envelope carrying a status reference.
1108    let json = r#"{"type":"BitstringStatusListEntry","statusPurpose":"revocation","statusListIndex":"94567","statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json"}"#;
1109    let entry: super::CredentialStatusEntry =
1110      serde_json::from_str(json).expect("the §6.3.3.1 shape parses");
1111    std::assert_eq!(entry.index().unwrap(), 94567u64);
1112    let back = serde_json::to_string(&entry).expect("entry serializes");
1113    std::assert_eq!(back, json);
1114  }
1115
1116  #[test]
1117  fn a_numeric_index_is_a_parse_failure() {
1118    // §6.3.3.6's entire argument: in runtimes where every JSON number is an
1119    // IEEE-754 double an index past 2^53 rounds SILENTLY and reads a
1120    // different mandate's bit. Typing the field `String` must make the
1121    // numeric form unparseable rather than merely discouraged.
1122    let json = r#"{"type":"BitstringStatusListEntry","statusPurpose":"revocation","statusListIndex":94567,"statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json"}"#;
1123    let parsed = serde_json::from_str::<super::CredentialStatusEntry>(json);
1124    std::assert!(parsed.is_err(), "a JSON number at statusListIndex must not parse");
1125  }
1126
1127  #[test]
1128  fn an_unrecognized_purpose_is_a_parse_failure_not_a_skip() {
1129    // §6.3.3.5: a verifier that treated an unknown purpose as "no status
1130    // claim was made" would let any producer disable the check by writing a
1131    // word that verifier has never seen. The closed enum makes that opt-out
1132    // unconstructible — the envelope does not parse at all.
1133    let json = r#"{"type":"BitstringStatusListEntry","statusPurpose":"suspension","statusListIndex":"1","statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json"}"#;
1134    std::assert!(serde_json::from_str::<super::CredentialStatusEntry>(json).is_err());
1135    let wrong_type = r#"{"type":"StatusList2021Entry","statusPurpose":"revocation","statusListIndex":"1","statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json"}"#;
1136    // §14.1 pins ONE vintage: an entry from a different vintage of the same
1137    // idea must not be read as though it were this one.
1138    std::assert!(serde_json::from_str::<super::CredentialStatusEntry>(wrong_type).is_err());
1139  }
1140
1141  #[test]
1142  fn entry_rejects_an_unknown_member() {
1143    // The entry travels inside the signed envelope, which parses strictly
1144    // (§7.1). A member APH never defined must be a hard error here too, or
1145    // the strictness stops at the envelope's edge.
1146    let json = r#"{"type":"BitstringStatusListEntry","statusPurpose":"revocation","statusListIndex":"1","statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json","statusSize":2}"#;
1147    std::assert!(serde_json::from_str::<super::CredentialStatusEntry>(json).is_err());
1148  }
1149
1150  #[test]
1151  fn index_refuses_signs_and_whitespace() {
1152    // `u64::from_str` accepts a leading `+`, and a permissive index parse is
1153    // how "+1" and "1" become two spellings of one bit — a producer could
1154    // then write an index one verifier reads and another refuses.
1155    let mut entry = entry_at(DERIVED_ENDPOINT, "+1");
1156    std::assert!(entry.index().is_err());
1157    entry.status_list_index = String::from(" 1");
1158    std::assert!(entry.index().is_err());
1159    entry.status_list_index = String::from("");
1160    std::assert!(entry.index().is_err());
1161    entry.status_list_index = String::from("1_000");
1162    std::assert!(entry.index().is_err());
1163  }
1164
1165  #[test]
1166  fn revocation_bit_reads_the_most_significant_bit_first() {
1167    // W3C bit order: index 0 is the MSB of byte 0. Reversing it does not
1168    // fail, it reads a real bit belonging to a DIFFERENT mandate — a wrong
1169    // answer delivered with full confidence — so the order is pinned here
1170    // rather than trusted to the reader.
1171    let list: [u8; 2] = [0b1000_0001, 0b0000_0010];
1172    std::assert!(super::revocation_bit(&list, 0).unwrap());
1173    std::assert!(!super::revocation_bit(&list, 1).unwrap());
1174    std::assert!(super::revocation_bit(&list, 7).unwrap());
1175    std::assert!(super::revocation_bit(&list, 14).unwrap());
1176    std::assert!(!super::revocation_bit(&list, 15).unwrap());
1177  }
1178
1179  #[test]
1180  fn an_index_past_the_list_is_a_refusal_not_a_zero() {
1181    // §6.3.3.4 case 2 names a too-short list explicitly. Reading a missing
1182    // bit as `0` would turn a truncated (or maliciously trimmed) list into a
1183    // blanket "nothing is revoked".
1184    let list: [u8; 1] = [0x00];
1185    std::assert_eq!(super::revocation_bit(&list, 8).unwrap_err().code(), "APH_E008");
1186  }
1187
1188  #[test]
1189  fn encoded_list_requires_the_multibase_prefix_and_gzip_magic() {
1190    // §14.1 pins W3C Bitstring Status List v1.0, whose `encodedList` is
1191    // multibase. The predecessor vintage wrote bare base64url at the same
1192    // position, so accepting a missing prefix would mean silently reading a
1193    // different specification's document and calling it conformant.
1194    let bare = crate::crypto::base64url::encode(&[0x1f, 0x8b, 0x08]);
1195    std::assert_eq!(
1196      super::decode_encoded_list(&bare).unwrap_err().code(),
1197      "APH_E008"
1198    );
1199    // Non-GZIP bytes are refused BEFORE a caller's decompressor sees them.
1200    let not_gzip = std::format!(
1201      "{}{}",
1202      super::MULTIBASE_BASE64URL_PREFIX,
1203      crate::crypto::base64url::encode(b"plain")
1204    );
1205    std::assert_eq!(
1206      super::decode_encoded_list(&not_gzip).unwrap_err().code(),
1207      "APH_E008"
1208    );
1209    let good = encoded_list_for(&[0x00]);
1210    std::assert_eq!(super::decode_encoded_list(&good).unwrap()[0], 0x1f);
1211  }
1212
1213  #[test]
1214  fn freshness_bound_is_five_minutes_plus_one_minute_of_skew() {
1215    // §6.3.3.3 is normative about both numbers and they are the reason the
1216    // check is worth a network round trip at all: a bound measured in hours
1217    // would cost a fetch and buy nothing, because the mandate would usually
1218    // expire before a stale answer changed.
1219    let document = status_document(&[0x00]);
1220    let credential = super::parse_status_list_credential(&document).unwrap();
1221    // 359s old: inside 300 + 60.
1222    std::assert!(credential.check_freshness("2026-05-28T00:05:59Z").is_ok());
1223    // 361s old: outside.
1224    std::assert_eq!(
1225      credential
1226        .check_freshness("2026-05-28T00:06:01Z")
1227        .unwrap_err()
1228        .code(),
1229      "APH_E008"
1230    );
1231    // Dated further into the future than the skew allowance: refused, or a
1232    // publisher buys unbounded staleness by writing tomorrow's date.
1233    std::assert_eq!(
1234      credential
1235        .check_freshness("2026-05-27T23:58:00Z")
1236        .unwrap_err()
1237        .code(),
1238      "APH_E008"
1239    );
1240  }
1241
1242  #[test]
1243  fn the_republish_deadline_falls_due_long_before_the_refusal_cliff() {
1244    // WHY THIS TEST EXISTS: a publisher that quietly stops fails closed, which
1245    // is the correct direction and a silent arrival — the first symptom is a
1246    // stranger's verifier refusing an envelope. These two distances are the
1247    // earlier warning, and they are only worth exposing if the alarm line
1248    // genuinely precedes the cliff by a usable margin.
1249    //
1250    // WHAT IT PINS: the §6.3.3.3 producer interval and the §6.3.3.3 freshness
1251    // bound as measured from one issuance instant; that the producer is
1252    // already late while verifiers still accept; that both distances agree
1253    // with `check_freshness` on either side of the cliff; that they go
1254    // NEGATIVE rather than erroring once passed, because a monitor needs "how
1255    // late am I" and not only "too late"; and that an unreadable clock
1256    // refuses instead of reporting a distance a dashboard would render as
1257    // time remaining.
1258    let document = status_document(&[0x00]);
1259    let credential = super::parse_status_list_credential(&document).unwrap();
1260
1261    // 30s after issuance: 90s of republish budget, 330s before refusal.
1262    std::assert_eq!(credential.seconds_until_republish_due(NOW).unwrap(), 90);
1263    std::assert_eq!(credential.seconds_until_stale(NOW).unwrap(), 330);
1264
1265    // Past the producer's line and still accepted. That gap is the mitigation.
1266    std::assert_eq!(
1267      credential
1268        .seconds_until_republish_due("2026-05-28T00:03:00Z")
1269        .unwrap(),
1270      -60
1271    );
1272    std::assert!(credential.check_freshness("2026-05-28T00:03:00Z").is_ok());
1273
1274    // The cliff and the verdict agree on both sides of it.
1275    std::assert_eq!(
1276      credential.seconds_until_stale("2026-05-28T00:05:59Z").unwrap(),
1277      1
1278    );
1279    std::assert!(credential.check_freshness("2026-05-28T00:05:59Z").is_ok());
1280    std::assert_eq!(
1281      credential.seconds_until_stale("2026-05-28T00:06:01Z").unwrap(),
1282      -1
1283    );
1284    std::assert!(credential.check_freshness("2026-05-28T00:06:01Z").is_err());
1285
1286    std::assert_eq!(
1287      credential
1288        .seconds_until_stale("not-a-timestamp")
1289        .unwrap_err()
1290        .code(),
1291      "APH_E008"
1292    );
1293    std::assert_eq!(
1294      credential
1295        .seconds_until_republish_due("not-a-timestamp")
1296        .unwrap_err()
1297        .code(),
1298      "APH_E008"
1299    );
1300  }
1301
1302  #[test]
1303  fn validate_binds_the_issuer_to_the_derived_notary() {
1304    // Same-origin alone does not make a document authoritative: one host
1305    // serves many documents, and the path is the envelope's to name. The
1306    // issuer binding is what ties the answer to the party whose key signed
1307    // the mandate.
1308    let document = status_document(&[0x00]).replace(NOTARY_DID, "did:web:evil.example");
1309    let credential = super::parse_status_list_credential(&document).unwrap();
1310    std::assert_eq!(
1311      credential.validate(NOTARY_DID, NOW).unwrap_err().code(),
1312      "APH_E008"
1313    );
1314  }
1315
1316  #[test]
1317  fn validate_refuses_a_document_whose_purpose_is_not_revocation() {
1318    // On the fetched DOCUMENT an unrecognized purpose is §6.3.3.4 case 2
1319    // (APH_E008), not the parse failure the ENVELOPE entry raises. The
1320    // asymmetry is the spec's and is easy to collapse by accident, so both
1321    // halves are pinned.
1322    // The UNSIGNED builder on purpose: `validate` is the keyless half of
1323    // §6.3.3.3 and never looks at a proof, so signing here would add nothing
1324    // — and the signed builder re-serialises compactly, which would silently
1325    // turn the string replace below into a no-op and make this test pass
1326    // while asserting nothing.
1327    let document = status_document_unsigned(&[0x00]).replace(
1328      "\"statusPurpose\": \"revocation\"",
1329      "\"statusPurpose\": \"suspension\"",
1330    );
1331    let credential = super::parse_status_list_credential(&document).unwrap();
1332    std::assert_eq!(
1333      credential.validate(NOTARY_DID, NOW).unwrap_err().code(),
1334      "APH_E008"
1335    );
1336  }
1337
1338  #[test]
1339  fn a_status_document_may_carry_members_aph_does_not_read() {
1340    // The status list credential is a general W3C artifact. Parsing it
1341    // strictly would refuse ordinary documents carrying `name`, `ttl` or a
1342    // `validUntil` — the same reason `DidDocument` is not strict either.
1343    let document = status_document(&[0x00]).replace(
1344      "\"validFrom\":",
1345      "\"name\": \"Squillo revocation list\", \"ttl\": 300, \"validFrom\":",
1346    );
1347    let credential = super::parse_status_list_credential(&document)
1348      .expect("extra W3C members must not refuse the document");
1349    std::assert!(credential.validate(NOTARY_DID, NOW).is_ok());
1350  }
1351
1352  #[test]
1353  fn issuer_is_read_from_a_string_or_an_object() {
1354    // W3C permits both forms and notaries emit both; a verifier that read
1355    // only one would refuse conformant issuers for a JSON-shape reason.
1356    let document = status_document(&[0x00]).replace(
1357      &std::format!("\"issuer\": \"{}\"", NOTARY_DID),
1358      &std::format!("\"issuer\": {{\"id\": \"{}\"}}", NOTARY_DID),
1359    );
1360    let credential = super::parse_status_list_credential(&document).unwrap();
1361    std::assert_eq!(credential.issuer_did(), std::option::Option::Some(NOTARY_DID));
1362    std::assert!(credential.validate(NOTARY_DID, NOW).is_ok());
1363  }
1364
1365  #[test]
1366  fn signing_base_drops_the_proof_and_is_stable() {
1367    // The caller verifies the notary's signature over exactly these bytes
1368    // (module preamble). Shipping the base here is what keeps the host from
1369    // re-deriving it and drifting from the signer.
1370    let signed = status_document(&[0x00]).replace(
1371      "\n}",
1372      ",\n  \"proof\": {\"type\": \"DataIntegrityProof\", \"proofValue\": \"zNotChecked\"}\n}",
1373    );
1374    let base = super::status_list_signing_base(&signed).unwrap();
1375    std::assert!(!base.contains("proofValue"));
1376    // Byte-identical to the base of the same document with no proof at all,
1377    // which is the property a signer relies on.
1378    let unsigned = super::status_list_signing_base(&status_document(&[0x00])).unwrap();
1379    std::assert_eq!(base, unsigned);
1380  }
1381
1382  // ── The §6.3.3.4 trichotomy, minus the caller's proof check ─────────
1383
1384  /// An envelope whose notary is [`NOTARY_DID`], carrying `status` and
1385  /// naming `mandate` as the parent Delegation Mandate.
1386  ///
1387  /// Built from the checked-in Slack golden so the trichotomy is exercised
1388  /// against a real published wire sample rather than a shape invented for
1389  /// this test — the golden is also what proves, in
1390  /// `golden_envelope_bytes_are_unchanged_by_the_new_field`, that an absent
1391  /// status reference changes nothing.
1392  fn envelope_with(
1393    status: std::option::Option<super::CredentialStatusEntry>,
1394    mandate: std::option::Option<&str>,
1395  ) -> crate::envelope::NotarizationEnvelope {
1396    let raw = std::include_str!("../tests/golden/slack_reply_envelope.json");
1397    let mut envelope: crate::envelope::NotarizationEnvelope =
1398      serde_json::from_str(raw).expect("golden fixture parses");
1399    envelope.credential_subject.notarization.notary_service.id = String::from(NOTARY_DID);
1400    envelope.credential_subject.policy.delegation_mandate_id =
1401      mandate.map(String::from);
1402    envelope.credential_status = status;
1403    envelope
1404  }
1405
1406  /// The mandate id every revoked-path assertion expects back in `APH_E015`.
1407  const MANDATE_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000d1";
1408
1409  #[test]
1410  fn absent_status_skips_without_error_and_without_touching_the_port() {
1411    // §6.3.3.4 case 1. Enforcing a claim nobody made is not fail-closed, it
1412    // is fail-arbitrary: it would refuse every conformant envelope written
1413    // before this revision. The port call count is the load-bearing
1414    // assertion — "skip" must mean no I/O at all, not a fetch whose result
1415    // is discarded.
1416    let fetch = RecordingFetch::new("");
1417    let envelope = envelope_with(std::option::Option::None, std::option::Option::Some(MANDATE_ID));
1418    let outcome = crate::discovery::test_support::block_on(super::check_envelope_status(
1419      &envelope,
1420      &fetch,
1421      &test_expander,
1422      &fixture_key(),
1423      NOW,
1424    ))
1425    .expect("an absent status reference is not an error");
1426    std::assert_eq!(outcome, super::StatusCheck::Skipped);
1427    std::assert_eq!(fetch.calls(), 0, "case 1 must perform no fetch");
1428  }
1429
1430  #[test]
1431  fn a_clear_bit_admits_the_envelope() {
1432    // §6.3.3.4 case 3, negative. The positive control for every refusal
1433    // below: without it a test suite where everything rejects would pass
1434    // even if the arm refused unconditionally.
1435    //
1436    // ⛔ THE GAP THIS COMMENT USED TO PIN IS CLOSED, and it closed exactly the
1437    // way the old text predicted it would have to. It read: "if this crate
1438    // ever starts verifying proofs, THIS TEST MUST FAIL, and the fix is to
1439    // give the fixture a real signature, never to relax the assertion." It
1440    // did fail, and the fixture now carries a REAL signature over
1441    // `status_list_signing_base` (see `sign_document`), verified against the
1442    // key this call supplies. The earlier design left §6.3.3.3's proof check
1443    // to the caller on the reasoning that a §8.4-resolved key does not belong
1444    // in this crate; the key is now an ARGUMENT, which keeps that reasoning
1445    // (the crate still resolves nothing) while making the check unskippable.
1446    // A caller cannot obtain a verdict without handing over the key that
1447    // authenticates it.
1448    let fetch = RecordingFetch::new(&status_document(&[0b0100_0000]));
1449    let entry = entry_at(DERIVED_ENDPOINT, "0");
1450    let envelope = envelope_with(
1451      std::option::Option::Some(entry),
1452      std::option::Option::Some(MANDATE_ID),
1453    );
1454    let outcome = crate::discovery::test_support::block_on(super::check_envelope_status(
1455      &envelope,
1456      &fetch,
1457      &test_expander,
1458      &fixture_key(),
1459      NOW,
1460    ))
1461    .expect("a clear bit continues verification");
1462    std::assert_eq!(outcome, super::StatusCheck::NotRevoked);
1463    std::assert_eq!(fetch.calls(), 1);
1464  }
1465
1466  /// WHY THIS TEST EXISTS: same-origin proves the bytes came from the right
1467  /// ORIGIN; it says nothing about which PARTY wrote them. Without a proof
1468  /// check, anyone able to write to (or answer for) the status endpoint could
1469  /// flip a revoked mandate back to live — turning the transport built to
1470  /// ENFORCE revocation into the way a revoked agent proves it is fine. This
1471  /// crate shipped with exactly that hole for one commit.
1472  /// WHAT IT PINS: an UNSIGNED status list is §6.3.3.4 case 2 — refused, never
1473  /// admitted as "nothing to check" — even when its bit is clear and every
1474  /// other check passes.
1475  #[test]
1476  fn an_unsigned_status_list_is_refused_even_when_the_bit_is_clear() {
1477    let fetch = RecordingFetch::new(&status_document_unsigned(&[0b0100_0000]));
1478    let entry = entry_at(DERIVED_ENDPOINT, "0");
1479    let envelope = envelope_with(
1480      std::option::Option::Some(entry),
1481      std::option::Option::Some(MANDATE_ID),
1482    );
1483    let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1484      &envelope,
1485      &fetch,
1486      &test_expander,
1487      &fixture_key(),
1488      NOW,
1489    ))
1490    .expect_err("an unsigned status list must never be admitted");
1491    std::assert!(std::matches!(
1492      error,
1493      crate::errors::AphError::NotaryServiceUnreachable
1494    ));
1495  }
1496
1497  /// WHY THIS TEST EXISTS: a signature that verifies under the WRONG key is
1498  /// the forgery case — an attacker signs a document with their own key and
1499  /// serves it from a host that satisfies same-origin.
1500  /// WHAT IT PINS: a well-formed proof made by a key other than the issuer's
1501  /// is refused.
1502  #[test]
1503  fn a_status_list_signed_by_the_wrong_key_is_refused() {
1504    let fetch = RecordingFetch::new(&status_document(&[0b0100_0000]));
1505    let entry = entry_at(DERIVED_ENDPOINT, "0");
1506    let envelope = envelope_with(
1507      std::option::Option::Some(entry),
1508      std::option::Option::Some(MANDATE_ID),
1509    );
1510    // A different obviously-fake seed: the impostor convention already used
1511    // in `crypto::eddsa_jcs`.
1512    let impostor = ed25519_dalek::SigningKey::from_bytes(&[13u8; 32]).verifying_key();
1513    let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1514      &envelope,
1515      &fetch,
1516      &test_expander,
1517      &impostor,
1518      NOW,
1519    ))
1520    .expect_err("a status list signed by a foreign key must be refused");
1521    std::assert!(std::matches!(
1522      error,
1523      crate::errors::AphError::NotaryServiceUnreachable
1524    ));
1525  }
1526
1527  #[test]
1528  fn a_set_bit_rejects_with_aph_e015_naming_the_mandate() {
1529    // §6.3.3.4 case 3. The code matters as much as the refusal: E015 says a
1530    // human WITHDREW the authority, while a signature code would send an
1531    // operator to inspect key material for a decision no key was involved
1532    // in. The mandate id has to travel with it or the operator cannot tell
1533    // WHICH grant was pulled.
1534    let fetch = RecordingFetch::new(&status_document(&[0b0010_0000]));
1535    let entry = entry_at(DERIVED_ENDPOINT, "2");
1536    let envelope = envelope_with(
1537      std::option::Option::Some(entry),
1538      std::option::Option::Some(MANDATE_ID),
1539    );
1540    let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1541      &envelope,
1542      &fetch,
1543      &test_expander,
1544      &fixture_key(),
1545      NOW,
1546    ))
1547    .expect_err("a set bit must refuse the envelope");
1548    std::assert_eq!(error.code(), "APH_E015");
1549    std::assert!(
1550      std::format!("{}", error).contains(MANDATE_ID),
1551      "APH_E015 must name the revoked mandate"
1552    );
1553  }
1554
1555  #[test]
1556  fn an_unreachable_status_surface_rejects_rather_than_skipping() {
1557    // §6.3.3.4 case 2, and the reason it is not case 1: an attacker who can
1558    // make the status check FAIL must not thereby get to choose that it is
1559    // SKIPPED. This is §8.4.6's published-and-failed rule one level up.
1560    let fetch = UnreachableFetch {
1561      calls: std::sync::atomic::AtomicUsize::new(0),
1562    };
1563    let entry = entry_at(DERIVED_ENDPOINT, "0");
1564    let envelope = envelope_with(
1565      std::option::Option::Some(entry),
1566      std::option::Option::Some(MANDATE_ID),
1567    );
1568    let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1569      &envelope,
1570      &fetch,
1571      &test_expander,
1572      &fixture_key(),
1573      NOW,
1574    ))
1575    .expect_err("an offered-but-unreachable reference must refuse");
1576    std::assert_eq!(error.code(), "APH_E008");
1577    std::assert_eq!(
1578      fetch.calls.load(std::sync::atomic::Ordering::SeqCst),
1579      1,
1580      "the derived origin was reached for, and it is the reach that failed"
1581    );
1582  }
1583
1584  #[test]
1585  fn a_cross_origin_status_url_is_refused_without_being_fetched() {
1586    // THE security core of §6.3.3.2. The authority for "is this mandate
1587    // revoked" is the notary that issued it; if the envelope could name the
1588    // host answering, then whoever holds an old envelope also chooses that
1589    // host — and a host of the attacker's choosing always answers "not
1590    // revoked". The spec says MUST reject and MUST NOT fetch, so the CALL
1591    // COUNT is the assertion. An error code alone would still pass if the
1592    // implementation fetched first and refused afterwards, by which time the
1593    // verifier has already made a request an attacker steered.
1594    let fetch = RecordingFetch::new(&status_document(&[0x00]));
1595    let entry = entry_at("https://evil.example/.well-known/aph-status.json", "0");
1596    let envelope = envelope_with(
1597      std::option::Option::Some(entry),
1598      std::option::Option::Some(MANDATE_ID),
1599    );
1600    let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1601      &envelope,
1602      &fetch,
1603      &test_expander,
1604      &fixture_key(),
1605      NOW,
1606    ))
1607    .expect_err("a cross-origin status URL must refuse");
1608    std::assert_eq!(error.code(), "APH_E008");
1609    std::assert_eq!(fetch.calls(), 0, "the named URL must never be fetched");
1610  }
1611
1612  #[test]
1613  fn a_same_host_different_port_or_scheme_is_also_cross_origin() {
1614    // "Same-origin" is scheme + host + PORT, and a verifier that compared
1615    // only the host would accept `http://` (no TLS anchor at all) and a
1616    // service on another port that the notary's certificate never covered.
1617    // Both must refuse unfetched, exactly like a foreign host.
1618    for url in [
1619      "http://aph-notary.squillo.com/.well-known/aph-status.json",
1620      "https://aph-notary.squillo.com:8443/.well-known/aph-status.json",
1621      // Userinfo is the classic disguise: the HOST here is `evil.example`,
1622      // and a prefix comparison would call it the notary's own origin.
1623      "https://aph-notary.squillo.com@evil.example/.well-known/aph-status.json",
1624    ] {
1625      let fetch = RecordingFetch::new(&status_document(&[0x00]));
1626      let envelope = envelope_with(
1627        std::option::Option::Some(entry_at(url, "0")),
1628        std::option::Option::Some(MANDATE_ID),
1629      );
1630      let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1631        &envelope,
1632        &fetch,
1633        &test_expander,
1634        &fixture_key(),
1635        NOW,
1636      ))
1637      .expect_err(url);
1638      std::assert_eq!(error.code(), "APH_E008", "{}", url);
1639      std::assert_eq!(fetch.calls(), 0, "{}", url);
1640    }
1641  }
1642
1643  #[test]
1644  fn a_different_path_on_the_derived_origin_is_admitted_and_is_what_gets_fetched() {
1645    // §6.3.3.2 permits a different PATH deliberately — it is how a notary
1646    // whose list is exhausted points at the successor list (§6.3.3.6). A
1647    // verifier that pinned the whole URL instead of the origin would refuse
1648    // every notary that ever filled a list.
1649    let fetch = RecordingFetch::new(&status_document(&[0x00]));
1650    let second_list = "https://aph-notary.squillo.com/status/list-2.json";
1651    let envelope = envelope_with(
1652      std::option::Option::Some(entry_at(second_list, "0")),
1653      std::option::Option::Some(MANDATE_ID),
1654    );
1655    let outcome = crate::discovery::test_support::block_on(super::check_envelope_status(
1656      &envelope,
1657      &fetch,
1658      &test_expander,
1659      &fixture_key(),
1660      NOW,
1661    ))
1662    .expect("a same-origin second list is legitimate");
1663    std::assert_eq!(outcome, super::StatusCheck::NotRevoked);
1664    let asked = fetch.urls.lock().expect("test mutex is uncontended").clone();
1665    std::assert_eq!(
1666      asked,
1667      std::vec![String::from(second_list)],
1668      "the BOUND url is fetched, not the derived one"
1669    );
1670  }
1671
1672  #[test]
1673  fn a_notary_that_is_not_did_web_cannot_be_status_checked() {
1674    // §6.3.3.4 case 2, first bullet: with no `did:web` there is no origin to
1675    // derive, so the same-origin rule cannot be satisfied at all. Refusing
1676    // is the only safe reading — treating it as case 1 would let any
1677    // producer disable the check by issuing under a `did:key` notary.
1678    let fetch = RecordingFetch::new(&status_document(&[0x00]));
1679    let mut envelope = envelope_with(
1680      std::option::Option::Some(entry_at(DERIVED_ENDPOINT, "0")),
1681      std::option::Option::Some(MANDATE_ID),
1682    );
1683    envelope.credential_subject.notarization.notary_service.id =
1684      String::from("did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV");
1685    let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1686      &envelope,
1687      &fetch,
1688      &test_expander,
1689      &fixture_key(),
1690      NOW,
1691    ))
1692    .expect_err("no derivable origin must refuse");
1693    std::assert_eq!(error.code(), "APH_E008");
1694    std::assert_eq!(fetch.calls(), 0);
1695  }
1696
1697  #[test]
1698  fn a_status_reference_with_no_parent_mandate_is_refused() {
1699    // §6.3.3.1: an envelope carrying `credentialStatus` MUST also carry a
1700    // non-null `delegationMandateId`. A status reference with nothing to be
1701    // the status OF would leave a verifier checking a bit whose subject it
1702    // cannot name — and `APH_E015` could not name the mandate it refused.
1703    let fetch = RecordingFetch::new(&status_document(&[0x00]));
1704    let envelope = envelope_with(
1705      std::option::Option::Some(entry_at(DERIVED_ENDPOINT, "0")),
1706      std::option::Option::None,
1707    );
1708    let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1709      &envelope,
1710      &fetch,
1711      &test_expander,
1712      &fixture_key(),
1713      NOW,
1714    ))
1715    .expect_err("a subjectless status reference must refuse");
1716    std::assert_eq!(error.code(), "APH_E008");
1717    std::assert_eq!(fetch.calls(), 0);
1718  }
1719
1720  #[test]
1721  fn a_stale_document_rejects_even_though_it_fetched_cleanly() {
1722    // §6.3.3.3's bound is what makes the check worth its round trip. A
1723    // verifier that fetched successfully and then ignored the age would
1724    // accept a list frozen at the moment before a revocation.
1725    let fetch = RecordingFetch::new(&status_document(&[0x00]));
1726    let envelope = envelope_with(
1727      std::option::Option::Some(entry_at(DERIVED_ENDPOINT, "0")),
1728      std::option::Option::Some(MANDATE_ID),
1729    );
1730    let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1731      &envelope,
1732      &fetch,
1733      &test_expander,
1734      &fixture_key(),
1735      // 361 seconds after ISSUED_AT: past 300 + 60.
1736      "2026-05-28T00:06:01Z",
1737    ))
1738    .expect_err("a stale status list must refuse");
1739    std::assert_eq!(error.code(), "APH_E008");
1740    std::assert_eq!(fetch.calls(), 1);
1741  }
1742
1743  #[test]
1744  fn the_expander_never_runs_on_a_document_that_failed_an_earlier_check() {
1745    // The decompressor is caller-supplied and runs over network bytes, so
1746    // the order of operations is a security property: same-origin, issuer,
1747    // purpose and freshness all pass FIRST, and only then is a codec asked
1748    // to expand anything. This test would fail if the arm expanded before
1749    // validating.
1750    let expanded = std::sync::atomic::AtomicUsize::new(0);
1751    let counting = |compressed: &[u8]| -> std::result::Result<
1752      std::vec::Vec<u8>,
1753      crate::errors::AphError,
1754    > {
1755      expanded.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1756      test_expander(compressed)
1757    };
1758    let fetch = RecordingFetch::new(
1759      &status_document(&[0x00]).replace(NOTARY_DID, "did:web:evil.example"),
1760    );
1761    let envelope = envelope_with(
1762      std::option::Option::Some(entry_at(DERIVED_ENDPOINT, "0")),
1763      std::option::Option::Some(MANDATE_ID),
1764    );
1765    let error = crate::discovery::test_support::block_on(super::check_envelope_status(
1766      &envelope,
1767      &fetch,
1768      &counting,
1769      &fixture_key(),
1770      NOW,
1771    ))
1772    .expect_err("a foreign issuer must refuse");
1773    std::assert_eq!(error.code(), "APH_E008");
1774    std::assert_eq!(
1775      expanded.load(std::sync::atomic::Ordering::SeqCst),
1776      0,
1777      "the caller's decompressor must not see bytes from a rejected document"
1778    );
1779  }
1780
1781  #[test]
1782  fn golden_envelope_bytes_are_unchanged_by_the_new_field() {
1783    // ⛔ PATTERN A, the rule that keeps every fixture and all four real
1784    // Ed25519 signatures valid without regeneration (§7.1.1, §7.5): an
1785    // envelope carrying NO status reference must be BYTE-IDENTICAL to one
1786    // written before the field existed. `#[serde(default)]` alone would emit
1787    // an explicit `null`, changing the JCS bytes of every golden and
1788    // invalidating every signature over them. This test reads the
1789    // checked-in golden, round-trips it through the type that now has the
1790    // field, and compares the SIGNING BASE — the exact bytes a proof
1791    // covers — so a regression is caught as the signature breakage it is.
1792    let raw = std::include_str!("../tests/golden/slack_reply_envelope.json");
1793    let envelope: crate::envelope::NotarizationEnvelope =
1794      serde_json::from_str(raw).expect("golden fixture parses");
1795    std::assert!(
1796      envelope.credential_status.is_none(),
1797      "the golden predates the field and must parse with it absent"
1798    );
1799    let reserialized = serde_json::to_string(&envelope).expect("envelope serializes");
1800    std::assert!(
1801      !reserialized.contains("credentialStatus"),
1802      "an absent status reference must emit no key at all"
1803    );
1804    let from_type: serde_json::Value =
1805      serde_json::from_str(&reserialized).expect("round-trip parses");
1806    let from_disk: serde_json::Value = serde_json::from_str(raw).expect("golden parses as JSON");
1807    std::assert_eq!(
1808      crate::crypto::jcs::canonicalize_rfc8785(&from_type),
1809      crate::crypto::jcs::canonicalize_rfc8785(&from_disk),
1810      "the canonical bytes a signature covers must be unchanged"
1811    );
1812  }
1813
1814  #[test]
1815  fn proof_verification_method_is_surfaced_for_the_caller() {
1816    // The accessor still matters after the crate started checking proofs
1817    // itself: a caller that resolves the key, or an operator diagnosing a
1818    // refusal, needs to know WHICH key the document claims to be signed by.
1819    // Both halves now come from the real builders rather than string surgery
1820    // — `status_document` signs, `status_document_unsigned` does not — so the
1821    // fixtures cannot drift from what the signer actually emits.
1822    let credential = super::parse_status_list_credential(&status_document(&[0x00])).unwrap();
1823    std::assert_eq!(
1824      credential.proof_verification_method(),
1825      std::option::Option::Some("did:web:aph-notary.squillo.com#k1")
1826    );
1827    let unsigned =
1828      super::parse_status_list_credential(&status_document_unsigned(&[0x00])).unwrap();
1829    std::assert_eq!(
1830      unsigned.proof_verification_method(),
1831      std::option::Option::None
1832    );
1833  }
1834
1835  #[test]
1836  fn both_entry_points_return_futures_that_can_cross_a_thread_boundary() {
1837    // WHY THIS EXISTS: every real caller drives this arm from a host
1838    // executor — the squillo verifier calls it inside an actor's inbound
1839    // handler — and `tokio::spawn` demands a `Send` future. Nothing else in
1840    // this file would notice if that stopped holding, because
1841    // `test_support::block_on` polls on the calling thread: the whole suite
1842    // would stay green while no host could compile against the crate.
1843    //
1844    // WHAT IT PINS: the `+ Send + Sync` on `super::ExpandEncodedList`.
1845    // `&dyn StatusCredentialFetch` is already `Send` via that trait's
1846    // supertraits and the envelope is plain data, so the caller-supplied
1847    // expander is the ONE capture that can poison the auto trait — a bare
1848    // `dyn Fn` carries neither. Moving both futures into a scoped thread is
1849    // the runtime form of the bound, the same shape
1850    // `discovery::ports::port_futures_can_cross_a_thread_boundary` uses for
1851    // `DiscoveryFuture`. Awaiting them there rather than merely dropping
1852    // them also pins that the OUTPUT is `Send`, which is what a spawned task
1853    // must return.
1854    let fetch = RecordingFetch::new(&status_document(&[0b0100_0000]));
1855    let entry = entry_at(DERIVED_ENDPOINT, "0");
1856    let envelope = envelope_with(
1857      std::option::Option::Some(entry.clone()),
1858      std::option::Option::Some(MANDATE_ID),
1859    );
1860    // Annotated with the alias rather than inferred: the coercion IS the
1861    // assertion, so the bound has to be named at the coercion site. Bound to
1862    // a `let` for a second reason — an inline `&test_expander` would be a
1863    // temporary dropped at the end of its own statement, and these futures
1864    // outlive their statement.
1865    let expander: &super::ExpandEncodedList<'_> = &test_expander;
1866    let key = fixture_key();
1867    let envelope_future = super::check_envelope_status(&envelope, &fetch, expander, &key, NOW);
1868    let direct_future =
1869      super::check_credential_status(&entry, MANDATE_ID, NOTARY_DID, &fetch, expander, &key, NOW);
1870    let outcomes = std::thread::scope(|scope| {
1871      scope
1872        .spawn(move || {
1873          (
1874            crate::discovery::test_support::block_on(envelope_future),
1875            crate::discovery::test_support::block_on(direct_future),
1876          )
1877        })
1878        .join()
1879    })
1880    .expect("the moved futures must complete on the other thread");
1881    std::assert_eq!(
1882      outcomes.0.expect("a clear bit admits the envelope"),
1883      super::StatusCheck::NotRevoked
1884    );
1885    std::assert_eq!(
1886      outcomes.1.expect("a clear bit admits the entry"),
1887      super::StatusCheck::NotRevoked
1888    );
1889  }
1890}