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(¬_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}