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