Skip to main content

aph_core/
verification.rs

1//! Structural verification of an envelope's proof block — spec §7.1.11,
2//! §7.1.7.1, §7.2.1 and §8.3.1 steps 1a, 1d, 1e.
3//!
4//! This module answers one question: *is this envelope's proof structure
5//! well formed, and does it say what it is?* It is deliberately PURE — no
6//! cryptography, no key resolution, no network, no clock. Signature checking
7//! belongs to [`crate::crypto`]; key discovery belongs to
8//! [`crate::discovery`]; deciding *which* mode a deployment demands belongs
9//! to the caller.
10//!
11//! The reason these checks are separated out and made mandatory is stated
12//! in §7.1.11: without them `attestationMode` is a **self-asserted string**.
13//! A holder of a notary key could write `PrincipalSigned` above a single
14//! notary proof whose `proofPurpose` is `assertionMethod` — indistinguishable
15//! from a principal proof by purpose alone — and a verifier that trusted the
16//! label would report a forged authorization as the human's own signature.
17//! Every rule below exists to make the label unforgeable by a notary.
18//!
19//! Order of use, mirroring §8.3.1:
20//!
21//! 1. [`require_mode`] — step 1a. Refuse an envelope weaker than policy
22//!    demands BEFORE doing any work on unauthenticated input.
23//! 2. [`verify_proof_structure`] — steps 1a (label/structure agreement) and
24//!    1e (chain linkage). Returns the mode the envelope is actually in.
25//! 3. [`verify_embedded_mandate_binding`] — step 1d, the non-cryptographic
26//!    half: is the embedded mandate THIS envelope's parent?
27//! 4. [`verify_timestamp_order`] — the issuance order §7.2.1 makes normative.
28//!
29//! Timestamps are compared by parsing them with `chrono`, which is what
30//! [`crate::delegation_mandate::DelegationMandate::is_valid_at`] and
31//! [`crate::discovery::dns_txt`] already do, and every comparison fails
32//! CLOSED on an unparseable value. No date library is introduced: `chrono`
33//! is already a dependency of this crate for exactly this purpose.
34
35/// `proofPurpose` of a principal proof, and of a lone notary proof kept for
36/// wire compatibility (§7.1.11).
37const ASSERTION_METHOD: &str = "assertionMethod";
38
39/// `proofPurpose` of a notary countersignature — the value that distinguishes
40/// the second proof of a chain from the first (§7.1.11).
41const AUTHENTICATION: &str = "authentication";
42
43/// Verifies the structural rules of §7.1.11 and returns the mode the
44/// envelope is actually in.
45///
46/// This is the check that makes `attestationMode` mean something. It
47/// enforces, each with its own `APH_E013` reason string:
48///
49/// - a chain carries EXACTLY two proofs;
50/// - position 1 has `proofPurpose == "assertionMethod"`, position 2
51///   `"authentication"`;
52/// - both chain proofs carry an `id`, and the two ids differ;
53/// - the notary proof's `previousProof` equals the principal proof's `id` —
54///   array position is a hint, this linkage is the binding (§7.1.11);
55/// - the principal proof carries NO `previousProof`; it is the chain head;
56/// - label and structure agree in BOTH directions: a chain MUST be labelled
57///   `PrincipalSigned`, and `PrincipalSigned` MUST be a chain. A mismatch is
58///   a rejection, never a silent coercion;
59/// - the principal proof's `verificationMethod` is a DID URL under
60///   `credentialSubject.humanPrincipal.id`. This is the rule that closes the
61///   forgery §7.1.11 describes, because the notary does not hold that key.
62///
63/// A single-object `proof` is a notary proof and yields `NotaryAttested` —
64/// the shape all eight published example envelopes carry.
65///
66/// A successful return says the structure is sound; it says NOTHING about
67/// whether either signature verifies. A caller that reports "the human
68/// signed this" on the strength of this function alone is reporting a claim
69/// no key has backed.
70pub fn verify_proof_structure(
71  envelope: &crate::envelope::NotarizationEnvelope,
72) -> std::result::Result<crate::envelope::AttestationMode, crate::errors::AphError> {
73  let declared = envelope.credential_subject.policy.attestation_mode;
74
75  // ── The single-object form ────────────────────────────────────────────
76  if !envelope.proof.is_chain() {
77    if declared
78      == std::option::Option::Some(crate::envelope::AttestationMode::PrincipalSigned)
79    {
80      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
81        "`attestationMode` is `PrincipalSigned` but `proof` is a single object; \
82         the label MUST accompany a two-element chain (§7.1.11)",
83      ));
84    }
85    // A lone proof links to nothing, so chain members on it are not merely
86    // redundant — they are a claim about a chain that does not exist. §7.1.11
87    // says `id` is "omitted for a single-object `proof`" and a
88    // `previousProof` here is dangling by construction. Accepting either
89    // would let a stripped chain keep the vocabulary of one, and a verifier
90    // reading loosely could conclude a principal proof had been present.
91    let lone = match envelope.proof.notary() {
92      std::option::Option::Some(proof) => proof,
93      // A non-chain always has exactly one proof; refuse rather than index.
94      std::option::Option::None => {
95        return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
96          "`proof` is neither a chain nor a single proof",
97        ));
98      }
99    };
100    if lone.id.is_some() || lone.previous_proof.is_some() {
101      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
102        "a single-object `proof` MUST carry neither `id` nor `previousProof`; \
103         both belong to a chain, and a lone proof links to nothing (§7.1.11)",
104      ));
105    }
106    // §7.1.11: a lone proof "uses `assertionMethod` for wire compatibility".
107    // `authentication` is the countersignature purpose and means a chain was
108    // intended, so a lone proof carrying it is a chain missing its head.
109    if lone.proof_purpose != ASSERTION_METHOD {
110      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
111        std::format!(
112          "a single-object `proof` MUST use `proofPurpose` `{}`; found `{}` (§7.1.11)",
113          ASSERTION_METHOD, lone.proof_purpose
114        ),
115      ));
116    }
117    return std::result::Result::Ok(crate::envelope::AttestationMode::NotaryAttested);
118  }
119
120  // ── The array form ────────────────────────────────────────────────────
121  let proofs = envelope.proof.all();
122  if proofs.len() != 2 {
123    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
124      std::format!(
125        "a proof chain MUST carry exactly two proofs (principal, then notary); found {}",
126        proofs.len()
127      ),
128    ));
129  }
130
131  // Label/structure agreement, the other direction: an array MUST be
132  // labelled `PrincipalSigned`. Left unenforced, an intermediary could
133  // present a chain as a notary attestation and the human's own proof would
134  // be read as the notary's.
135  match declared {
136    std::option::Option::None => {
137      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
138        "`proof` is a chain but `attestationMode` is absent; absent means \
139         `NotaryAttested`, and a chain MUST declare `PrincipalSigned` (§7.1.11)",
140      ));
141    }
142    std::option::Option::Some(crate::envelope::AttestationMode::NotaryAttested) => {
143      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
144        "`proof` is a chain but `attestationMode` is `NotaryAttested`; \
145         a chain MUST declare `PrincipalSigned` (§7.1.11)",
146      ));
147    }
148    std::option::Option::Some(crate::envelope::AttestationMode::PrincipalSigned) => {}
149  }
150
151  let (principal, notary) = match (envelope.proof.principal(), envelope.proof.notary()) {
152    (std::option::Option::Some(p), std::option::Option::Some(n)) => (p, n),
153    // Unreachable while the accessors key off the same length just checked.
154    // Written as a refusal rather than an `unwrap` so that a future change
155    // to `EnvelopeProofs` fails closed instead of panicking on hostile input.
156    _ => {
157      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
158        "`proof` is not a well-formed two-element chain",
159      ));
160    }
161  };
162
163  // ── Proof purposes, per position (§7.1.11) ────────────────────────────
164  if principal.proof_purpose != ASSERTION_METHOD {
165    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
166      std::format!(
167        "chain position 1 (principal proof) MUST have `proofPurpose` \
168         `assertionMethod`; found `{}`",
169        principal.proof_purpose
170      ),
171    ));
172  }
173  if notary.proof_purpose != AUTHENTICATION {
174    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
175      std::format!(
176        "chain position 2 (notary proof) MUST have `proofPurpose` \
177         `authentication`; found `{}`",
178        notary.proof_purpose
179      ),
180    ));
181  }
182
183  // ── Proof ids ─────────────────────────────────────────────────────────
184  let principal_id = match principal.id.as_deref() {
185    std::option::Option::Some(id) => id,
186    std::option::Option::None => {
187      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
188        "chain position 1 (principal proof) MUST carry an `id`; \
189         without one the notary proof has nothing to name (§7.1.11)",
190      ));
191    }
192  };
193  let notary_id = match notary.id.as_deref() {
194    std::option::Option::Some(id) => id,
195    std::option::Option::None => {
196      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
197        "chain position 2 (notary proof) MUST carry an `id`; \
198         every proof in a chain is identified (§7.1.11)",
199      ));
200    }
201  };
202  if principal_id == notary_id {
203    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
204      std::format!(
205        "the two proofs in a chain MUST carry distinct `id`s; both are `{}`",
206        principal_id
207      ),
208    ));
209  }
210
211  // ── Chain linkage (§8.3.1 step 1e) ────────────────────────────────────
212  // Array position is a hint. `previousProof` is the binding, because a
213  // verifier that trusted order alone would accept a chain an intermediary
214  // reordered.
215  if principal.previous_proof.is_some() {
216    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
217      "the principal proof is the HEAD of the chain and MUST NOT carry \
218       `previousProof` (§7.1.11)",
219    ));
220  }
221  let previous = match notary.previous_proof.as_deref() {
222    std::option::Option::Some(previous) => previous,
223    std::option::Option::None => {
224      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
225        "the notary proof MUST carry `previousProof` naming the principal \
226         proof's `id`; a chain without linkage is only an ordered array",
227      ));
228    }
229  };
230  if previous == notary_id {
231    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
232      std::format!(
233        "`previousProof` is self-referential: the notary proof names its own \
234         `id` `{}`",
235        notary_id
236      ),
237    ));
238  }
239  if previous != principal_id {
240    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
241      std::format!(
242        "`previousProof` `{}` is dangling: it does not name a proof in this \
243         chain (the principal proof's `id` is `{}`)",
244        previous, principal_id
245      ),
246    ));
247  }
248
249  // ── The binding that makes the label unforgeable (§7.1.11) ────────────
250  // A DID URL is `<did>#<fragment>`. The principal proof MUST be made under
251  // the human principal's own DID; a proof made by any other key is not the
252  // principal's proof, whatever its `proofPurpose` says (§8.3.1 step 1c).
253  // Only this check stops a notary — who does not hold the human's key —
254  // from writing `PrincipalSigned` above a proof of its own.
255  let human_did = envelope.credential_subject.human_principal.id.as_str();
256  let principal_did = match principal.verification_method.split_once('#') {
257    std::option::Option::Some((did, _fragment)) => did,
258    std::option::Option::None => principal.verification_method.as_str(),
259  };
260  if principal_did != human_did {
261    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
262      std::format!(
263        "the principal proof's `verificationMethod` `{}` is not a DID URL under \
264         the human principal `{}`; a proof made by any other key is not the \
265         principal's proof (§7.1.11)",
266        principal.verification_method, human_did
267      ),
268    ));
269  }
270
271  std::result::Result::Ok(crate::envelope::AttestationMode::PrincipalSigned)
272}
273
274/// Refuses an envelope weaker than the caller's policy demands (§8.3.1 1a).
275///
276/// `PrincipalSigned` required against a `NotaryAttested` envelope is
277/// `APH_E012`. The reverse is fine: a stronger envelope satisfies a weaker
278/// policy. There is no silent downgrade, for the same reason §8.4.6 forbids
279/// downgrading key discovery — an attacker who can defeat the weak path will
280/// always present the weak path.
281///
282/// This reads the DECLARED label, and deliberately so: §8.3.1 puts it at
283/// step 1a precisely so a verifier refuses a too-weak claim *before* doing
284/// work on unauthenticated input. The label is not evidence on its own, so a
285/// caller MUST also call [`verify_proof_structure`], which is what rejects a
286/// `PrincipalSigned` label written above a structure that does not support
287/// it. Calling this function alone accepts a forged label.
288pub fn require_mode(
289  envelope: &crate::envelope::NotarizationEnvelope,
290  required: crate::envelope::AttestationMode,
291) -> std::result::Result<(), crate::errors::AphError> {
292  let actual = envelope
293    .credential_subject
294    .policy
295    .effective_attestation_mode();
296  match (required, actual) {
297    (
298      crate::envelope::AttestationMode::PrincipalSigned,
299      crate::envelope::AttestationMode::NotaryAttested,
300    ) => std::result::Result::Err(crate::errors::AphError::attestation_mode_refused(
301      required.label(),
302      actual.label(),
303    )),
304    _ => std::result::Result::Ok(()),
305  }
306}
307
308/// Binds an embedded Delegation Mandate to the envelope carrying it
309/// (§7.1.7.1 steps 3-4, §8.3.1 step 1d). Signature checking is NOT here.
310///
311/// **Why this exists.** `policy.delegationMandateId` names the parent
312/// mandate by id only, and an id is not verifiable. Embedding the whole
313/// mandate lets a recipient check the human's `principalSignature` offline —
314/// but a valid signature over *some* grant proves nothing about *this*
315/// message. Without the equalities below, an attacker could staple any
316/// validly-signed mandate to any envelope and the notary's word would again
317/// be the only thing linking the human to the send. Closing that hole is the
318/// entire purpose of §7.1.7.1.
319///
320/// Checks, in order:
321///
322/// - `mandate.humanPrincipalDid == credentialSubject.humanPrincipal.id`
323/// - `mandate.agentDid == credentialSubject.agent.id`
324/// - `mandate.id == policy.delegationMandateId`, when that field is present
325/// - `channel.kind` is in `mandate.allowedChannels` (else `APH_E005`)
326/// - the envelope's `validFrom` AND `validUntil` fall inside the mandate's
327///   window (else `APH_E003`)
328///
329/// The three identity equalities report `APH_E011`: the embedded mandate
330/// carries a principal signature that does not authorize THIS envelope, so
331/// no valid signature by this human covers this authorization. §11 scopes
332/// `APH_E011` to step 1d, and the two codes §8.3.1 names explicitly for that
333/// step (`APH_E005`, `APH_E003`) cover only the scope and window failures.
334///
335/// A `None` embedded mandate is `Ok(())`. Absence is not a structural
336/// defect: §7.1.7.1 makes embedding SHOULD, not MUST, because an embedded
337/// mandate discloses the human's entire standing grant to every recipient.
338/// What absence means is that the human's authorization is NOT verifiable by
339/// this recipient, who should then treat the credential as the notary's
340/// assertion alone — a policy decision, and the caller's to make.
341///
342/// **Bounds.** The embedded mandate parses under the same
343/// `deny_unknown_fields` rule as the envelope (§7.1.7.1), so a mandate
344/// carrying an unknown field never reaches this function. Rejecting an
345/// oversized envelope before canonicalizing it remains the caller's job.
346pub fn verify_embedded_mandate_binding(
347  envelope: &crate::envelope::NotarizationEnvelope,
348) -> std::result::Result<(), crate::errors::AphError> {
349  let subject = &envelope.credential_subject;
350  let mandate = match subject.policy.delegation_mandate.as_ref() {
351    std::option::Option::Some(mandate) => mandate,
352    std::option::Option::None => return std::result::Result::Ok(()),
353  };
354
355  if mandate.human_principal_did != subject.human_principal.id {
356    return std::result::Result::Err(crate::errors::AphError::PrincipalSignatureInvalid);
357  }
358  if mandate.agent_did != subject.agent.id {
359    return std::result::Result::Err(crate::errors::AphError::PrincipalSignatureInvalid);
360  }
361  if let std::option::Option::Some(declared_id) = subject.policy.delegation_mandate_id.as_deref() {
362    if mandate.id != declared_id {
363      return std::result::Result::Err(crate::errors::AphError::PrincipalSignatureInvalid);
364    }
365  }
366
367  // §7.1.7.1 is explicit that a Delegation Mandate constrains channel, rate
368  // and time and NOTHING else — it cannot express a recipient allow-list or
369  // a content class, so this is a channel-and-window check and must not be
370  // described as more.
371  if !mandate.allows_channel(subject.channel.kind) {
372    return std::result::Result::Err(crate::errors::AphError::channel_not_allowed(
373      subject.channel.kind.label(),
374    ));
375  }
376
377  // RFC 0005: when the grant constrains WHO consumes, the envelope must
378  // declare a class inside it. An envelope declaring NOTHING under a
379  // constrained grant refuses too — a constraint escapable by omission
380  // would teach every over-broad agent the same trick, and the party this
381  // check disciplines is the principal's own honest agent.
382  if let std::option::Option::Some(allowed) = &mandate.allowed_recipient_classes {
383    let allowed_labels = allowed
384      .iter()
385      .map(crate::envelope::RecipientClass::label)
386      .collect::<std::vec::Vec<&'static str>>()
387      .join(", ");
388    match envelope.credential_subject.channel.recipient_class {
389      std::option::Option::Some(declared) if allowed.contains(&declared) => {}
390      std::option::Option::Some(declared) => {
391        return std::result::Result::Err(crate::errors::AphError::recipient_class_not_allowed(
392          declared.label(),
393          &mandate.id,
394          allowed_labels,
395        ));
396      }
397      std::option::Option::None => {
398        return std::result::Result::Err(crate::errors::AphError::recipient_class_not_allowed(
399          "nothing",
400          &mandate.id,
401          allowed_labels,
402        ));
403      }
404    }
405  }
406
407  // `is_valid_at` is the crate's existing RFC 3339 window comparison: it
408  // parses with `chrono` and returns false on anything unparseable, so a
409  // garbage timestamp denies rather than defaulting to valid. Both endpoints
410  // are checked because §8.3.1 step 1d requires the envelope's WINDOW to
411  // fall inside the mandate's, not merely its start.
412  if !mandate.is_valid_at(&envelope.valid_from) || !mandate.is_valid_at(&envelope.valid_until) {
413    return std::result::Result::Err(crate::errors::AphError::mandate_expired(
414      mandate.id.as_str(),
415    ));
416  }
417
418  std::result::Result::Ok(())
419}
420
421/// Pins the issuance order §7.2.1 makes normative:
422/// `decisionTimestamp <= principal.created <= notary.created`.
423///
424/// The order is not stylistic. The notary prepares the complete envelope
425/// (including its own `notarization` metadata), THEN the principal signs
426/// what was prepared, THEN the notary countersigns the result. Reverse the
427/// first two and the principal would have to sign notary-produced fields
428/// that do not yet exist — the circularity the §7.2.1 canonicalization bases
429/// are written to avoid. §7.2.1 states outright that a verifier MUST NOT
430/// accept a chain whose notary proof is dated before the principal proof it
431/// claims to have observed.
432///
433/// Out-of-order is `APH_E013`. Applies only to a chain: a single proof has
434/// nothing to order, so a single-proof envelope returns `Ok(())`.
435///
436/// Timestamps are parsed with `chrono`, matching
437/// [`crate::delegation_mandate::DelegationMandate::is_valid_at`] rather than
438/// comparing strings, so an offset written as `+00:00` orders correctly
439/// against the same instant written as `Z`. An unparseable timestamp is
440/// `APH_E013`: this fails CLOSED, because an unordered chain and an
441/// unreadable one are equally unacceptable as evidence.
442pub fn verify_timestamp_order(
443  envelope: &crate::envelope::NotarizationEnvelope,
444) -> std::result::Result<(), crate::errors::AphError> {
445  if !envelope.proof.is_chain() {
446    return std::result::Result::Ok(());
447  }
448  let (principal, notary) = match (envelope.proof.principal(), envelope.proof.notary()) {
449    (std::option::Option::Some(p), std::option::Option::Some(n)) => (p, n),
450    _ => {
451      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
452        "cannot check issuance order: `proof` is not a well-formed two-element chain",
453      ));
454    }
455  };
456
457  let decision = parse_rfc3339(
458    &envelope.credential_subject.notarization.decision_timestamp,
459    "credentialSubject.notarization.decisionTimestamp",
460  )?;
461  let principal_created = parse_rfc3339(&principal.created, "the principal proof's `created`")?;
462  let notary_created = parse_rfc3339(&notary.created, "the notary proof's `created`")?;
463
464  if principal_created < decision {
465    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
466      std::format!(
467        "the principal proof's `created` `{}` precedes \
468         `notarization.decisionTimestamp` `{}`; the principal signs the \
469         envelope the notary has already prepared (§7.2.1)",
470        principal.created, envelope.credential_subject.notarization.decision_timestamp
471      ),
472    ));
473  }
474  if notary_created < principal_created {
475    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
476      std::format!(
477        "the notary proof's `created` `{}` precedes the principal proof's \
478         `created` `{}`; a countersignature cannot predate the proof it \
479         claims to have observed (§7.2.1)",
480        notary.created, principal.created
481      ),
482    ));
483  }
484
485  std::result::Result::Ok(())
486}
487
488/// §8.3's audience step (RFC 0003 §1): when the envelope names who may
489/// accept it, this verifier had better be that party.
490///
491/// The semantics, in refusal order:
492/// - `audience` ABSENT: admitted. Absence is the producer's decision to
493///   issue a bearer credential, never an oversight the verifier forgives.
494/// - `audience.id` differs from `verifier_id`: refused, `APH_E017`.
495/// - `audience.channelBinding` present: every member it names — `kind`
496///   included — must equal the same-named member of `act_coordinates`, the
497///   delivery coordinates of the act this verifier is being asked to
498///   perform. A member the act's coordinates lack is a MISMATCH, and
499///   `act_coordinates` being `None` entirely is a mismatch too: a verifier
500///   that cannot determine the act's coordinates MUST reject rather than
501///   skip, exactly as one that cannot determine its own identity must —
502///   which is why `verifier_id` is a required parameter rather than an
503///   `Option` this function politely tolerates.
504///
505/// A comparison, not cryptography: nothing here checks a signature, and
506/// the caller runs this AFTER signature verification (§8.3 ordering).
507pub fn verify_audience(
508  envelope: &crate::envelope::NotarizationEnvelope,
509  verifier_id: &str,
510  act_coordinates: std::option::Option<&crate::envelope::AudienceChannelBinding>,
511) -> std::result::Result<(), crate::errors::AphError> {
512  let audience = match &envelope.credential_subject.audience {
513    std::option::Option::None => return std::result::Result::Ok(()),
514    std::option::Option::Some(audience) => audience,
515  };
516  if audience.id != verifier_id {
517    return std::result::Result::Err(crate::errors::AphError::audience_mismatch(
518      &audience.id,
519      verifier_id,
520    ));
521  }
522  let binding = match &audience.channel_binding {
523    std::option::Option::None => return std::result::Result::Ok(()),
524    std::option::Option::Some(binding) => binding,
525  };
526  let act = match act_coordinates {
527    std::option::Option::Some(act) => act,
528    std::option::Option::None => {
529      // Reject-rather-than-skip: a binding nobody can check is refused,
530      // not waved through, because a check a verifier may skip when
531      // inconvenient is not a check.
532      return std::result::Result::Err(crate::errors::AphError::audience_mismatch(
533        std::format!("{} (channelBinding present)", audience.id),
534        std::format!("{} (act delivery coordinates unknown to this verifier)", verifier_id),
535      ));
536    }
537  };
538  if binding.kind != act.kind {
539    return std::result::Result::Err(crate::errors::AphError::audience_mismatch(
540      std::format!("{} (channelBinding kind: `{}`)", audience.id, binding.kind.label()),
541      std::format!("{} (act kind: `{}`)", verifier_id, act.kind.label()),
542    ));
543  }
544  for (member, bound) in &binding.coordinates {
545    match act.coordinates.get(member) {
546      std::option::Option::Some(actual) if actual == bound => {}
547      std::option::Option::Some(actual) => {
548        return std::result::Result::Err(crate::errors::AphError::audience_mismatch(
549          std::format!("{} (channelBinding {}: `{}`)", audience.id, member, bound),
550          std::format!("{} (act {}: `{}`)", verifier_id, member, actual),
551        ));
552      }
553      std::option::Option::None => {
554        return std::result::Result::Err(crate::errors::AphError::audience_mismatch(
555          std::format!("{} (channelBinding {}: `{}`)", audience.id, member, bound),
556          std::format!("{} (act has no `{}` coordinate)", verifier_id, member),
557        ));
558      }
559    }
560  }
561  std::result::Result::Ok(())
562}
563
564/// The envelope's OWN validity window, judged against a clock the CALLER
565/// supplies (RFC 0003's `APH_E019`; §8.3 step 2's check finally has its
566/// code). Distinct from anything mandate-shaped: `APH_E003` is a mandate
567/// consulted past its expiry, and routing an expired ENVELOPE there was
568/// the miscite this crate's own tests nearly shipped.
569///
570/// `observed_at` is a parameter, not a syscall: verification stays a pure
571/// function of its inputs, testable at any instant, and the caller states
572/// which clock it trusts. Every timestamp unparseable → refused with the
573/// same code, fail-closed — an unreadable window never admits.
574pub fn verify_envelope_window(
575  envelope: &crate::envelope::NotarizationEnvelope,
576  observed_at: &str,
577) -> std::result::Result<(), crate::errors::AphError> {
578  let window_invalid = || {
579    crate::errors::AphError::envelope_window_invalid(
580      &envelope.valid_from,
581      &envelope.valid_until,
582      observed_at,
583    )
584  };
585  let from = chrono::DateTime::parse_from_rfc3339(&envelope.valid_from)
586    .map_err(|_| window_invalid())?;
587  let until = chrono::DateTime::parse_from_rfc3339(&envelope.valid_until)
588    .map_err(|_| window_invalid())?;
589  let at = chrono::DateTime::parse_from_rfc3339(observed_at).map_err(|_| window_invalid())?;
590  if at < from || at > until {
591    return std::result::Result::Err(window_invalid());
592  }
593  std::result::Result::Ok(())
594}
595
596/// The single-use record §8.3's final step obliges a recipient to keep
597/// (RFC 0003 §2): `id` is spent at ACCEPTANCE, and a second presentation
598/// of the same `id` is refused with `APH_E018`.
599///
600/// The protocol states the obligation, not the storage — a single process
601/// may use a set, a cluster needs shared state, a notary MAY offer a burn
602/// endpoint — so this is a port. The retention contract an implementor
603/// signs up for: an id MUST be retained at least until its envelope's
604/// `validUntil` has passed, and MAY be discarded thereafter, because
605/// [`verify_envelope_window`] already refuses the envelope from then on.
606pub trait ConsumedEnvelopeLedger {
607  /// Records `envelope_id` as spent iff it was not already; returns whether
608  /// this call was the one that spent it. One atomic question, because
609  /// separate "seen?" and "record!" calls are a check-then-act race — two
610  /// concurrent presentations would both pass the check.
611  fn spend(&mut self, envelope_id: &str) -> bool;
612}
613
614/// The single-process ledger: a set. Exists so the obligation is testable
615/// here and so a caller with one process needs no ceremony; anything
616/// distributed brings its own implementation of the trait.
617#[derive(std::fmt::Debug, std::default::Default)]
618pub struct InMemoryConsumedEnvelopeLedger {
619  spent: std::collections::BTreeSet<String>,
620}
621
622impl ConsumedEnvelopeLedger for InMemoryConsumedEnvelopeLedger {
623  fn spend(&mut self, envelope_id: &str) -> bool {
624    self.spent.insert(envelope_id.to_string())
625  }
626}
627
628/// §8.3's single-use step: call this AT THE MOMENT the verifier commits to
629/// the act — acceptance, in the RFC 5321 §6.1 sense — after every other
630/// check has passed. Calling it earlier consumes the envelope on a
631/// presentation that some LATER check may still refuse, and RFC 0003 is
632/// explicit that a verifier which rejects for any other reason has not
633/// consumed the id.
634pub fn enforce_single_use(
635  ledger: &mut dyn ConsumedEnvelopeLedger,
636  envelope: &crate::envelope::NotarizationEnvelope,
637) -> std::result::Result<(), crate::errors::AphError> {
638  if ledger.spend(&envelope.id) {
639    std::result::Result::Ok(())
640  } else {
641    std::result::Result::Err(crate::errors::AphError::envelope_already_spent(&envelope.id))
642  }
643}
644
645/// Parses an RFC 3339 timestamp, reporting `APH_E013` and naming the field
646/// when it cannot be read. Fails closed: an unreadable timestamp is never
647/// treated as satisfying an ordering constraint.
648fn parse_rfc3339(
649  value: &str,
650  field: &str,
651) -> std::result::Result<
652  chrono::DateTime<chrono::FixedOffset>,
653  crate::errors::AphError,
654> {
655  chrono::DateTime::parse_from_rfc3339(value).map_err(|_| {
656    crate::errors::AphError::proof_chain_invalid(std::format!(
657      "{} `{}` is not a parseable RFC 3339 timestamp, so issuance order \
658       cannot be checked",
659      field, value
660    ))
661  })
662}
663
664#[cfg(test)]
665mod tests {
666  // ── Fixtures ──────────────────────────────────────────────────────────
667  //
668  // The human principal's DID appears in three places that MUST agree —
669  // `humanPrincipal.id`, the principal proof's `verificationMethod`, and an
670  // embedded mandate's `humanPrincipalDid` — so it is named once here. A
671  // test that needs them to disagree changes one copy explicitly, which is
672  // what makes the disagreement visible in the test body.
673  const HUMAN_DID: &str = "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy";
674  const NOTARY_DID: &str = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV";
675  const AGENT_DID: &str = "did:web:agent.squillo.com";
676  const PRINCIPAL_PROOF_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000a1";
677  const NOTARY_PROOF_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000b1";
678  const MANDATE_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000c1";
679
680  fn notary_proof() -> crate::envelope::EnvelopeProof {
681    crate::envelope::EnvelopeProof {
682      r#type: std::string::String::from("DataIntegrityProof"),
683      cryptosuite: std::option::Option::Some(std::string::String::from("eddsa-jcs-2022")),
684      verification_method: std::format!("{}#{}", NOTARY_DID, "z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"),
685      created: std::string::String::from("2026-05-21T00:00:01Z"),
686      proof_purpose: std::string::String::from("assertionMethod"),
687      proof_value: std::string::String::from("z-illustrative-lone-notary-proof"),
688      id: std::option::Option::None,
689      previous_proof: std::option::Option::None,
690    }
691  }
692
693  fn principal_proof() -> crate::envelope::EnvelopeProof {
694    crate::envelope::EnvelopeProof {
695      r#type: std::string::String::from("DataIntegrityProof"),
696      cryptosuite: std::option::Option::Some(std::string::String::from("eddsa-jcs-2022")),
697      verification_method: std::format!("{}#{}", HUMAN_DID, "z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy"),
698      created: std::string::String::from("2026-05-21T00:00:02Z"),
699      proof_purpose: std::string::String::from("assertionMethod"),
700      proof_value: std::string::String::from("z-illustrative-principal-proof"),
701      id: std::option::Option::Some(std::string::String::from(PRINCIPAL_PROOF_ID)),
702      previous_proof: std::option::Option::None,
703    }
704  }
705
706  fn countersignature() -> crate::envelope::EnvelopeProof {
707    crate::envelope::EnvelopeProof {
708      r#type: std::string::String::from("DataIntegrityProof"),
709      cryptosuite: std::option::Option::Some(std::string::String::from("eddsa-jcs-2022")),
710      verification_method: std::format!("{}#{}", NOTARY_DID, "z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"),
711      created: std::string::String::from("2026-05-21T00:00:03Z"),
712      proof_purpose: std::string::String::from("authentication"),
713      proof_value: std::string::String::from("z-illustrative-countersignature"),
714      id: std::option::Option::Some(std::string::String::from(NOTARY_PROOF_ID)),
715      previous_proof: std::option::Option::Some(std::string::String::from(PRINCIPAL_PROOF_ID)),
716    }
717  }
718
719  fn mandate() -> crate::delegation_mandate::DelegationMandate {
720    crate::delegation_mandate::DelegationMandate {
721      id: std::string::String::from(MANDATE_ID),
722      human_principal_did: std::string::String::from(HUMAN_DID),
723      agent_did: std::string::String::from(AGENT_DID),
724      allowed_channels: std::vec![crate::envelope::ChannelKind::Slack, crate::envelope::ChannelKind::Email],
725      allowed_recipient_classes: std::option::Option::None,
726      rate_limit_per_hour: std::option::Option::Some(12),
727      valid_from: std::string::String::from("2026-05-01T00:00:00Z"),
728      valid_until: std::string::String::from("2026-06-01T00:00:00Z"),
729      principal_signature: std::string::String::from("z-illustrative-principal-signature"),
730      notary_signature: std::string::String::from("z-illustrative-notary-signature"),
731    }
732  }
733
734  /// A `NotaryAttested` envelope in exactly the shape all eight published
735  /// examples carry: one proof object, no `attestationMode`.
736  fn single_proof_envelope() -> crate::envelope::NotarizationEnvelope {
737    crate::envelope::NotarizationEnvelope {
738      aph_version: std::string::String::from("0.1"),
739      context: std::vec![
740        std::string::String::from("https://www.w3.org/ns/credentials/v2"),
741        std::string::String::from("https://w3id.org/aph/v1"),
742      ],
743      r#type: std::vec![
744        std::string::String::from("VerifiableCredential"),
745        std::string::String::from("AgentSendAuthorizationCredential"),
746      ],
747      id: std::string::String::from("urn:uuid:00000000-0000-4000-8000-000000000001"),
748      issuer: std::string::String::from(NOTARY_DID),
749      valid_from: std::string::String::from("2026-05-21T00:00:00Z"),
750      valid_until: std::string::String::from("2026-05-22T00:00:00Z"),
751      credential_subject: crate::envelope::CredentialSubject {
752        human_principal: crate::envelope::HumanPrincipalRef {
753          id: std::string::String::from(HUMAN_DID),
754          display_name: std::string::String::from("Scott Wyatt"),
755        },
756        agent: crate::envelope::AgentRef {
757          id: std::string::String::from(AGENT_DID),
758          agent_card_uri: std::option::Option::None,
759          display_name: std::string::String::from("Squillo Concierge"),
760          version: std::string::String::from("1.0"),
761        },
762        channel: crate::envelope::ChannelDescriptor {
763          kind: crate::envelope::ChannelKind::Slack,
764          recipient_addressing: serde_json::json!({"teamId": "T01234567"}),
765          recipient_class: std::option::Option::None,
766        },
767        communication: crate::envelope::CommunicationDescriptor {
768          content_class: crate::envelope::ContentClass::Reply,
769          body_sha256: std::string::String::from(
770            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
771          ),
772          body_size: 1842,
773          preview_lines: 3,
774          preview: std::string::String::from("hello world"),
775        },
776        policy: crate::envelope::PolicyDescriptor {
777          decision: crate::envelope::PolicyDecision::AskEveryTime,
778          matched_scope: std::string::String::from("per-channel"),
779          delegation_mandate_id: std::option::Option::None,
780          act_chain: std::vec::Vec::new(),
781          attestation_mode: std::option::Option::None,
782          delegation_mandate: std::option::Option::None,
783        },
784        notarization: crate::envelope::NotarizationMetadata {
785          notary_service: crate::envelope::NotaryServiceRef {
786            id: std::string::String::from("did:web:notary.squillo.com"),
787            name: std::string::String::from("Squillo Notary Service"),
788            version: std::string::String::from("0.1.0"),
789            attested_digest: std::option::Option::None,
790            attestation_uri: std::option::Option::None,
791          },
792          decision_timestamp: std::string::String::from("2026-05-21T00:00:01Z"),
793          decision_latency_ms: 1834,
794        },
795        apple_aur_acceptance: std::option::Option::None,
796        audience: std::option::Option::None,
797        sealed_payload: std::option::Option::None,
798        act_classification: std::option::Option::None,
799      },
800      linked_mandate: std::option::Option::None,
801      // Pattern A (§7.1.1): absent here means NO `credentialStatus` key on
802      // the wire, which is what keeps this fixture byte-identical to the
803      // pre-revocation shape its signatures were made over.
804      credential_status: std::option::Option::None,
805      proof: crate::envelope::EnvelopeProofs::Single(notary_proof()),
806    }
807  }
808
809  /// A well-formed `PrincipalSigned` envelope: two linked proofs, the label
810  /// that matches, and the principal proof made under the human's own DID.
811  fn chain_envelope() -> crate::envelope::NotarizationEnvelope {
812    let mut envelope = single_proof_envelope();
813    envelope.credential_subject.policy.attestation_mode =
814      std::option::Option::Some(crate::envelope::AttestationMode::PrincipalSigned);
815    envelope.proof =
816      crate::envelope::EnvelopeProofs::Chain(std::vec![principal_proof(), countersignature()]);
817    envelope
818  }
819
820  /// Builds a chain envelope after letting the caller mutate either proof —
821  /// the shape every rejection test below needs.
822  fn chain_envelope_with(
823    mutate: impl std::ops::FnOnce(
824      &mut crate::envelope::EnvelopeProof,
825      &mut crate::envelope::EnvelopeProof,
826    ),
827  ) -> crate::envelope::NotarizationEnvelope {
828    let mut principal = principal_proof();
829    let mut notary = countersignature();
830    mutate(&mut principal, &mut notary);
831    let mut envelope = chain_envelope();
832    envelope.proof = crate::envelope::EnvelopeProofs::Chain(std::vec![principal, notary]);
833    envelope
834  }
835
836  // ── verify_proof_structure: positive cases ────────────────────────────
837
838  #[test]
839  fn single_proof_without_a_label_verifies_as_notary_attested() {
840    // This is the shape of ALL EIGHT published example envelopes and every
841    // envelope any notary signed before `attestationMode` existed. If it
842    // failed, or resolved to `PrincipalSigned`, the implementation would
843    // either reject the entire deployed corpus or silently promote it to a
844    // claim no human ever made.
845    let mode = super::verify_proof_structure(&single_proof_envelope())
846      .expect("a lone notary proof is a well-formed NotaryAttested envelope");
847    std::assert_eq!(mode, crate::envelope::AttestationMode::NotaryAttested);
848  }
849
850  #[test]
851  fn a_lone_proof_carrying_chain_members_is_rejected() {
852    // `id` and `previousProof` are chain vocabulary. On a lone proof they
853    // describe a chain that is not there, which is exactly what a stripped
854    // chain looks like: an attacker removes the countersignature and the
855    // remainder still speaks as though a principal proof were present. A
856    // verifier reading loosely could conclude the human had signed.
857    let mut envelope = single_proof_envelope();
858    if let crate::envelope::EnvelopeProofs::Single(proof) = &mut envelope.proof {
859      proof.previous_proof =
860        std::option::Option::Some(std::string::String::from("urn:uuid:00000000-0000-4000-8000-0000000000f1"));
861    }
862    let err = super::verify_proof_structure(&envelope)
863      .expect_err("a lone proof naming a previous proof is not well formed");
864    std::assert_eq!(err.code(), "APH_E013");
865  }
866
867  #[test]
868  fn a_lone_proof_using_the_countersignature_purpose_is_rejected() {
869    // `authentication` is the purpose of the SECOND proof of a chain. A lone
870    // proof carrying it is a chain missing its head — the principal proof
871    // that was the entire authorization. Accepting it would admit an
872    // envelope whose own proof block says a countersignature is all that
873    // remains.
874    let mut envelope = single_proof_envelope();
875    if let crate::envelope::EnvelopeProofs::Single(proof) = &mut envelope.proof {
876      proof.proof_purpose = std::string::String::from("authentication");
877    }
878    let err = super::verify_proof_structure(&envelope)
879      .expect_err("a lone proof may not use the countersignature purpose");
880    std::assert_eq!(err.code(), "APH_E013");
881  }
882
883  #[test]
884  fn single_proof_labelled_notary_attested_verifies() {
885    // A producer MAY state the weaker mode explicitly rather than relying on
886    // absence. Rejecting the explicit spelling would punish the more honest
887    // producer and push everyone toward the ambiguous form.
888    let mut envelope = single_proof_envelope();
889    envelope.credential_subject.policy.attestation_mode =
890      std::option::Option::Some(crate::envelope::AttestationMode::NotaryAttested);
891    let mode = super::verify_proof_structure(&envelope)
892      .expect("an explicitly-labelled NotaryAttested envelope is well formed");
893    std::assert_eq!(mode, crate::envelope::AttestationMode::NotaryAttested);
894  }
895
896  #[test]
897  fn well_formed_chain_verifies_as_principal_signed() {
898    // The positive control for the whole §7.1.11 rule set. Every rejection
899    // test below mutates exactly one field of this envelope, so if this case
900    // failed they would all pass for the wrong reason and the suite would
901    // prove nothing.
902    let mode = super::verify_proof_structure(&chain_envelope())
903      .expect("a correctly linked and labelled chain must verify");
904    std::assert_eq!(mode, crate::envelope::AttestationMode::PrincipalSigned);
905  }
906
907  #[test]
908  fn principal_verification_method_without_a_fragment_is_accepted() {
909    // A DID URL is `<did>#<fragment>`, but the fragment is what selects a
910    // key, not what identifies the principal. A bare DID equal to
911    // `humanPrincipal.id` still binds the proof to the right human, so
912    // rejecting it would refuse a conformant producer on a formatting
913    // preference rather than a security property.
914    let envelope = chain_envelope_with(|principal, _notary| {
915      principal.verification_method = std::string::String::from(HUMAN_DID);
916    });
917    std::assert!(super::verify_proof_structure(&envelope).is_ok());
918  }
919
920  // ── verify_proof_structure: chain length ──────────────────────────────
921
922  #[test]
923  fn a_one_element_chain_is_rejected() {
924    // §7.2.1's stripped-proof attack lands here. An intermediary that
925    // removes the notary proof from a PrincipalSigned envelope leaves a
926    // one-element ARRAY, and because the principal's signature covers the
927    // array form it would still verify. Rejecting length 1 is what stops the
928    // human's own proof being re-presented as a notary attestation.
929    let mut envelope = chain_envelope();
930    envelope.proof = crate::envelope::EnvelopeProofs::Chain(std::vec![principal_proof()]);
931    let err = super::verify_proof_structure(&envelope)
932      .expect_err("a one-element chain must be rejected");
933    std::assert_eq!(err.code(), "APH_E013");
934  }
935
936  #[test]
937  fn a_three_element_chain_is_rejected() {
938    // §7.1.11 constrains the chain to exactly two ROLES. A third proof has
939    // no defined role, no canonicalization base (§7.2.1 defines two), and no
940    // verification order — accepting it would mean verifying a signature
941    // whose covered bytes are undefined.
942    let mut envelope = chain_envelope();
943    envelope.proof = crate::envelope::EnvelopeProofs::Chain(std::vec![
944      principal_proof(),
945      countersignature(),
946      countersignature(),
947    ]);
948    let err = super::verify_proof_structure(&envelope)
949      .expect_err("a three-element chain must be rejected");
950    std::assert_eq!(err.code(), "APH_E013");
951  }
952
953  #[test]
954  fn an_empty_chain_is_rejected() {
955    // An empty array is a `proof` member that proves nothing while still
956    // satisfying "the proof field is present". Without this rule an envelope
957    // with no signatures at all would reach the signature-verification step
958    // and pass it vacuously, having verified zero proofs.
959    let mut envelope = chain_envelope();
960    envelope.proof = crate::envelope::EnvelopeProofs::Chain(std::vec::Vec::new());
961    let err =
962      super::verify_proof_structure(&envelope).expect_err("an empty chain must be rejected");
963    std::assert_eq!(err.code(), "APH_E013");
964  }
965
966  // ── verify_proof_structure: label/structure agreement ─────────────────
967
968  #[test]
969  fn a_chain_with_no_attestation_mode_is_rejected() {
970    // Absence means `NotaryAttested` (§7.1.7), and a NotaryAttested envelope
971    // has no principal proof. An unlabelled chain therefore contradicts
972    // itself, and §7.1.11 requires rejection rather than inferring the
973    // stronger mode from the structure — the label must be asserted, so that
974    // its absence can never be read as a claim.
975    let mut envelope = chain_envelope();
976    envelope.credential_subject.policy.attestation_mode = std::option::Option::None;
977    let err = super::verify_proof_structure(&envelope)
978      .expect_err("an unlabelled chain must be rejected");
979    std::assert_eq!(err.code(), "APH_E013");
980  }
981
982  #[test]
983  fn a_chain_labelled_notary_attested_is_rejected() {
984    // The mismatch must be a rejection, never a silent coercion to the
985    // weaker mode: an envelope carrying the human's own proof but read as
986    // the notary's assertion would understate the evidence, and a verifier
987    // that quietly rewrote the label would hide a producer bug that could
988    // just as easily have gone the other way.
989    let mut envelope = chain_envelope();
990    envelope.credential_subject.policy.attestation_mode =
991      std::option::Option::Some(crate::envelope::AttestationMode::NotaryAttested);
992    let err = super::verify_proof_structure(&envelope)
993      .expect_err("a chain labelled NotaryAttested must be rejected");
994    std::assert_eq!(err.code(), "APH_E013");
995  }
996
997  #[test]
998  fn a_single_proof_labelled_principal_signed_is_rejected() {
999    // THE attack §7.1.11 names outright. A holder of a notary key can write
1000    // `PrincipalSigned` above a single notary proof whose `proofPurpose` is
1001    // `assertionMethod` — indistinguishable from a principal proof by purpose
1002    // alone. A verifier that trusted the label would report a forged
1003    // authorization as the human's own signature.
1004    let mut envelope = single_proof_envelope();
1005    envelope.credential_subject.policy.attestation_mode =
1006      std::option::Option::Some(crate::envelope::AttestationMode::PrincipalSigned);
1007    let err = super::verify_proof_structure(&envelope)
1008      .expect_err("a PrincipalSigned label over a single proof must be rejected");
1009    std::assert_eq!(err.code(), "APH_E013");
1010  }
1011
1012  // ── verify_proof_structure: proof purposes ────────────────────────────
1013
1014  #[test]
1015  fn a_principal_proof_with_the_wrong_purpose_is_rejected() {
1016    // Position 1 asserts the authorization, so its purpose is
1017    // `assertionMethod` (§7.1.11). A head proof carrying `authentication`
1018    // describes a countersignature, and a chain of two countersignatures
1019    // contains no authorization at all.
1020    let envelope = chain_envelope_with(|principal, _notary| {
1021      principal.proof_purpose = std::string::String::from("authentication");
1022    });
1023    let err = super::verify_proof_structure(&envelope)
1024      .expect_err("principal proof must carry assertionMethod");
1025    std::assert_eq!(err.code(), "APH_E013");
1026  }
1027
1028  #[test]
1029  fn a_notary_proof_with_the_wrong_purpose_is_rejected() {
1030    // Position 2 is a COUNTERSIGNATURE — `authentication`, not
1031    // `assertionMethod`. A notary proof claiming `assertionMethod` claims to
1032    // be making the assertion rather than witnessing it, which is precisely
1033    // the substitution the chain exists to prevent.
1034    let envelope = chain_envelope_with(|_principal, notary| {
1035      notary.proof_purpose = std::string::String::from("assertionMethod");
1036    });
1037    let err = super::verify_proof_structure(&envelope)
1038      .expect_err("notary proof must carry authentication");
1039    std::assert_eq!(err.code(), "APH_E013");
1040  }
1041
1042  // ── verify_proof_structure: proof ids ─────────────────────────────────
1043
1044  #[test]
1045  fn a_principal_proof_without_an_id_is_rejected() {
1046    // The `id` is what `previousProof` names. Without it the chain can only
1047    // be read positionally, and §7.1.11 says a verifier that trusted order
1048    // alone would accept a chain an intermediary reordered.
1049    let envelope = chain_envelope_with(|principal, notary| {
1050      principal.id = std::option::Option::None;
1051      notary.previous_proof = std::option::Option::None;
1052    });
1053    let err = super::verify_proof_structure(&envelope)
1054      .expect_err("a chain proof without an id must be rejected");
1055    std::assert_eq!(err.code(), "APH_E013");
1056  }
1057
1058  #[test]
1059  fn a_notary_proof_without_an_id_is_rejected() {
1060    // §7.1.11 requires an `id` on EVERY proof in a chain, not only on the
1061    // one that is named. Enforcing it on the head alone would leave the
1062    // requirement half-implemented and let a producer ship a chain no other
1063    // implementation accepts.
1064    let envelope = chain_envelope_with(|_principal, notary| {
1065      notary.id = std::option::Option::None;
1066    });
1067    let err = super::verify_proof_structure(&envelope)
1068      .expect_err("the notary proof must carry an id");
1069    std::assert_eq!(err.code(), "APH_E013");
1070  }
1071
1072  #[test]
1073  fn two_proofs_sharing_one_id_are_rejected() {
1074    // Duplicate ids make `previousProof` ambiguous: the link would resolve
1075    // to either proof, so a self-reference and a real countersignature
1076    // become indistinguishable and the linkage stops carrying information.
1077    let envelope = chain_envelope_with(|_principal, notary| {
1078      notary.id = std::option::Option::Some(std::string::String::from(PRINCIPAL_PROOF_ID));
1079      notary.previous_proof = std::option::Option::Some(std::string::String::from(PRINCIPAL_PROOF_ID));
1080    });
1081    let err = super::verify_proof_structure(&envelope)
1082      .expect_err("duplicate proof ids must be rejected");
1083    std::assert_eq!(err.code(), "APH_E013");
1084  }
1085
1086  // ── verify_proof_structure: linkage ───────────────────────────────────
1087
1088  #[test]
1089  fn a_notary_proof_without_previous_proof_is_rejected() {
1090    // Array position is a HINT; `previousProof` is the binding (§8.3.1 1e).
1091    // An unlinked pair of proofs is just two signatures that happen to share
1092    // an envelope — nothing states that the notary observed THIS principal
1093    // proof, which is the one thing the countersignature is for.
1094    let envelope = chain_envelope_with(|_principal, notary| {
1095      notary.previous_proof = std::option::Option::None;
1096    });
1097    let err = super::verify_proof_structure(&envelope)
1098      .expect_err("missing previousProof must be rejected");
1099    std::assert_eq!(err.code(), "APH_E013");
1100    std::assert!(
1101      std::string::ToString::to_string(&err).contains("previousProof"),
1102      "the reason must name the missing member: {}",
1103      err
1104    );
1105  }
1106
1107  #[test]
1108  fn a_dangling_previous_proof_is_rejected() {
1109    // §7.1.11: a verifier MUST reject a chain whose `previousProof` does not
1110    // resolve to a proof present in the same chain. A link pointing outside
1111    // this envelope could name a proof from a DIFFERENT envelope, which is
1112    // how a countersignature would get detached and reattached.
1113    let envelope = chain_envelope_with(|_principal, notary| {
1114      notary.previous_proof =
1115        std::option::Option::Some(std::string::String::from("urn:uuid:not-in-this-chain"));
1116    });
1117    let err = super::verify_proof_structure(&envelope)
1118      .expect_err("a dangling previousProof must be rejected");
1119    std::assert_eq!(err.code(), "APH_E013");
1120    std::assert!(
1121      std::string::ToString::to_string(&err).contains("dangling"),
1122      "the reason must distinguish dangling from missing: {}",
1123      err
1124    );
1125  }
1126
1127  #[test]
1128  fn a_self_referential_previous_proof_is_rejected() {
1129    // A proof that countersigns itself is a cycle: it claims to cover bytes
1130    // that include its own signature, which is unconstructible. Reported
1131    // distinctly from "dangling" so an implementer sees which mistake they
1132    // made rather than hunting for a proof id that was never missing.
1133    let envelope = chain_envelope_with(|_principal, notary| {
1134      notary.previous_proof = std::option::Option::Some(std::string::String::from(NOTARY_PROOF_ID));
1135    });
1136    let err = super::verify_proof_structure(&envelope)
1137      .expect_err("a self-referential previousProof must be rejected");
1138    std::assert_eq!(err.code(), "APH_E013");
1139    std::assert!(
1140      std::string::ToString::to_string(&err).contains("self-referential"),
1141      "the reason must distinguish a cycle from a dangling link: {}",
1142      err
1143    );
1144  }
1145
1146  #[test]
1147  fn a_principal_proof_carrying_previous_proof_is_rejected() {
1148    // The principal proof is the HEAD of the chain and countersigns nothing
1149    // (§7.1.11). A `previousProof` on it either names a proof outside this
1150    // envelope or forms a cycle with the notary proof; both make the chain's
1151    // direction — and therefore the verification order — undefined.
1152    let envelope = chain_envelope_with(|principal, _notary| {
1153      principal.previous_proof = std::option::Option::Some(std::string::String::from(NOTARY_PROOF_ID));
1154    });
1155    let err = super::verify_proof_structure(&envelope)
1156      .expect_err("the chain head must not carry previousProof");
1157    std::assert_eq!(err.code(), "APH_E013");
1158  }
1159
1160  // ── verify_proof_structure: the principal-key binding ─────────────────
1161
1162  #[test]
1163  fn a_principal_proof_made_by_another_key_is_rejected() {
1164    // The check that makes `PrincipalSigned` unforgeable by a notary. Here
1165    // the chain is perfectly formed and correctly labelled, but the head
1166    // proof is made under the NOTARY's DID — so the "principal proof" is the
1167    // notary signing twice. §7.1.11 says a proof made by any other key is
1168    // not the principal's proof, whatever its `proofPurpose` says, and the
1169    // notary does not hold the human's key.
1170    let envelope = chain_envelope_with(|principal, _notary| {
1171      principal.verification_method = std::format!("{}#key-1", NOTARY_DID);
1172    });
1173    let err = super::verify_proof_structure(&envelope)
1174      .expect_err("a principal proof under a foreign DID must be rejected");
1175    std::assert_eq!(err.code(), "APH_E013");
1176  }
1177
1178  #[test]
1179  fn a_principal_verification_method_that_only_prefixes_the_human_did_is_rejected() {
1180    // Prefix matching on the RAW string would accept
1181    // `did:key:zHuman-evil#k` for the principal `did:key:zHuman`. The rule
1182    // is equality of the DID part BEFORE the `#`, not string containment, or
1183    // an attacker could register a DID whose text extends the victim's.
1184    let envelope = chain_envelope_with(|principal, _notary| {
1185      principal.verification_method = std::format!("{}-attacker#key-1", HUMAN_DID);
1186    });
1187    let err = super::verify_proof_structure(&envelope)
1188      .expect_err("a DID that merely starts with the human's DID must be rejected");
1189    std::assert_eq!(err.code(), "APH_E013");
1190  }
1191
1192  // ── require_mode ──────────────────────────────────────────────────────
1193
1194  #[test]
1195  fn require_principal_signed_refuses_a_notary_attested_envelope() {
1196    // §8.3.1 step 1a: there is no silent downgrade from a stronger
1197    // attestation to a weaker one, because an attacker who can defeat the
1198    // weak path will always present the weak path. APH_E012 specifically —
1199    // this is a policy refusal, not a malformed envelope, and an operator
1200    // reading a log must be able to tell those apart.
1201    let err = super::require_mode(
1202      &single_proof_envelope(),
1203      crate::envelope::AttestationMode::PrincipalSigned,
1204    )
1205    .expect_err("a NotaryAttested envelope must not satisfy a PrincipalSigned policy");
1206    std::assert_eq!(err.code(), "APH_E012");
1207  }
1208
1209  #[test]
1210  fn require_notary_attested_accepts_a_principal_signed_envelope() {
1211    // A STRONGER envelope satisfies a weaker policy. Rejecting it would
1212    // punish the producer who did more work and give every deployment a
1213    // reason to stay on the weaker mode.
1214    std::assert!(
1215      super::require_mode(
1216        &chain_envelope(),
1217        crate::envelope::AttestationMode::NotaryAttested,
1218      )
1219      .is_ok()
1220    );
1221  }
1222
1223  #[test]
1224  fn require_mode_accepts_an_exact_match() {
1225    // The ordinary case, pinned so a future rewrite of the comparison cannot
1226    // make the common path fail while the two interesting cases still pass.
1227    std::assert!(
1228      super::require_mode(
1229        &single_proof_envelope(),
1230        crate::envelope::AttestationMode::NotaryAttested,
1231      )
1232      .is_ok()
1233    );
1234  }
1235
1236  #[test]
1237  fn require_mode_reads_absence_as_the_weaker_mode() {
1238    // The eight published envelopes carry no `attestationMode`, so a
1239    // deployment that demands `PrincipalSigned` must refuse them rather than
1240    // treat the missing field as unconstrained. §7.1.7 fixes absent as
1241    // `NotaryAttested`; this is that default doing security work.
1242    let mut envelope = single_proof_envelope();
1243    envelope.credential_subject.policy.attestation_mode = std::option::Option::None;
1244    let err = super::require_mode(&envelope, crate::envelope::AttestationMode::PrincipalSigned)
1245      .expect_err("an unlabelled envelope must not satisfy a PrincipalSigned policy");
1246    std::assert_eq!(err.code(), "APH_E012");
1247  }
1248
1249  // ── verify_embedded_mandate_binding ───────────────────────────────────
1250
1251  #[test]
1252  fn an_absent_embedded_mandate_is_not_a_structural_error() {
1253    // §7.1.7.1 makes embedding SHOULD, not MUST, because an embedded mandate
1254    // discloses the human's entire standing grant to every recipient.
1255    // Absence means the human's authorization is not VERIFIABLE here — a
1256    // policy conclusion for the caller, not a malformed envelope.
1257    std::assert!(super::verify_embedded_mandate_binding(&single_proof_envelope()).is_ok());
1258  }
1259
1260  #[test]
1261  fn a_matching_embedded_mandate_binds() {
1262    // Positive control for all five bindings at once. Every rejection test
1263    // below changes exactly one field of this envelope, so if this failed
1264    // they would all pass without proving the specific rule they name.
1265    let mut envelope = single_proof_envelope();
1266    envelope.credential_subject.policy.delegation_mandate_id =
1267      std::option::Option::Some(std::string::String::from(MANDATE_ID));
1268    envelope.credential_subject.policy.delegation_mandate = std::option::Option::Some(mandate());
1269    std::assert!(super::verify_embedded_mandate_binding(&envelope).is_ok());
1270  }
1271
1272  #[test]
1273  fn an_embedded_mandate_for_another_human_is_rejected() {
1274    // The staple attack §7.1.7.1 exists to close: a validly-signed mandate
1275    // belonging to a DIFFERENT human, attached to this envelope. Its
1276    // principalSignature verifies perfectly; it just does not authorize this
1277    // send. Without this equality the embedded mandate proves only that
1278    // SOME human granted SOME agent SOMETHING.
1279    let mut envelope = single_proof_envelope();
1280    let mut stapled = mandate();
1281    stapled.human_principal_did = std::string::String::from("did:key:z6MkSomeoneElse");
1282    envelope.credential_subject.policy.delegation_mandate = std::option::Option::Some(stapled);
1283    let err = super::verify_embedded_mandate_binding(&envelope)
1284      .expect_err("a mandate for another human must not bind");
1285    std::assert_eq!(err.code(), "APH_E011");
1286  }
1287
1288  #[test]
1289  fn an_embedded_mandate_for_another_agent_is_rejected() {
1290    // Same staple, one hop over: the human is right but the grant was made
1291    // to a different agent. Accepting it would let any agent borrow another
1292    // agent's delegation from the same human, defeating the per-agent scope
1293    // the mandate exists to express.
1294    let mut envelope = single_proof_envelope();
1295    let mut stapled = mandate();
1296    stapled.agent_did = std::string::String::from("did:web:other-agent.example");
1297    envelope.credential_subject.policy.delegation_mandate = std::option::Option::Some(stapled);
1298    let err = super::verify_embedded_mandate_binding(&envelope)
1299      .expect_err("a mandate for another agent must not bind");
1300    std::assert_eq!(err.code(), "APH_E011");
1301  }
1302
1303  #[test]
1304  fn an_embedded_mandate_disagreeing_with_the_declared_id_is_rejected() {
1305    // When `delegationMandateId` is present the envelope has NAMED its
1306    // parent. A different mandate in the `delegationMandate` member means
1307    // the notary's own record and the embedded evidence disagree, and a
1308    // verifier cannot know which one policy was actually evaluated against.
1309    let mut envelope = single_proof_envelope();
1310    envelope.credential_subject.policy.delegation_mandate_id =
1311      std::option::Option::Some(std::string::String::from("urn:uuid:some-other-mandate"));
1312    envelope.credential_subject.policy.delegation_mandate = std::option::Option::Some(mandate());
1313    let err = super::verify_embedded_mandate_binding(&envelope)
1314      .expect_err("an embedded mandate must be the one the envelope names");
1315    std::assert_eq!(err.code(), "APH_E011");
1316  }
1317
1318  #[test]
1319  fn an_embedded_mandate_not_covering_the_channel_is_rejected() {
1320    // APH_E005 specifically, because this is the one §8.3.1 step 1d names
1321    // for it: the grant is genuine and belongs to this pair, but this send
1322    // is outside the channel scope the human approved. A verifier reporting
1323    // E011 here would tell an operator the signature was bad when the
1324    // signature was fine and the SCOPE was wrong.
1325    let mut envelope = single_proof_envelope();
1326    envelope.credential_subject.channel.kind = crate::envelope::ChannelKind::Discord;
1327    envelope.credential_subject.policy.delegation_mandate = std::option::Option::Some(mandate());
1328    let err = super::verify_embedded_mandate_binding(&envelope)
1329      .expect_err("a channel outside allowedChannels must not bind");
1330    std::assert_eq!(err.code(), "APH_E005");
1331  }
1332
1333  #[test]
1334  fn an_envelope_outside_the_mandate_window_is_rejected() {
1335    // APH_E003, the other code §8.3.1 step 1d names. Expiry is the primary
1336    // revocation mechanism in v0.1 (§6.3.1 defers on-wire revocation), so an
1337    // envelope dated outside the mandate's window that still bound would
1338    // leave a human with no way to withdraw authority at all.
1339    let mut envelope = single_proof_envelope();
1340    envelope.valid_from = std::string::String::from("2026-07-01T00:00:00Z");
1341    envelope.valid_until = std::string::String::from("2026-07-02T00:00:00Z");
1342    envelope.credential_subject.policy.delegation_mandate = std::option::Option::Some(mandate());
1343    let err = super::verify_embedded_mandate_binding(&envelope)
1344      .expect_err("an envelope outside the mandate window must not bind");
1345    std::assert_eq!(err.code(), "APH_E003");
1346  }
1347
1348  #[test]
1349  fn an_envelope_expiring_after_the_mandate_is_rejected() {
1350    // §8.3.1 step 1d requires the envelope's WINDOW to fall inside the
1351    // mandate's, not merely its start. Checking `validFrom` alone would let
1352    // a notary issue an envelope that starts inside the grant and stays
1353    // valid for a year after the human's authority ended.
1354    let mut envelope = single_proof_envelope();
1355    envelope.valid_from = std::string::String::from("2026-05-31T00:00:00Z");
1356    envelope.valid_until = std::string::String::from("2026-06-30T00:00:00Z");
1357    envelope.credential_subject.policy.delegation_mandate = std::option::Option::Some(mandate());
1358    let err = super::verify_embedded_mandate_binding(&envelope)
1359      .expect_err("an envelope outliving the mandate must not bind");
1360    std::assert_eq!(err.code(), "APH_E003");
1361  }
1362
1363  #[test]
1364  fn an_unparseable_envelope_window_fails_closed() {
1365    // Hostile input must deny, never default to valid. `is_valid_at`
1366    // returns false on anything it cannot parse, and this pins that the
1367    // caller inherits that fail-closed behavior instead of a panic or a
1368    // silent pass.
1369    let mut envelope = single_proof_envelope();
1370    envelope.valid_from = std::string::String::from("not-a-timestamp");
1371    envelope.credential_subject.policy.delegation_mandate = std::option::Option::Some(mandate());
1372    let err = super::verify_embedded_mandate_binding(&envelope)
1373      .expect_err("an unparseable window must not bind");
1374    std::assert_eq!(err.code(), "APH_E003");
1375  }
1376
1377  // ── verify_timestamp_order ────────────────────────────────────────────
1378
1379  #[test]
1380  fn a_single_proof_envelope_has_nothing_to_order() {
1381    // §7.2.1's ordering constrains a CHAIN. A lone notary proof has no
1382    // principal proof to precede it, so the check must be vacuous rather
1383    // than inventing a constraint that would reject the published corpus.
1384    std::assert!(super::verify_timestamp_order(&single_proof_envelope()).is_ok());
1385  }
1386
1387  #[test]
1388  fn issuance_order_decision_then_principal_then_notary_is_accepted() {
1389    // The order §7.2.1 makes normative: the notary prepares the envelope,
1390    // the principal signs what was prepared, the notary countersigns. This
1391    // is the positive control for the two rejections below.
1392    std::assert!(super::verify_timestamp_order(&chain_envelope()).is_ok());
1393  }
1394
1395  #[test]
1396  fn equal_timestamps_are_accepted() {
1397    // The constraint is `<=`, not `<`. Three signatures produced inside the
1398    // same second are ordinary — the notary is typically co-located with the
1399    // principal (§4) — and a strict comparison would reject correct
1400    // envelopes for having a fast device.
1401    let mut envelope = chain_envelope_with(|principal, notary| {
1402      principal.created = std::string::String::from("2026-05-21T00:00:01Z");
1403      notary.created = std::string::String::from("2026-05-21T00:00:01Z");
1404    });
1405    envelope.credential_subject.notarization.decision_timestamp =
1406      std::string::String::from("2026-05-21T00:00:01Z");
1407    std::assert!(super::verify_timestamp_order(&envelope).is_ok());
1408  }
1409
1410  #[test]
1411  fn a_principal_proof_predating_the_decision_is_rejected() {
1412    // Reverse §7.2.1 steps 1 and 2 and the principal would have to sign
1413    // notary-produced fields — the decision timestamp, the latency — that do
1414    // not exist yet. A proof dated before the decision it authorizes is
1415    // evidence that the envelope was assembled out of order, or that its
1416    // timestamps were written to tell a story.
1417    let envelope = chain_envelope_with(|principal, _notary| {
1418      principal.created = std::string::String::from("2026-05-21T00:00:00Z");
1419    });
1420    let err = super::verify_timestamp_order(&envelope)
1421      .expect_err("a principal proof predating the decision must be rejected");
1422    std::assert_eq!(err.code(), "APH_E013");
1423  }
1424
1425  #[test]
1426  fn a_countersignature_predating_the_principal_proof_is_rejected() {
1427    // §7.2.1 states it outright: a verifier MUST NOT accept a chain whose
1428    // notary proof is dated before the principal proof it claims to have
1429    // observed. The notary proof covers the principal's `proofValue`, so a
1430    // notary that signed first signed bytes that did not yet exist.
1431    let envelope = chain_envelope_with(|_principal, notary| {
1432      notary.created = std::string::String::from("2026-05-21T00:00:01Z");
1433    });
1434    let err = super::verify_timestamp_order(&envelope)
1435      .expect_err("a countersignature predating its principal proof must be rejected");
1436    std::assert_eq!(err.code(), "APH_E013");
1437  }
1438
1439  #[test]
1440  fn an_unparseable_proof_timestamp_is_rejected() {
1441    // Fail closed. An unreadable timestamp cannot be shown to satisfy the
1442    // ordering, and treating "cannot check" as "checked" would make the
1443    // whole rule optional for any producer willing to emit garbage.
1444    let envelope = chain_envelope_with(|principal, _notary| {
1445      principal.created = std::string::String::from("yesterday");
1446    });
1447    let err = super::verify_timestamp_order(&envelope)
1448      .expect_err("an unparseable timestamp must be rejected");
1449    std::assert_eq!(err.code(), "APH_E013");
1450  }
1451
1452  #[test]
1453  fn timestamps_are_compared_as_instants_not_strings() {
1454    // `2026-05-21T00:00:03Z` and `2026-05-20T20:00:03-04:00` are the SAME
1455    // instant, but the second sorts earlier lexicographically. Comparing the
1456    // text would reject this correct chain, so the crate parses with
1457    // `chrono` — the same thing `DelegationMandate::is_valid_at` does.
1458    let envelope = chain_envelope_with(|_principal, notary| {
1459      notary.created = std::string::String::from("2026-05-20T20:00:03-04:00");
1460    });
1461    std::assert!(
1462      super::verify_timestamp_order(&envelope).is_ok(),
1463      "an equivalent instant written with an offset must order correctly"
1464    );
1465  }
1466
1467  #[test]
1468  fn a_malformed_chain_cannot_have_its_order_checked() {
1469    // Fail closed on a structure this function cannot interpret. Returning
1470    // Ok would let a caller that runs the ordering check but skips
1471    // `verify_proof_structure` conclude a malformed chain was fine.
1472    let mut envelope = chain_envelope();
1473    envelope.proof = crate::envelope::EnvelopeProofs::Chain(std::vec![principal_proof()]);
1474    let err = super::verify_timestamp_order(&envelope)
1475      .expect_err("a one-element chain has no checkable order");
1476    std::assert_eq!(err.code(), "APH_E013");
1477  }
1478  // ── RFC 0003: audience, window, single-use ──────────────────────
1479
1480  fn audience(id: &str) -> crate::envelope::Audience {
1481    crate::envelope::Audience {
1482      id: id.to_string(),
1483      channel_binding: std::option::Option::None,
1484    }
1485  }
1486
1487  fn binding(
1488    kind: crate::envelope::ChannelKind,
1489    coordinates: &[(&str, &str)],
1490  ) -> crate::envelope::AudienceChannelBinding {
1491    crate::envelope::AudienceChannelBinding {
1492      kind,
1493      coordinates: coordinates
1494        .iter()
1495        .map(|(k, v)| (k.to_string(), serde_json::json!(v)))
1496        .collect(),
1497    }
1498  }
1499
1500  #[test]
1501  fn an_envelope_with_no_audience_is_a_bearer_credential_by_choice() {
1502    // RFC 0003 makes bearer semantics the producer's EXPLICIT decision:
1503    // absence admits, and turning absence into a refusal would break every
1504    // envelope minted before the field existed.
1505    let envelope = single_proof_envelope();
1506    super::verify_audience(&envelope, "did:web:anyone.example.com", std::option::Option::None)
1507      .expect("absence is not a constraint");
1508  }
1509
1510  #[test]
1511  fn an_audience_naming_another_verifier_is_refused() {
1512    // The core of the replay fix: a captured envelope stops being valid in
1513    // anyone's hands. The refusal must be E017 and must name both parties,
1514    // or the operator reading the log cannot tell WHICH side is misrouted.
1515    let mut envelope = single_proof_envelope();
1516    envelope.credential_subject.audience =
1517      std::option::Option::Some(audience("did:web:intended.example.com"));
1518    let err =
1519      super::verify_audience(&envelope, "did:web:other.example.com", std::option::Option::None)
1520        .expect_err("a mismatched audience must refuse");
1521    std::assert_eq!(err.code(), "APH_E017");
1522    let text = std::format!("{}", err);
1523    std::assert!(text.contains("did:web:intended.example.com"));
1524    std::assert!(text.contains("did:web:other.example.com"));
1525  }
1526
1527  #[test]
1528  fn the_named_verifier_is_admitted() {
1529    // The positive half of the same coin, pinned so the check cannot rot
1530    // into refusing everyone and passing its negative tests forever.
1531    let mut envelope = single_proof_envelope();
1532    envelope.credential_subject.audience =
1533      std::option::Option::Some(audience("did:web:intended.example.com"));
1534    super::verify_audience(&envelope, "did:web:intended.example.com", std::option::Option::None)
1535      .expect("the named audience is exactly who may accept");
1536  }
1537
1538  #[test]
1539  fn a_channel_binding_with_no_act_coordinates_is_refused_not_skipped() {
1540    // RFC 0003's sharpest sentence: a verifier that cannot determine the
1541    // coordinates MUST reject rather than skip. Skipping here would turn
1542    // the binding into decoration precisely when an attacker relays the
1543    // envelope somewhere its coordinates are unknown.
1544    let mut envelope = single_proof_envelope();
1545    let mut aud = audience("did:web:intended.example.com");
1546    aud.channel_binding = std::option::Option::Some(binding(
1547      crate::envelope::ChannelKind::Slack,
1548      &[("teamId", "T01234567")],
1549    ));
1550    envelope.credential_subject.audience = std::option::Option::Some(aud);
1551    let err = super::verify_audience(
1552      &envelope,
1553      "did:web:intended.example.com",
1554      std::option::Option::None,
1555    )
1556    .expect_err("unknown act coordinates must refuse, not skip");
1557    std::assert_eq!(err.code(), "APH_E017");
1558  }
1559
1560  #[test]
1561  fn a_channel_binding_member_mismatch_is_refused_naming_the_member() {
1562    // The member-by-member comparison is what stops an envelope for one
1563    // channel being spent on another; the refusal names the member so the
1564    // mismatch is diagnosable without diffing JSON by eye.
1565    let mut envelope = single_proof_envelope();
1566    let mut aud = audience("did:web:intended.example.com");
1567    aud.channel_binding = std::option::Option::Some(binding(
1568      crate::envelope::ChannelKind::Slack,
1569      &[("teamId", "T01234567"), ("channelId", "C01234567")],
1570    ));
1571    envelope.credential_subject.audience = std::option::Option::Some(aud);
1572    let act = binding(
1573      crate::envelope::ChannelKind::Slack,
1574      &[("teamId", "T01234567"), ("channelId", "C_ELSEWHERE")],
1575    );
1576    let err = super::verify_audience(
1577      &envelope,
1578      "did:web:intended.example.com",
1579      std::option::Option::Some(&act),
1580    )
1581    .expect_err("a differing coordinate must refuse");
1582    std::assert_eq!(err.code(), "APH_E017");
1583    std::assert!(std::format!("{}", err).contains("channelId"));
1584  }
1585
1586  #[test]
1587  fn a_matching_channel_binding_is_admitted_and_extra_act_members_are_fine() {
1588    // The binding constrains what it NAMES: coordinates the act carries
1589    // beyond the bound ones are the channel's business, not a mismatch —
1590    // otherwise no binding could be written without enumerating every
1591    // incidental member a transport adds.
1592    let mut envelope = single_proof_envelope();
1593    let mut aud = audience("did:web:intended.example.com");
1594    aud.channel_binding = std::option::Option::Some(binding(
1595      crate::envelope::ChannelKind::Slack,
1596      &[("teamId", "T01234567")],
1597    ));
1598    envelope.credential_subject.audience = std::option::Option::Some(aud);
1599    let act = binding(
1600      crate::envelope::ChannelKind::Slack,
1601      &[("teamId", "T01234567"), ("threadTs", "1727000000.000100")],
1602    );
1603    super::verify_audience(
1604      &envelope,
1605      "did:web:intended.example.com",
1606      std::option::Option::Some(&act),
1607    )
1608    .expect("every bound member matches");
1609  }
1610
1611  #[test]
1612  fn a_kind_mismatch_in_the_binding_is_refused() {
1613    // Cross-channel spend is the attack the binding exists for: an envelope
1614    // bound to slack coordinates presented as an email act must refuse even
1615    // when no other member overlaps.
1616    let mut envelope = single_proof_envelope();
1617    let mut aud = audience("did:web:intended.example.com");
1618    aud.channel_binding =
1619      std::option::Option::Some(binding(crate::envelope::ChannelKind::Slack, &[]));
1620    envelope.credential_subject.audience = std::option::Option::Some(aud);
1621    let act = binding(crate::envelope::ChannelKind::Email, &[]);
1622    let err = super::verify_audience(
1623      &envelope,
1624      "did:web:intended.example.com",
1625      std::option::Option::Some(&act),
1626    )
1627    .expect_err("kind is a bound member like any other");
1628    std::assert_eq!(err.code(), "APH_E017");
1629  }
1630
1631  #[test]
1632  fn the_envelope_window_admits_inside_and_refuses_both_sides_with_e019() {
1633    // APH_E019 exists because refusing an expired ENVELOPE used to have no
1634    // code of its own and E003 (a mandate consulted past expiry) was the
1635    // standing miscite. The fixture window is 2026-05-21T00:00:00Z ..
1636    // 2026-05-22T00:00:00Z; both refusals must carry E019 and the verbatim
1637    // window so the operator sees WHAT was judged, not only that it failed.
1638    let envelope = single_proof_envelope();
1639    super::verify_envelope_window(&envelope, "2026-05-21T12:00:00Z").expect("inside the window");
1640    let early = super::verify_envelope_window(&envelope, "2026-05-20T23:59:59Z")
1641      .expect_err("not yet valid");
1642    std::assert_eq!(early.code(), "APH_E019");
1643    let late = super::verify_envelope_window(&envelope, "2026-05-22T00:00:01Z")
1644      .expect_err("expired");
1645    std::assert_eq!(late.code(), "APH_E019");
1646    std::assert!(std::format!("{}", late).contains("2026-05-22T00:00:00Z"));
1647  }
1648
1649  #[test]
1650  fn an_unparseable_window_fails_closed_to_e019() {
1651    // An unreadable window never admits — same fail-closed rule as proof
1652    // timestamps, but routed to the envelope-window code, because "your
1653    // window is garbage" and "your proof chain is garbage" are different
1654    // repairs.
1655    let mut envelope = single_proof_envelope();
1656    envelope.valid_until = std::string::String::from("not-a-timestamp");
1657    let err = super::verify_envelope_window(&envelope, "2026-05-21T12:00:00Z")
1658      .expect_err("garbage must refuse");
1659    std::assert_eq!(err.code(), "APH_E019");
1660  }
1661
1662  #[test]
1663  fn an_envelope_is_spent_by_acceptance_and_a_second_presentation_refuses() {
1664    // RFC 0003 §2 measured the defect: 100 presentations of one golden
1665    // envelope, 100 admissions. The ledger turns presentation two into
1666    // APH_E018 naming the id, and refusing is not accepting — the refusal
1667    // consumes nothing further.
1668    let mut ledger = super::InMemoryConsumedEnvelopeLedger::default();
1669    let envelope = single_proof_envelope();
1670    super::enforce_single_use(&mut ledger, &envelope).expect("first acceptance spends");
1671    let err = super::enforce_single_use(&mut ledger, &envelope)
1672      .expect_err("second presentation is a replay");
1673    std::assert_eq!(err.code(), "APH_E018");
1674    std::assert!(std::format!("{}", err).contains(&envelope.id));
1675    let again =
1676      super::enforce_single_use(&mut ledger, &envelope).expect_err("and it stays spent");
1677    std::assert_eq!(again.code(), "APH_E018");
1678  }
1679
1680  #[test]
1681  fn distinct_envelope_ids_spend_independently() {
1682    // The ledger is keyed on `id` alone — two different envelopes never
1683    // contend, which is what makes the retention bound implementable: the
1684    // store grows with ids inside live windows, not with traffic.
1685    let mut ledger = super::InMemoryConsumedEnvelopeLedger::default();
1686    let first = single_proof_envelope();
1687    let mut second = single_proof_envelope();
1688    second.id = std::string::String::from("urn:uuid:00000000-0000-4000-8000-000000000002");
1689    super::enforce_single_use(&mut ledger, &first).expect("first id is fresh");
1690    super::enforce_single_use(&mut ledger, &second).expect("second id is fresh too");
1691  }
1692  // ── RFC 0005: the recipient-class constraint ─────────────────────
1693
1694  #[test]
1695  fn an_unconstrained_mandate_ignores_recipient_class_entirely() {
1696    // None means the grant says nothing about consumers — which every
1697    // mandate signed before RFC 0005 is, so this arm is also the
1698    // signature-compatibility arm.
1699    let mut envelope = single_proof_envelope();
1700    envelope.credential_subject.policy.delegation_mandate = std::option::Option::Some(mandate());
1701    super::verify_embedded_mandate_binding(&envelope)
1702      .expect("no constraint, nothing to check");
1703  }
1704
1705  #[test]
1706  fn a_declared_class_inside_the_grant_is_admitted() {
1707    let mut envelope = single_proof_envelope();
1708    envelope.credential_subject.channel.recipient_class =
1709      std::option::Option::Some(crate::envelope::RecipientClass::Human);
1710    let mut constrained = mandate();
1711    constrained.allowed_recipient_classes =
1712      std::option::Option::Some(std::vec![crate::envelope::RecipientClass::Human]);
1713    envelope.credential_subject.policy.delegation_mandate =
1714      std::option::Option::Some(constrained);
1715    super::verify_embedded_mandate_binding(&envelope)
1716      .expect("human is exactly what the grant covers");
1717  }
1718
1719  #[test]
1720  fn a_declared_class_outside_the_grant_is_refused_with_e020() {
1721    // The motivating case verbatim: the grant says "email to PEOPLE", the
1722    // act is unattended machine-to-machine traffic on the same medium.
1723    let mut envelope = single_proof_envelope();
1724    envelope.credential_subject.channel.recipient_class =
1725      std::option::Option::Some(crate::envelope::RecipientClass::Agent);
1726    let mut constrained = mandate();
1727    constrained.allowed_recipient_classes =
1728      std::option::Option::Some(std::vec![crate::envelope::RecipientClass::Human]);
1729    envelope.credential_subject.policy.delegation_mandate =
1730      std::option::Option::Some(constrained);
1731    let err = super::verify_embedded_mandate_binding(&envelope)
1732      .expect_err("agent traffic under a human-only grant must refuse");
1733    std::assert_eq!(err.code(), "APH_E020");
1734    std::assert!(std::format!("{}", err).contains("agent"));
1735  }
1736
1737  #[test]
1738  fn declaring_nothing_under_a_constrained_grant_is_refused_not_waved() {
1739    // The omission arm: were absence admitted, the constraint would be
1740    // escapable by simply not declaring — and the refusal message says
1741    // `nothing` so the operator sees WHICH failure this was.
1742    let mut envelope = single_proof_envelope();
1743    let mut constrained = mandate();
1744    constrained.allowed_recipient_classes =
1745      std::option::Option::Some(std::vec![crate::envelope::RecipientClass::Human]);
1746    envelope.credential_subject.policy.delegation_mandate =
1747      std::option::Option::Some(constrained);
1748    let err = super::verify_embedded_mandate_binding(&envelope)
1749      .expect_err("an unverifiable constraint refuses");
1750    std::assert_eq!(err.code(), "APH_E020");
1751    std::assert!(std::format!("{}", err).contains("nothing"));
1752  }
1753}