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}