Skip to main content

web4_core/
pair_channel.rs

1// Copyright (c) 2026 MetaLINXX Inc.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4//! PAIRED-CHANNELS Sprint E: end-to-end encryption for LCT pair messages.
5//!
6//! Composes the Sprint A ECDH primitive (`KeyPair::ecdh_with_peer`)
7//! with HKDF-SHA256 (session-key derivation) + ChaCha20-Poly1305 AEAD
8//! (payload encryption + authentication). This is the moment "the hub
9//! cannot read content" stops being aspirational — the seal/open
10//! happens entirely at endpoints; the hub stores opaque ciphertext.
11//!
12//! ## Key derivation
13//!
14//! ```text
15//! shared_secret = ECDH(my_x25519_secret, peer_x25519_public)
16//! session_key   = HKDF-SHA256(
17//!                     salt = pair_id_bytes,            // pair-distinguishing salt
18//!                     ikm  = shared_secret,
19//!                     info = "web4-paired-channel-v1",
20//!                     L    = 32 bytes,
21//!                 )
22//! ```
23//!
24//! The salt being `pair_id` addresses the open question in the PRD
25//! (§8.1): two LCTs can have multiple distinct pairs without session-key
26//! reuse — the same shared secret produces a different session key per
27//! pair. Both endpoints know `pair_id` from the hub (it's metadata),
28//! so they derive identical session keys without coordination.
29//!
30//! ## Wire format
31//!
32//! ```text
33//! sealed = nonce_12_bytes || ciphertext_n_bytes
34//! ```
35//!
36//! ChaCha20-Poly1305 uses a 12-byte nonce. The ciphertext includes
37//! the 16-byte Poly1305 authentication tag at the end (AEAD-attached,
38//! per the standard). At the wire layer (over JSON to the hub), the
39//! sealed bytes are base64-encoded — that part's the caller's
40//! responsibility (`Sealed::to_base64` / `Sealed::from_base64` are
41//! provided as convenience).
42//!
43//! ## Nonce strategy (Sprint E MVP)
44//!
45//! Random 12-byte nonce per message. With 2^96 possible nonces and
46//! ChaCha20-Poly1305's birthday-bound at 2^48 messages per key,
47//! collision probability is negligible at any practical message
48//! volume. Sprint F adds proper per-session counter nonces + ephemeral
49//! ratchet keys for forward secrecy; Sprint E is the static-key
50//! baseline.
51//!
52//! ## What this gives you
53//!
54//! - **Confidentiality:** hub stores opaque ciphertext; only the two
55//!   pair participants can decrypt.
56//! - **Integrity / authenticity-of-payload:** AEAD detects tampering
57//!   (the Poly1305 tag fails if a byte flips). Authenticity-of-sender
58//!   still rides the *envelope* signature at the REST layer — the
59//!   AEAD only proves "whoever knew the session key wrote this," which
60//!   the envelope signature pins to a specific LCT.
61//!
62//! ## What this does NOT give you (deferred)
63//!
64//! - **Forward secrecy** — Sprint F. If an LCT's static key is later
65//!   compromised, an attacker who captured past ciphertexts can derive
66//!   the session key and decrypt them. Sprint F's ephemeral-key
67//!   ratchet closes this.
68//! - **Future secrecy / post-compromise security** — full Signal
69//!   double-ratchet, deferred per the PRD §6 out-of-scope list.
70//! - **Group channels** — 2-party only.
71
72use crate::crypto::{KeyPair, PublicKey, SharedSecret};
73use crate::error::{Result, Web4Error};
74use chacha20poly1305::{
75    aead::{Aead, KeyInit},
76    ChaCha20Poly1305, Key, Nonce,
77};
78use hkdf::Hkdf;
79use rand::RngCore;
80use sha2::Sha256;
81use uuid::Uuid;
82
83/// Info-string for HKDF. Bump the version suffix if the derivation
84/// inputs / output usage ever changes — it's a clean way to enforce
85/// that old session keys can't be accidentally repurposed.
86const HKDF_INFO: &[u8] = b"web4-paired-channel-v1";
87
88/// ChaCha20-Poly1305 nonce length per the AEAD construction. The
89/// `chacha20poly1305` crate's type system also enforces this.
90const NONCE_LEN: usize = 12;
91
92/// A 32-byte session key derived from an ECDH shared secret +
93/// pair_id salt. Carries a redacted Debug like SharedSecret.
94#[derive(Clone)]
95pub struct SessionKey([u8; 32]);
96
97impl SessionKey {
98    pub fn as_bytes(&self) -> &[u8; 32] { &self.0 }
99}
100
101impl std::fmt::Debug for SessionKey {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_str("SessionKey(<redacted 32 bytes>)")
104    }
105}
106
107/// A sealed pair message: 12-byte nonce prefix + ciphertext (with
108/// trailing 16-byte Poly1305 tag). Caller transports the raw bytes
109/// (typically base64 over JSON to the hub).
110#[derive(Clone, Debug)]
111pub struct Sealed(Vec<u8>);
112
113impl Sealed {
114    /// Raw on-wire bytes: nonce ‖ ciphertext.
115    pub fn as_bytes(&self) -> &[u8] { &self.0 }
116
117    /// Move out the raw bytes.
118    pub fn into_bytes(self) -> Vec<u8> { self.0 }
119
120    /// Convenience: base64-encode for JSON transport.
121    pub fn to_base64(&self) -> String {
122        use base64::Engine;
123        base64::engine::general_purpose::STANDARD.encode(&self.0)
124    }
125
126    /// Convenience: parse from base64.
127    pub fn from_base64(s: &str) -> Result<Self> {
128        use base64::Engine;
129        let bytes = base64::engine::general_purpose::STANDARD
130            .decode(s)
131            .map_err(|e| Web4Error::Crypto(format!("base64 decode: {}", e)))?;
132        if bytes.len() < NONCE_LEN + 16 {
133            return Err(Web4Error::Crypto(
134                format!("sealed blob too short: {} bytes < nonce+tag = {}",
135                    bytes.len(), NONCE_LEN + 16)
136            ));
137        }
138        Ok(Self(bytes))
139    }
140
141    /// Construct from raw bytes (skip base64). Validates min length.
142    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
143        if bytes.len() < NONCE_LEN + 16 {
144            return Err(Web4Error::Crypto(
145                format!("sealed blob too short: {} bytes < nonce+tag = {}",
146                    bytes.len(), NONCE_LEN + 16)
147            ));
148        }
149        Ok(Self(bytes))
150    }
151}
152
153/// Derive the per-pair session key from an ECDH shared secret + the
154/// pair's identifier. Both endpoints can do this independently from
155/// public information (peer's LCT pubkey, pair_id from the hub) +
156/// their own private key.
157pub fn derive_session_key(shared: &SharedSecret, pair_id: Uuid) -> SessionKey {
158    let hk = Hkdf::<Sha256>::new(Some(pair_id.as_bytes()), shared.as_bytes());
159    let mut okm = [0u8; 32];
160    // HKDF-Expand can only fail for L > 255*HashLen, which we're nowhere near.
161    hk.expand(HKDF_INFO, &mut okm)
162        .expect("HKDF expand for 32 bytes never fails");
163    SessionKey(okm)
164}
165
166/// Encrypt `plaintext` under `session_key` with a fresh random nonce.
167/// Output is `nonce || ciphertext_with_tag` wrapped in `Sealed`.
168pub fn encrypt(session_key: &SessionKey, plaintext: &[u8]) -> Result<Sealed> {
169    let cipher = ChaCha20Poly1305::new(Key::from_slice(session_key.as_bytes()));
170    let mut nonce_bytes = [0u8; NONCE_LEN];
171    rand::thread_rng().fill_bytes(&mut nonce_bytes);
172    let nonce = Nonce::from_slice(&nonce_bytes);
173    let ciphertext = cipher.encrypt(nonce, plaintext)
174        .map_err(|e| Web4Error::Crypto(format!("ChaCha20-Poly1305 encrypt: {}", e)))?;
175    let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
176    out.extend_from_slice(&nonce_bytes);
177    out.extend_from_slice(&ciphertext);
178    Ok(Sealed(out))
179}
180
181/// Decrypt a `Sealed` blob under `session_key`. Returns plaintext on
182/// success; errors (AEAD authentication failed) if the ciphertext was
183/// tampered or the wrong session key was used.
184pub fn decrypt(session_key: &SessionKey, sealed: &Sealed) -> Result<Vec<u8>> {
185    let bytes = sealed.as_bytes();
186    if bytes.len() < NONCE_LEN + 16 {
187        return Err(Web4Error::Crypto("sealed blob too short".into()));
188    }
189    let (nonce_bytes, ciphertext) = bytes.split_at(NONCE_LEN);
190    let cipher = ChaCha20Poly1305::new(Key::from_slice(session_key.as_bytes()));
191    let nonce = Nonce::from_slice(nonce_bytes);
192    cipher.decrypt(nonce, ciphertext)
193        .map_err(|e| Web4Error::Crypto(format!("ChaCha20-Poly1305 decrypt: {}", e)))
194}
195
196/// End-to-end convenience: given my LCT keypair, the peer's LCT
197/// public key, the pair_id, and a plaintext — produce a sealed blob
198/// the recipient can [`open`] using the symmetric inverse path.
199pub fn seal(my: &KeyPair, peer: &PublicKey, pair_id: Uuid, plaintext: &[u8]) -> Result<Sealed> {
200    let shared = my.ecdh_with_peer(peer)?;
201    let key = derive_session_key(&shared, pair_id);
202    encrypt(&key, plaintext)
203}
204
205/// End-to-end convenience: given my LCT keypair, the peer's LCT
206/// public key, the pair_id, and a sealed blob — recover the plaintext.
207pub fn open(my: &KeyPair, peer: &PublicKey, pair_id: Uuid, sealed: &Sealed) -> Result<Vec<u8>> {
208    let shared = my.ecdh_with_peer(peer)?;
209    let key = derive_session_key(&shared, pair_id);
210    decrypt(&key, sealed)
211}
212
213// ============================================================================
214// PAIRED-CHANNELS Sprint F — Forward secrecy via ephemeral session keys
215// ============================================================================
216//
217// Sprint E used a static-key ECDH only:
218//
219//     shared = ECDH(my_lct_secret, peer_lct_public)
220//     key    = HKDF(salt=pair_id, ikm=shared, info=...)
221//
222// Compromise of the LCT static key (after the fact) + captured
223// ciphertexts → attacker derives `shared` and decrypts everything.
224// No forward secrecy.
225//
226// Sprint F mixes in an **ephemeral X25519 keypair per pair-session**
227// (generated when the pair is confirmed; destroyed when the session
228// ends). Each party publishes their ephemeral public to the ledger
229// (in PairingRequested / PairingConfirmed); both parties keep their
230// own ephemeral SECRET locally. The session key derivation becomes:
231//
232//     static_shared    = ECDH(my_lct_secret, peer_lct_public)
233//     ephemeral_shared = ECDH(my_eph_secret, peer_eph_public)
234//     ikm              = static_shared || ephemeral_shared
235//     key              = HKDF(salt=pair_id, ikm, info="web4-paired-channel-v2")
236//
237// Both shareds are needed to derive the key. An attacker who later
238// compromises the LCT static keys but never had access to the
239// ephemeral secrets cannot derive the key — past sessions remain
240// confidential. (Future secrecy / post-compromise security would
241// require Signal-style per-message ratcheting, deferred per the
242// PRD §6 out-of-scope list.)
243//
244// **What "session" means here:** the lifetime of the pair. One
245// ephemeral key per pair, kept alive for the pair's duration. When
246// the pair is revoked or expires, both parties wipe their ephemeral
247// secrets. Sprint F-confirmed pairs that haven't been revoked yet
248// retain forward secrecy as long as the endpoints actually wipe.
249// (Endpoint discipline; hub can't enforce.)
250//
251// Wire format unchanged: still `nonce ‖ ciphertext_with_tag`. The
252// derivation is different but the AEAD output looks identical, so
253// the hub-side relay code (rest.rs) needs no changes.
254
255/// Info-string for the Sprint F derivation. Distinct from v1 so an
256/// attacker can't try to mix-and-match a v1 session key onto a v2
257/// ciphertext (domain separation).
258const HKDF_INFO_V2: &[u8] = b"web4-paired-channel-v2";
259
260/// An ephemeral X25519 keypair for one pair-session. Caller must
261/// persist the secret locally (we never put it on the wire) and
262/// publish the public via PairingRequested / PairingConfirmed.
263///
264/// When the pair ends, drop this struct (and any local copies of
265/// `secret_hex`) to honor forward secrecy.
266pub struct EphemeralKeyPair {
267    secret: x25519_dalek::StaticSecret,
268    public: x25519_dalek::PublicKey,
269}
270
271impl EphemeralKeyPair {
272    /// Generate a fresh ephemeral keypair from the OS RNG. One per
273    /// pair-session; do NOT reuse across pairs (no advantage, and
274    /// any reuse weakens the forward-secrecy claim).
275    pub fn generate() -> Self {
276        let secret = x25519_dalek::StaticSecret::random_from_rng(&mut rand::thread_rng());
277        let public = x25519_dalek::PublicKey::from(&secret);
278        Self { secret, public }
279    }
280
281    pub fn public_bytes(&self) -> [u8; 32] {
282        *self.public.as_bytes()
283    }
284
285    /// 32-byte secret as hex for local persistence. Endpoint
286    /// implementations save this alongside their PairState; wipe
287    /// the file when the pair is revoked.
288    pub fn secret_hex(&self) -> String {
289        hex_encode(&self.secret.to_bytes())
290    }
291
292    pub fn public_hex(&self) -> String {
293        hex_encode(self.public.as_bytes())
294    }
295
296    /// Reconstruct from a secret hex string (matched output of
297    /// `secret_hex`). Errors on bad hex or wrong length.
298    pub fn from_secret_hex(s: &str) -> Result<Self> {
299        let bytes = hex_decode(s)?;
300        let arr: [u8; 32] = bytes.as_slice().try_into()
301            .map_err(|_| Web4Error::Crypto(
302                format!("ephemeral secret must be 32 bytes, got {}", bytes.len())
303            ))?;
304        let secret = x25519_dalek::StaticSecret::from(arr);
305        let public = x25519_dalek::PublicKey::from(&secret);
306        Ok(Self { secret, public })
307    }
308}
309
310impl std::fmt::Debug for EphemeralKeyPair {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        f.write_str("EphemeralKeyPair(<secret redacted>)")
313    }
314}
315
316/// Decode a 32-byte X25519 public from hex. Used when reading the
317/// peer's ephemeral pub from a PairingRequested / PairingConfirmed
318/// event in the ledger.
319pub fn ephemeral_public_from_hex(s: &str) -> Result<x25519_dalek::PublicKey> {
320    let bytes = hex_decode(s)?;
321    let arr: [u8; 32] = bytes.as_slice().try_into()
322        .map_err(|_| Web4Error::Crypto(
323            format!("ephemeral pubkey must be 32 bytes, got {}", bytes.len())
324        ))?;
325    Ok(x25519_dalek::PublicKey::from(arr))
326}
327
328/// V2 session-key derivation. Mixes the LCT-static ECDH and the
329/// per-session ephemeral ECDH. Both endpoints derive the same key
330/// from their own secret + the peer's two publics + pair_id.
331pub fn derive_session_key_v2(
332    static_shared: &SharedSecret,
333    ephemeral_shared: &SharedSecret,
334    pair_id: Uuid,
335) -> SessionKey {
336    let mut ikm = Vec::with_capacity(64);
337    ikm.extend_from_slice(static_shared.as_bytes());
338    ikm.extend_from_slice(ephemeral_shared.as_bytes());
339    let hk = Hkdf::<Sha256>::new(Some(pair_id.as_bytes()), &ikm);
340    let mut okm = [0u8; 32];
341    hk.expand(HKDF_INFO_V2, &mut okm)
342        .expect("HKDF expand for 32 bytes never fails");
343    SessionKey(okm)
344}
345
346/// End-to-end seal with forward secrecy. Caller supplies their own
347/// LCT keypair + ephemeral keypair + peer's LCT public + peer's
348/// ephemeral public + pair_id + plaintext.
349pub fn seal_fs(
350    my_lct: &KeyPair,
351    my_eph: &EphemeralKeyPair,
352    peer_lct: &PublicKey,
353    peer_eph_pub: &x25519_dalek::PublicKey,
354    pair_id: Uuid,
355    plaintext: &[u8],
356) -> Result<Sealed> {
357    let static_shared = my_lct.ecdh_with_peer(peer_lct)?;
358    let eph_shared = SharedSecret::from_bytes(*my_eph.secret.diffie_hellman(peer_eph_pub).as_bytes());
359    let key = derive_session_key_v2(&static_shared, &eph_shared, pair_id);
360    encrypt(&key, plaintext)
361}
362
363/// Symmetric inverse of `seal_fs`.
364pub fn open_fs(
365    my_lct: &KeyPair,
366    my_eph: &EphemeralKeyPair,
367    peer_lct: &PublicKey,
368    peer_eph_pub: &x25519_dalek::PublicKey,
369    pair_id: Uuid,
370    sealed: &Sealed,
371) -> Result<Vec<u8>> {
372    let static_shared = my_lct.ecdh_with_peer(peer_lct)?;
373    let eph_shared = SharedSecret::from_bytes(*my_eph.secret.diffie_hellman(peer_eph_pub).as_bytes());
374    let key = derive_session_key_v2(&static_shared, &eph_shared, pair_id);
375    decrypt(&key, sealed)
376}
377
378// ---------- internal hex helpers (no external dep) ----------
379
380fn hex_encode(bytes: &[u8]) -> String {
381    bytes.iter().map(|b| format!("{:02x}", b)).collect()
382}
383
384fn hex_decode(s: &str) -> Result<Vec<u8>> {
385    if s.len() % 2 != 0 {
386        return Err(Web4Error::Crypto("hex string must have even length".into()));
387    }
388    (0..s.len()).step_by(2).map(|i| {
389        u8::from_str_radix(&s[i..i + 2], 16)
390            .map_err(|e| Web4Error::Crypto(format!("invalid hex: {}", e)))
391    }).collect()
392}
393
394// SharedSecret needs to expose a private constructor for the ephemeral
395// path above. We do that by making it a sibling helper — keeps the
396// (LCT-derived only) public construction path through `ecdh_with_peer`.
397
398// Note: `SharedSecret` struct field is private at the module level,
399// so this only works because `seal_fs` / `open_fs` live in the same
400// file. External callers still get a SharedSecret only via
401// `KeyPair::ecdh_with_peer`. Internal-only construction is fine.
402
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    /// Foundation property: Alice seals to Bob, Bob opens with his own
409    /// keypair + Alice's pubkey, recovers the original plaintext.
410    #[test]
411    fn seal_open_round_trip() {
412        let alice = KeyPair::generate();
413        let bob = KeyPair::generate();
414        let pair_id = Uuid::new_v4();
415        let plaintext = b"hello bob - this should be e2e";
416
417        let sealed = seal(&alice, &bob.verifying_key(), pair_id, plaintext).unwrap();
418        let recovered = open(&bob, &alice.verifying_key(), pair_id, &sealed).unwrap();
419        assert_eq!(recovered, plaintext);
420    }
421
422    /// Symmetric direction: Bob seals to Alice, Alice opens. Same key.
423    #[test]
424    fn seal_open_is_direction_symmetric() {
425        let alice = KeyPair::generate();
426        let bob = KeyPair::generate();
427        let pair_id = Uuid::new_v4();
428
429        let from_alice = seal(&alice, &bob.verifying_key(), pair_id, b"to bob").unwrap();
430        let from_bob = seal(&bob, &alice.verifying_key(), pair_id, b"to alice").unwrap();
431
432        assert_eq!(open(&bob, &alice.verifying_key(), pair_id, &from_alice).unwrap(), b"to bob");
433        assert_eq!(open(&alice, &bob.verifying_key(), pair_id, &from_bob).unwrap(), b"to alice");
434    }
435
436    /// AEAD integrity: flipping a single ciphertext byte must cause
437    /// open() to error. This is what "the hub cannot tamper without
438    /// detection" buys us at the wire layer.
439    #[test]
440    fn tampered_ciphertext_fails_open() {
441        let alice = KeyPair::generate();
442        let bob = KeyPair::generate();
443        let pair_id = Uuid::new_v4();
444
445        let sealed = seal(&alice, &bob.verifying_key(), pair_id, b"important").unwrap();
446        let mut bytes = sealed.into_bytes();
447        // Flip a byte in the ciphertext region (past the 12-byte nonce).
448        let target = bytes.len() / 2;
449        bytes[target] ^= 0xff;
450        let tampered = Sealed::from_bytes(bytes).unwrap();
451        assert!(open(&bob, &alice.verifying_key(), pair_id, &tampered).is_err(),
452            "tampered ciphertext must fail AEAD authentication");
453    }
454
455    /// Different pair_ids must produce different session keys — so
456    /// the same shared secret can serve many pairs without key reuse.
457    /// Addresses PRD §8.1 open question.
458    #[test]
459    fn different_pair_ids_use_distinct_session_keys() {
460        let alice = KeyPair::generate();
461        let bob = KeyPair::generate();
462        let p1 = Uuid::new_v4();
463        let p2 = Uuid::new_v4();
464
465        let sealed_p1 = seal(&alice, &bob.verifying_key(), p1, b"x").unwrap();
466        // Opening sealed_p1 with the WRONG pair_id must fail — proves
467        // the pair_id is mixed into the session key, not just metadata.
468        assert!(open(&bob, &alice.verifying_key(), p2, &sealed_p1).is_err(),
469            "seal under p1 must not open under p2");
470    }
471
472    /// Wrong peer pubkey must fail — confirms the ECDH derivation
473    /// actually binds to the peer's identity.
474    #[test]
475    fn wrong_peer_pubkey_fails_open() {
476        let alice = KeyPair::generate();
477        let bob = KeyPair::generate();
478        let carol = KeyPair::generate();
479        let pair_id = Uuid::new_v4();
480
481        let sealed = seal(&alice, &bob.verifying_key(), pair_id, b"for bob").unwrap();
482        assert!(open(&bob, &carol.verifying_key(), pair_id, &sealed).is_err(),
483            "bob using carol's pubkey instead of alice's must fail");
484    }
485
486    /// Base64 round-trip for the wire format.
487    #[test]
488    fn sealed_base64_round_trip() {
489        let alice = KeyPair::generate();
490        let bob = KeyPair::generate();
491        let pair_id = Uuid::new_v4();
492
493        let sealed = seal(&alice, &bob.verifying_key(), pair_id, b"hello").unwrap();
494        let b64 = sealed.to_base64();
495        let back = Sealed::from_base64(&b64).unwrap();
496        assert_eq!(sealed.as_bytes(), back.as_bytes());
497        // And the round-tripped Sealed still decrypts:
498        let plain = open(&bob, &alice.verifying_key(), pair_id, &back).unwrap();
499        assert_eq!(plain, b"hello");
500    }
501
502    /// SessionKey's Debug must redact.
503    #[test]
504    fn session_key_debug_is_redacted() {
505        let alice = KeyPair::generate();
506        let bob = KeyPair::generate();
507        let pair_id = Uuid::new_v4();
508        let shared = alice.ecdh_with_peer(&bob.verifying_key()).unwrap();
509        let key = derive_session_key(&shared, pair_id);
510        let s = format!("{:?}", key);
511        assert!(s.contains("redacted"));
512        // Spot-check the raw bytes don't appear in hex form
513        let hex: String = key.as_bytes().iter()
514            .map(|b| format!("{:02x}", b)).collect();
515        assert!(!s.contains(&hex[..16]));
516    }
517
518    /// Nonces are random per encryption — encrypting the SAME plaintext
519    /// twice produces DIFFERENT ciphertexts.
520    #[test]
521    fn nonces_are_unique_per_encryption() {
522        let alice = KeyPair::generate();
523        let bob = KeyPair::generate();
524        let pair_id = Uuid::new_v4();
525
526        let s1 = seal(&alice, &bob.verifying_key(), pair_id, b"same plaintext").unwrap();
527        let s2 = seal(&alice, &bob.verifying_key(), pair_id, b"same plaintext").unwrap();
528        assert_ne!(s1.as_bytes(), s2.as_bytes(),
529            "identical plaintexts must produce distinct ciphertexts (random nonces)");
530    }
531
532    /// Too-short sealed blob errors cleanly (defense against
533    /// malformed wire input).
534    #[test]
535    fn short_sealed_blob_errors() {
536        // Length < 12 (nonce) + 16 (tag) = 28
537        let result = Sealed::from_bytes(vec![0u8; 20]);
538        assert!(result.is_err());
539    }
540
541    // ---------- Sprint F: forward secrecy ----------
542
543    /// FS happy path: Alice and Bob each generate ephemeral keys,
544    /// publish the publics, derive the same v2 session key, seal/open
545    /// successfully.
546    #[test]
547    fn fs_seal_open_round_trip() {
548        let alice = KeyPair::generate();
549        let bob = KeyPair::generate();
550        let alice_eph = EphemeralKeyPair::generate();
551        let bob_eph = EphemeralKeyPair::generate();
552        let pair_id = Uuid::new_v4();
553        let plaintext = b"forward secrecy demo";
554
555        let sealed = seal_fs(
556            &alice, &alice_eph,
557            &bob.verifying_key(), &bob_eph.public,
558            pair_id, plaintext,
559        ).unwrap();
560        let recovered = open_fs(
561            &bob, &bob_eph,
562            &alice.verifying_key(), &alice_eph.public,
563            pair_id, &sealed,
564        ).unwrap();
565        assert_eq!(recovered, plaintext);
566    }
567
568    /// **The forward secrecy property itself, demonstrated as a test:**
569    /// Alice seals with FS (v2). An attacker later obtains BOTH
570    /// parties' static LCT keys but NOT the ephemeral secrets
571    /// (because they were wiped). The v1 open() call (static-only
572    /// derivation) MUST fail — proving the captured ciphertext is
573    /// unreachable without the ephemerals.
574    #[test]
575    fn fs_v2_ciphertext_cannot_be_opened_with_static_keys_alone() {
576        let alice = KeyPair::generate();
577        let bob = KeyPair::generate();
578        let alice_eph = EphemeralKeyPair::generate();
579        let bob_eph = EphemeralKeyPair::generate();
580        let pair_id = Uuid::new_v4();
581        let plaintext = b"top secret with forward secrecy";
582
583        // Alice seals using FS path
584        let sealed = seal_fs(
585            &alice, &alice_eph,
586            &bob.verifying_key(), &bob_eph.public,
587            pair_id, plaintext,
588        ).unwrap();
589
590        // Attacker later has BOTH static private keys (worst case),
591        // and the captured ciphertext. They try the v1 open path
592        // (static-only). Should fail — the v1 derivation produces a
593        // different key, AEAD authentication fails.
594        let result = open(&alice, &bob.verifying_key(), pair_id, &sealed);
595        assert!(result.is_err(),
596            "FS-sealed ciphertext must not open via the static-only v1 path");
597
598        // And just to be exhaustive: attacker can't even fabricate
599        // their own ephemeral and try; without one of the real
600        // ephemeral *secrets*, ECDH produces the wrong shared.
601        let fake_eph = EphemeralKeyPair::generate();
602        let result_with_fake = open_fs(
603            &alice, &fake_eph,
604            &bob.verifying_key(), &bob_eph.public,
605            pair_id, &sealed,
606        );
607        assert!(result_with_fake.is_err(),
608            "FS-sealed ciphertext must not open with a fabricated ephemeral");
609    }
610
611    /// Symmetric verification: Bob with HIS ephemeral secret and
612    /// alice's ephemeral public derives the same v2 key as Alice did
613    /// for the seal — recover succeeds.
614    #[test]
615    fn fs_direction_symmetric() {
616        let alice = KeyPair::generate();
617        let bob = KeyPair::generate();
618        let alice_eph = EphemeralKeyPair::generate();
619        let bob_eph = EphemeralKeyPair::generate();
620        let pair_id = Uuid::new_v4();
621
622        let from_alice = seal_fs(
623            &alice, &alice_eph,
624            &bob.verifying_key(), &bob_eph.public,
625            pair_id, b"to bob",
626        ).unwrap();
627        let from_bob = seal_fs(
628            &bob, &bob_eph,
629            &alice.verifying_key(), &alice_eph.public,
630            pair_id, b"to alice",
631        ).unwrap();
632
633        assert_eq!(
634            open_fs(&bob, &bob_eph, &alice.verifying_key(), &alice_eph.public,
635                pair_id, &from_alice).unwrap(),
636            b"to bob"
637        );
638        assert_eq!(
639            open_fs(&alice, &alice_eph, &bob.verifying_key(), &bob_eph.public,
640                pair_id, &from_bob).unwrap(),
641            b"to alice"
642        );
643    }
644
645    /// Ephemeral secret round-trips through hex (for local persistence).
646    /// Reconstructed keypair produces the same public.
647    #[test]
648    fn fs_ephemeral_secret_hex_round_trip() {
649        let eph = EphemeralKeyPair::generate();
650        let secret_hex = eph.secret_hex();
651        let recovered = EphemeralKeyPair::from_secret_hex(&secret_hex).unwrap();
652        assert_eq!(eph.public_bytes(), recovered.public_bytes(),
653            "recovered keypair must produce the same public key");
654    }
655
656    /// Ephemeral keypair Debug must redact.
657    #[test]
658    fn fs_ephemeral_debug_is_redacted() {
659        let eph = EphemeralKeyPair::generate();
660        let s = format!("{:?}", eph);
661        assert!(s.contains("redacted"));
662        // The secret hex must not appear anywhere in Debug output
663        let secret = eph.secret_hex();
664        assert!(!s.contains(&secret[..16]));
665    }
666
667    /// Distinct pair_ids still produce distinct v2 session keys
668    /// (the salt mixing carries over from v1).
669    #[test]
670    fn fs_pair_id_still_binds_session_key() {
671        let alice = KeyPair::generate();
672        let bob = KeyPair::generate();
673        let alice_eph = EphemeralKeyPair::generate();
674        let bob_eph = EphemeralKeyPair::generate();
675        let p1 = Uuid::new_v4();
676        let p2 = Uuid::new_v4();
677
678        let sealed = seal_fs(
679            &alice, &alice_eph, &bob.verifying_key(), &bob_eph.public,
680            p1, b"hello",
681        ).unwrap();
682        let wrong_pair = open_fs(
683            &bob, &bob_eph, &alice.verifying_key(), &alice_eph.public,
684            p2, &sealed,
685        );
686        assert!(wrong_pair.is_err(),
687            "v2 ciphertext under p1 must not open under p2");
688    }
689
690    /// `ephemeral_public_from_hex` parses the same bytes back as the
691    /// keypair produced — closing the wire-format loop.
692    #[test]
693    fn fs_ephemeral_public_hex_parse_round_trip() {
694        let eph = EphemeralKeyPair::generate();
695        let pub_hex = eph.public_hex();
696        let parsed = ephemeral_public_from_hex(&pub_hex).unwrap();
697        assert_eq!(parsed.as_bytes(), &eph.public_bytes());
698    }
699}