Skip to main content

aph_sealed/
lib.rs

1//! RFC 0008 — sealed payloads: carriage without readership. The
2//! reference implementation, spec/aph-0.2.md §§1-4 (v0.2, cut
3//! 2026-08-29).
4//!
5//! RFC 0008 is Accepted onto the v0.2 line and implemented here: the
6//! WIRE types live in `aph-core`
7//! (re-exported below) so the envelope can carry them, the wire-version
8//! rule (`sealed_payload_is_declared`) keeps them off any aphVersion-0.1
9//! envelope, and THIS crate holds the cryptography plus the
10//! envelope-level operations — `seal_into_envelope` /
11//! `unseal_from_envelope` — that RFC 0008 §§3-4 specify. v0.1.0 itself is
12//! untouched: a v0.1-only verifier still refuses the member at strict
13//! parse, correctly.
14//!
15//! Two scenarios, one mechanism (RFC 0008 §"The problem"):
16//! - sealed TO THE RECEIVER: intermediate agents verify the envelope and
17//!   carry what they cannot read;
18//! - sealed TO THE SENDER (or any designated third key): the counterparty
19//!   carries and proves receipt of what it cannot open.
20//!
21//! The cryptography is RFC 9180 HPKE single-shot, one pinned suite
22//! (X25519-HKDF-SHA256 / HKDF-SHA256 / ChaCha20-Poly1305), through the
23//! pure-Rust `hpke` crate — this repository writes no cryptography. The
24//! load-bearing choice is the AAD: a canonical CONTEXT — suite, reader id,
25//! reader kid, envelope id — authenticates every seal, so a ciphertext
26//! lifted into a different envelope OR relabeled about itself (a different
27//! claimed reader, a different claimed suite) fails open even for its
28//! rightful key. The audit probe that forced the widening is kept as a
29//! test. Errors are this crate's own type, deliberately
30//! NOT `APH_E`-prefixed: the §11 set is closed, and RFC 0008 §5 assigns
31//! codes when a version exists that can declare them.
32
33use hpke::aead::ChaCha20Poly1305;
34use hpke::kdf::HkdfSha256;
35use hpke::kem::X25519HkdfSha256;
36use hpke::{Deserializable, Kem as KemTrait, OpModeR, OpModeS, Serializable};
37
38type Kem = X25519HkdfSha256;
39
40/// The one suite this draft compiles. A wire member exists so a future
41/// version CAN move; an unseal of any other value refuses. Closed-set
42/// discipline applied to ciphersuites from birth (RFC 0008 §1).
43pub const SUITE: &str = "APH-SEAL-1";
44
45/// HPKE `info` — domain separation for this construction, distinct from any
46/// other use of the same keys.
47const INFO: &[u8] = b"aph sealed payload v1";
48
49/// The wire types are `aph-core`'s — the envelope carries them — and this
50/// crate re-exports them so a sealing caller needs one import path.
51pub use aph_core::{SealedPayload, SealedReader};
52
53/// This crate's own refusals — deliberately NOT `APH_E` codes (RFC 0008 §5:
54/// the §11 set is closed, and names are minted by the version that can
55/// declare them, not by a draft).
56#[derive(std::fmt::Debug, thiserror::Error, std::cmp::PartialEq, std::cmp::Eq)]
57pub enum SealError {
58  /// The payload names a suite this implementation does not compile. One
59  /// suite, no negotiation — an unknown value is refused, never skipped.
60  #[error("unknown seal suite `{0}`; this implementation seals only `{SUITE}`")]
61  UnknownSuite(String),
62  /// A base64url field would not decode; malformed before cryptography.
63  #[error("sealed payload field `{0}` is not unpadded base64url")]
64  MalformedEncoding(&'static str),
65  /// A key was not the KEM's serialized length/shape.
66  #[error("the {0} key is not a valid X25519 key for this suite")]
67  MalformedKey(&'static str),
68  /// AEAD open failed: the wrong key, a tampered ciphertext, or — the case
69  /// the AAD exists for — a seal lifted from a different envelope. All
70  /// three are indistinguishable BY DESIGN (that is what AEAD promises),
71  /// so the refusal names all three rather than guessing.
72  #[error(
73    "the seal did not open: wrong reader key, tampered ciphertext, a seal \
74     staged under a different envelope id, or a payload relabeled about its \
75     own suite or reader"
76  )]
77  OpenRefused,
78  /// The HPKE encapsulation step itself failed (a malformed reader key
79  /// surfaces here when it parses but cannot be used).
80  #[error("sealing failed: {0}")]
81  SealFailed(String),
82}
83
84/// The authenticated context: everything a sealed payload CLAIMS about
85/// itself, plus the envelope that stages it. Serialized as JSON from a
86/// struct with a fixed field order — deterministic for these string
87/// fields, and unambiguous where a delimiter-joined string would not be
88/// (a `|` inside a DID would otherwise collide field boundaries).
89///
90/// Both sides derive it independently: the sealer from its inputs, the
91/// opener from the sealed payload's OWN claimed fields — which is exactly
92/// why a relabeled `reader` or `suite` refuses AEAD open.
93#[derive(serde::Serialize)]
94struct SealContext<'a> {
95  suite: &'a str,
96  reader_id: &'a str,
97  reader_kid: &'a str,
98  envelope_id: &'a str,
99}
100
101fn context_aad(suite: &str, reader: &SealedReader, envelope_id: &str) -> Vec<u8> {
102  serde_json::to_vec(&SealContext {
103    suite,
104    reader_id: &reader.id,
105    reader_kid: &reader.kid,
106    envelope_id,
107  })
108  .expect("four string fields serialize infallibly")
109}
110
111fn b64(bytes: &[u8]) -> String {
112  use base64::Engine as _;
113  base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
114}
115
116fn unb64(text: &str, field: &'static str) -> std::result::Result<Vec<u8>, SealError> {
117  use base64::Engine as _;
118  base64::engine::general_purpose::URL_SAFE_NO_PAD
119    .decode(text)
120    .map_err(|_| SealError::MalformedEncoding(field))
121}
122
123/// Seals `plaintext` so that ONLY the holder of `reader`'s private key can
124/// open it, bound to the full seal CONTEXT — suite, reader, envelope id —
125/// as HPKE additional authenticated data (RFC 0008 §3). Sealing happens
126/// BEFORE the envelope is signed, so the signature covers the ciphertext
127/// and every hop verifies blind.
128///
129/// `reader_public_key` is the reader's X25519 `keyAgreement` public key,
130/// 32 raw bytes, discovered through the §8.4 surfaces. The RNG is a caller
131/// parameter for the same reason `now` is one in verification: this
132/// function stays deterministic under test and honest about its inputs —
133/// and in PRODUCTION the argument must be an operating-system CSPRNG
134/// (`rand::rngs::OsRng`); a seeded RNG there reuses ephemeral keys, which
135/// is key compromise by another name.
136pub fn seal(
137  csprng: &mut impl rand::CryptoRng,
138  reader: SealedReader,
139  reader_public_key: &[u8],
140  envelope_id: &str,
141  plaintext: &[u8],
142) -> std::result::Result<SealedPayload, SealError> {
143  let pk = <Kem as KemTrait>::PublicKey::from_bytes(reader_public_key)
144    .map_err(|_| SealError::MalformedKey("reader public"))?;
145  let aad = context_aad(SUITE, &reader, envelope_id);
146  let (encapped, ciphertext) = hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(
147    &OpModeS::Base,
148    &pk,
149    INFO,
150    plaintext,
151    &aad,
152    csprng,
153  )
154  .map_err(|e| SealError::SealFailed(std::format!("{e}")))?;
155  std::result::Result::Ok(SealedPayload {
156    suite: SUITE.to_string(),
157    reader,
158    enc: b64(&encapped.to_bytes()),
159    ciphertext: b64(&ciphertext),
160  })
161}
162
163/// Opens a seal with the reader's private key, under the SAME context it
164/// was sealed to — with `suite` and `reader` taken from the sealed
165/// payload's OWN claims, so a payload relabeled about itself refuses.
166/// Any mismatch — key, bytes, envelope, or claimed context — refuses with
167/// one indistinguishable [`SealError::OpenRefused`], and RFC 0008 §4 tells
168/// a reader-verifier what that refusal means: refuse the ENVELOPE, because
169/// an unopenable seal addressed to you is evidence, not an inconvenience.
170pub fn unseal(
171  sealed: &SealedPayload,
172  reader_private_key: &[u8],
173  envelope_id: &str,
174) -> std::result::Result<Vec<u8>, SealError> {
175  if sealed.suite != SUITE {
176    return std::result::Result::Err(SealError::UnknownSuite(sealed.suite.clone()));
177  }
178  let sk = <Kem as KemTrait>::PrivateKey::from_bytes(reader_private_key)
179    .map_err(|_| SealError::MalformedKey("reader private"))?;
180  let encapped_bytes = unb64(&sealed.enc, "enc")?;
181  let encapped = <Kem as KemTrait>::EncappedKey::from_bytes(&encapped_bytes)
182    .map_err(|_| SealError::MalformedKey("encapsulated"))?;
183  let ciphertext = unb64(&sealed.ciphertext, "ciphertext")?;
184  let aad = context_aad(&sealed.suite, &sealed.reader, envelope_id);
185  hpke::single_shot_open::<ChaCha20Poly1305, HkdfSha256, Kem>(
186    &OpModeR::Base,
187    &sk,
188    &encapped,
189    INFO,
190    &ciphertext,
191    &aad,
192  )
193  .map_err(|_| SealError::OpenRefused)
194}
195
196/// Why an envelope-level unseal can fail — three DIFFERENT kinds of
197/// failure with three different repairs, kept as distinct as the codes
198/// that carry two of them.
199#[derive(std::fmt::Debug, thiserror::Error)]
200pub enum EnvelopeSealError {
201  /// The envelope carries no `sealedPayload`. Not a refusal: an unsealed
202  /// envelope is an ordinary envelope, and the caller decides whether it
203  /// expected one.
204  #[error("the envelope carries no sealed payload")]
205  Absent,
206  /// The wire-version rule refused: `sealedPayload` on an aphVersion that
207  /// does not declare it. Strict-parse CLASS — a plain message below the
208  /// protocol's code vocabulary, per the same reasoning as an
209  /// unrecognized closed-set value.
210  #[error("{0}")]
211  WireUndeclared(String),
212  /// A protocol refusal with its 0.2 code: `APH_E021` (the seal
213  /// addressed to this verifier did not open) or `APH_E022` (unknown
214  /// suite). Per RFC 0008 §4 the caller MUST refuse the ENVELOPE.
215  #[error(transparent)]
216  Refused(aph_core::AphError),
217}
218
219/// Seals `plaintext` INTO an envelope (RFC 0008 §3): binds the seal to the
220/// envelope's own `id` and claimed context, then places it on
221/// `credentialSubject.sealedPayload`. Call this BEFORE signing — the
222/// signature must cover the ciphertext — and only on an envelope whose
223/// `aphVersion` declares the member; anything else refuses up front rather
224/// than minting a wire no conformant verifier admits.
225pub fn seal_into_envelope(
226  csprng: &mut impl rand::CryptoRng,
227  envelope: &mut aph_core::NotarizationEnvelope,
228  reader: SealedReader,
229  reader_public_key: &[u8],
230  plaintext: &[u8],
231) -> std::result::Result<(), EnvelopeSealError> {
232  if envelope.aph_version != "0.2" {
233    return std::result::Result::Err(EnvelopeSealError::WireUndeclared(std::format!(
234      "refusing to seal into an aphVersion `{}` envelope: `sealedPayload` is        declared from aphVersion 0.2 (spec/aph-0.2.md), and minting it        earlier produces a wire every conformant verifier refuses",
235      envelope.aph_version
236    )));
237  }
238  let sealed = seal(csprng, reader, reader_public_key, &envelope.id, plaintext)
239    .map_err(|e| EnvelopeSealError::WireUndeclared(std::format!("sealing failed: {e}")))?;
240  envelope.credential_subject.sealed_payload = std::option::Option::Some(sealed);
241  std::result::Result::Ok(())
242}
243
244/// Opens the seal an envelope carries, for the reader holding the private
245/// key (RFC 0008 §4): run AFTER the envelope itself verifies, and treat a
246/// [`EnvelopeSealError::Refused`] as a refusal of the ENVELOPE — an
247/// unopenable seal addressed to you is evidence, never a shrug. The
248/// wire-version rule runs first, so a mis-versioned envelope refuses
249/// before any cryptography.
250pub fn unseal_from_envelope(
251  envelope: &aph_core::NotarizationEnvelope,
252  reader_private_key: &[u8],
253) -> std::result::Result<Vec<u8>, EnvelopeSealError> {
254  aph_core::sealed_payload_is_declared(envelope).map_err(EnvelopeSealError::WireUndeclared)?;
255  let sealed = envelope
256    .credential_subject
257    .sealed_payload
258    .as_ref()
259    .ok_or(EnvelopeSealError::Absent)?;
260  unseal(sealed, reader_private_key, &envelope.id).map_err(|e| match e {
261    SealError::UnknownSuite(suite) => {
262      EnvelopeSealError::Refused(aph_core::AphError::seal_suite_unknown(suite, SUITE))
263    }
264    _ => EnvelopeSealError::Refused(aph_core::AphError::seal_unopenable(&envelope.id)),
265  })
266}
267
268#[cfg(test)]
269mod tests {
270  /// Derives a deterministic X25519 keypair from input keying material.
271  /// TEST-ONLY BY CONSTRUCTION: this lives inside `#[cfg(test)]`, so no
272  /// consumer can derive a production key from low-entropy material — the
273  /// compiler is the fence, where a doc comment was one an audit ago.
274  fn derive_keypair_for_tests(ikm: &[u8]) -> (Vec<u8>, Vec<u8>) {
275    use hpke::{Kem as KemTrait, Serializable};
276    let (sk, pk) = super::Kem::derive_keypair(ikm);
277    (sk.to_bytes().to_vec(), pk.to_bytes().to_vec())
278  }
279  // TEST-ONLY key material: every keypair below is DERIVED in-test from a
280  // fixed, clearly-labeled IKM string via RFC 9180 DeriveKeyPair — nothing
281  // secret is stored, and nothing here is a production key.
282  const RECEIVER_IKM: &[u8] = b"APH-SEALED-TEST-RECEIVER-IKM-0001";
283  const SENDER_IKM: &[u8] = b"APH-SEALED-TEST-SENDER-IKM---0001";
284  const ENVELOPE_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000e1";
285
286  fn rng() -> impl rand::CryptoRng {
287    // Deterministic under test: the seal's ephemeral key comes from a
288    // seeded RNG so failures reproduce byte-for-byte.
289    <rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(7)
290  }
291
292  fn reader(id: &str) -> super::SealedReader {
293    super::SealedReader { id: id.to_string(), kid: std::string::String::from("enc-1") }
294  }
295
296  #[test]
297  fn scenario_one_the_carrier_cannot_read_but_the_receiver_can() {
298    // RFC 0008's first scenario end to end: seal to the RECEIVER's key.
299    // The sending agent holds only the SealedPayload — ciphertext and an
300    // encapsulated key useless without the receiver's private half — and
301    // the receiver opens it under the envelope id it arrived in.
302    let (receiver_sk, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
303    let sealed = super::seal(
304      &mut rng(),
305      reader("did:web:receiver.example.com"),
306      &receiver_pk,
307      ENVELOPE_ID,
308      b"the order: attack at dawn",
309    )
310    .expect("sealing to a valid reader key succeeds");
311
312    let opened = super::unseal(&sealed, &receiver_sk, ENVELOPE_ID)
313      .expect("the named reader opens the seal");
314    std::assert_eq!(opened, b"the order: attack at dawn");
315
316    // The carrier's view: without the private key there is nothing to try —
317    // pinned here by the SENDER's own key failing to open it, which also
318    // pins that a seal is not symmetric.
319    let (sender_sk, _) = derive_keypair_for_tests(SENDER_IKM);
320    std::assert_eq!(
321      super::unseal(&sealed, &sender_sk, ENVELOPE_ID).unwrap_err(),
322      super::SealError::OpenRefused,
323    );
324  }
325
326  #[test]
327  fn scenario_two_the_sender_seals_to_itself_and_the_counterparty_cannot_read() {
328    // The second scenario is the same mechanism with the reader pointed at
329    // the SENDER: the receiving agent can carry, store, and prove receipt
330    // of bytes it cannot open.
331    let (sender_sk, sender_pk) = derive_keypair_for_tests(SENDER_IKM);
332    let sealed = super::seal(
333      &mut rng(),
334      reader("did:web:sender.example.com"),
335      &sender_pk,
336      ENVELOPE_ID,
337      b"guardrail overlay the counterparty must hold but not read",
338    )
339    .expect("sealing to one's own key is the same operation");
340
341    let (receiver_sk, _) = derive_keypair_for_tests(RECEIVER_IKM);
342    std::assert_eq!(
343      super::unseal(&sealed, &receiver_sk, ENVELOPE_ID).unwrap_err(),
344      super::SealError::OpenRefused,
345    );
346    std::assert!(super::unseal(&sealed, &sender_sk, ENVELOPE_ID).is_ok());
347  }
348
349  #[test]
350  fn a_seal_lifted_into_a_different_envelope_refuses_even_for_its_reader() {
351    // THE load-bearing binding (RFC 0008 §3): the AAD is the envelope id,
352    // so re-staging a ciphertext under a different authorization fails
353    // AEAD open even with the right key. Without this, a sealed payload
354    // would be a bearer blob any envelope could adopt.
355    let (receiver_sk, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
356    let sealed = super::seal(
357      &mut rng(),
358      reader("did:web:receiver.example.com"),
359      &receiver_pk,
360      ENVELOPE_ID,
361      b"bound to one envelope",
362    )
363    .expect("seal succeeds");
364    std::assert_eq!(
365      super::unseal(&sealed, &receiver_sk, "urn:uuid:00000000-0000-4000-8000-0000000000e2")
366        .unwrap_err(),
367      super::SealError::OpenRefused,
368    );
369  }
370
371  #[test]
372  fn a_tampered_ciphertext_refuses() {
373    // Integrity through untrusted hops is the messenger half of the
374    // generals' problem: one flipped bit anywhere in the ciphertext and
375    // the AEAD tag refuses.
376    let (receiver_sk, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
377    let mut sealed = super::seal(
378      &mut rng(),
379      reader("did:web:receiver.example.com"),
380      &receiver_pk,
381      ENVELOPE_ID,
382      b"integrity or refusal",
383    )
384    .expect("seal succeeds");
385    let mut bytes = super::unb64(&sealed.ciphertext, "ciphertext").expect("decodes");
386    bytes[0] ^= 0x01;
387    sealed.ciphertext = super::b64(&bytes);
388    std::assert_eq!(
389      super::unseal(&sealed, &receiver_sk, ENVELOPE_ID).unwrap_err(),
390      super::SealError::OpenRefused,
391    );
392  }
393
394  #[test]
395  fn an_unknown_suite_is_refused_before_any_cryptography() {
396    // Closed-set discipline for ciphersuites from birth: a verifier meeting
397    // a suite it does not compile refuses by NAME, before touching a byte
398    // of key material.
399    let (receiver_sk, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
400    let mut sealed = super::seal(
401      &mut rng(),
402      reader("did:web:receiver.example.com"),
403      &receiver_pk,
404      ENVELOPE_ID,
405      b"x",
406    )
407    .expect("seal succeeds");
408    sealed.suite = std::string::String::from("APH-SEAL-99");
409    std::assert_eq!(
410      super::unseal(&sealed, &receiver_sk, ENVELOPE_ID).unwrap_err(),
411      super::SealError::UnknownSuite(std::string::String::from("APH-SEAL-99")),
412    );
413  }
414
415  #[test]
416  fn the_wire_shape_round_trips_and_refuses_unknown_members() {
417    // The serde shape IS the RFC's §2 wire member; review reads this test
418    // instead of trusting the prose. Strict on its own members exactly as
419    // every wire struct in the reference is.
420    let (_, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
421    let sealed = super::seal(
422      &mut rng(),
423      reader("did:web:receiver.example.com"),
424      &receiver_pk,
425      ENVELOPE_ID,
426      b"round trip",
427    )
428    .expect("seal succeeds");
429    let json = serde_json::to_string(&sealed).expect("serializes");
430    std::assert!(json.contains("\"suite\":\"APH-SEAL-1\""));
431    std::assert!(json.contains("\"reader\""));
432    let back: super::SealedPayload = serde_json::from_str(&json).expect("parses");
433    std::assert_eq!(back, sealed);
434
435    let smuggled = json.replacen('{', "{\"extra\":1,", 1);
436    std::assert!(
437      serde_json::from_str::<super::SealedPayload>(&smuggled).is_err(),
438      "an unknown member is refused at strict parse, like every wire struct"
439    );
440  }
441
442  #[test]
443  fn a_relabeled_reader_or_suite_refuses_even_with_the_right_key() {
444    // AUDIT PROBE, kept as the weld: the seal must authenticate its OWN
445    // wire context — suite and reader — not only the envelope id. Before
446    // the context-AAD fix, a party holding the payload pre-signing could
447    // relabel `reader` (mis-routing who is asked to open) or `suite`
448    // without the AEAD noticing; the envelope signature catches it later,
449    // but a seal that lies about itself until signing is a seam.
450    let (receiver_sk, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
451    let sealed = super::seal(
452      &mut rng(),
453      reader("did:web:receiver.example.com"),
454      &receiver_pk,
455      ENVELOPE_ID,
456      b"context-bound",
457    )
458    .expect("seal succeeds");
459
460    let mut relabeled = sealed.clone();
461    relabeled.reader.id = std::string::String::from("did:web:attacker.example.com");
462    std::assert_eq!(
463      super::unseal(&relabeled, &receiver_sk, ENVELOPE_ID).unwrap_err(),
464      super::SealError::OpenRefused,
465      "a relabeled reader must refuse: the seal authenticates its context"
466    );
467
468    let mut rekidded = sealed.clone();
469    rekidded.reader.kid = std::string::String::from("enc-2");
470    std::assert_eq!(
471      super::unseal(&rekidded, &receiver_sk, ENVELOPE_ID).unwrap_err(),
472      super::SealError::OpenRefused,
473      "a relabeled kid must refuse for the same reason"
474    );
475  }
476
477  // ── The envelope-level operations, on a real golden ─────────────────
478
479  fn envelope_v02() -> aph_core::NotarizationEnvelope {
480    // The published audience golden, parsed and lifted to aphVersion 0.2:
481    // reusing a committed vector instead of a 60-line literal keeps this
482    // suite welded to the same bytes every other gate reads.
483    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
484      .join("../../../examples/audience_bound_envelope.json");
485    let raw = std::fs::read_to_string(&path)
486      .unwrap_or_else(|e| std::panic!("failed to read {:?}: {}", path, e));
487    let mut envelope: aph_core::NotarizationEnvelope =
488      serde_json::from_str(&raw).expect("the golden parses");
489    envelope.aph_version = std::string::String::from("0.2");
490    envelope
491  }
492
493  #[test]
494  fn seal_into_and_unseal_from_an_envelope_round_trip() {
495    // RFC 0008 §§3-4 end to end on the wire shape itself: the seal lands
496    // on credentialSubject, is bound to THIS envelope's id, and the named
497    // reader opens it back out.
498    let (receiver_sk, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
499    let mut envelope = envelope_v02();
500    super::seal_into_envelope(
501      &mut rng(),
502      &mut envelope,
503      reader("did:web:receiver.example.com"),
504      &receiver_pk,
505      b"carried, verified, unread until here",
506    )
507    .expect("sealing into a 0.2 envelope succeeds");
508    std::assert!(envelope.credential_subject.sealed_payload.is_some());
509
510    let opened = super::unseal_from_envelope(&envelope, &receiver_sk)
511      .expect("the named reader opens the envelope's seal");
512    std::assert_eq!(opened, b"carried, verified, unread until here");
513  }
514
515  #[test]
516  fn sealing_into_a_v01_envelope_refuses_up_front() {
517    // Minting a wire every conformant verifier refuses is not a feature;
518    // the version rule fires BEFORE any cryptography, both directions.
519    let (_, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
520    let mut envelope = envelope_v02();
521    envelope.aph_version = std::string::String::from("0.1");
522    let err = super::seal_into_envelope(
523      &mut rng(),
524      &mut envelope,
525      reader("did:web:receiver.example.com"),
526      &receiver_pk,
527      b"x",
528    )
529    .expect_err("a 0.1 wire cannot carry the member");
530    std::assert!(std::matches!(err, super::EnvelopeSealError::WireUndeclared(_)));
531  }
532
533  #[test]
534  fn a_sealed_member_on_a_v01_wire_refuses_at_the_declaration_rule() {
535    // The unseal side of the same rule: a hand-staged sealedPayload on an
536    // aphVersion 0.1 envelope is malformed for the version it claims, and
537    // the refusal is strict-parse CLASS — below the code vocabulary —
538    // exactly like an unrecognized closed-set value.
539    let (receiver_sk, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
540    let mut envelope = envelope_v02();
541    super::seal_into_envelope(
542      &mut rng(),
543      &mut envelope,
544      reader("did:web:receiver.example.com"),
545      &receiver_pk,
546      b"x",
547    )
548    .expect("seal succeeds on 0.2");
549    envelope.aph_version = std::string::String::from("0.1");
550    let err = super::unseal_from_envelope(&envelope, &receiver_sk)
551      .expect_err("the declaration rule refuses first");
552    std::assert!(std::matches!(err, super::EnvelopeSealError::WireUndeclared(_)));
553  }
554
555  #[test]
556  fn envelope_unseal_failures_carry_their_draft_codes() {
557    // The two runtime refusals map to the 0.2 codes the spec delta
558    // declares: unknown suite is APH_E022 by name before any key material,
559    // an unopenable seal is APH_E021 — and per RFC 0008 §4 the caller
560    // refuses the ENVELOPE on either.
561    let (receiver_sk, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
562    let mut envelope = envelope_v02();
563    super::seal_into_envelope(
564      &mut rng(),
565      &mut envelope,
566      reader("did:web:receiver.example.com"),
567      &receiver_pk,
568      b"coded refusals",
569    )
570    .expect("seal succeeds");
571
572    let mut suite_relabeled = envelope.clone();
573    suite_relabeled
574      .credential_subject
575      .sealed_payload
576      .as_mut()
577      .expect("sealed")
578      .suite = std::string::String::from("APH-SEAL-99");
579    match super::unseal_from_envelope(&suite_relabeled, &receiver_sk) {
580      std::result::Result::Err(super::EnvelopeSealError::Refused(e)) => {
581        std::assert_eq!(e.code(), "APH_E022")
582      }
583      other => std::panic!("expected an APH_E022 refusal, got {:?}", other),
584    }
585
586    let (sender_sk, _) = derive_keypair_for_tests(SENDER_IKM);
587    match super::unseal_from_envelope(&envelope, &sender_sk) {
588      std::result::Result::Err(super::EnvelopeSealError::Refused(e)) => {
589        std::assert_eq!(e.code(), "APH_E021")
590      }
591      other => std::panic!("expected an APH_E021 refusal, got {:?}", other),
592    }
593  }
594
595  #[test]
596  fn body_sha256_binds_the_raw_ciphertext_octets_so_hops_verify_blind() {
597    // RFC 0008 §3's second binding, demonstrated: bodySha256 is computed
598    // over the base64url-DECODED ciphertext — never the JSON around it —
599    // so any hop can run §8.3 step 8 with no plaintext, and two
600    // implementations cannot disagree about which bytes were hashed.
601    use sha2::Digest as _;
602    let (_, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
603    let mut envelope = envelope_v02();
604    super::seal_into_envelope(
605      &mut rng(),
606      &mut envelope,
607      reader("did:web:receiver.example.com"),
608      &receiver_pk,
609      b"the body no hop reads",
610    )
611    .expect("seal succeeds");
612    let sealed = envelope.credential_subject.sealed_payload.as_ref().expect("sealed");
613    let raw = super::unb64(&sealed.ciphertext, "ciphertext").expect("decodes");
614    let digest = std::format!("{:x}", sha2::Sha256::digest(&raw));
615    envelope.credential_subject.communication.body_sha256 = digest.clone();
616
617    // The blind hop's whole check, reproduced: decode, hash, compare.
618    let recomputed = std::format!(
619      "{:x}",
620      sha2::Sha256::digest(super::unb64(&sealed.ciphertext, "ciphertext").expect("decodes"))
621    );
622    std::assert_eq!(recomputed, envelope.credential_subject.communication.body_sha256);
623  }
624
625  // ── The committed 0.2 vector ──────────────────────────────────
626
627  fn draft_vector_path() -> std::path::PathBuf {
628    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
629      .join("../../../examples/v0.2/sealed_envelope.json")
630  }
631
632  /// The vector's exact bytes, regenerated: fully deterministic because the
633  /// keys derive from fixed IKM, the RNG is seeded, and the base is a
634  /// committed golden — so the committed file can be byte-compared against
635  /// a fresh mint exactly as the signed v0.1 vectors are.
636  fn regenerate_draft_vector() -> String {
637    let (_, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
638    let mut envelope = envelope_v02();
639    envelope.id = std::string::String::from("urn:uuid:00000000-0000-4000-8000-00000000000e");
640    super::seal_into_envelope(
641      &mut rng(),
642      &mut envelope,
643      reader("did:web:receiver.example.com"),
644      &receiver_pk,
645      b"the body no hop reads: the v0.2-draft sealed vector",
646    )
647    .expect("sealing the vector succeeds");
648    use sha2::Digest as _;
649    let sealed = envelope.credential_subject.sealed_payload.as_ref().expect("sealed");
650    let raw = super::unb64(&sealed.ciphertext, "ciphertext").expect("decodes");
651    envelope.credential_subject.communication.body_sha256 =
652      std::format!("{:x}", sha2::Sha256::digest(&raw));
653    std::format!(
654      "{}\n",
655      serde_json::to_string_pretty(&envelope).expect("the vector serializes")
656    )
657  }
658
659  #[test]
660  fn the_committed_draft_vector_is_byte_identical_to_a_fresh_mint() {
661    // The same drift discipline the signed v0.1 vectors live under: the
662    // committed file IS what this code mints, or this test prints the
663    // replacement between cut lines and fails.
664    let regenerated = regenerate_draft_vector();
665    let path = draft_vector_path();
666    // A missing file is TOTAL drift, not a separate failure: the first mint
667    // and a deleted vector both resolve the same way — write the printed
668    // replacement.
669    let published = std::fs::read_to_string(&path).unwrap_or_default();
670    if published != regenerated {
671      std::panic!(
672        "examples/v0.2/sealed_envelope.json has drifted from the bytes \
673         this suite mints.\nTo fix in ONE step, overwrite that file with EXACTLY \
674         the content between the cut lines (the final newline is part of the \
675         content):\n----8<----\n{}----8<----",
676        regenerated
677      );
678    }
679  }
680
681  #[test]
682  fn the_committed_draft_vector_opens_for_its_reader_and_verifies_blind() {
683    // The vector proves both halves at once: a blind hop's bodySha256
684    // check succeeds over the raw ciphertext octets, and the named
685    // reader's derived test key opens the committed bytes — pinning the
686    // wire format's long-term stability, not only this build's.
687    use sha2::Digest as _;
688    let raw_json = std::fs::read_to_string(draft_vector_path()).expect("the vector is on disk");
689    let envelope: aph_core::NotarizationEnvelope =
690      aph_core::parse_envelope_json(&raw_json).expect("the vector strict-parses as 0.2");
691
692    let sealed = envelope.credential_subject.sealed_payload.as_ref().expect("sealed");
693    let raw = super::unb64(&sealed.ciphertext, "ciphertext").expect("decodes");
694    std::assert_eq!(
695      std::format!("{:x}", sha2::Sha256::digest(&raw)),
696      envelope.credential_subject.communication.body_sha256,
697      "the blind hop's body binding holds over the committed bytes"
698    );
699
700    let (receiver_sk, _) = derive_keypair_for_tests(RECEIVER_IKM);
701    let opened = super::unseal_from_envelope(&envelope, &receiver_sk)
702      .expect("the named reader opens the committed vector");
703    std::assert_eq!(opened, b"the body no hop reads: the v0.2-draft sealed vector");
704  }
705
706  // ── The SIGNED 0.2 vectors ──────────────────────────────────────────
707
708  const NOTARY_SEED: [u8; 32] = [
709    // RFC 8032 §7.1 TEST 1's secret key — the published test vector the
710    // signing suites already cite. Authorizes nothing.
711    0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
712    0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
713    0x7f, 0x60,
714  ];
715
716  fn notary_key() -> ed25519_dalek::SigningKey {
717    ed25519_dalek::SigningKey::from_bytes(&NOTARY_SEED)
718  }
719
720  fn signed_vector_path() -> std::path::PathBuf {
721    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
722      .join("../../../examples/v0.2/sealed_signed_envelope.json")
723  }
724
725  /// The signed sealed vector, regenerated byte-for-byte: seal FIRST (the
726  /// RFC 0008 §3 order — the signature must cover the ciphertext), bind
727  /// the body hash to the raw ciphertext octets, then sign as a lone
728  /// did:key notary so any implementation verifies it with no supplied
729  /// keys, exactly as the ts-minted v0.1 vector is verified.
730  fn regenerate_signed_vector() -> String {
731    use sha2::Digest as _;
732    let key = notary_key();
733    let did = aph_core::crypto::did_key::encode_ed25519(&key.verifying_key());
734    let fragment = did.trim_start_matches("did:key:").to_string();
735
736    let (_, receiver_pk) = derive_keypair_for_tests(RECEIVER_IKM);
737    let mut envelope = envelope_v02();
738    envelope.id = std::string::String::from("urn:uuid:00000000-0000-4000-8000-00000000000f");
739    envelope.issuer = did.clone();
740    super::seal_into_envelope(
741      &mut rng(),
742      &mut envelope,
743      reader("did:web:receiver.example.com"),
744      &receiver_pk,
745      b"the signed sealed vector: carried blind, verified by anyone, read by one",
746    )
747    .expect("sealing succeeds");
748    let raw = {
749      let sealed = envelope.credential_subject.sealed_payload.as_ref().expect("sealed");
750      super::unb64(&sealed.ciphertext, "ciphertext").expect("decodes")
751    };
752    envelope.credential_subject.communication.body_sha256 =
753      std::format!("{:x}", sha2::Sha256::digest(&raw));
754
755    envelope.proof = aph_core::EnvelopeProofs::Single(aph_core::EnvelopeProof {
756      r#type: std::string::String::from("DataIntegrityProof"),
757      cryptosuite: std::option::Option::None, // sign_envelope writes it
758      verification_method: std::format!("{did}#{fragment}"),
759      created: std::string::String::from("2026-08-29T00:00:01Z"),
760      proof_purpose: std::string::String::from("assertionMethod"),
761      proof_value: std::string::String::new(),
762      id: std::option::Option::None,
763      previous_proof: std::option::Option::None,
764    });
765    aph_core::crypto::eddsa_jcs::sign_envelope(&mut envelope, &key).expect("signing succeeds");
766    std::format!(
767      "{}\n",
768      serde_json::to_string_pretty(&envelope).expect("the vector serializes")
769    )
770  }
771
772  #[test]
773  fn the_signed_sealed_vector_is_byte_identical_to_a_fresh_mint() {
774    let regenerated = regenerate_signed_vector();
775    let path = signed_vector_path();
776    let published = std::fs::read_to_string(&path).unwrap_or_default();
777    if published != regenerated {
778      std::panic!(
779        "examples/v0.2/sealed_signed_envelope.json has drifted from the \
780         bytes this suite mints.\nOverwrite it with EXACTLY the content between \
781         the cut lines (final newline included):\n----8<----\n{}----8<----",
782        regenerated
783      );
784    }
785  }
786
787  #[test]
788  fn the_signed_sealed_vector_verifies_blind_and_opens_for_its_reader() {
789    // The whole RFC in one committed artifact: a stranger verifies the
790    // did:key signature over the CIPHERTEXT with no supplied keys and no
791    // plaintext; the named reader additionally opens it.
792    let raw_json =
793      std::fs::read_to_string(signed_vector_path()).expect("the signed vector is on disk");
794    let envelope: aph_core::NotarizationEnvelope =
795      aph_core::parse_envelope_json(&raw_json).expect("strict-parses as 0.2");
796    aph_core::crypto::eddsa_jcs::verify_envelope_did_key(&envelope)
797      .expect("the lone did:key proof verifies offline, sealed member covered");
798
799    let (receiver_sk, _) = derive_keypair_for_tests(RECEIVER_IKM);
800    let opened = super::unseal_from_envelope(&envelope, &receiver_sk)
801      .expect("the named reader opens the committed signed vector");
802    std::assert_eq!(
803      opened,
804      b"the signed sealed vector: carried blind, verified by anyone, read by one"
805    );
806  }
807
808  fn rotation_vector_path() -> std::path::PathBuf {
809    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
810      .join("../../../examples/v0.2/rotation_attestation.json")
811  }
812
813  fn regenerate_rotation_vector() -> String {
814    let key = notary_key();
815    let did = aph_core::crypto::did_key::encode_ed25519(&key.verifying_key());
816    let successor_multibase = did.trim_start_matches("did:key:").to_string();
817    let mut attestation = aph_core::RotationAttestation {
818      aph_version: std::string::String::from("0.2"),
819      r#type: std::string::String::from(aph_core::ROTATION_ATTESTATION_TYPE),
820      id: std::string::String::from("urn:uuid:00000000-0000-4000-8000-0000000000a0"),
821      subject: std::string::String::from("did:web:notary.squillo.com"),
822      predecessor: std::string::String::from("did:web:notary.squillo.com#k1"),
823      successor: aph_core::RotationSuccessor {
824        kid: std::string::String::from("k2"),
825        alg: std::string::String::from("EdDSA"),
826        public_key_multibase: successor_multibase,
827        not_before: std::string::String::from("2027-01-01T00:00:00Z"),
828        not_after: std::string::String::from("2029-01-01T00:00:00Z"),
829      },
830      created: std::string::String::from("2026-08-29T00:00:00Z"),
831      proof: aph_core::EnvelopeProof {
832        r#type: std::string::String::from("DataIntegrityProof"),
833        cryptosuite: std::option::Option::Some(std::string::String::from("eddsa-jcs-2022")),
834        verification_method: std::string::String::from("did:web:notary.squillo.com#k1"),
835        created: std::string::String::from("2026-08-29T00:00:00Z"),
836        proof_purpose: std::string::String::from("assertionMethod"),
837        proof_value: std::string::String::new(),
838        id: std::option::Option::None,
839        previous_proof: std::option::Option::None,
840      },
841    };
842    aph_core::sign_rotation_attestation(&mut attestation, &key).expect("signing succeeds");
843    std::format!(
844      "{}\n",
845      serde_json::to_string_pretty(&attestation).expect("the vector serializes")
846    )
847  }
848
849  #[test]
850  fn the_rotation_attestation_vector_is_byte_identical_to_a_fresh_mint() {
851    let regenerated = regenerate_rotation_vector();
852    let path = rotation_vector_path();
853    let published = std::fs::read_to_string(&path).unwrap_or_default();
854    if published != regenerated {
855      std::panic!(
856        "examples/v0.2/rotation_attestation.json has drifted from the \
857         bytes this suite mints.\nOverwrite it with EXACTLY the content between \
858         the cut lines (final newline included):\n----8<----\n{}----8<----",
859        regenerated
860      );
861    }
862  }
863
864  #[test]
865  fn the_committed_rotation_vector_verifies_against_its_predecessor_key() {
866    let raw = std::fs::read_to_string(rotation_vector_path()).expect("the vector is on disk");
867    let attestation: aph_core::RotationAttestation =
868      serde_json::from_str(&raw).expect("strict-parses");
869    aph_core::verify_rotation_attestation(&attestation, &notary_key().verifying_key())
870      .expect("the predecessor's committed statement verifies");
871  }
872}