Skip to main content

xenia_wire/
session.rs

1// Copyright (c) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Session state: keys, nonce construction, replay protection.
5//!
6//! A [`Session`] holds the minimum state required to seal and open AEAD
7//! envelopes for a single logical stream:
8//!
9//! - the current 32-byte ChaCha20-Poly1305 key,
10//! - an optional previous key with a grace period so in-flight envelopes
11//!   sealed under the old key still verify after rekey,
12//! - a per-session random 8-byte `source_id` and 1-byte `epoch` that
13//!   domain-separate this session's nonces from any other session sharing
14//!   (by accident or compromise) the same key material,
15//! - a monotonic 64-bit `nonce_counter` that advances on every seal,
16//! - a [`crate::ReplayWindow`] that rejects duplicates and too-old sequences
17//!   on the receive path.
18//!
19//! Not in this crate's scope:
20//!
21//! - **Handshake**: session keys arrive from an outer layer (ML-KEM-768 in
22//!   production deployments; a shared fixture in tests).
23//! - **Transport**: sealed envelope bytes are handed to the caller; the
24//!   caller ships them over TCP / WebSocket / QUIC / UDP / IPFS / bytes
25//!   on a napkin.
26//! - **Session lifecycle**: connecting, authenticating, closing — all
27//!   application concerns. `Session` has no state machine; `install_key`
28//!   is idempotent and `seal` / `open` simply fail when no key is present.
29
30use std::time::Duration;
31
32// `std::time::Instant` panics on `wasm32-unknown-unknown` because there is no
33// default time source. `web-time` provides a drop-in replacement that delegates
34// to `performance.now()` via wasm-bindgen. On native targets it re-exports
35// `std::time::Instant`, so the API surface is identical.
36#[cfg(not(target_arch = "wasm32"))]
37use std::time::Instant;
38#[cfg(target_arch = "wasm32")]
39use web_time::Instant;
40
41use zeroize::Zeroizing;
42
43use crate::replay_window::ReplayWindow;
44use crate::WireError;
45
46#[cfg(feature = "consent")]
47use crate::consent::ConsentEvent;
48
49/// Default grace period for the previous session key after a rekey.
50///
51/// In-flight envelopes sealed under the old key continue to verify for
52/// this window after the new key is installed, then fall back to failing
53/// [`Session::open`]. 5 seconds accommodates a ~30 fps frame stream with
54/// generous RTT headroom; callers with slower streams should widen via
55/// [`Session::with_rekey_grace`].
56pub const DEFAULT_REKEY_GRACE: Duration = Duration::from_secs(5);
57
58/// Session state for a single logical stream.
59///
60/// See the module-level docs for what `Session` owns and what it
61/// deliberately does not. Use [`Session::new`] to construct with random
62/// `source_id` + `epoch`, then [`Session::install_key`] before the first
63/// seal or open.
64pub struct Session {
65    /// Current session key (wrapped in `Zeroizing` so drop wipes it).
66    session_key: Option<Zeroizing<[u8; 32]>>,
67    /// Previous session key, still valid during the rekey grace period.
68    prev_session_key: Option<Zeroizing<[u8; 32]>>,
69    /// When the current key was installed (for observability + rotation
70    /// policy decisions by higher layers).
71    key_established_at: Option<Instant>,
72    /// When the previous key stops being accepted for opens.
73    prev_key_expires_at: Option<Instant>,
74    /// Monotonic AEAD nonce counter.
75    nonce_counter: u64,
76    /// Per-session random 8-byte source identifier. 6 bytes land in the
77    /// nonce; the remaining 2 are available for higher-layer routing if
78    /// needed (not currently used by the wire).
79    source_id: [u8; 8],
80    /// Per-session random epoch byte. Further domain-separates nonces
81    /// across sessions that happen to share the same key + source_id.
82    epoch: u8,
83    /// Sliding-window replay protection on the open path.
84    replay_window: ReplayWindow,
85    /// How long the previous key remains valid after rekey.
86    rekey_grace: Duration,
87    /// Epoch of the current session key (SPEC draft-02r1 §5.3).
88    /// Increments (wrapping) on each `install_key`. Purely internal;
89    /// not transmitted on the wire. Used to key per-epoch replay-window
90    /// state so a counter reset at rekey doesn't clash with lingering
91    /// high-water marks from the previous key.
92    current_key_epoch: u8,
93    /// Epoch of the previous session key during the rekey grace
94    /// window. `Some` iff `prev_session_key` is `Some`.
95    prev_key_epoch: Option<u8>,
96    /// Consent ceremony state (draft-03 §12). Only enforced when the
97    /// `consent` feature is compiled in.
98    #[cfg(feature = "consent")]
99    consent_state: crate::consent::ConsentState,
100    /// The `request_id` of the active ceremony, if any. Set when a
101    /// `Request` event transitions the session to `Requested`; bumped
102    /// on replacement / new-ceremony-after-terminal-state. `None`
103    /// while state is `LegacyBypass` or `AwaitingRequest`.
104    /// Used by `observe_consent` to distinguish stale responses,
105    /// contradictory responses, and legitimate replacements.
106    #[cfg(feature = "consent")]
107    active_request_id: Option<u64>,
108    /// The last observed `approved` decision for `active_request_id`,
109    /// if any. `Some(true)` in `Approved`; `Some(false)` in `Denied`;
110    /// `None` otherwise. Enables detection of contradictory responses
111    /// (SPEC draft-03 §12.6 / `ConsentViolation::ContradictoryResponse`).
112    #[cfg(feature = "consent")]
113    last_response_approved: Option<bool>,
114}
115
116impl Session {
117    /// Construct a session with random `source_id` + `epoch` and no key yet.
118    ///
119    /// Call [`Self::install_key`] before the first seal or open.
120    pub fn new() -> Self {
121        Self::with_source_id(rand::random(), rand::random())
122    }
123
124    /// Construct a session with caller-supplied `source_id` + `epoch`.
125    ///
126    /// Primarily useful for test fixtures and deterministic replay. The
127    /// caller MUST ensure no two live sessions share the same
128    /// `(source_id, epoch, key)` tuple — nonce reuse under ChaCha20-Poly1305
129    /// catastrophically breaks confidentiality.
130    pub fn with_source_id(source_id: [u8; 8], epoch: u8) -> Self {
131        Self {
132            session_key: None,
133            prev_session_key: None,
134            key_established_at: None,
135            prev_key_expires_at: None,
136            nonce_counter: 0,
137            source_id,
138            epoch,
139            replay_window: ReplayWindow::new(),
140            rekey_grace: DEFAULT_REKEY_GRACE,
141            current_key_epoch: 0,
142            prev_key_epoch: None,
143            #[cfg(feature = "consent")]
144            consent_state: crate::consent::ConsentState::LegacyBypass,
145            #[cfg(feature = "consent")]
146            active_request_id: None,
147            #[cfg(feature = "consent")]
148            last_response_approved: None,
149        }
150    }
151
152    /// Override the default rekey grace period. Must be called before the
153    /// first rekey.
154    pub fn with_rekey_grace(mut self, grace: Duration) -> Self {
155        self.rekey_grace = grace;
156        self
157    }
158
159    /// Install a 32-byte session key.
160    ///
161    /// First call installs the initial key. Subsequent calls perform a
162    /// rekey: the previous key is moved to `prev_session_key` with a
163    /// grace-period expiry of the configured `rekey_grace`; the new key becomes
164    /// current; the nonce counter resets to zero.
165    ///
166    /// The replay window is NOT cleared on rekey, but it IS scoped by
167    /// key epoch (SPEC §5.3 / draft-02r1) — the incoming new-key
168    /// stream starts a fresh per-epoch window rather than fighting
169    /// the old key's high-water mark. When the previous key expires
170    /// in [`Self::tick`], its per-epoch replay state is dropped.
171    pub fn install_key(&mut self, key: [u8; 32]) {
172        if self.session_key.is_some() {
173            self.prev_session_key = self.session_key.take();
174            self.prev_key_expires_at = Some(Instant::now() + self.rekey_grace);
175            self.prev_key_epoch = Some(self.current_key_epoch);
176            self.current_key_epoch = self.current_key_epoch.wrapping_add(1);
177        }
178        self.session_key = Some(Zeroizing::new(key));
179        self.key_established_at = Some(Instant::now());
180        self.nonce_counter = 0;
181    }
182
183    /// Return `true` if the session has a current key.
184    pub fn has_key(&self) -> bool {
185        self.session_key.is_some()
186    }
187
188    /// Advance session state that depends on wall-clock time.
189    ///
190    /// Call periodically (e.g. once per tick, or lazily before seal/open)
191    /// to expire the previous key once the grace period has elapsed.
192    pub fn tick(&mut self) {
193        if let Some(expires) = self.prev_key_expires_at {
194            if Instant::now() > expires {
195                self.prev_session_key = None;
196                self.prev_key_expires_at = None;
197                // Drop replay state for the old epoch — its envelopes
198                // can no longer AEAD-verify, so the window is pure
199                // memory overhead now.
200                if let Some(old_epoch) = self.prev_key_epoch.take() {
201                    self.replay_window.drop_epoch(old_epoch);
202                }
203            }
204        }
205    }
206
207    /// Allocate the next AEAD nonce sequence number.
208    ///
209    /// Uses `wrapping_add` to avoid a debug-build panic on overflow; the
210    /// low 32 bits embedded in the nonce wrap in ~4.5 years of continuous
211    /// operation at 30 fps. Real session lifetime is governed by rekey
212    /// cadence, so wraparound is not a practical concern.
213    pub fn next_nonce(&mut self) -> u64 {
214        let n = self.nonce_counter;
215        self.nonce_counter = self.nonce_counter.wrapping_add(1);
216        n
217    }
218
219    /// Current AEAD nonce counter, for observability.
220    pub fn nonce_counter(&self) -> u64 {
221        self.nonce_counter
222    }
223
224    /// Per-session source identifier.
225    pub fn source_id(&self) -> &[u8; 8] {
226        &self.source_id
227    }
228
229    /// Per-session epoch byte.
230    pub fn epoch(&self) -> u8 {
231        self.epoch
232    }
233
234    /// Time the current key was installed, if any.
235    pub fn key_established_at(&self) -> Option<Instant> {
236        self.key_established_at
237    }
238
239    /// Current consent ceremony state (SPEC draft-03 §12).
240    ///
241    /// Only available when the `consent` feature is enabled.
242    #[cfg(feature = "consent")]
243    pub fn consent_state(&self) -> crate::consent::ConsentState {
244        self.consent_state
245    }
246
247    /// Derive the 32-byte session fingerprint for a given `request_id`
248    /// (SPEC draft-03 §12.3.1).
249    ///
250    /// The fingerprint is HKDF-SHA-256 over the current session key:
251    ///
252    /// ```text
253    /// salt   = b"xenia-session-fingerprint-v1"
254    /// ikm    = current session_key   (32 bytes)
255    /// info   = source_id || epoch || request_id.to_be_bytes()
256    ///          (8 + 1 + 8 = 17 bytes)
257    /// output = 32 bytes
258    /// ```
259    ///
260    /// Both peers derive the same fingerprint from their own copy of
261    /// the session key. Each peer embeds the derived fingerprint in
262    /// every signed consent message body; receivers re-derive locally
263    /// and compare. A signed consent message whose fingerprint does
264    /// not match the receiver's derivation has been replayed from a
265    /// different session and MUST be rejected.
266    ///
267    /// Returns [`WireError::NoSessionKey`] if no key is installed.
268    /// Callers SHOULD derive the fingerprint immediately before
269    /// signing / verifying and not cache it across rekeys — the
270    /// fingerprint changes with the session key.
271    ///
272    /// On the verify side, prefer the convenience helpers
273    /// [`Self::verify_consent_request`] / `_response` / `_revocation`
274    /// rather than calling this directly, because those helpers
275    /// transparently probe the previous key during the rekey grace
276    /// window (a consent message sealed moments before rekey can
277    /// legitimately carry a prev-key fingerprint; see §12.3.1 rekey
278    /// interaction).
279    #[cfg(feature = "consent")]
280    pub fn session_fingerprint(&self, request_id: u64) -> Result<[u8; 32], WireError> {
281        let key = self.session_key.as_ref().ok_or(WireError::NoSessionKey)?;
282        Ok(self.session_fingerprint_from_key(request_id, key))
283    }
284
285    /// Internal fingerprint derivation against an arbitrary 32-byte
286    /// AEAD key. Private because the public surface only exposes
287    /// "derive against the current key"; verify-path probing of the
288    /// previous key uses this helper internally.
289    ///
290    /// See [`Self::session_fingerprint`] for the derivation spec.
291    #[cfg(feature = "consent")]
292    fn session_fingerprint_from_key(&self, request_id: u64, key: &[u8; 32]) -> [u8; 32] {
293        use hkdf::Hkdf;
294        use sha2::Sha256;
295
296        let mut info = [0u8; 8 + 1 + 8];
297        info[..8].copy_from_slice(&self.source_id);
298        info[8] = self.epoch;
299        info[9..17].copy_from_slice(&request_id.to_be_bytes());
300
301        let hk = Hkdf::<Sha256>::new(Some(b"xenia-session-fingerprint-v1"), key);
302        let mut out = [0u8; 32];
303        hk.expand(&info, &mut out)
304            .expect("HKDF-SHA-256 expand of 32 bytes cannot fail");
305        out
306    }
307
308    /// Probe the current and (if present) previous session keys for
309    /// a fingerprint match against `claimed` (SPEC draft-03 §12.3.1
310    /// rekey interaction).
311    ///
312    /// When the previous session key is present (i.e. we are within
313    /// the rekey grace window), this function derives fingerprints
314    /// from BOTH keys unconditionally and combines the constant-
315    /// time compares with a non-short-circuiting bitwise OR (`|`
316    /// on `bool`, not `||`). This removes the timing distinguisher
317    /// that a naive "try current first, fall back to prev on
318    /// mismatch" implementation would leak — a remote observer of
319    /// verify-path latency otherwise learns which key-epoch the
320    /// counterparty signed the consent under, which is sensitive
321    /// metadata about session state near rekey.
322    ///
323    /// The extra HKDF-SHA-256 call is cheap (~microseconds on
324    /// commodity hardware) and only incurred while `prev_session_key`
325    /// is Some — i.e. during the grace window. Outside the grace
326    /// window there is only one key and only one derivation.
327    ///
328    /// Returns `true` iff either derivation matches. Returns `false`
329    /// if no key is installed.
330    #[cfg(feature = "consent")]
331    fn verify_fingerprint_either_epoch(
332        &self,
333        request_id: u64,
334        claimed: &[u8; 32],
335    ) -> bool {
336        let current_match = match self.session_key.as_ref() {
337            Some(key) => {
338                let fp = self.session_fingerprint_from_key(request_id, key);
339                ct_eq_32(&fp, claimed)
340            }
341            None => false,
342        };
343        let prev_match = match self.prev_session_key.as_ref() {
344            Some(prev) => {
345                let fp = self.session_fingerprint_from_key(request_id, prev);
346                ct_eq_32(&fp, claimed)
347            }
348            None => false,
349        };
350        // Bitwise OR on `bool` — NOT short-circuiting `||`. The `|`
351        // variant forces both operands to be evaluated and combined
352        // without control-flow branches on the intermediate values.
353        current_match | prev_match
354    }
355
356    /// Sign a [`ConsentRequestCore`] after injecting the session
357    /// fingerprint derived from this session's state and the core's
358    /// `request_id` (SPEC draft-03 §12.3 / §12.3.1).
359    ///
360    /// The caller constructs a `ConsentRequestCore` with any
361    /// `session_fingerprint` value (the helper overwrites it). On the
362    /// send path this is the recommended entry point; it removes the
363    /// possibility of a caller forgetting to derive-and-inject.
364    #[cfg(feature = "consent")]
365    pub fn sign_consent_request(
366        &self,
367        mut core: crate::consent::ConsentRequestCore,
368        signing_key: &ed25519_dalek::SigningKey,
369    ) -> Result<crate::consent::ConsentRequest, WireError> {
370        core.session_fingerprint = self.session_fingerprint(core.request_id)?;
371        Ok(crate::consent::ConsentRequest::sign(core, signing_key))
372    }
373
374    /// Sign a [`ConsentResponseCore`] after injecting the session
375    /// fingerprint for the core's `request_id`. See
376    /// [`Self::sign_consent_request`].
377    #[cfg(feature = "consent")]
378    pub fn sign_consent_response(
379        &self,
380        mut core: crate::consent::ConsentResponseCore,
381        signing_key: &ed25519_dalek::SigningKey,
382    ) -> Result<crate::consent::ConsentResponse, WireError> {
383        core.session_fingerprint = self.session_fingerprint(core.request_id)?;
384        Ok(crate::consent::ConsentResponse::sign(core, signing_key))
385    }
386
387    /// Sign a [`ConsentRevocationCore`] after injecting the session
388    /// fingerprint for the core's `request_id`. See
389    /// [`Self::sign_consent_request`].
390    #[cfg(feature = "consent")]
391    pub fn sign_consent_revocation(
392        &self,
393        mut core: crate::consent::ConsentRevocationCore,
394        signing_key: &ed25519_dalek::SigningKey,
395    ) -> Result<crate::consent::ConsentRevocation, WireError> {
396        core.session_fingerprint = self.session_fingerprint(core.request_id)?;
397        Ok(crate::consent::ConsentRevocation::sign(core, signing_key))
398    }
399
400    /// Verify a [`ConsentRequest`] against this session's fingerprint
401    /// AND the requester's public key (SPEC draft-03 §12.3.1).
402    ///
403    /// Returns `true` iff:
404    ///
405    /// 1. The Ed25519 signature is valid,
406    /// 2. The embedded public key matches `expected_pubkey` (if provided),
407    ///    and
408    /// 3. The embedded `session_fingerprint` matches the fingerprint
409    ///    this session derives locally under **either** the current
410    ///    session key OR the previous session key (during the rekey
411    ///    grace window). Probing both covers the in-flight case where
412    ///    the sender signed under the previous key moments before a
413    ///    rekey and the message arrived after the receiver rekeyed.
414    ///
415    /// Returns `false` (never a `WireError`) on any mismatch — per SPEC
416    /// §11 the caller should react to verification failure the same way
417    /// for all sub-cases.
418    #[cfg(feature = "consent")]
419    pub fn verify_consent_request(
420        &self,
421        req: &crate::consent::ConsentRequest,
422        expected_pubkey: Option<&[u8; 32]>,
423    ) -> bool {
424        if !req.verify(expected_pubkey) {
425            return false;
426        }
427        self.verify_fingerprint_either_epoch(req.core.request_id, &req.core.session_fingerprint)
428    }
429
430    /// Verify a [`ConsentResponse`] against this session's fingerprint
431    /// AND the responder's public key. See
432    /// [`Self::verify_consent_request`] — same both-epochs probing
433    /// behavior for the rekey grace window.
434    #[cfg(feature = "consent")]
435    pub fn verify_consent_response(
436        &self,
437        resp: &crate::consent::ConsentResponse,
438        expected_pubkey: Option<&[u8; 32]>,
439    ) -> bool {
440        if !resp.verify(expected_pubkey) {
441            return false;
442        }
443        self.verify_fingerprint_either_epoch(
444            resp.core.request_id,
445            &resp.core.session_fingerprint,
446        )
447    }
448
449    /// Verify a [`ConsentRevocation`] against this session's fingerprint
450    /// AND the revoker's public key. See
451    /// [`Self::verify_consent_request`] — same both-epochs probing
452    /// behavior for the rekey grace window.
453    #[cfg(feature = "consent")]
454    pub fn verify_consent_revocation(
455        &self,
456        rev: &crate::consent::ConsentRevocation,
457        expected_pubkey: Option<&[u8; 32]>,
458    ) -> bool {
459        if !rev.verify(expected_pubkey) {
460            return false;
461        }
462        self.verify_fingerprint_either_epoch(rev.core.request_id, &rev.core.session_fingerprint)
463    }
464
465    /// Drive the consent state machine from an observed consent message.
466    ///
467    /// Callers invoke this AFTER successfully opening a consent envelope
468    /// (`PAYLOAD_TYPE_CONSENT_REQUEST` / `_RESPONSE` / `_REVOCATION`)
469    /// and verifying the signature AND the session fingerprint. The
470    /// session does not validate signatures or fingerprints itself —
471    /// that's an application policy decision (which pubkeys to trust,
472    /// which expiry windows to accept). Use
473    /// [`Self::verify_consent_request`] (and siblings) for the
474    /// standard verification path.
475    ///
476    /// # Transition table (SPEC draft-03 §12.6)
477    ///
478    /// `LegacyBypass` is **sticky** — every event is a no-op, state
479    /// stays `LegacyBypass`. The caller opts into ceremony mode at
480    /// construction via [`SessionBuilder::require_consent`]; a session
481    /// in LegacyBypass never emits or honors consent events.
482    ///
483    /// For the remaining states, `id` refers to `event.request_id()`
484    /// and `active` refers to the session's `active_request_id`.
485    ///
486    /// | Current          | Event                    | Next state / action                                    |
487    /// |------------------|--------------------------|--------------------------------------------------------|
488    /// | `AwaitingRequest`| `Request{id}`            | → `Requested`, `active_id = id`                        |
489    /// | `AwaitingRequest`| `Response{*, id}`        | → **`StaleResponseForUnknownRequest`**                 |
490    /// | `AwaitingRequest`| `Revocation{id}`         | → **`RevocationBeforeApproval`**                       |
491    /// | `Requested`      | `Request{id}`, id > active | → `Requested`, `active_id = id` (replacement)        |
492    /// | `Requested`      | `Request{id}`, id ≤ active | no-op (stale)                                        |
493    /// | `Requested`      | `ResponseApproved{id==active}` | → `Approved`, record `last_response=true`         |
494    /// | `Requested`      | `ResponseDenied{id==active}`   | → `Denied`, record `last_response=false`          |
495    /// | `Requested`      | `Response{id≠active}`    | → **`StaleResponseForUnknownRequest`**                 |
496    /// | `Requested`      | `Revocation{id}`         | → **`RevocationBeforeApproval`**                       |
497    /// | `Approved`       | `Request{id}`, id > active | → `Requested`, reset tracking (new ceremony)         |
498    /// | `Approved`       | `ResponseApproved{id==active}` | no-op (idempotent)                                |
499    /// | `Approved`       | `ResponseDenied{id==active}` | → **`ContradictoryResponse{prior=true, new=false}`** |
500    /// | `Approved`       | `Response{id≠active}`    | → **`StaleResponseForUnknownRequest`**                 |
501    /// | `Approved`       | `Revocation{id==active}` | → `Revoked`                                            |
502    /// | `Approved`       | `Revocation{id≠active}`  | no-op (stale revocation)                               |
503    /// | `Denied`         | `Request{id}`, id > active | → `Requested`, reset tracking (new ceremony)         |
504    /// | `Denied`         | `ResponseDenied{id==active}` | no-op (idempotent)                                 |
505    /// | `Denied`         | `ResponseApproved{id==active}` | → **`ContradictoryResponse{prior=false, new=true}`** |
506    /// | `Denied`         | `Revocation{id}`         | no-op (nothing to revoke)                              |
507    /// | `Revoked`        | `Request{id}`, id > active | → `Requested`, reset tracking (fresh ceremony)       |
508    /// | `Revoked`        | *                        | no-op                                                  |
509    ///
510    /// Bold entries return `Err(ConsentViolation)`; the state is NOT
511    /// mutated on violation (the caller is expected to tear down).
512    ///
513    /// # Returns
514    ///
515    /// - `Ok(state)` on any legal transition or benign no-op.
516    /// - `Err(ConsentViolation)` when the peer emitted an event that
517    ///   cannot follow the current state. The session state is left
518    ///   untouched. The caller SHOULD terminate the session.
519    #[cfg(feature = "consent")]
520    pub fn observe_consent(
521        &mut self,
522        event: ConsentEvent,
523    ) -> Result<crate::consent::ConsentState, crate::consent::ConsentViolation> {
524        use crate::consent::{ConsentState, ConsentViolation};
525
526        // LegacyBypass is sticky — all events are no-ops.
527        if self.consent_state == ConsentState::LegacyBypass {
528            return Ok(self.consent_state);
529        }
530
531        let event_id = event.request_id();
532
533        match (self.consent_state, event) {
534            // ─── AwaitingRequest ───────────────────────────────────
535            (ConsentState::AwaitingRequest, ConsentEvent::Request { request_id }) => {
536                self.consent_state = ConsentState::Requested;
537                self.active_request_id = Some(request_id);
538                self.last_response_approved = None;
539            }
540            (ConsentState::AwaitingRequest, ConsentEvent::ResponseApproved { .. })
541            | (ConsentState::AwaitingRequest, ConsentEvent::ResponseDenied { .. }) => {
542                return Err(ConsentViolation::StaleResponseForUnknownRequest {
543                    request_id: event_id,
544                });
545            }
546            (ConsentState::AwaitingRequest, ConsentEvent::Revocation { .. }) => {
547                return Err(ConsentViolation::RevocationBeforeApproval {
548                    request_id: event_id,
549                });
550            }
551
552            // ─── Requested ─────────────────────────────────────────
553            (ConsentState::Requested, ConsentEvent::Request { request_id }) => {
554                match self.active_request_id {
555                    Some(active) if request_id > active => {
556                        self.active_request_id = Some(request_id);
557                        self.last_response_approved = None;
558                    }
559                    _ => { /* stale / equal — drop */ }
560                }
561            }
562            (ConsentState::Requested, ConsentEvent::ResponseApproved { request_id }) => {
563                if self.active_request_id != Some(request_id) {
564                    return Err(ConsentViolation::StaleResponseForUnknownRequest {
565                        request_id,
566                    });
567                }
568                self.consent_state = ConsentState::Approved;
569                self.last_response_approved = Some(true);
570            }
571            (ConsentState::Requested, ConsentEvent::ResponseDenied { request_id }) => {
572                if self.active_request_id != Some(request_id) {
573                    return Err(ConsentViolation::StaleResponseForUnknownRequest {
574                        request_id,
575                    });
576                }
577                self.consent_state = ConsentState::Denied;
578                self.last_response_approved = Some(false);
579            }
580            (ConsentState::Requested, ConsentEvent::Revocation { .. }) => {
581                return Err(ConsentViolation::RevocationBeforeApproval {
582                    request_id: event_id,
583                });
584            }
585
586            // ─── Approved ──────────────────────────────────────────
587            (ConsentState::Approved, ConsentEvent::Request { request_id }) => {
588                match self.active_request_id {
589                    Some(active) if request_id > active => {
590                        // New ceremony starting after approval.
591                        self.consent_state = ConsentState::Requested;
592                        self.active_request_id = Some(request_id);
593                        self.last_response_approved = None;
594                    }
595                    _ => { /* stale — drop */ }
596                }
597            }
598            (ConsentState::Approved, ConsentEvent::ResponseApproved { request_id }) => {
599                match self.active_request_id {
600                    Some(active) if active == request_id => { /* idempotent */ }
601                    _ => {
602                        return Err(ConsentViolation::StaleResponseForUnknownRequest {
603                            request_id,
604                        });
605                    }
606                }
607            }
608            (ConsentState::Approved, ConsentEvent::ResponseDenied { request_id }) => {
609                if self.active_request_id == Some(request_id) {
610                    return Err(ConsentViolation::ContradictoryResponse {
611                        request_id,
612                        prior_approved: true,
613                        new_approved: false,
614                    });
615                }
616                return Err(ConsentViolation::StaleResponseForUnknownRequest { request_id });
617            }
618            (ConsentState::Approved, ConsentEvent::Revocation { request_id }) => {
619                if self.active_request_id == Some(request_id) {
620                    self.consent_state = ConsentState::Revoked;
621                }
622                // Stale revocation (different request_id) is a no-op.
623            }
624
625            // ─── Denied ────────────────────────────────────────────
626            (ConsentState::Denied, ConsentEvent::Request { request_id }) => {
627                match self.active_request_id {
628                    Some(active) if request_id > active => {
629                        self.consent_state = ConsentState::Requested;
630                        self.active_request_id = Some(request_id);
631                        self.last_response_approved = None;
632                    }
633                    _ => { /* stale — drop */ }
634                }
635            }
636            (ConsentState::Denied, ConsentEvent::ResponseDenied { request_id }) => {
637                match self.active_request_id {
638                    Some(active) if active == request_id => { /* idempotent */ }
639                    _ => {
640                        return Err(ConsentViolation::StaleResponseForUnknownRequest {
641                            request_id,
642                        });
643                    }
644                }
645            }
646            (ConsentState::Denied, ConsentEvent::ResponseApproved { request_id }) => {
647                if self.active_request_id == Some(request_id) {
648                    return Err(ConsentViolation::ContradictoryResponse {
649                        request_id,
650                        prior_approved: false,
651                        new_approved: true,
652                    });
653                }
654                return Err(ConsentViolation::StaleResponseForUnknownRequest { request_id });
655            }
656            (ConsentState::Denied, ConsentEvent::Revocation { .. }) => {
657                // Nothing to revoke; no-op.
658            }
659
660            // ─── Revoked ───────────────────────────────────────────
661            (ConsentState::Revoked, ConsentEvent::Request { request_id }) => {
662                match self.active_request_id {
663                    Some(active) if request_id > active => {
664                        self.consent_state = ConsentState::Requested;
665                        self.active_request_id = Some(request_id);
666                        self.last_response_approved = None;
667                    }
668                    _ => { /* stale — drop */ }
669                }
670            }
671            (ConsentState::Revoked, _) => { /* no-op */ }
672
673            // ─── LegacyBypass handled up top ───────────────────────
674            (ConsentState::LegacyBypass, _) => unreachable!(),
675        }
676
677        Ok(self.consent_state)
678    }
679
680    /// Gate predicate: is the session allowed to seal/open a `FRAME`
681    /// payload right now?
682    ///
683    /// See SPEC §12.7 for the normative rule. Summary:
684    ///
685    /// - `LegacyBypass` (default — consent system not in use):
686    ///   **allowed**. Preserves draft-02 behavior for callers with
687    ///   an out-of-band consent mechanism.
688    /// - `AwaitingRequest` (opt-in via
689    ///   [`SessionBuilder::require_consent`]): **blocked** until a
690    ///   ceremony completes. `NoConsent` error.
691    /// - `Requested` (ceremony in progress, awaiting response):
692    ///   blocked. `NoConsent` error.
693    /// - `Approved`: **allowed**.
694    /// - `Denied`: blocked. `NoConsent` error.
695    /// - `Revoked`: blocked. `ConsentRevoked` error.
696    #[cfg(feature = "consent")]
697    #[inline]
698    fn can_seal_frame(&self) -> Result<(), WireError> {
699        use crate::consent::ConsentState;
700        match self.consent_state {
701            ConsentState::LegacyBypass | ConsentState::Approved => Ok(()),
702            ConsentState::Revoked => Err(WireError::ConsentRevoked),
703            ConsentState::AwaitingRequest
704            | ConsentState::Requested
705            | ConsentState::Denied => Err(WireError::NoConsent),
706        }
707    }
708
709    /// Seal a binary plaintext under the current session key using
710    /// ChaCha20-Poly1305.
711    ///
712    /// Wire format: `[ nonce (12 bytes) | ciphertext | poly1305 tag (16 bytes) ]`.
713    ///
714    /// Nonce layout: `source_id[0..6] | payload_type | epoch | sequence[0..4]`
715    /// — little-endian on the sequence portion.
716    ///
717    /// Returns [`WireError::NoSessionKey`] if no key is installed, or
718    /// [`WireError::SealFailed`] if the underlying AEAD implementation
719    /// rejects the input (should not happen with a valid 32-byte key).
720    pub fn seal(&mut self, plaintext: &[u8], payload_type: u8) -> Result<Vec<u8>, WireError> {
721        use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, KeyInit, Nonce};
722
723        // Consent gate — only when the consent feature is compiled in.
724        // Applies only to the reference application payload types (FRAME,
725        // INPUT, FRAME_LZ4); consent-ceremony payloads (0x20..=0x22) and
726        // application-range payloads (0x30..=0xFF) flow ungated.
727        #[cfg(feature = "consent")]
728        if matches!(
729            payload_type,
730            crate::payload_types::PAYLOAD_TYPE_FRAME
731                | crate::payload_types::PAYLOAD_TYPE_INPUT
732                | crate::payload_types::PAYLOAD_TYPE_FRAME_LZ4
733        ) {
734            self.can_seal_frame()?;
735        }
736
737        let key = self.session_key.as_ref().ok_or(WireError::NoSessionKey)?;
738        let key_bytes: [u8; 32] = **key;
739
740        // The nonce embeds only the low 32 bits of the counter. Once the
741        // counter reaches 2^32, the next seal would wrap to sequence 0
742        // under the same key — catastrophic AEAD failure (nonce reuse
743        // reveals the keystream XOR of the two plaintexts). Refuse rather
744        // than wrap. Caller must rekey via install_key() before sealing
745        // more. See SPEC.md §3.1.
746        if self.nonce_counter >= (1u64 << 32) {
747            return Err(WireError::SequenceExhausted);
748        }
749
750        let seq = (self.next_nonce() & 0xFFFF_FFFF) as u32;
751
752        let mut nonce_bytes = [0u8; 12];
753        nonce_bytes[..6].copy_from_slice(&self.source_id[..6]);
754        nonce_bytes[6] = payload_type;
755        nonce_bytes[7] = self.epoch;
756        nonce_bytes[8..12].copy_from_slice(&seq.to_le_bytes());
757
758        let cipher = ChaCha20Poly1305::new((&key_bytes).into());
759        let nonce = Nonce::from(nonce_bytes);
760        let ciphertext = cipher
761            .encrypt(&nonce, plaintext)
762            .map_err(|_| WireError::SealFailed)?;
763
764        let mut out = Vec::with_capacity(12 + ciphertext.len());
765        out.extend_from_slice(&nonce_bytes);
766        out.extend_from_slice(&ciphertext);
767        Ok(out)
768    }
769
770    /// Open a sealed envelope and return the plaintext.
771    ///
772    /// Performs three checks in order:
773    ///
774    /// 1. **Length**: envelope must be at least 28 bytes (12 nonce + 16 tag).
775    /// 2. **AEAD verify**: ChaCha20-Poly1305 decrypt against the current
776    ///    session key, falling back to the previous key during the rekey
777    ///    grace period.
778    /// 3. **Replay window**: the sequence embedded in nonce bytes 8..12
779    ///    (little-endian u32) must be either strictly higher than any
780    ///    previously accepted sequence for the same `(source_id,
781    ///    payload_type)` stream, OR within the 64-message sliding window
782    ///    AND not previously seen.
783    ///
784    /// Returns [`WireError::OpenFailed`] on any failure. Mutates `self`
785    /// to advance the replay window on success.
786    ///
787    /// The payload type embedded in the nonce is used for replay-window
788    /// keying only — the caller is responsible for dispatching the returned
789    /// plaintext to the correct deserializer.
790    pub fn open(&mut self, envelope: &[u8]) -> Result<Vec<u8>, WireError> {
791        use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, KeyInit, Nonce};
792
793        if envelope.len() < 12 + 16 {
794            return Err(WireError::OpenFailed);
795        }
796        let (nonce_bytes, ciphertext) = envelope.split_at(12);
797        let nonce = Nonce::from_slice(nonce_bytes);
798
799        // AEAD verify: current key, then prev_session_key fallback.
800        // Track which key verified so the replay-window check below can
801        // use the correct key_epoch (SPEC §5.3 / draft-02r1).
802        let (plaintext, verified_epoch) = if let Some(key) = self.session_key.as_ref() {
803            let key_bytes: [u8; 32] = **key;
804            let cipher = ChaCha20Poly1305::new((&key_bytes).into());
805            if let Ok(pt) = cipher.decrypt(nonce, ciphertext) {
806                (Some(pt), Some(self.current_key_epoch))
807            } else if let (Some(prev), Some(prev_epoch)) =
808                (self.prev_session_key.as_ref(), self.prev_key_epoch)
809            {
810                let prev_bytes: [u8; 32] = **prev;
811                let cipher = ChaCha20Poly1305::new((&prev_bytes).into());
812                match cipher.decrypt(nonce, ciphertext) {
813                    Ok(pt) => (Some(pt), Some(prev_epoch)),
814                    Err(_) => (None, None),
815                }
816            } else {
817                (None, None)
818            }
819        } else if let (Some(prev), Some(prev_epoch)) =
820            (self.prev_session_key.as_ref(), self.prev_key_epoch)
821        {
822            let prev_bytes: [u8; 32] = **prev;
823            let cipher = ChaCha20Poly1305::new((&prev_bytes).into());
824            match cipher.decrypt(nonce, ciphertext) {
825                Ok(pt) => (Some(pt), Some(prev_epoch)),
826                Err(_) => (None, None),
827            }
828        } else {
829            return Err(WireError::NoSessionKey);
830        };
831
832        let plaintext = plaintext.ok_or(WireError::OpenFailed)?;
833        // If AEAD succeeded we MUST have an epoch; debug-assert to catch
834        // any refactor that breaks the invariant.
835        let verified_epoch =
836            verified_epoch.expect("AEAD succeeded so a key verified; epoch must be set");
837
838        // Replay window check — scoped to the epoch of the key that
839        // verified (NOT the current epoch; an old envelope that
840        // verified under prev_key is checked against the prev-epoch
841        // window, not the current-epoch window).
842        let mut source_id_u64 = 0u64;
843        for (i, b) in nonce_bytes[..6].iter().enumerate() {
844            source_id_u64 |= (*b as u64) << (i * 8);
845        }
846        let payload_type = nonce_bytes[6];
847        let seq = u32::from_le_bytes([
848            nonce_bytes[8],
849            nonce_bytes[9],
850            nonce_bytes[10],
851            nonce_bytes[11],
852        ]) as u64;
853
854        if !self
855            .replay_window
856            .accept(source_id_u64, payload_type, verified_epoch, seq)
857        {
858            return Err(WireError::OpenFailed);
859        }
860
861        // Consent gate on the open path — symmetric with seal. Only
862        // application-reference payload types are gated. The caller may
863        // still drive state transitions via `observe_consent` after
864        // opening consent-ceremony envelopes (0x20..=0x22).
865        #[cfg(feature = "consent")]
866        if matches!(
867            payload_type,
868            crate::payload_types::PAYLOAD_TYPE_FRAME
869                | crate::payload_types::PAYLOAD_TYPE_INPUT
870                | crate::payload_types::PAYLOAD_TYPE_FRAME_LZ4
871        ) {
872            self.can_seal_frame()?;
873        }
874
875        Ok(plaintext)
876    }
877}
878
879impl Default for Session {
880    fn default() -> Self {
881        Self::new()
882    }
883}
884
885// ─── SessionBuilder (added in draft-02r2) ─────────────────────────────
886//
887// The builder pattern exists to let callers opt into behaviors that
888// would otherwise require either (a) new `Session::with_*` constructors
889// (API churn) or (b) post-construction mutators (awkward order-of-
890// operations). The builder is additive: existing `Session::new`,
891// `Session::with_source_id`, and `Session::with_rekey_grace` remain
892// unchanged.
893
894/// Opt-in configuration for a fresh [`Session`]. Constructed via
895/// [`Session::builder`]; finalized via [`SessionBuilder::build`].
896///
897/// Defaults reproduce [`Session::new`]: random `source_id` + `epoch`,
898/// [`DEFAULT_REKEY_GRACE`] grace, 64-slot replay window, and
899/// `consent_required = false` (→ [`crate::consent::ConsentState::LegacyBypass`]
900/// when the `consent` feature is on).
901pub struct SessionBuilder {
902    source_id: Option<[u8; 8]>,
903    epoch: Option<u8>,
904    rekey_grace: Duration,
905    #[cfg(feature = "consent")]
906    consent_required: bool,
907    replay_window_bits: u32,
908}
909
910impl SessionBuilder {
911    /// Create a builder with default values.
912    pub fn new() -> Self {
913        Self {
914            source_id: None,
915            epoch: None,
916            rekey_grace: DEFAULT_REKEY_GRACE,
917            #[cfg(feature = "consent")]
918            consent_required: false,
919            replay_window_bits: 64,
920        }
921    }
922
923    /// Pin the `source_id` + `epoch` for deterministic test fixtures.
924    /// Normal callers SHOULD omit this and let the builder randomize.
925    pub fn with_source_id(mut self, source_id: [u8; 8], epoch: u8) -> Self {
926        self.source_id = Some(source_id);
927        self.epoch = Some(epoch);
928        self
929    }
930
931    /// Override the previous-key grace duration. See
932    /// [`DEFAULT_REKEY_GRACE`].
933    pub fn with_rekey_grace(mut self, grace: Duration) -> Self {
934        self.rekey_grace = grace;
935        self
936    }
937
938    /// Require the consent ceremony to complete before application
939    /// `FRAME` / `INPUT` / `FRAME_LZ4` payloads are accepted.
940    ///
941    /// - `require = false` (default): initial state is `LegacyBypass`;
942    ///   consent is handled out-of-band by the application.
943    /// - `require = true`: initial state is `AwaitingRequest`;
944    ///   application payloads are blocked until a `ConsentRequest` +
945    ///   approving `ConsentResponse` transition the session to
946    ///   `Approved`.
947    ///
948    /// Only available with the `consent` feature.
949    #[cfg(feature = "consent")]
950    pub fn require_consent(mut self, require: bool) -> Self {
951        self.consent_required = require;
952        self
953    }
954
955    /// Override the per-stream replay window size in bits.
956    /// Must be a multiple of 64; valid values are 64 (default),
957    /// 128, 256, 512, 1024.
958    ///
959    /// Memory cost per `(source_id, pld_type, key_epoch)` stream is
960    /// `bits / 8` bytes of bitmap plus a small constant. 1024-slot
961    /// windows cost 128 bytes per stream.
962    ///
963    /// Panics at `build()` time if the value is out of range.
964    pub fn with_replay_window_bits(mut self, bits: u32) -> Self {
965        self.replay_window_bits = bits;
966        self
967    }
968
969    /// Finalize the builder and construct a [`Session`].
970    ///
971    /// Panics if `replay_window_bits` is invalid (not a multiple of 64,
972    /// less than 64, or more than 1024).
973    pub fn build(self) -> Session {
974        let source_id = self.source_id.unwrap_or_else(rand::random);
975        let epoch = self.epoch.unwrap_or_else(rand::random);
976        let replay_window = ReplayWindow::with_window_bits(self.replay_window_bits);
977
978        Session {
979            session_key: None,
980            prev_session_key: None,
981            key_established_at: None,
982            prev_key_expires_at: None,
983            nonce_counter: 0,
984            source_id,
985            epoch,
986            replay_window,
987            rekey_grace: self.rekey_grace,
988            current_key_epoch: 0,
989            prev_key_epoch: None,
990            #[cfg(feature = "consent")]
991            consent_state: if self.consent_required {
992                crate::consent::ConsentState::AwaitingRequest
993            } else {
994                crate::consent::ConsentState::LegacyBypass
995            },
996            #[cfg(feature = "consent")]
997            active_request_id: None,
998            #[cfg(feature = "consent")]
999            last_response_approved: None,
1000        }
1001    }
1002}
1003
1004impl Default for SessionBuilder {
1005    fn default() -> Self {
1006        Self::new()
1007    }
1008}
1009
1010impl Session {
1011    /// Start a [`SessionBuilder`] for opt-in configuration. Use this
1012    /// when you want `require_consent()`, a non-default replay window
1013    /// size, or deterministic fixture `source_id` / `epoch` without
1014    /// stacking multiple post-construction mutators.
1015    ///
1016    /// Added in draft-02r2.
1017    pub fn builder() -> SessionBuilder {
1018        SessionBuilder::new()
1019    }
1020}
1021
1022/// Constant-time equality for two 32-byte arrays.
1023///
1024/// Avoids a data-dependent early-return in the fingerprint compare path.
1025/// Kept inline here rather than reaching for `subtle` — one byte of
1026/// dependency surface for a loop we can read in three lines.
1027#[cfg(feature = "consent")]
1028#[inline]
1029fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
1030    let mut diff: u8 = 0;
1031    for i in 0..32 {
1032        diff |= a[i] ^ b[i];
1033    }
1034    diff == 0
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040
1041    #[test]
1042    fn new_session_has_no_key() {
1043        let s = Session::new();
1044        assert!(!s.has_key());
1045    }
1046
1047    #[test]
1048    fn install_key_sets_has_key() {
1049        let mut s = Session::new();
1050        s.install_key([0x11; 32]);
1051        assert!(s.has_key());
1052        assert_eq!(s.nonce_counter(), 0);
1053    }
1054
1055    #[test]
1056    fn seal_fails_without_key() {
1057        let mut s = Session::new();
1058        assert!(matches!(s.seal(b"hi", 0x10), Err(WireError::NoSessionKey)));
1059    }
1060
1061    #[test]
1062    fn open_fails_without_key() {
1063        let mut s = Session::new();
1064        let envelope = [0u8; 40];
1065        assert!(matches!(s.open(&envelope), Err(WireError::NoSessionKey)));
1066    }
1067
1068    #[test]
1069    fn open_short_envelope_fails() {
1070        let mut s = Session::new();
1071        s.install_key([0u8; 32]);
1072        assert!(matches!(s.open(&[0u8; 10]), Err(WireError::OpenFailed)));
1073    }
1074
1075    #[test]
1076    fn seal_open_roundtrip() {
1077        let mut sender = Session::with_source_id([1; 8], 0xAA);
1078        let mut receiver = Session::with_source_id([1; 8], 0xAA);
1079        sender.install_key([0x33; 32]);
1080        receiver.install_key([0x33; 32]);
1081
1082        let sealed = sender.seal(b"hello xenia", 0x10).unwrap();
1083        let opened = receiver.open(&sealed).unwrap();
1084        assert_eq!(opened, b"hello xenia");
1085    }
1086
1087    #[test]
1088    fn nonce_counter_monotonic() {
1089        let mut s = Session::new();
1090        assert_eq!(s.next_nonce(), 0);
1091        assert_eq!(s.next_nonce(), 1);
1092        assert_eq!(s.next_nonce(), 2);
1093    }
1094
1095    #[test]
1096    fn nonce_counter_wraps_without_panic() {
1097        // `next_nonce` uses wrapping_add internally; the guard against
1098        // catastrophic nonce reuse lives in `seal` (see
1099        // `seal_refuses_at_sequence_exhaustion` below), not here.
1100        let mut s = Session::new();
1101        s.nonce_counter = u64::MAX;
1102        assert_eq!(s.next_nonce(), u64::MAX);
1103        assert_eq!(s.next_nonce(), 0);
1104    }
1105
1106    #[test]
1107    fn seal_refuses_at_sequence_exhaustion() {
1108        // After 2^32 successful seals, the low-32-bit sequence embedded in
1109        // the AEAD nonce would wrap to 0 on the next seal — catastrophic
1110        // nonce reuse under the same key. `seal` must refuse instead.
1111        let mut s = Session::with_source_id([0; 8], 0);
1112        s.install_key([0x77; 32]);
1113        // Seed the counter at the boundary. The 2^32-th seal completed
1114        // with seq = 2^32 - 1; the next seal would wrap.
1115        s.nonce_counter = 1u64 << 32;
1116        assert!(matches!(
1117            s.seal(b"must-refuse", 0x10),
1118            Err(WireError::SequenceExhausted)
1119        ));
1120    }
1121
1122    #[test]
1123    fn seal_allows_last_valid_sequence_before_exhaustion() {
1124        // The 2^32 - 1 value (u32::MAX) is a legitimate sequence — it's
1125        // the final seal before the boundary kicks in. Verify it succeeds.
1126        let mut s = Session::with_source_id([0; 8], 0);
1127        s.install_key([0x77; 32]);
1128        s.nonce_counter = (1u64 << 32) - 1; // = u32::MAX as u64
1129        let sealed = s.seal(b"last-valid", 0x10).expect("seal at boundary - 1");
1130        assert_eq!(sealed.len(), 12 + 10 + 16); // nonce + plaintext + tag
1131                                                // Counter is now at the boundary — next seal must refuse.
1132        assert!(matches!(
1133            s.seal(b"over-the-edge", 0x10),
1134            Err(WireError::SequenceExhausted)
1135        ));
1136    }
1137
1138    #[test]
1139    fn rekey_resets_sequence_after_exhaustion() {
1140        // The caller's only escape from `SequenceExhausted` is to rekey.
1141        // Verify that install_key resets the counter so seals resume.
1142        let mut s = Session::with_source_id([0; 8], 0);
1143        s.install_key([0x77; 32]);
1144        s.nonce_counter = 1u64 << 32;
1145        assert!(s.seal(b"blocked", 0x10).is_err());
1146        // Rekey.
1147        s.install_key([0x88; 32]);
1148        // Counter reset to 0, sealing works again.
1149        assert!(s.seal(b"unblocked", 0x10).is_ok());
1150    }
1151
1152    #[test]
1153    fn rekey_preserves_old_envelopes_during_grace() {
1154        let mut sender = Session::with_source_id([2; 8], 0xBB);
1155        let mut receiver = Session::with_source_id([2; 8], 0xBB);
1156        sender.install_key([0x44; 32]);
1157        receiver.install_key([0x44; 32]);
1158
1159        // Seal under old key.
1160        let sealed_old = sender.seal(b"first", 0x10).unwrap();
1161
1162        // Rekey on receiver only (simulating a rotation where sealed_old is
1163        // already in flight when the new key lands).
1164        receiver.install_key([0x55; 32]);
1165
1166        // Old envelope still opens during the grace period.
1167        let opened = receiver.open(&sealed_old).unwrap();
1168        assert_eq!(opened, b"first");
1169    }
1170
1171    #[test]
1172    fn replay_rejected() {
1173        let mut sender = Session::with_source_id([3; 8], 0xCC);
1174        let mut receiver = Session::with_source_id([3; 8], 0xCC);
1175        sender.install_key([0x66; 32]);
1176        receiver.install_key([0x66; 32]);
1177
1178        let sealed = sender.seal(b"once", 0x10).unwrap();
1179        assert!(receiver.open(&sealed).is_ok());
1180        assert!(matches!(receiver.open(&sealed), Err(WireError::OpenFailed)));
1181    }
1182
1183    #[test]
1184    fn wrong_key_fails() {
1185        let mut sender = Session::with_source_id([4; 8], 0xDD);
1186        let mut receiver = Session::with_source_id([4; 8], 0xDD);
1187        sender.install_key([0x77; 32]);
1188        receiver.install_key([0x88; 32]);
1189
1190        let sealed = sender.seal(b"secret", 0x10).unwrap();
1191        assert!(matches!(receiver.open(&sealed), Err(WireError::OpenFailed)));
1192    }
1193
1194    #[test]
1195    fn independent_payload_types_do_not_collide() {
1196        let mut sender = Session::with_source_id([5; 8], 0xEE);
1197        let mut receiver = Session::with_source_id([5; 8], 0xEE);
1198        sender.install_key([0x99; 32]);
1199        receiver.install_key([0x99; 32]);
1200
1201        // Same sequence on two different payload types: both accepted.
1202        let a = sender.seal(b"frame-0", 0x10).unwrap();
1203        let b = sender.seal(b"input-0", 0x11).unwrap();
1204        assert!(receiver.open(&a).is_ok());
1205        assert!(receiver.open(&b).is_ok());
1206    }
1207
1208    #[test]
1209    fn tick_expires_prev_key_after_grace() {
1210        let mut sender = Session::with_source_id([6; 8], 0xFF);
1211        let mut receiver =
1212            Session::with_source_id([6; 8], 0xFF).with_rekey_grace(Duration::from_millis(1));
1213        sender.install_key([0xAA; 32]);
1214        receiver.install_key([0xAA; 32]);
1215
1216        let sealed_old = sender.seal(b"old", 0x10).unwrap();
1217
1218        // Rekey receiver with ~1ms grace.
1219        receiver.install_key([0xBB; 32]);
1220        std::thread::sleep(Duration::from_millis(5));
1221        receiver.tick();
1222
1223        // Old envelope no longer opens.
1224        assert!(receiver.open(&sealed_old).is_err());
1225    }
1226}