Skip to main content

logicaffeine_compile/concurrency/
channel.rs

1//! `SecureChannel` envelope — the end-to-end crypto seam that wraps the opaque
2//! wire blob `marshal` produces, between the codec and the transport.
3//!
4//! Layering (work/QUANTUM_MAP.md §1, §L): the channel owns its *own* versioned envelope
5//! and never inspects or mutates the payload, exactly as the FEC layer in [`super::fec`]
6//! does. `seal` frames an opaque blob under a suite id; `open` reverses it, returning
7//! `None` on any malformed input — the same `None`-on-malformed contract
8//! [`super::marshal::message_from_wire`] honours, so a corrupt or truncated envelope is
9//! dropped, never decoded.
10//!
11//! ```text
12//! [ CHAN_MAGIC | CHAN_VER | suite_id (LE u16) | body ]
13//! ```
14//!
15//! The `null` suite (`SUITE_NULL`) is identity framing — no cryptography — and exists to
16//! prove the embedding seam end-to-end before any primitive lands. Real suites replace
17//! `body` with `handshake/sequence ‖ AEAD(blob)+tag` under their own suite id; because the
18//! suite id is bound in the envelope, swapping a suite is a registry change, never a wire
19//! break.
20
21/// Envelope magic byte — distinct from [`super::fec`]'s `0xFE` so a framed blob is
22/// self-identifying.
23pub(crate) const CHAN_MAGIC: u8 = 0xC0;
24
25/// Envelope format version.
26pub(crate) const CHAN_VER: u8 = 1;
27
28/// Fixed header: magic (1) + version (1) + suite id (2, little-endian).
29pub(crate) const CHAN_HEADER_LEN: usize = 4;
30
31/// The suite id of the identity suite.
32pub const SUITE_NULL: u16 = 0;
33
34/// The suite id of the post-quantum suite: an ML-KEM-768 handshake establishes the shared secret,
35/// SHAKE256 derives the key, and every body is ChaCha20-Poly1305 AEAD-sealed.
36pub const SUITE_PQ: u16 = 1;
37
38/// The suite id of the `PNP` tier — the information-theoretic true one-time pad (see [`super::pnp`]).
39/// It is the last resort should computational cryptography fall (the `P = NP` scenario): its secrecy
40/// rests on Shannon, not on any hardness assumption. Like [`SUITE_PQ`] it is keyed and stateful, so
41/// it lives outside the stateless [`suite_for`] registry and is used through [`super::pnp::PnpSuite`]
42/// rather than [`seal`] / [`open`].
43pub const SUITE_PNP: u16 = 2;
44
45/// A pluggable crypto suite. Each posture level — `null`, `Classic`, `Hybrid`, `PQ`,
46/// `PQ-Max` — is one `Suite` registered in [`suite_for`], so adding a primitive is a
47/// registration, not a change to [`seal`] / [`open`]. The seam stays suite-agnostic; all
48/// cryptography lives behind this trait (work/QUANTUM_MAP.md §3 — crypto-agility).
49pub trait Suite: Sync {
50    /// The wire suite id bound into the envelope header.
51    fn id(&self) -> u16;
52    /// Transform an opaque blob into the envelope body — identity for `null`; for a real
53    /// suite, `handshake/sequence ‖ AEAD(blob)+tag`.
54    fn seal_body(&self, blob: &[u8]) -> Vec<u8>;
55    /// Reverse [`Suite::seal_body`], or `None` on a tampered / malformed body.
56    fn open_body(&self, body: &[u8]) -> Option<Vec<u8>>;
57}
58
59/// The identity suite: no cryptography, `body == blob`. Proves the seam; never the shipped
60/// default once a real suite exists.
61pub struct NullSuite;
62
63impl Suite for NullSuite {
64    fn id(&self) -> u16 {
65        SUITE_NULL
66    }
67    fn seal_body(&self, blob: &[u8]) -> Vec<u8> {
68        blob.to_vec()
69    }
70    fn open_body(&self, body: &[u8]) -> Option<Vec<u8>> {
71        Some(body.to_vec())
72    }
73}
74
75/// Resolve a registered STATELESS suite by its wire id, or `None` for an unknown suite (so
76/// [`open`] returns `None` rather than decoding under the wrong primitive). The keyed [`PqSuite`]
77/// is not here — it carries a per-session key and is used via [`seal_with`] / [`open_with`].
78pub fn suite_for(id: u16) -> Option<&'static dyn Suite> {
79    static NULL: NullSuite = NullSuite;
80    match id {
81        SUITE_NULL => Some(&NULL),
82        _ => None,
83    }
84}
85
86/// The post-quantum suite: a per-session ChaCha20-Poly1305 key (from an ML-KEM-768 handshake,
87/// see [`pq_handshake_initiator`] / [`pq_handshake_responder`]). Each `seal_body` draws a fresh
88/// counter nonce, so every frame is unique; the nonce is carried in the body, so the peer opens
89/// statelessly. The suite id is bound as the AEAD associated data, so a frame can't be replayed
90/// under a different suite. (Bidirectional use derives a key per direction — see the handshake.)
91pub struct PqSuite {
92    key: [u8; 32],
93    seq: std::sync::atomic::AtomicU64,
94}
95
96impl PqSuite {
97    /// A PQ suite over an already-derived 32-byte AEAD key.
98    pub fn new(key: [u8; 32]) -> Self {
99        Self { key, seq: std::sync::atomic::AtomicU64::new(0) }
100    }
101}
102
103impl Suite for PqSuite {
104    fn id(&self) -> u16 {
105        SUITE_PQ
106    }
107    fn seal_body(&self, blob: &[u8]) -> Vec<u8> {
108        let seq = self.seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
109        let mut nonce = [0u8; 12];
110        nonce[..8].copy_from_slice(&seq.to_le_bytes());
111        let sealed =
112            logicaffeine_system::aead::chacha20poly1305_seal(&self.key, &nonce, &SUITE_PQ.to_le_bytes(), blob);
113        let mut body = Vec::with_capacity(12 + sealed.len());
114        body.extend_from_slice(&nonce);
115        body.extend_from_slice(&sealed);
116        body
117    }
118    fn open_body(&self, body: &[u8]) -> Option<Vec<u8>> {
119        if body.len() < 12 {
120            return None;
121        }
122        let nonce: [u8; 12] = body[..12].try_into().ok()?;
123        logicaffeine_system::aead::chacha20poly1305_open(&self.key, &nonce, &SUITE_PQ.to_le_bytes(), &body[12..])
124    }
125}
126
127/// Derive a directional 32-byte AEAD key from an ML-KEM shared secret via SHAKE256, domain-separated
128/// by `label` (e.g. `b"i2r"` / `b"r2i"`) so each direction has an independent key — no nonce reuse
129/// across the two streams that share the handshake secret.
130pub fn derive_aead_key(shared_secret: &[u8], label: &[u8]) -> [u8; 32] {
131    let mut input = Vec::with_capacity(shared_secret.len() + label.len() + 24);
132    input.extend_from_slice(b"logos-pq-channel-v1\x00");
133    input.extend_from_slice(label);
134    input.extend_from_slice(shared_secret);
135    let mut k = [0u8; 32];
136    k.copy_from_slice(&logicaffeine_system::keccak::shake256_bytes(&input, 32));
137    k
138}
139
140/// The freshly generated handshake material an initiator publishes + retains.
141pub struct PqHandshake {
142    /// ML-KEM-768 encapsulation key, sent to the responder.
143    pub ek: Vec<u8>,
144    /// ML-KEM-768 decapsulation key, kept secret.
145    dk: Vec<u8>,
146}
147
148/// Initiator step 1: generate an ML-KEM-768 keypair. Publish [`PqHandshake::ek`] to the responder.
149pub fn pq_handshake_initiator(seed_d: &[u8; 32], seed_z: &[u8; 32]) -> PqHandshake {
150    let (ek, dk) = logicaffeine_system::mlkem::keygen(seed_d, seed_z);
151    PqHandshake { ek, dk }
152}
153
154/// Responder: encapsulate to the initiator's `ek`, returning the ciphertext to send back and the
155/// two directional suites (`initiator→responder`, `responder→initiator`).
156pub fn pq_handshake_responder(ek: &[u8], msg: &[u8; 32]) -> (Vec<u8>, PqSuite, PqSuite) {
157    let (ct, ss) = logicaffeine_system::mlkem::encaps(ek, msg);
158    (ct, PqSuite::new(derive_aead_key(&ss, b"i2r")), PqSuite::new(derive_aead_key(&ss, b"r2i")))
159}
160
161/// Initiator step 2: decapsulate the responder's ciphertext, yielding the matching directional
162/// suites (`initiator→responder`, `responder→initiator`).
163pub fn pq_handshake_finish(hs: &PqHandshake, ct: &[u8]) -> (PqSuite, PqSuite) {
164    let ss = logicaffeine_system::mlkem::decaps(&hs.dk, ct);
165    (PqSuite::new(derive_aead_key(&ss, b"i2r")), PqSuite::new(derive_aead_key(&ss, b"r2i")))
166}
167
168/// Frame a blob under a keyed [`Suite`] instance (the [`PqSuite`] path), binding the suite id in the
169/// header exactly like [`seal`]. The instance carries the per-session key the static registry can't.
170pub fn seal_with(suite: &dyn Suite, blob: &[u8]) -> Vec<u8> {
171    let body = suite.seal_body(blob);
172    let mut out = Vec::with_capacity(CHAN_HEADER_LEN + body.len());
173    out.push(CHAN_MAGIC);
174    out.push(CHAN_VER);
175    out.extend_from_slice(&suite.id().to_le_bytes());
176    out.extend_from_slice(&body);
177    out
178}
179
180/// Reverse [`seal_with`] under a keyed [`Suite`] instance, or `None` on a bad header, a suite-id
181/// mismatch, or a body the suite rejects (a tampered AEAD tag).
182pub fn open_with(suite: &dyn Suite, bytes: &[u8]) -> Option<Vec<u8>> {
183    if bytes.len() < CHAN_HEADER_LEN || bytes[0] != CHAN_MAGIC || bytes[1] != CHAN_VER {
184        return None;
185    }
186    if u16::from_le_bytes([bytes[2], bytes[3]]) != suite.id() {
187        return None;
188    }
189    suite.open_body(&bytes[CHAN_HEADER_LEN..])
190}
191
192/// Frame an opaque blob under `suite_id`, dispatching the body through the registered
193/// [`Suite`]. Panics only if sealed with an unregistered suite — a programmer error, since
194/// callers select from the registry.
195pub fn seal(suite_id: u16, blob: &[u8]) -> Vec<u8> {
196    let suite = suite_for(suite_id).expect("seal with a registered suite");
197    let body = suite.seal_body(blob);
198    let mut out = Vec::with_capacity(CHAN_HEADER_LEN + body.len());
199    out.push(CHAN_MAGIC);
200    out.push(CHAN_VER);
201    out.extend_from_slice(&suite_id.to_le_bytes());
202    out.extend_from_slice(&body);
203    out
204}
205
206/// Reverse [`seal`], returning the inner blob. `None` on a too-short header, a bad
207/// magic/version, an unknown suite id, or a body the suite rejects — never a panic, never
208/// a partial decode (mirrors [`super::marshal::message_from_wire`]).
209pub fn open(bytes: &[u8]) -> Option<Vec<u8>> {
210    if bytes.len() < CHAN_HEADER_LEN || bytes[0] != CHAN_MAGIC || bytes[1] != CHAN_VER {
211        return None;
212    }
213    let suite_id = u16::from_le_bytes([bytes[2], bytes[3]]);
214    let suite = suite_for(suite_id)?;
215    suite.open_body(&bytes[CHAN_HEADER_LEN..])
216}
217
218thread_local! {
219    /// The suite sealing outbound / opening inbound frames on this thread. `None` (the
220    /// default) means the channel is disengaged: [`seal_active`] / [`open_active`] are pure
221    /// pass-throughs, so a program that never activates a suite is byte-identical on the wire.
222    static ACTIVE_SUITE: std::cell::Cell<Option<u16>> = const { std::cell::Cell::new(None) };
223}
224
225/// The suite active for seal/open on this thread, if any.
226pub fn active_suite() -> Option<u16> {
227    ACTIVE_SUITE.with(|s| s.get())
228}
229
230/// Run `f` with `suite` active for seal/open on this thread, restoring the prior suite after
231/// (mirrors `marshal`'s scoped wire-knob setters).
232pub fn with_suite<T>(suite: Option<u16>, f: impl FnOnce() -> T) -> T {
233    let prev = ACTIVE_SUITE.with(|s| s.replace(suite));
234    let out = f();
235    ACTIVE_SUITE.with(|s| s.set(prev));
236    out
237}
238
239/// A keyed, stateful crypto session installed for the live send/receive path. Unlike the stateless
240/// [`Suite`] registry, a session carries per-direction key/pad material and may **fail closed** on
241/// seal — a one-time pad can run out. Both the keyed [`PqSuite`] and the [`super::pnp`] one-time pad
242/// plug in here, so [`seal_active_checked`] / [`open_active`] stay suite-agnostic.
243pub trait ActiveSession {
244    /// Seal outbound bytes, or `None` to fail closed — the caller must then refuse to send, never
245    /// transmit the plaintext instead.
246    fn seal(&self, bytes: &[u8]) -> Option<Vec<u8>>;
247    /// Open inbound bytes, or `None` on a tampered / foreign / replayed frame.
248    fn open(&self, bytes: &[u8]) -> Option<Vec<u8>>;
249}
250
251thread_local! {
252    /// The keyed session sealing outbound / opening inbound frames on this thread, if installed. It
253    /// takes precedence over [`ACTIVE_SUITE`]; `None` (the default) leaves the stateless path — and
254    /// so the wire — unchanged for programs that never engage a session.
255    static ACTIVE_SESSION: std::cell::RefCell<Option<std::rc::Rc<dyn ActiveSession>>> =
256        const { std::cell::RefCell::new(None) };
257}
258
259/// The keyed session active for seal/open on this thread, if any.
260pub fn active_session() -> Option<std::rc::Rc<dyn ActiveSession>> {
261    ACTIVE_SESSION.with(|s| s.borrow().clone())
262}
263
264/// Install `session` as the active keyed session on this thread, returning the prior one. The live
265/// [`seal_active_checked`] / [`open_active`] path then routes through it until it is replaced/cleared.
266pub fn install_session(
267    session: Option<std::rc::Rc<dyn ActiveSession>>,
268) -> Option<std::rc::Rc<dyn ActiveSession>> {
269    ACTIVE_SESSION.with(|s| std::mem::replace(&mut *s.borrow_mut(), session))
270}
271
272/// Run `f` with `session` active for seal/open on this thread, restoring the prior session after
273/// (mirrors [`with_suite`]).
274pub fn with_session<T>(session: Option<std::rc::Rc<dyn ActiveSession>>, f: impl FnOnce() -> T) -> T {
275    let prev = install_session(session);
276    let out = f();
277    install_session(prev);
278    out
279}
280
281/// Seal `bytes` under the active suite; with no active suite, return them unchanged so the
282/// wire stays byte-identical for non-secure programs. (Stateless path only — the keyed, possibly
283/// fail-closing session path is [`seal_active_checked`].)
284pub fn seal_active(bytes: Vec<u8>) -> Vec<u8> {
285    match active_suite() {
286        Some(id) => seal(id, &bytes),
287        None => bytes,
288    }
289}
290
291/// Seal `bytes` for the live send path: through the active keyed [`ActiveSession`] if installed
292/// (which may **fail closed**, returning `None` — e.g. a one-time pad is exhausted), else through the
293/// active stateless suite, else pass-through. A `None` result is the fail-closed signal the caller
294/// MUST surface as a send error — never transmit the plaintext instead.
295pub fn seal_active_checked(bytes: Vec<u8>) -> Option<Vec<u8>> {
296    match active_session() {
297        Some(session) => session.seal(&bytes),
298        None => Some(seal_active(bytes)),
299    }
300}
301
302/// Open an inbound frame under the active keyed session if installed, else the active suite;
303/// `None` on a tampered/foreign frame (the caller drops it). With neither engaged, pass `bytes`
304/// through so the wire stays byte-identical for non-secure programs.
305pub fn open_active(bytes: Vec<u8>) -> Option<Vec<u8>> {
306    if let Some(session) = active_session() {
307        return session.open(&bytes);
308    }
309    match active_suite() {
310        Some(_) => open(&bytes),
311        None => Some(bytes),
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use super::super::marshal::{message_to_wire_with, WireCodec, WireIntegrity};
319    use crate::interpreter::RuntimeValue;
320
321    #[test]
322    fn null_envelope_round_trips_and_rejects_malformed() {
323        let blob = message_to_wire_with("alice", &RuntimeValue::Int(7), WireCodec::Native, WireIntegrity::Checked)
324            .expect("encode");
325
326        let sealed = seal(SUITE_NULL, &blob);
327
328        // Round-trip: the null envelope returns the exact blob.
329        assert_eq!(open(&sealed).as_deref(), Some(blob.as_slice()), "null suite round-trip");
330
331        // Truncated header → None.
332        assert!(open(&sealed[..2]).is_none(), "truncated envelope rejected");
333
334        // Unknown suite id (0xFFFF) → None.
335        let mut bad = sealed.clone();
336        bad[2] = 0xFF;
337        bad[3] = 0xFF;
338        assert!(open(&bad).is_none(), "unknown suite rejected");
339    }
340
341    /// A small deterministic PRNG so the fuzz lock is reproducible (house style).
342    fn splitmix64(state: &mut u64) -> u64 {
343        *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
344        let mut z = *state;
345        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
346        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
347        z ^ (z >> 31)
348    }
349
350    #[test]
351    fn null_round_trips_arbitrary_blobs() {
352        let mut s = 0x1234_5678_9ABC_DEF0u64;
353        for _ in 0..2000 {
354            let len = (splitmix64(&mut s) % 257) as usize; // 0..=256, incl. empty
355            let blob: Vec<u8> = (0..len).map(|_| splitmix64(&mut s) as u8).collect();
356            let sealed = seal(SUITE_NULL, &blob);
357            assert_eq!(open(&sealed), Some(blob), "null suite round-trip, len {len}");
358        }
359    }
360
361    #[test]
362    fn rejects_bad_magic_version_and_empty() {
363        let sealed = seal(SUITE_NULL, b"payload");
364        assert!(open(&[]).is_none(), "empty input rejected");
365        assert!(open(&sealed[..CHAN_HEADER_LEN - 1]).is_none(), "header-short rejected");
366
367        let mut bad_magic = sealed.clone();
368        bad_magic[0] ^= 0xFF;
369        assert!(open(&bad_magic).is_none(), "bad magic rejected");
370
371        let mut bad_ver = sealed.clone();
372        bad_ver[1] = bad_ver[1].wrapping_add(1);
373        assert!(open(&bad_ver).is_none(), "bad version rejected");
374    }
375
376    #[test]
377    fn seal_is_framed_not_raw() {
378        let blob = b"the relay sees only the envelope";
379        let sealed = seal(SUITE_NULL, blob);
380        assert_eq!(&sealed[..2], &[CHAN_MAGIC, CHAN_VER], "header magic+version present");
381        assert_eq!(&sealed[2..4], &SUITE_NULL.to_le_bytes(), "suite id bound little-endian");
382        assert_eq!(sealed.len(), CHAN_HEADER_LEN + blob.len(), "null body length-exact");
383        assert_ne!(sealed.as_slice(), blob.as_slice(), "sealed bytes differ from the raw blob");
384    }
385
386    #[test]
387    fn null_suite_trait_dispatch() {
388        let suite = suite_for(SUITE_NULL).expect("null suite registered");
389        assert_eq!(suite.id(), SUITE_NULL);
390        let body = suite.seal_body(b"abc");
391        assert_eq!(suite.open_body(&body).as_deref(), Some(&b"abc"[..]), "identity body");
392        assert!(suite_for(0xFFFF).is_none(), "unknown suite id is not registered");
393    }
394
395    #[test]
396    fn no_active_suite_is_passthrough() {
397        assert_eq!(active_suite(), None, "default: channel disengaged");
398        let blob = b"plain".to_vec();
399        assert_eq!(seal_active(blob.clone()), blob, "seal is identity when off");
400        assert_eq!(open_active(blob.clone()), Some(blob), "open is identity when off");
401    }
402
403    #[test]
404    fn with_suite_scopes_and_restores() {
405        assert_eq!(active_suite(), None);
406        with_suite(Some(SUITE_NULL), || {
407            assert_eq!(active_suite(), Some(SUITE_NULL), "suite active inside scope");
408            let blob = b"secret payload".to_vec();
409            let sealed = seal_active(blob.clone());
410            assert_ne!(sealed, blob, "sealed under an active suite is framed");
411            assert_eq!(open_active(sealed), Some(blob), "round-trip under the active suite");
412        });
413        assert_eq!(active_suite(), None, "prior suite restored after scope");
414    }
415
416    #[test]
417    fn open_active_drops_foreign_frame_under_active_suite() {
418        with_suite(Some(SUITE_NULL), || {
419            assert!(open_active(b"not an envelope".to_vec()).is_none(), "foreign frame dropped");
420        });
421    }
422
423    #[test]
424    fn pq_channel_seals_a_real_wire_message_end_to_end() {
425        // Full post-quantum handshake + AEAD seal over an actual marshalled wire blob.
426        // Initiator generates an ML-KEM-768 keypair and publishes ek; responder encapsulates to it
427        // and returns the ciphertext; both derive the same directional ChaCha20-Poly1305 suites.
428        let hs = pq_handshake_initiator(&[0xA1; 32], &[0xA2; 32]);
429        let (ct, resp_i2r, resp_r2i) = pq_handshake_responder(&hs.ek, &[0xB0; 32]);
430        let (init_i2r, init_r2i) = pq_handshake_finish(&hs, &ct);
431
432        // The actual payload is a real codec-encoded wire blob, exactly what the transport carries.
433        let blob = message_to_wire_with(
434            "responder",
435            &RuntimeValue::Int(42),
436            WireCodec::Native,
437            WireIntegrity::Checked,
438        )
439        .expect("encode");
440
441        // Responder→initiator: responder seals, initiator opens. End-to-end post-quantum secrecy.
442        let sealed = seal_with(&resp_r2i, &blob);
443        assert_eq!(&sealed[..2], &[CHAN_MAGIC, CHAN_VER], "channel header present");
444        assert_eq!(&sealed[2..4], &SUITE_PQ.to_le_bytes(), "PQ suite id bound in the envelope");
445        assert_ne!(sealed[CHAN_HEADER_LEN..].to_vec(), blob, "body is ciphertext, not the raw blob");
446        assert_eq!(
447            open_with(&init_r2i, &sealed).as_deref(),
448            Some(blob.as_slice()),
449            "initiator opens the responder's PQ-sealed wire message"
450        );
451
452        // Initiator→responder direction works independently (distinct key).
453        let s2 = seal_with(&init_i2r, b"ack");
454        assert_eq!(open_with(&resp_i2r, &s2).as_deref(), Some(&b"ack"[..]), "i2r direction opens");
455
456        // The counter nonce makes every seal unique, and each still opens.
457        let sealed_again = seal_with(&resp_r2i, &blob);
458        assert_ne!(sealed, sealed_again, "fresh counter nonce ⇒ a distinct frame each time");
459        assert_eq!(open_with(&init_r2i, &sealed_again).as_deref(), Some(blob.as_slice()));
460
461        // A tampered tag is rejected; a foreign session key (failed handshake) cannot open.
462        let mut tampered = sealed.clone();
463        let last = tampered.len() - 1;
464        tampered[last] ^= 1;
465        assert!(open_with(&init_r2i, &tampered).is_none(), "tampered PQ frame rejected");
466        let eve = PqSuite::new(derive_aead_key(&[0u8; 32], b"r2i"));
467        assert!(open_with(&eve, &sealed).is_none(), "wrong session key cannot open");
468    }
469
470    #[test]
471    fn pq_handshake_disagrees_on_a_corrupted_ciphertext() {
472        // If the KEM ciphertext is corrupted in flight, ML-KEM's implicit reject makes the two
473        // sides derive DIFFERENT secrets, so the initiator cannot open the responder's frames.
474        let hs = pq_handshake_initiator(&[1; 32], &[2; 32]);
475        let (ct, _resp_i2r, resp_r2i) = pq_handshake_responder(&hs.ek, &[3; 32]);
476        let mut bad_ct = ct.clone();
477        bad_ct[0] ^= 1;
478        let (_init_i2r, init_r2i) = pq_handshake_finish(&hs, &bad_ct);
479        let sealed = seal_with(&resp_r2i, b"top secret");
480        assert!(
481            open_with(&init_r2i, &sealed).is_none(),
482            "a corrupted handshake yields divergent keys ⇒ frames don't open"
483        );
484    }
485}