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/// ⚠️ **INTERNAL USE ONLY** — restricted to `pub(crate)` to prevent accidental nonce-reuse in
111/// production code. Reusing ephemeralss breaks forward secrecy (ChaCha20Poly1305 catastrophic failure)
112/// and is incompatible with WU4 streaming. Use [`seal_message`] instead, which auto-generates
113/// fresh ephemeralss. Deterministic KAT tests access this from within the crate (unit tests in
114/// this module, not integration tests).
115///
116/// # Errors
117/// As [`seal_message`].
118pub(crate) fn seal_with_ephemeral(
119    params: &SealParams,
120    esk: &SecretKey,
121) -> Result<DigMessageEnvelope> {
122    // SPEC §1: compress BEFORE sealing (sealed ciphertext is incompressible).
123    let compressed = compress_payload(params.payload)?;
124
125    let sender_pub = public_key_bytes(params.sender_sk);
126    let kem_enc = public_key_bytes(esk);
127
128    // SPEC §5.1 AuthEncap: Z = dh(esk, recipient_pub) || dh(sender_static_sk, recipient_pub).
129    let z = auth_encap_secret(esk, params.sender_sk, params.recipient_pub)?;
130    let okm = kdf(&z, &kem_enc, &sender_pub, params.recipient_pub);
131
132    // SPEC §5.1: BLS-G2 sign the transcript, then place the signature inside the sealed InnerMessage.
133    let transcript = TranscriptFields {
134        version: VERSION,
135        message_type: params.message_type,
136        flags: params.shape.as_bits() | FLAG_SEALED,
137        correlation_id: params.correlation_id,
138        sender: params.sender,
139        recipient: params.recipient,
140        sender_epoch: params.sender_epoch,
141        counter: params.counter,
142        timestamp_ms: params.timestamp_ms,
143        expires_at: params.expires_at,
144        stream: params.stream,
145        kem_enc: &kem_enc,
146        compression: compressed.compression,
147        uncompressed_len: compressed.uncompressed_len,
148        compressed_payload: &compressed.bytes,
149    };
150    let sender_sig = transcript.sign(params.sender_sk);
151
152    let inner = InnerMessage {
153        message_type: params.message_type,
154        correlation_id: params.correlation_id,
155        compression: compressed.compression,
156        uncompressed_len: compressed.uncompressed_len,
157        counter: params.counter,
158        timestamp_ms: params.timestamp_ms,
159        expires_at: params.expires_at,
160        payload: compressed.bytes,
161        sender_sig: Bytes96::new(sender_sig),
162    };
163    let inner_bytes = inner
164        .to_bytes()
165        .map_err(|e| MessageError::SealFailed(e.to_string()))?;
166
167    // The cleartext header is bound as AEAD AAD so an on-path party cannot alter routing metadata
168    // (SPEC §5.2). Build the envelope with the final kem_enc + an empty ciphertext to derive the
169    // header bytes, then fill the ciphertext.
170    let mut envelope = DigMessageEnvelope {
171        version: VERSION,
172        message_type: params.message_type,
173        flags: params.shape.as_bits() | FLAG_SEALED,
174        correlation_id: params.correlation_id,
175        sender: params.sender,
176        recipient: params.recipient,
177        sender_epoch: params.sender_epoch,
178        stream: params.stream,
179        sealed: SealedPayload {
180            kem_enc: Bytes48::new(kem_enc),
181            ciphertext: Vec::new(),
182        },
183    };
184    let aad = envelope.header_bytes()?;
185    envelope.sealed.ciphertext = aead_seal(&okm, &aad, &inner_bytes)?;
186    Ok(envelope)
187}
188
189/// Open + fully verify a sealed envelope (SPEC §5.2 / §5.7), advancing the anti-replay guard.
190///
191/// `resolve_sender_pub` maps `(sender DID, sender_epoch)` to the sender's 48-byte BLS G1 key (wire a
192/// `dig-identity` chain resolution here); returning `None` fails closed with
193/// [`MessageError::UnresolvableSender`]. `now_ms` is the receiver's wall clock for the freshness +
194/// expiry checks.
195///
196/// # Errors
197/// Fail-closed at each step: [`MessageError::UnresolvableSender`], [`MessageError::InvalidPoint`],
198/// [`MessageError::OpenFailed`], [`MessageError::BadSignature`], [`MessageError::HeaderMismatch`],
199/// [`MessageError::TtlTooLong`], [`MessageError::Expired`], [`MessageError::Replay`],
200/// [`MessageError::DecompressionBomb`], and the codec/compression errors.
201pub fn open_message(
202    recipient_sk: &SecretKey,
203    envelope: &DigMessageEnvelope,
204    resolve_sender_pub: impl Fn(Bytes32, u32) -> Option<[u8; 48]>,
205    guard: &mut ReplayGuard,
206    now_ms: u64,
207) -> Result<OpenedMessage> {
208    let shape = InteractionShape::from_flags(envelope.flags).ok_or(MessageError::HeaderMismatch)?;
209
210    // Resolve the sender key from the (AAD-bound, un-tamperable) cleartext DID + epoch.
211    let sender_pub = resolve_sender_pub(envelope.sender, envelope.sender_epoch)
212        .ok_or(MessageError::UnresolvableSender)?;
213
214    let kem_enc: [u8; 48] = envelope
215        .sealed
216        .kem_enc
217        .as_ref()
218        .try_into()
219        .expect("Bytes48 is exactly 48 bytes");
220
221    // SPEC §5.1 (HARD): subgroup-check every received G1 point BEFORE any DH.
222    if !g1_subgroup_check(&kem_enc) || !g1_subgroup_check(&sender_pub) {
223        return Err(MessageError::InvalidPoint);
224    }
225
226    // SPEC §5.1 AuthDecap: Z2 = dh(recipient_sk, kem_enc) || dh(recipient_sk, sender_static_pub).
227    let z = auth_decap_secret(recipient_sk, &sender_pub, &kem_enc)?;
228    let recipient_pub = public_key_bytes(recipient_sk);
229    let okm = kdf(&z, &kem_enc, &sender_pub, &recipient_pub);
230
231    let aad = envelope.header_bytes()?;
232    let inner_bytes = aead_open(&okm, &aad, &envelope.sealed.ciphertext)?;
233    let inner =
234        InnerMessage::from_bytes(&inner_bytes).map_err(|e| MessageError::Codec(e.to_string()))?;
235
236    // (a) Verify the mandatory BLS G2 sender signature over the full transcript (SPEC §5.2).
237    let transcript = TranscriptFields {
238        version: envelope.version,
239        message_type: envelope.message_type,
240        flags: envelope.flags,
241        correlation_id: envelope.correlation_id,
242        sender: envelope.sender,
243        recipient: envelope.recipient,
244        sender_epoch: envelope.sender_epoch,
245        counter: inner.counter,
246        timestamp_ms: inner.timestamp_ms,
247        expires_at: inner.expires_at,
248        stream: envelope.stream,
249        kem_enc: &kem_enc,
250        compression: inner.compression,
251        uncompressed_len: inner.uncompressed_len,
252        compressed_payload: &inner.payload,
253    };
254    let sig: [u8; 96] = inner
255        .sender_sig
256        .as_ref()
257        .try_into()
258        .expect("Bytes96 is exactly 96 bytes");
259    if !transcript.verify(&sender_pub, &sig) {
260        return Err(MessageError::BadSignature);
261    }
262
263    // (b) Anti type-confusion / anti-splice: inner header MUST equal the cleartext header.
264    if inner.message_type != envelope.message_type
265        || inner.correlation_id != envelope.correlation_id
266    {
267        return Err(MessageError::HeaderMismatch);
268    }
269
270    // (c) Expiry discard (SPEC §5.6b) — BEFORE touching replay state.
271    check_expiry(inner.timestamp_ms, inner.expires_at, now_ms)?;
272
273    // (d) Anti-replay (SPEC §5.6): freshness window + bounded sliding-window dedup.
274    if !guard.check_and_admit(
275        envelope.sender,
276        envelope.sender_epoch,
277        inner.counter,
278        inner.timestamp_ms,
279        now_ms,
280    ) {
281        return Err(MessageError::Replay);
282    }
283
284    // (e) Bomb-guard cap + (f) decompress under the §1.1 output bound.
285    if inner.uncompressed_len as usize > MAX_DECOMPRESSED_BYTES {
286        return Err(MessageError::DecompressionBomb {
287            declared: inner.uncompressed_len as usize,
288            max: MAX_DECOMPRESSED_BYTES,
289        });
290    }
291    let payload = decompress_payload(inner.compression, &inner.payload, inner.uncompressed_len)?;
292
293    Ok(OpenedMessage {
294        message_type: inner.message_type,
295        correlation_id: inner.correlation_id,
296        shape,
297        sender: envelope.sender,
298        sender_epoch: envelope.sender_epoch,
299        counter: inner.counter,
300        timestamp_ms: inner.timestamp_ms,
301        expires_at: inner.expires_at,
302        payload,
303    })
304}
305
306/// SPEC §5.6b expiry semantics: reject an over-long TTL, discard a past-expiry message; `expires_at`
307/// == 0 means no explicit expiry.
308fn check_expiry(timestamp_ms: u64, expires_at: u64, now_ms: u64) -> Result<()> {
309    if expires_at == 0 {
310        return Ok(());
311    }
312    if expires_at > timestamp_ms.saturating_add(crate::constants::MAX_MESSAGE_TTL_MS) {
313        return Err(MessageError::TtlTooLong);
314    }
315    if now_ms > expires_at {
316        return Err(MessageError::Expired);
317    }
318    Ok(())
319}
320
321/// Sender-side AuthEncap ikm: `dh(esk, recipient_pub) || dh(sender_static_sk, recipient_pub)`.
322fn auth_encap_secret(
323    esk: &SecretKey,
324    sender_sk: &SecretKey,
325    recipient_pub: &[u8; 48],
326) -> Result<[u8; 96]> {
327    let ephemeral = g1_dh(esk, recipient_pub).ok_or(MessageError::InvalidPoint)?;
328    let static_term = g1_dh(sender_sk, recipient_pub).ok_or(MessageError::InvalidPoint)?;
329    Ok(concat_dh(&ephemeral, &static_term))
330}
331
332/// Recipient-side AuthDecap ikm: `dh(recipient_sk, kem_enc) || dh(recipient_sk, sender_pub)`.
333fn auth_decap_secret(
334    recipient_sk: &SecretKey,
335    sender_pub: &[u8; 48],
336    kem_enc: &[u8; 48],
337) -> Result<[u8; 96]> {
338    let ephemeral = g1_dh(recipient_sk, kem_enc).ok_or(MessageError::InvalidPoint)?;
339    let static_term = g1_dh(recipient_sk, sender_pub).ok_or(MessageError::InvalidPoint)?;
340    Ok(concat_dh(&ephemeral, &static_term))
341}
342
343/// Concatenate the two 48-byte DH results into the 96-byte HKDF ikm (`Z`).
344fn concat_dh(a: &[u8; 48], b: &[u8; 48]) -> [u8; 96] {
345    let mut z = [0u8; 96];
346    z[..48].copy_from_slice(a);
347    z[48..].copy_from_slice(b);
348    z
349}
350
351/// HKDF-SHA256 key schedule (SPEC §5.1): extract over `salt=empty, ikm=Z`, expand over
352/// `info = KDF_INFO_LABEL || kem_enc || sender_pub || recipient_pub` to the AEAD key + base nonce.
353fn kdf(
354    z: &[u8; 96],
355    kem_enc: &[u8; 48],
356    sender_pub: &[u8; 48],
357    recipient_pub: &[u8; 48],
358) -> [u8; OKM_LEN] {
359    let hk = Hkdf::<Sha256>::new(None, z);
360    let mut info = Vec::with_capacity(KDF_INFO_LABEL.len() + 48 * 3);
361    info.extend_from_slice(KDF_INFO_LABEL);
362    info.extend_from_slice(kem_enc);
363    info.extend_from_slice(sender_pub);
364    info.extend_from_slice(recipient_pub);
365    let mut okm = [0u8; OKM_LEN];
366    hk.expand(&info, &mut okm)
367        .expect("OKM_LEN is well within the HKDF-SHA256 output limit");
368    okm
369}
370
371/// ChaCha20Poly1305 seal with the header as AAD (SPEC §5.2).
372fn aead_seal(okm: &[u8; OKM_LEN], aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
373    let cipher = ChaCha20Poly1305::new_from_slice(&okm[..32])
374        .map_err(|_| MessageError::SealFailed("invalid AEAD key length".into()))?;
375    let nonce: [u8; 12] = okm[32..].try_into().expect("OKM tail is exactly 12 bytes");
376    cipher
377        .encrypt(
378            (&nonce).into(),
379            Payload {
380                msg: plaintext,
381                aad,
382            },
383        )
384        .map_err(|_| MessageError::SealFailed("AEAD encrypt failed".into()))
385}
386
387/// ChaCha20Poly1305 open with the header as AAD (SPEC §5.2). A wrong key, wrong sender, tampered
388/// ciphertext, or tampered AAD all fail here — fail-closed, no plaintext leaks.
389fn aead_open(okm: &[u8; OKM_LEN], aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
390    let cipher =
391        ChaCha20Poly1305::new_from_slice(&okm[..32]).map_err(|_| MessageError::OpenFailed)?;
392    let nonce: [u8; 12] = okm[32..].try_into().expect("OKM tail is exactly 12 bytes");
393    cipher
394        .decrypt(
395            (&nonce).into(),
396            Payload {
397                msg: ciphertext,
398                aad,
399            },
400        )
401        .map_err(|_| MessageError::OpenFailed)
402}
403
404/// Generate a fresh ephemeral BLS secret key for the DHKEM encapsulation (SPEC §5.1 forward secrecy).
405fn random_ephemeral() -> Result<SecretKey> {
406    let mut ikm = [0u8; 32];
407    getrandom::getrandom(&mut ikm)
408        .map_err(|e| MessageError::SealFailed(format!("ephemeral key entropy: {e}")))?;
409    Ok(SecretKey::from_seed(&ikm))
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use dig_identity::{derive_identity_sk, master_secret_key_from_seed};
416    use sha2::Digest;
417
418    fn sk(label: &str) -> SecretKey {
419        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
420        derive_identity_sk(&master_secret_key_from_seed(&seed))
421    }
422
423    /// A deterministic ephemeral for reproducible KATs (never a hard-coded literal — CodeQL).
424    fn ephemeral(label: &str) -> SecretKey {
425        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
426        SecretKey::from_seed(&seed)
427    }
428
429    fn did(n: u8) -> Bytes32 {
430        Bytes32::new([n; 32])
431    }
432
433    struct Party {
434        sk: SecretKey,
435        did: Bytes32,
436        pub_key: [u8; 48],
437    }
438    fn party(label: &str, did_byte: u8) -> Party {
439        let sk = sk(label);
440        let pub_key = public_key_bytes(&sk);
441        Party {
442            sk,
443            did: did(did_byte),
444            pub_key,
445        }
446    }
447
448    fn params<'a>(
449        sender: &'a Party,
450        recipient: &'a Party,
451        payload: &'a [u8],
452        counter: u64,
453        timestamp_ms: u64,
454        expires_at: u64,
455    ) -> SealParams<'a> {
456        SealParams {
457            sender_sk: &sender.sk,
458            sender: sender.did,
459            sender_epoch: 0,
460            recipient: recipient.did,
461            recipient_pub: &recipient.pub_key,
462            message_type: 0x0000_0201,
463            shape: InteractionShape::OneShot,
464            correlation_id: did(0xAB),
465            stream: None,
466            counter,
467            timestamp_ms,
468            expires_at,
469            payload,
470        }
471    }
472
473    /// A resolver returning a fixed sender key (the unit-test stand-in for dig-identity resolution).
474    fn resolver(pk: [u8; 48]) -> impl Fn(Bytes32, u32) -> Option<[u8; 48]> {
475        move |_did, _epoch| Some(pk)
476    }
477
478    const NOW: u64 = 1_700_000_000_000;
479
480    #[test]
481    fn seal_open_round_trip_raw() {
482        let alice = party("seal/alice", 1);
483        let bob = party("seal/bob", 2);
484        let msg = b"hello bob";
485        let env =
486            seal_with_ephemeral(&params(&alice, &bob, msg, 0, NOW, 0), &ephemeral("e1")).unwrap();
487        let mut guard = ReplayGuard::new();
488        let opened = open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap();
489        assert_eq!(opened.payload, msg);
490        assert_eq!(opened.sender, alice.did);
491        assert_eq!(opened.counter, 0);
492    }
493
494    #[test]
495    fn seal_open_round_trip_compressed() {
496        let alice = party("seal/alice2", 1);
497        let bob = party("seal/bob2", 2);
498        let msg: Vec<u8> = (0..4096).map(|i| (i % 7) as u8).collect();
499        let env =
500            seal_with_ephemeral(&params(&alice, &bob, &msg, 0, NOW, 0), &ephemeral("e2")).unwrap();
501        // Compression engaged: the sealed frame is far smaller than the 4 KiB plaintext.
502        assert!(env.sealed.ciphertext.len() < msg.len());
503        let mut guard = ReplayGuard::new();
504        let opened = open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap();
505        assert_eq!(opened.payload, msg);
506    }
507
508    #[test]
509    fn relay_sees_only_ciphertext_no_plaintext_substring() {
510        let alice = party("seal/leak-a", 1);
511        let bob = party("seal/leak-b", 2);
512        let secret = b"TOP-SECRET-PLAINTEXT-MARKER";
513        let env = seal_with_ephemeral(
514            &params(&alice, &bob, secret, 0, NOW, 0),
515            &ephemeral("eleak"),
516        )
517        .unwrap();
518        let wire = crate::envelope::encode_envelope(&env).unwrap();
519        assert!(
520            wire.windows(secret.len()).all(|w| w != secret),
521            "the plaintext must never appear on the wire"
522        );
523    }
524
525    #[test]
526    fn wrong_recipient_fails() {
527        let alice = party("seal/a3", 1);
528        let bob = party("seal/b3", 2);
529        let eve = party("seal/eve3", 9);
530        let env = seal_with_ephemeral(
531            &params(&alice, &bob, b"secret", 0, NOW, 0),
532            &ephemeral("e3"),
533        )
534        .unwrap();
535        let mut guard = ReplayGuard::new();
536        let err =
537            open_message(&eve.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
538        assert_eq!(err, MessageError::OpenFailed);
539    }
540
541    #[test]
542    fn wrong_sender_pub_fails() {
543        let alice = party("seal/a4", 1);
544        let bob = party("seal/b4", 2);
545        let mallory = party("seal/m4", 9);
546        let env = seal_with_ephemeral(
547            &params(&alice, &bob, b"secret", 0, NOW, 0),
548            &ephemeral("e4"),
549        )
550        .unwrap();
551        let mut guard = ReplayGuard::new();
552        // Resolver returns the WRONG sender key -> the auth-DH static term differs -> AEAD open fails.
553        let err =
554            open_message(&bob.sk, &env, resolver(mallory.pub_key), &mut guard, NOW).unwrap_err();
555        assert_eq!(err, MessageError::OpenFailed);
556    }
557
558    #[test]
559    fn unresolvable_sender_fails_closed() {
560        let alice = party("seal/a5", 1);
561        let bob = party("seal/b5", 2);
562        let env =
563            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e5")).unwrap();
564        let mut guard = ReplayGuard::new();
565        let err = open_message(&bob.sk, &env, |_d, _e| None, &mut guard, NOW).unwrap_err();
566        assert_eq!(err, MessageError::UnresolvableSender);
567    }
568
569    #[test]
570    fn tampered_ciphertext_rejected() {
571        let alice = party("seal/a6", 1);
572        let bob = party("seal/b6", 2);
573        let mut env =
574            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e6")).unwrap();
575        let last = env.sealed.ciphertext.len() - 1;
576        env.sealed.ciphertext[last] ^= 0x01;
577        let mut guard = ReplayGuard::new();
578        let err =
579            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
580        assert_eq!(err, MessageError::OpenFailed);
581    }
582
583    #[test]
584    fn tampered_header_aad_rejected() {
585        let alice = party("seal/a7", 1);
586        let bob = party("seal/b7", 2);
587        let mut env =
588            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e7")).unwrap();
589        // Flip the cleartext message_type: the AAD no longer matches -> AEAD open fails.
590        env.message_type ^= 0xFF;
591        let mut guard = ReplayGuard::new();
592        let err =
593            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
594        assert_eq!(err, MessageError::OpenFailed);
595    }
596
597    #[test]
598    fn non_subgroup_kem_enc_rejected() {
599        let alice = party("seal/a8", 1);
600        let bob = party("seal/b8", 2);
601        let mut env =
602            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e8")).unwrap();
603        // A crafted non-subgroup / malformed kem_enc (all-0xFF is off-curve).
604        env.sealed.kem_enc = Bytes48::new([0xFFu8; 48]);
605        let mut guard = ReplayGuard::new();
606        let err =
607            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
608        assert_eq!(err, MessageError::InvalidPoint);
609    }
610
611    #[test]
612    fn bad_signature_rejected() {
613        // Isolate the signature gate from the AEAD gate: seal a correctly-keyed envelope whose inner
614        // carries a ZEROED (invalid) sender_sig, so AEAD-open succeeds but sig-verify must REJECT.
615        let alice = party("seal/sigA", 1);
616        let bob = party("seal/sigB", 2);
617        let esk = ephemeral("esig");
618        let compressed = compress_payload(b"x").unwrap();
619        let sender_pub = public_key_bytes(&alice.sk);
620        let kem_enc = public_key_bytes(&esk);
621        let z = auth_encap_secret(&esk, &alice.sk, &bob.pub_key).unwrap();
622        let okm = kdf(&z, &kem_enc, &sender_pub, &bob.pub_key);
623
624        let inner = InnerMessage {
625            message_type: 0x0000_0201,
626            correlation_id: did(0xAB),
627            compression: compressed.compression,
628            uncompressed_len: compressed.uncompressed_len,
629            counter: 0,
630            timestamp_ms: NOW,
631            expires_at: 0,
632            payload: compressed.bytes,
633            sender_sig: Bytes96::new([0u8; 96]), // invalid signature
634        };
635        let inner_bytes = inner.to_bytes().unwrap();
636        let mut env = DigMessageEnvelope {
637            version: VERSION,
638            message_type: 0x0000_0201,
639            flags: InteractionShape::OneShot.as_bits() | FLAG_SEALED,
640            correlation_id: did(0xAB),
641            sender: alice.did,
642            recipient: bob.did,
643            sender_epoch: 0,
644            stream: None,
645            sealed: SealedPayload {
646                kem_enc: Bytes48::new(kem_enc),
647                ciphertext: Vec::new(),
648            },
649        };
650        let aad = env.header_bytes().unwrap();
651        env.sealed.ciphertext = aead_seal(&okm, &aad, &inner_bytes).unwrap();
652
653        let mut guard = ReplayGuard::new();
654        let err =
655            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
656        assert_eq!(err, MessageError::BadSignature);
657    }
658
659    #[test]
660    fn replay_rejected() {
661        let alice = party("seal/a9", 1);
662        let bob = party("seal/b9", 2);
663        let env =
664            seal_with_ephemeral(&params(&alice, &bob, b"x", 0, NOW, 0), &ephemeral("e9")).unwrap();
665        let mut guard = ReplayGuard::new();
666        assert!(open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).is_ok());
667        let err =
668            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
669        assert_eq!(err, MessageError::Replay);
670    }
671
672    #[test]
673    fn stale_timestamp_rejected() {
674        let alice = party("seal/a10", 1);
675        let bob = party("seal/b10", 2);
676        let env = seal_with_ephemeral(
677            &params(&alice, &bob, b"x", 0, NOW - 3_600_000, 0),
678            &ephemeral("e10"),
679        )
680        .unwrap();
681        let mut guard = ReplayGuard::new();
682        let err =
683            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
684        assert_eq!(err, MessageError::Replay);
685    }
686
687    #[test]
688    fn in_window_reorder_accepted() {
689        let alice = party("seal/a11", 1);
690        let bob = party("seal/b11", 2);
691        let mk = |c: u64| {
692            seal_with_ephemeral(
693                &params(&alice, &bob, b"x", c, NOW, 0),
694                &ephemeral(&format!("e11-{c}")),
695            )
696            .unwrap()
697        };
698        let mut guard = ReplayGuard::new();
699        assert!(open_message(&bob.sk, &mk(5), resolver(alice.pub_key), &mut guard, NOW).is_ok());
700        assert!(open_message(&bob.sk, &mk(3), resolver(alice.pub_key), &mut guard, NOW).is_ok());
701        assert_eq!(
702            open_message(&bob.sk, &mk(3), resolver(alice.pub_key), &mut guard, NOW).unwrap_err(),
703            MessageError::Replay
704        );
705    }
706
707    #[test]
708    fn expired_message_discarded() {
709        let alice = party("seal/a12", 1);
710        let bob = party("seal/b12", 2);
711        let env = seal_with_ephemeral(
712            &params(&alice, &bob, b"x", 0, NOW, NOW + 1000),
713            &ephemeral("e12"),
714        )
715        .unwrap();
716        let mut guard = ReplayGuard::new();
717        let err = open_message(
718            &bob.sk,
719            &env,
720            resolver(alice.pub_key),
721            &mut guard,
722            NOW + 5000,
723        )
724        .unwrap_err();
725        assert_eq!(err, MessageError::Expired);
726    }
727
728    #[test]
729    fn within_expiry_accepted() {
730        let alice = party("seal/a13", 1);
731        let bob = party("seal/b13", 2);
732        let env = seal_with_ephemeral(
733            &params(&alice, &bob, b"x", 0, NOW, NOW + 60_000),
734            &ephemeral("e13"),
735        )
736        .unwrap();
737        let mut guard = ReplayGuard::new();
738        assert!(open_message(
739            &bob.sk,
740            &env,
741            resolver(alice.pub_key),
742            &mut guard,
743            NOW + 1000
744        )
745        .is_ok());
746    }
747
748    #[test]
749    fn over_long_ttl_rejected() {
750        let alice = party("seal/a14", 1);
751        let bob = party("seal/b14", 2);
752        let expires = NOW + crate::constants::MAX_MESSAGE_TTL_MS + 10_000;
753        let env = seal_with_ephemeral(
754            &params(&alice, &bob, b"x", 0, NOW, expires),
755            &ephemeral("e14"),
756        )
757        .unwrap();
758        let mut guard = ReplayGuard::new();
759        let err =
760            open_message(&bob.sk, &env, resolver(alice.pub_key), &mut guard, NOW).unwrap_err();
761        assert_eq!(err, MessageError::TtlTooLong);
762    }
763
764    #[test]
765    fn self_addressed_round_trips() {
766        // SPEC §5.6a: sender == recipient key (IPC / note-to-self) is first-class.
767        let me = party("seal/self", 7);
768        let env = seal_with_ephemeral(
769            &SealParams {
770                sender_sk: &me.sk,
771                sender: me.did,
772                sender_epoch: 0,
773                recipient: me.did,
774                recipient_pub: &me.pub_key,
775                message_type: 0x0000_0601,
776                shape: InteractionShape::OneShot,
777                correlation_id: did(0xCD),
778                stream: None,
779                counter: 0,
780                timestamp_ms: NOW,
781                expires_at: 0,
782                payload: b"note to self",
783            },
784            &ephemeral("eself"),
785        )
786        .unwrap();
787        let mut guard = ReplayGuard::new();
788        let opened = open_message(&me.sk, &env, resolver(me.pub_key), &mut guard, NOW).unwrap();
789        assert_eq!(opened.payload, b"note to self");
790        // Replay of the self-message is still rejected.
791        assert_eq!(
792            open_message(&me.sk, &env, resolver(me.pub_key), &mut guard, NOW).unwrap_err(),
793            MessageError::Replay
794        );
795    }
796
797    #[test]
798    fn seal_message_uses_random_ephemeral_and_round_trips() {
799        // The production path (random ephemeral): two seals of the same message differ (fresh KEM) but
800        // both open correctly — the forward-secrecy property (SPEC §5.1).
801        let alice = party("seal/rand-a", 1);
802        let bob = party("seal/rand-b", 2);
803        let p = params(&alice, &bob, b"random-ephemeral", 0, NOW, 0);
804        let env1 = seal_message(&p).unwrap();
805        let env2 = seal_message(&p).unwrap();
806        assert_ne!(
807            env1.sealed.kem_enc, env2.sealed.kem_enc,
808            "fresh ephemeral per seal"
809        );
810        let mut guard = ReplayGuard::new();
811        let opened =
812            open_message(&bob.sk, &env1, resolver(alice.pub_key), &mut guard, NOW).unwrap();
813        assert_eq!(opened.payload, b"random-ephemeral");
814    }
815
816    // ── WU2 deterministic-ephemeral KATs (golden vectors for integration testing) ──
817    // These moved from tests/kat.rs into the crate so they can access pub(crate) seal_with_ephemeral.
818    // Non-seal KATs stay in tests/kat.rs and use only the public seal_message API.
819
820    const KAT_NOW: u64 = 1_700_000_000_000;
821
822    fn b32_from_seed(tag: &[u8]) -> Bytes32 {
823        let seed: [u8; 32] = sha2::Sha256::digest(tag).into();
824        Bytes32::new(seed)
825    }
826
827    fn kat_sk(label: &str) -> SecretKey {
828        let seed: [u8; 32] = sha2::Sha256::digest(label.as_bytes()).into();
829        let msk = dig_identity::master_secret_key_from_seed(&seed);
830        dig_identity::derive_identity_sk(&msk)
831    }
832
833    fn kat_ephemeral(label: &str) -> SecretKey {
834        let seed: [u8; 32] = sha2::Sha256::digest(label.as_bytes()).into();
835        SecretKey::from_seed(&seed)
836    }
837
838    fn kat_resolver(pk: [u8; 48]) -> impl Fn(Bytes32, u32) -> Option<[u8; 48]> {
839        move |_did, _epoch| Some(pk)
840    }
841
842    fn kat_params<'a>(
843        sender_sk: &'a SecretKey,
844        sender: Bytes32,
845        recipient: Bytes32,
846        recipient_pub: &'a [u8; 48],
847        payload: &'a [u8],
848    ) -> SealParams<'a> {
849        SealParams {
850            sender_sk,
851            sender,
852            sender_epoch: 0,
853            recipient,
854            recipient_pub,
855            message_type: 0x0001_0101,
856            shape: InteractionShape::OneShot,
857            correlation_id: b32_from_seed(b"kat/corr"),
858            stream: None,
859            counter: 0,
860            timestamp_ms: KAT_NOW,
861            expires_at: 0,
862            payload,
863        }
864    }
865
866    #[test]
867    fn kat_seal_open_round_trip_raw_and_compressed() {
868        let alice = kat_sk("kat/seal/alice");
869        let bob = kat_sk("kat/seal/bob");
870        let (a_did, b_did) = (b32_from_seed(b"kat/a"), b32_from_seed(b"kat/b"));
871        let b_pub = public_key_bytes(&bob);
872
873        for (label, msg) in [
874            ("raw", b"hi".to_vec()),
875            (
876                "zstd",
877                (0..2048).map(|i| (i % 5) as u8).collect::<Vec<u8>>(),
878            ),
879        ] {
880            let params = kat_params(&alice, a_did, b_did, &b_pub, &msg);
881            let env = seal_with_ephemeral(&params, &kat_ephemeral(label)).unwrap();
882            let mut guard = ReplayGuard::new();
883            let opened = open_message(
884                &bob,
885                &env,
886                kat_resolver(public_key_bytes(&alice)),
887                &mut guard,
888                KAT_NOW,
889            )
890            .unwrap();
891            assert_eq!(opened.payload, msg, "{label} payload round-trips");
892        }
893    }
894
895    #[test]
896    fn kat_relay_sees_only_ciphertext() {
897        let alice = kat_sk("kat/leak/a");
898        let bob = kat_sk("kat/leak/b");
899        let secret = b"UNIQUE-PLAINTEXT-NEEDLE-XYZ";
900        let b_pub = public_key_bytes(&bob);
901        let params = kat_params(
902            &alice,
903            b32_from_seed(b"a"),
904            b32_from_seed(b"b"),
905            &b_pub,
906            secret,
907        );
908        let env = seal_with_ephemeral(&params, &kat_ephemeral("leak")).unwrap();
909        let wire = crate::envelope::encode_envelope(&env).unwrap();
910        assert!(wire.windows(secret.len()).all(|w| w != secret));
911    }
912
913    #[test]
914    fn kat_wrong_recipient_and_wrong_sender_reject() {
915        let alice = kat_sk("kat/wr/a");
916        let bob = kat_sk("kat/wr/b");
917        let eve = kat_sk("kat/wr/eve");
918        let b_pub = public_key_bytes(&bob);
919        let params = kat_params(
920            &alice,
921            b32_from_seed(b"a"),
922            b32_from_seed(b"b"),
923            &b_pub,
924            b"secret",
925        );
926        let env = seal_with_ephemeral(&params, &kat_ephemeral("wr")).unwrap();
927
928        let mut g1 = ReplayGuard::new();
929        assert_eq!(
930            open_message(
931                &eve,
932                &env,
933                kat_resolver(public_key_bytes(&alice)),
934                &mut g1,
935                KAT_NOW
936            )
937            .unwrap_err(),
938            MessageError::OpenFailed,
939            "wrong recipient key cannot open"
940        );
941        let mut g2 = ReplayGuard::new();
942        assert_eq!(
943            open_message(
944                &bob,
945                &env,
946                kat_resolver(public_key_bytes(&eve)),
947                &mut g2,
948                KAT_NOW
949            )
950            .unwrap_err(),
951            MessageError::OpenFailed,
952            "wrong sender key cannot open"
953        );
954    }
955
956    #[test]
957    fn kat_non_subgroup_kem_enc_rejected() {
958        let alice = kat_sk("kat/sg/a");
959        let bob = kat_sk("kat/sg/b");
960        let b_pub = public_key_bytes(&bob);
961        let params = kat_params(
962            &alice,
963            b32_from_seed(b"a"),
964            b32_from_seed(b"b"),
965            &b_pub,
966            b"x",
967        );
968        let mut env = seal_with_ephemeral(&params, &kat_ephemeral("sg")).unwrap();
969        env.sealed.kem_enc = Bytes48::new([0xFFu8; 48]);
970        let mut guard = ReplayGuard::new();
971        assert_eq!(
972            open_message(
973                &bob,
974                &env,
975                kat_resolver(public_key_bytes(&alice)),
976                &mut guard,
977                KAT_NOW
978            )
979            .unwrap_err(),
980            MessageError::InvalidPoint
981        );
982    }
983
984    #[test]
985    fn kat_replay_and_expiry() {
986        let alice = kat_sk("kat/re/a");
987        let bob = kat_sk("kat/re/b");
988        let a_pub = public_key_bytes(&alice);
989        let b_pub = public_key_bytes(&bob);
990        let (a_did, b_did) = (b32_from_seed(b"a"), b32_from_seed(b"b"));
991
992        // Replay: the same envelope twice -> second REJECT.
993        let env = seal_with_ephemeral(
994            &kat_params(&alice, a_did, b_did, &b_pub, b"x"),
995            &kat_ephemeral("re0"),
996        )
997        .unwrap();
998        let mut guard = ReplayGuard::new();
999        assert!(open_message(&bob, &env, kat_resolver(a_pub), &mut guard, KAT_NOW).is_ok());
1000        assert_eq!(
1001            open_message(&bob, &env, kat_resolver(a_pub), &mut guard, KAT_NOW).unwrap_err(),
1002            MessageError::Replay
1003        );
1004
1005        // Past-expires -> DISCARD.
1006        let mut expiring_params = kat_params(&alice, a_did, b_did, &b_pub, b"x");
1007        expiring_params.counter = 1;
1008        expiring_params.expires_at = KAT_NOW + 1000;
1009        let expiring = seal_with_ephemeral(&expiring_params, &kat_ephemeral("re1")).unwrap();
1010        let mut g2 = ReplayGuard::new();
1011        assert_eq!(
1012            open_message(
1013                &bob,
1014                &expiring,
1015                kat_resolver(a_pub),
1016                &mut g2,
1017                KAT_NOW + 5000
1018            )
1019            .unwrap_err(),
1020            MessageError::Expired
1021        );
1022    }
1023
1024    #[test]
1025    fn kat_self_addressed_round_trip() {
1026        let me = kat_sk("kat/self");
1027        let me_pub = public_key_bytes(&me);
1028        let did = b32_from_seed(b"kat/self/did");
1029        let params = kat_params(&me, did, did, &me_pub, b"note to self");
1030        let env = seal_with_ephemeral(&params, &kat_ephemeral("self")).unwrap();
1031        let mut guard = ReplayGuard::new();
1032        let opened = open_message(&me, &env, kat_resolver(me_pub), &mut guard, KAT_NOW).unwrap();
1033        assert_eq!(opened.payload, b"note to self");
1034    }
1035}