Skip to main content

dig_message/
seal.rs

1//! WU2: the e2e SEAL pipeline (SPEC §5) — the crypto crux of dig-message.
2//!
3//! ONE Chia BLS12-381 identity keypair does everything: its G2 signature authenticates the sender and
4//! ECDH over its G1 group seals the payload. There is NO X25519 and NO Ed25519.
5//!
6//! ## Seal (send) — SPEC §1 pipeline + §5.1
7//! serialize payload → **compress** (§1.1) → **BLS-G2 sign** the transcript (§5.1, domain-separated so
8//! it can never be a chain AGG_SIG) → **G1-DHKEM auth-seal** (HPKE AuthEncap analog over BLS12-381 G1:
9//! ephemeral + static-sender ECDH → HKDF-SHA256 → ChaCha20Poly1305). The `kem_enc` is the 48-byte
10//! ephemeral G1 point; the sealed region opens ONLY with the recipient private key AND the correct
11//! sender public key.
12//!
13//! ## Open (receive) — SPEC §5.2 / §5.7
14//! subgroup-check `kem_enc` + sender key BEFORE any DH → G1-DHKEM auth-decap + AEAD-open → verify the
15//! BLS-G2 sender signature → header-match check → **expiry** discard (§5.6b) → **anti-replay** check
16//! (§5.6) → decompress under the bomb guard (§1.1) → deliver. Fail-closed at every step.
17//!
18//! The DH / sign / verify / subgroup primitives come from `dig-identity` (never re-rolled). The DHKEM /
19//! HKDF / AEAD composition lives HERE (the decider's crate split).
20
21use chacha20poly1305::aead::{Aead, Payload};
22use chacha20poly1305::{ChaCha20Poly1305, KeyInit};
23use chia_bls::SecretKey;
24use chia_protocol::{Bytes32, Bytes48, Bytes96};
25use chia_traits::Streamable as _;
26use hkdf::Hkdf;
27use sha2::Sha256;
28
29use crate::compression::{compress_payload, decompress_payload};
30use crate::constants::MAX_DECOMPRESSED_BYTES;
31use crate::envelope::{
32    DigMessageEnvelope, InnerMessage, InteractionShape, SealedPayload, StreamHeader, FLAG_SEALED,
33};
34use crate::error::{MessageError, Result};
35use crate::replay::ReplayGuard;
36use crate::transcript::TranscriptFields;
37use dig_identity::{g1_dh, g1_subgroup_check, public_key_bytes};
38
39/// The HKDF info label for the DHKEM-over-G1 key schedule (SPEC §5.1). Binding the KEM material into
40/// `info` (below) ties the derived key to this exact encapsulation, sender, and recipient.
41const KDF_INFO_LABEL: &[u8] = b"dig-message/dhkem-g1/v1";
42
43/// The AEAD key (32) + base nonce (12) derived from the shared secret (SPEC §5.1).
44const OKM_LEN: usize = 32 + 12;
45
46/// The current envelope format version this sealer emits (SPEC §2 field 1).
47const VERSION: u8 = crate::constants::ENVELOPE_VERSION;
48
49/// Everything the sender supplies to seal one message (SPEC §5.1). The anti-replay `counter` +
50/// `timestamp_ms` and the `expires_at` TTL are sender-chosen; the crate binds + enforces them.
51pub struct SealParams<'a> {
52    /// The sender identity secret key (the ONE BLS12-381 key — signs G2 and does the static G1 DH).
53    pub sender_sk: &'a SecretKey,
54    /// The sender DID launcher id (cleartext header, bound as AAD).
55    pub sender: Bytes32,
56    /// The sender key epoch for rotation disambiguation (SPEC §2 field 7).
57    pub sender_epoch: u32,
58    /// The recipient DID launcher id.
59    pub recipient: Bytes32,
60    /// The recipient BLS G1 identity public key (48-byte compressed), the seal target.
61    pub recipient_pub: &'a [u8; 48],
62    /// The message type id (SPEC §4).
63    pub message_type: u32,
64    /// The interaction shape (SPEC §3).
65    pub shape: InteractionShape,
66    /// The correlation id (SPEC §2 field 4).
67    pub correlation_id: Bytes32,
68    /// The stream header, present iff `shape` is a stream frame (SPEC §3).
69    pub stream: Option<StreamHeader>,
70    /// The per-(sender→recipient) strictly-monotonic anti-replay counter (SPEC §5.6).
71    pub counter: u64,
72    /// Sender wall-clock Unix milliseconds — the freshness field (SPEC §5.6).
73    pub timestamp_ms: u64,
74    /// Sender-controlled TTL, Unix milliseconds; 0 = no explicit expiry (SPEC §5.6b).
75    pub expires_at: u64,
76    /// The raw (uncompressed, unsealed) type-payload bytes.
77    pub payload: &'a [u8],
78}
79
80/// A successfully opened + verified message (SPEC §5.2). The `payload` is the decompressed plaintext;
81/// the caller routes it via the type registry (SPEC §4).
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct OpenedMessage {
84    pub message_type: u32,
85    pub correlation_id: Bytes32,
86    pub shape: InteractionShape,
87    pub sender: Bytes32,
88    pub sender_epoch: u32,
89    pub counter: u64,
90    pub timestamp_ms: u64,
91    pub expires_at: u64,
92    /// The decompressed, verified plaintext type-payload bytes.
93    pub payload: Vec<u8>,
94}
95
96/// Seal a message end-to-end (SPEC §5.1), generating a fresh ephemeral for forward secrecy.
97///
98/// # Errors
99/// [`MessageError::InvalidPoint`] if `recipient_pub` fails the subgroup check;
100/// [`MessageError::SealFailed`] on an ephemeral-key or AEAD failure;
101/// [`MessageError::PayloadTooLarge`] / [`MessageError::Codec`] from compression.
102pub fn seal_message(params: &SealParams) -> Result<DigMessageEnvelope> {
103    let esk = random_ephemeral()?;
104    seal_with_ephemeral(params, &esk)
105}
106
107/// Seal with a caller-provided ephemeral secret (deterministic KATs). Prefer [`seal_message`] in
108/// production — a fresh random ephemeral per message is what gives forward secrecy (SPEC §5.1).
109///
110/// # Errors
111/// As [`seal_message`].
112pub fn seal_with_ephemeral(params: &SealParams, esk: &SecretKey) -> Result<DigMessageEnvelope> {
113    // SPEC §1: compress BEFORE sealing (sealed ciphertext is incompressible).
114    let compressed = compress_payload(params.payload)?;
115
116    let sender_pub = public_key_bytes(params.sender_sk);
117    let kem_enc = public_key_bytes(esk);
118
119    // SPEC §5.1 AuthEncap: Z = dh(esk, recipient_pub) || dh(sender_static_sk, recipient_pub).
120    let z = auth_encap_secret(esk, params.sender_sk, params.recipient_pub)?;
121    let okm = kdf(&z, &kem_enc, &sender_pub, params.recipient_pub);
122
123    // SPEC §5.1: BLS-G2 sign the transcript, then place the signature inside the sealed InnerMessage.
124    let transcript = TranscriptFields {
125        version: VERSION,
126        message_type: params.message_type,
127        flags: params.shape.as_bits() | FLAG_SEALED,
128        correlation_id: params.correlation_id,
129        sender: params.sender,
130        recipient: params.recipient,
131        sender_epoch: params.sender_epoch,
132        counter: params.counter,
133        timestamp_ms: params.timestamp_ms,
134        expires_at: params.expires_at,
135        stream: params.stream,
136        kem_enc: &kem_enc,
137        compression: compressed.compression,
138        uncompressed_len: compressed.uncompressed_len,
139        compressed_payload: &compressed.bytes,
140    };
141    let sender_sig = transcript.sign(params.sender_sk);
142
143    let inner = InnerMessage {
144        message_type: params.message_type,
145        correlation_id: params.correlation_id,
146        compression: compressed.compression,
147        uncompressed_len: compressed.uncompressed_len,
148        counter: params.counter,
149        timestamp_ms: params.timestamp_ms,
150        expires_at: params.expires_at,
151        payload: compressed.bytes,
152        sender_sig: Bytes96::new(sender_sig),
153    };
154    let inner_bytes = inner
155        .to_bytes()
156        .map_err(|e| MessageError::SealFailed(e.to_string()))?;
157
158    // The cleartext header is bound as AEAD AAD so an on-path party cannot alter routing metadata
159    // (SPEC §5.2). Build the envelope with the final kem_enc + an empty ciphertext to derive the
160    // header bytes, then fill the ciphertext.
161    let mut envelope = DigMessageEnvelope {
162        version: VERSION,
163        message_type: params.message_type,
164        flags: params.shape.as_bits() | FLAG_SEALED,
165        correlation_id: params.correlation_id,
166        sender: params.sender,
167        recipient: params.recipient,
168        sender_epoch: params.sender_epoch,
169        stream: params.stream,
170        sealed: SealedPayload {
171            kem_enc: Bytes48::new(kem_enc),
172            ciphertext: Vec::new(),
173        },
174    };
175    let aad = envelope.header_bytes()?;
176    envelope.sealed.ciphertext = aead_seal(&okm, &aad, &inner_bytes)?;
177    Ok(envelope)
178}
179
180/// Open + fully verify a sealed envelope (SPEC §5.2 / §5.7), advancing the anti-replay guard.
181///
182/// `resolve_sender_pub` maps `(sender DID, sender_epoch)` to the sender's 48-byte BLS G1 key (wire a
183/// `dig-identity` chain resolution here); returning `None` fails closed with
184/// [`MessageError::UnresolvableSender`]. `now_ms` is the receiver's wall clock for the freshness +
185/// expiry checks.
186///
187/// # Errors
188/// Fail-closed at each step: [`MessageError::UnresolvableSender`], [`MessageError::InvalidPoint`],
189/// [`MessageError::OpenFailed`], [`MessageError::BadSignature`], [`MessageError::HeaderMismatch`],
190/// [`MessageError::TtlTooLong`], [`MessageError::Expired`], [`MessageError::Replay`],
191/// [`MessageError::DecompressionBomb`], and the codec/compression errors.
192pub fn open_message(
193    recipient_sk: &SecretKey,
194    envelope: &DigMessageEnvelope,
195    resolve_sender_pub: impl Fn(Bytes32, u32) -> Option<[u8; 48]>,
196    guard: &mut ReplayGuard,
197    now_ms: u64,
198) -> Result<OpenedMessage> {
199    let shape = InteractionShape::from_flags(envelope.flags).ok_or(MessageError::HeaderMismatch)?;
200
201    // Resolve the sender key from the (AAD-bound, un-tamperable) cleartext DID + epoch.
202    let sender_pub = resolve_sender_pub(envelope.sender, envelope.sender_epoch)
203        .ok_or(MessageError::UnresolvableSender)?;
204
205    let kem_enc: [u8; 48] = envelope
206        .sealed
207        .kem_enc
208        .as_ref()
209        .try_into()
210        .expect("Bytes48 is exactly 48 bytes");
211
212    // SPEC §5.1 (HARD): subgroup-check every received G1 point BEFORE any DH.
213    if !g1_subgroup_check(&kem_enc) || !g1_subgroup_check(&sender_pub) {
214        return Err(MessageError::InvalidPoint);
215    }
216
217    // SPEC §5.1 AuthDecap: Z2 = dh(recipient_sk, kem_enc) || dh(recipient_sk, sender_static_pub).
218    let z = auth_decap_secret(recipient_sk, &sender_pub, &kem_enc)?;
219    let recipient_pub = public_key_bytes(recipient_sk);
220    let okm = kdf(&z, &kem_enc, &sender_pub, &recipient_pub);
221
222    let aad = envelope.header_bytes()?;
223    let inner_bytes = aead_open(&okm, &aad, &envelope.sealed.ciphertext)?;
224    let inner =
225        InnerMessage::from_bytes(&inner_bytes).map_err(|e| MessageError::Codec(e.to_string()))?;
226
227    // (a) Verify the mandatory BLS G2 sender signature over the full transcript (SPEC §5.2).
228    let transcript = TranscriptFields {
229        version: envelope.version,
230        message_type: envelope.message_type,
231        flags: envelope.flags,
232        correlation_id: envelope.correlation_id,
233        sender: envelope.sender,
234        recipient: envelope.recipient,
235        sender_epoch: envelope.sender_epoch,
236        counter: inner.counter,
237        timestamp_ms: inner.timestamp_ms,
238        expires_at: inner.expires_at,
239        stream: envelope.stream,
240        kem_enc: &kem_enc,
241        compression: inner.compression,
242        uncompressed_len: inner.uncompressed_len,
243        compressed_payload: &inner.payload,
244    };
245    let sig: [u8; 96] = inner
246        .sender_sig
247        .as_ref()
248        .try_into()
249        .expect("Bytes96 is exactly 96 bytes");
250    if !transcript.verify(&sender_pub, &sig) {
251        return Err(MessageError::BadSignature);
252    }
253
254    // (b) Anti type-confusion / anti-splice: inner header MUST equal the cleartext header.
255    if inner.message_type != envelope.message_type
256        || inner.correlation_id != envelope.correlation_id
257    {
258        return Err(MessageError::HeaderMismatch);
259    }
260
261    // (c) Expiry discard (SPEC §5.6b) — BEFORE touching replay state.
262    check_expiry(inner.timestamp_ms, inner.expires_at, now_ms)?;
263
264    // (d) Anti-replay (SPEC §5.6): freshness window + bounded sliding-window dedup.
265    if !guard.check_and_admit(
266        envelope.sender,
267        envelope.sender_epoch,
268        inner.counter,
269        inner.timestamp_ms,
270        now_ms,
271    ) {
272        return Err(MessageError::Replay);
273    }
274
275    // (e) Bomb-guard cap + (f) decompress under the §1.1 output bound.
276    if inner.uncompressed_len as usize > MAX_DECOMPRESSED_BYTES {
277        return Err(MessageError::DecompressionBomb {
278            declared: inner.uncompressed_len as usize,
279            max: MAX_DECOMPRESSED_BYTES,
280        });
281    }
282    let payload = decompress_payload(inner.compression, &inner.payload, inner.uncompressed_len)?;
283
284    Ok(OpenedMessage {
285        message_type: inner.message_type,
286        correlation_id: inner.correlation_id,
287        shape,
288        sender: envelope.sender,
289        sender_epoch: envelope.sender_epoch,
290        counter: inner.counter,
291        timestamp_ms: inner.timestamp_ms,
292        expires_at: inner.expires_at,
293        payload,
294    })
295}
296
297/// SPEC §5.6b expiry semantics: reject an over-long TTL, discard a past-expiry message; `expires_at`
298/// == 0 means no explicit expiry.
299fn check_expiry(timestamp_ms: u64, expires_at: u64, now_ms: u64) -> Result<()> {
300    if expires_at == 0 {
301        return Ok(());
302    }
303    if expires_at > timestamp_ms.saturating_add(crate::constants::MAX_MESSAGE_TTL_MS) {
304        return Err(MessageError::TtlTooLong);
305    }
306    if now_ms > expires_at {
307        return Err(MessageError::Expired);
308    }
309    Ok(())
310}
311
312/// Sender-side AuthEncap ikm: `dh(esk, recipient_pub) || dh(sender_static_sk, recipient_pub)`.
313fn auth_encap_secret(
314    esk: &SecretKey,
315    sender_sk: &SecretKey,
316    recipient_pub: &[u8; 48],
317) -> Result<[u8; 96]> {
318    let ephemeral = g1_dh(esk, recipient_pub).ok_or(MessageError::InvalidPoint)?;
319    let static_term = g1_dh(sender_sk, recipient_pub).ok_or(MessageError::InvalidPoint)?;
320    Ok(concat_dh(&ephemeral, &static_term))
321}
322
323/// Recipient-side AuthDecap ikm: `dh(recipient_sk, kem_enc) || dh(recipient_sk, sender_pub)`.
324fn auth_decap_secret(
325    recipient_sk: &SecretKey,
326    sender_pub: &[u8; 48],
327    kem_enc: &[u8; 48],
328) -> Result<[u8; 96]> {
329    let ephemeral = g1_dh(recipient_sk, kem_enc).ok_or(MessageError::InvalidPoint)?;
330    let static_term = g1_dh(recipient_sk, sender_pub).ok_or(MessageError::InvalidPoint)?;
331    Ok(concat_dh(&ephemeral, &static_term))
332}
333
334/// Concatenate the two 48-byte DH results into the 96-byte HKDF ikm (`Z`).
335fn concat_dh(a: &[u8; 48], b: &[u8; 48]) -> [u8; 96] {
336    let mut z = [0u8; 96];
337    z[..48].copy_from_slice(a);
338    z[48..].copy_from_slice(b);
339    z
340}
341
342/// HKDF-SHA256 key schedule (SPEC §5.1): extract over `salt=empty, ikm=Z`, expand over
343/// `info = KDF_INFO_LABEL || kem_enc || sender_pub || recipient_pub` to the AEAD key + base nonce.
344fn kdf(
345    z: &[u8; 96],
346    kem_enc: &[u8; 48],
347    sender_pub: &[u8; 48],
348    recipient_pub: &[u8; 48],
349) -> [u8; OKM_LEN] {
350    let hk = Hkdf::<Sha256>::new(None, z);
351    let mut info = Vec::with_capacity(KDF_INFO_LABEL.len() + 48 * 3);
352    info.extend_from_slice(KDF_INFO_LABEL);
353    info.extend_from_slice(kem_enc);
354    info.extend_from_slice(sender_pub);
355    info.extend_from_slice(recipient_pub);
356    let mut okm = [0u8; OKM_LEN];
357    hk.expand(&info, &mut okm)
358        .expect("OKM_LEN is well within the HKDF-SHA256 output limit");
359    okm
360}
361
362/// ChaCha20Poly1305 seal with the header as AAD (SPEC §5.2).
363fn aead_seal(okm: &[u8; OKM_LEN], aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
364    let cipher = ChaCha20Poly1305::new_from_slice(&okm[..32])
365        .map_err(|_| MessageError::SealFailed("invalid AEAD key length".into()))?;
366    let nonce: [u8; 12] = okm[32..].try_into().expect("OKM tail is exactly 12 bytes");
367    cipher
368        .encrypt(
369            (&nonce).into(),
370            Payload {
371                msg: plaintext,
372                aad,
373            },
374        )
375        .map_err(|_| MessageError::SealFailed("AEAD encrypt failed".into()))
376}
377
378/// ChaCha20Poly1305 open with the header as AAD (SPEC §5.2). A wrong key, wrong sender, tampered
379/// ciphertext, or tampered AAD all fail here — fail-closed, no plaintext leaks.
380fn aead_open(okm: &[u8; OKM_LEN], aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
381    let cipher =
382        ChaCha20Poly1305::new_from_slice(&okm[..32]).map_err(|_| MessageError::OpenFailed)?;
383    let nonce: [u8; 12] = okm[32..].try_into().expect("OKM tail is exactly 12 bytes");
384    cipher
385        .decrypt(
386            (&nonce).into(),
387            Payload {
388                msg: ciphertext,
389                aad,
390            },
391        )
392        .map_err(|_| MessageError::OpenFailed)
393}
394
395/// Generate a fresh ephemeral BLS secret key for the DHKEM encapsulation (SPEC §5.1 forward secrecy).
396fn random_ephemeral() -> Result<SecretKey> {
397    let mut ikm = [0u8; 32];
398    getrandom::getrandom(&mut ikm)
399        .map_err(|e| MessageError::SealFailed(format!("ephemeral key entropy: {e}")))?;
400    Ok(SecretKey::from_seed(&ikm))
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use dig_identity::{derive_identity_sk, master_secret_key_from_seed};
407    use sha2::Digest;
408
409    fn sk(label: &str) -> SecretKey {
410        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
411        derive_identity_sk(&master_secret_key_from_seed(&seed))
412    }
413
414    /// A deterministic ephemeral for reproducible KATs (never a hard-coded literal — CodeQL).
415    fn ephemeral(label: &str) -> SecretKey {
416        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
417        SecretKey::from_seed(&seed)
418    }
419
420    fn did(n: u8) -> Bytes32 {
421        Bytes32::new([n; 32])
422    }
423
424    struct Party {
425        sk: SecretKey,
426        did: Bytes32,
427        pub_key: [u8; 48],
428    }
429    fn party(label: &str, did_byte: u8) -> Party {
430        let sk = sk(label);
431        let pub_key = public_key_bytes(&sk);
432        Party {
433            sk,
434            did: did(did_byte),
435            pub_key,
436        }
437    }
438
439    fn params<'a>(
440        sender: &'a Party,
441        recipient: &'a Party,
442        payload: &'a [u8],
443        counter: u64,
444        timestamp_ms: u64,
445        expires_at: u64,
446    ) -> SealParams<'a> {
447        SealParams {
448            sender_sk: &sender.sk,
449            sender: sender.did,
450            sender_epoch: 0,
451            recipient: recipient.did,
452            recipient_pub: &recipient.pub_key,
453            message_type: 0x0000_0201,
454            shape: InteractionShape::OneShot,
455            correlation_id: did(0xAB),
456            stream: None,
457            counter,
458            timestamp_ms,
459            expires_at,
460            payload,
461        }
462    }
463
464    /// A resolver returning a fixed sender key (the unit-test stand-in for dig-identity resolution).
465    fn resolver(pk: [u8; 48]) -> impl Fn(Bytes32, u32) -> Option<[u8; 48]> {
466        move |_did, _epoch| Some(pk)
467    }
468
469    const NOW: u64 = 1_700_000_000_000;
470
471    #[test]
472    fn seal_open_round_trip_raw() {
473        let alice = party("seal/alice", 1);
474        let bob = party("seal/bob", 2);
475        let msg = b"hello bob";
476        let env =
477            seal_with_ephemeral(&params(&alice, &bob, msg, 0, NOW, 0), &ephemeral("e1")).unwrap();
478        let mut guard = ReplayGuard::new();
479        let opened = open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap();
480        assert_eq!(opened.payload, msg);
481        assert_eq!(opened.sender, alice.did);
482        assert_eq!(opened.counter, 0);
483    }
484
485    #[test]
486    fn seal_open_round_trip_compressed() {
487        let alice = party("seal/alice2", 1);
488        let bob = party("seal/bob2", 2);
489        let msg: Vec<u8> = (0..4096).map(|i| (i % 7) as u8).collect();
490        let env =
491            seal_with_ephemeral(&params(&alice, &bob, &msg, 0, NOW, 0), &ephemeral("e2")).unwrap();
492        // Compression engaged: the sealed frame is far smaller than the 4 KiB plaintext.
493        assert!(env.sealed.ciphertext.len() < msg.len());
494        let mut guard = ReplayGuard::new();
495        let opened = open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap();
496        assert_eq!(opened.payload, msg);
497    }
498
499    #[test]
500    fn relay_sees_only_ciphertext_no_plaintext_substring() {
501        let alice = party("seal/leak-a", 1);
502        let bob = party("seal/leak-b", 2);
503        let secret = b"TOP-SECRET-PLAINTEXT-MARKER";
504        let env = seal_with_ephemeral(
505            &params(&alice, &bob, secret, 0, NOW, 0),
506            &ephemeral("eleak"),
507        )
508        .unwrap();
509        let wire = crate::envelope::encode_envelope(&env).unwrap();
510        assert!(
511            wire.windows(secret.len()).all(|w| w != secret),
512            "the plaintext must never appear on the wire"
513        );
514    }
515
516    #[test]
517    fn wrong_recipient_fails() {
518        let alice = party("seal/a3", 1);
519        let bob = party("seal/b3", 2);
520        let eve = party("seal/eve3", 9);
521        let env = seal_with_ephemeral(
522            &params(&alice, &bob, b"secret", 0, NOW, 0),
523            &ephemeral("e3"),
524        )
525        .unwrap();
526        let mut guard = ReplayGuard::new();
527        let err =
528            open_message(&eve.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
529        assert_eq!(err, MessageError::OpenFailed);
530    }
531
532    #[test]
533    fn wrong_sender_pub_fails() {
534        let alice = party("seal/a4", 1);
535        let bob = party("seal/b4", 2);
536        let mallory = party("seal/m4", 9);
537        let env = seal_with_ephemeral(
538            &params(&alice, &bob, b"secret", 0, NOW, 0),
539            &ephemeral("e4"),
540        )
541        .unwrap();
542        let mut guard = ReplayGuard::new();
543        // Resolver returns the WRONG sender key -> the auth-DH static term differs -> AEAD open fails.
544        let err =
545            open_message(&bob.sk, &env, resolver(mallory.pub_key), &mut guard, NOW).unwrap_err();
546        assert_eq!(err, MessageError::OpenFailed);
547    }
548
549    #[test]
550    fn unresolvable_sender_fails_closed() {
551        let alice = party("seal/a5", 1);
552        let bob = party("seal/b5", 2);
553        let env =
554            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e5")).unwrap();
555        let mut guard = ReplayGuard::new();
556        let err = open_message(&bob.sk, &env, |_d, _e| None, &mut guard, NOW).unwrap_err();
557        assert_eq!(err, MessageError::UnresolvableSender);
558    }
559
560    #[test]
561    fn tampered_ciphertext_rejected() {
562        let alice = party("seal/a6", 1);
563        let bob = party("seal/b6", 2);
564        let mut env =
565            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e6")).unwrap();
566        let last = env.sealed.ciphertext.len() - 1;
567        env.sealed.ciphertext[last] ^= 0x01;
568        let mut guard = ReplayGuard::new();
569        let err =
570            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
571        assert_eq!(err, MessageError::OpenFailed);
572    }
573
574    #[test]
575    fn tampered_header_aad_rejected() {
576        let alice = party("seal/a7", 1);
577        let bob = party("seal/b7", 2);
578        let mut env =
579            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e7")).unwrap();
580        // Flip the cleartext message_type: the AAD no longer matches -> AEAD open fails.
581        env.message_type ^= 0xFF;
582        let mut guard = ReplayGuard::new();
583        let err =
584            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
585        assert_eq!(err, MessageError::OpenFailed);
586    }
587
588    #[test]
589    fn non_subgroup_kem_enc_rejected() {
590        let alice = party("seal/a8", 1);
591        let bob = party("seal/b8", 2);
592        let mut env =
593            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e8")).unwrap();
594        // A crafted non-subgroup / malformed kem_enc (all-0xFF is off-curve).
595        env.sealed.kem_enc = Bytes48::new([0xFFu8; 48]);
596        let mut guard = ReplayGuard::new();
597        let err =
598            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
599        assert_eq!(err, MessageError::InvalidPoint);
600    }
601
602    #[test]
603    fn bad_signature_rejected() {
604        // Isolate the signature gate from the AEAD gate: seal a correctly-keyed envelope whose inner
605        // carries a ZEROED (invalid) sender_sig, so AEAD-open succeeds but sig-verify must REJECT.
606        let alice = party("seal/sigA", 1);
607        let bob = party("seal/sigB", 2);
608        let esk = ephemeral("esig");
609        let compressed = compress_payload(b"x").unwrap();
610        let sender_pub = public_key_bytes(&alice.sk);
611        let kem_enc = public_key_bytes(&esk);
612        let z = auth_encap_secret(&esk, &alice.sk, &bob.pub_key).unwrap();
613        let okm = kdf(&z, &kem_enc, &sender_pub, &bob.pub_key);
614
615        let inner = InnerMessage {
616            message_type: 0x0000_0201,
617            correlation_id: did(0xAB),
618            compression: compressed.compression,
619            uncompressed_len: compressed.uncompressed_len,
620            counter: 0,
621            timestamp_ms: NOW,
622            expires_at: 0,
623            payload: compressed.bytes,
624            sender_sig: Bytes96::new([0u8; 96]), // invalid signature
625        };
626        let inner_bytes = inner.to_bytes().unwrap();
627        let mut env = DigMessageEnvelope {
628            version: VERSION,
629            message_type: 0x0000_0201,
630            flags: InteractionShape::OneShot.as_bits() | FLAG_SEALED,
631            correlation_id: did(0xAB),
632            sender: alice.did,
633            recipient: bob.did,
634            sender_epoch: 0,
635            stream: None,
636            sealed: SealedPayload {
637                kem_enc: Bytes48::new(kem_enc),
638                ciphertext: Vec::new(),
639            },
640        };
641        let aad = env.header_bytes().unwrap();
642        env.sealed.ciphertext = aead_seal(&okm, &aad, &inner_bytes).unwrap();
643
644        let mut guard = ReplayGuard::new();
645        let err =
646            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
647        assert_eq!(err, MessageError::BadSignature);
648    }
649
650    #[test]
651    fn replay_rejected() {
652        let alice = party("seal/a9", 1);
653        let bob = party("seal/b9", 2);
654        let env =
655            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e9")).unwrap();
656        let mut guard = ReplayGuard::new();
657        assert!(open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).is_ok());
658        let err =
659            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
660        assert_eq!(err, MessageError::Replay);
661    }
662
663    #[test]
664    fn stale_timestamp_rejected() {
665        let alice = party("seal/a10", 1);
666        let bob = party("seal/b10", 2);
667        let env = seal_with_ephemeral(
668            &params(&alice, &bob, b"x", 0, NOW - 3_600_000, 0),
669            &ephemeral("e10"),
670        )
671        .unwrap();
672        let mut guard = ReplayGuard::new();
673        let err =
674            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
675        assert_eq!(err, MessageError::Replay);
676    }
677
678    #[test]
679    fn in_window_reorder_accepted() {
680        let alice = party("seal/a11", 1);
681        let bob = party("seal/b11", 2);
682        let mk = |c: u64| {
683            seal_with_ephemeral(
684                &params(&alice, &bob, b"x", c, NOW, 0),
685                &ephemeral(&format!("e11-{c}")),
686            )
687            .unwrap()
688        };
689        let mut guard = ReplayGuard::new();
690        assert!(open_message(&bob.sk, &mk(5), resolver(alice.pub_key), &mut guard, NOW).is_ok());
691        assert!(open_message(&bob.sk, &mk(3), resolver(alice.pub_key), &mut guard, NOW).is_ok());
692        assert_eq!(
693            open_message(&bob.sk, &mk(3), resolver(alice.pub_key), &mut guard, NOW).unwrap_err(),
694            MessageError::Replay
695        );
696    }
697
698    #[test]
699    fn expired_message_discarded() {
700        let alice = party("seal/a12", 1);
701        let bob = party("seal/b12", 2);
702        let env = seal_with_ephemeral(
703            &params(&alice, &bob, b"x", 0, NOW, NOW + 1000),
704            &ephemeral("e12"),
705        )
706        .unwrap();
707        let mut guard = ReplayGuard::new();
708        let err = open_message(
709            &bob.sk,
710            &env,
711            resolver(alice.pub_key),
712            &mut guard,
713            NOW + 5000,
714        )
715        .unwrap_err();
716        assert_eq!(err, MessageError::Expired);
717    }
718
719    #[test]
720    fn within_expiry_accepted() {
721        let alice = party("seal/a13", 1);
722        let bob = party("seal/b13", 2);
723        let env = seal_with_ephemeral(
724            &params(&alice, &bob, b"x", 0, NOW, NOW + 60_000),
725            &ephemeral("e13"),
726        )
727        .unwrap();
728        let mut guard = ReplayGuard::new();
729        assert!(open_message(
730            &bob.sk,
731            &env,
732            resolver(alice.pub_key),
733            &mut guard,
734            NOW + 1000
735        )
736        .is_ok());
737    }
738
739    #[test]
740    fn over_long_ttl_rejected() {
741        let alice = party("seal/a14", 1);
742        let bob = party("seal/b14", 2);
743        let expires = NOW + crate::constants::MAX_MESSAGE_TTL_MS + 10_000;
744        let env = seal_with_ephemeral(
745            &params(&alice, &bob, b"x", 0, NOW, expires),
746            &ephemeral("e14"),
747        )
748        .unwrap();
749        let mut guard = ReplayGuard::new();
750        let err =
751            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
752        assert_eq!(err, MessageError::TtlTooLong);
753    }
754
755    #[test]
756    fn self_addressed_round_trips() {
757        // SPEC §5.6a: sender == recipient key (IPC / note-to-self) is first-class.
758        let me = party("seal/self", 7);
759        let env = seal_with_ephemeral(
760            &SealParams {
761                sender_sk: &me.sk,
762                sender: me.did,
763                sender_epoch: 0,
764                recipient: me.did,
765                recipient_pub: &me.pub_key,
766                message_type: 0x0000_0601,
767                shape: InteractionShape::OneShot,
768                correlation_id: did(0xCD),
769                stream: None,
770                counter: 0,
771                timestamp_ms: NOW,
772                expires_at: 0,
773                payload: b"note to self",
774            },
775            &ephemeral("eself"),
776        )
777        .unwrap();
778        let mut guard = ReplayGuard::new();
779        let opened = open_message(&me.sk, &env, resolver(me.pub_key), &mut guard, NOW).unwrap();
780        assert_eq!(opened.payload, b"note to self");
781        // Replay of the self-message is still rejected.
782        assert_eq!(
783            open_message(&me.sk, &env, resolver(me.pub_key), &mut guard, NOW).unwrap_err(),
784            MessageError::Replay
785        );
786    }
787
788    #[test]
789    fn seal_message_uses_random_ephemeral_and_round_trips() {
790        // The production path (random ephemeral): two seals of the same message differ (fresh KEM) but
791        // both open correctly — the forward-secrecy property (SPEC §5.1).
792        let alice = party("seal/rand-a", 1);
793        let bob = party("seal/rand-b", 2);
794        let p = params(&alice, &bob, b"random-ephemeral", 0, NOW, 0);
795        let env1 = seal_message(&p).unwrap();
796        let env2 = seal_message(&p).unwrap();
797        assert_ne!(
798            env1.sealed.kem_enc, env2.sealed.kem_enc,
799            "fresh ephemeral per seal"
800        );
801        let mut guard = ReplayGuard::new();
802        let opened =
803            open_message(&bob.sk, &env1, resolver(alice.pub_key), &mut guard, NOW).unwrap();
804        assert_eq!(opened.payload, b"random-ephemeral");
805    }
806}