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