Skip to main content

net/adapter/net/
crypto.rs

1//! Cryptographic primitives for Net.
2//!
3//! This module provides:
4//! - Noise protocol handshake (NKpsk0 pattern)
5//! - ChaCha20-Poly1305 AEAD encryption with counter-based nonces
6//! - Key derivation for session keys
7
8use bytes::{Bytes, BytesMut};
9use chacha20poly1305::{
10    aead::{Aead, AeadInPlace, KeyInit},
11    ChaCha20Poly1305,
12};
13use parking_lot::Mutex;
14use snow::{params::NoiseParams, Builder, HandshakeState};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Arc;
17
18use super::protocol::{NONCE_SIZE, TAG_SIZE};
19
20/// Noise protocol pattern: NKpsk0
21///
22/// - N: No static key for initiator (anonymous)
23/// - K: Responder's static key is known to initiator
24/// - psk0: Pre-shared key mixed at start
25const NOISE_PATTERN: &str = "Noise_NKpsk0_25519_ChaChaPoly_BLAKE2s";
26
27/// Domain-separated Noise prologue binding `(src_node_id, dest_node_id)`
28/// into the handshake transcript.
29///
30/// Both direct and relayed handshakes use this construction. A relay
31/// that rewrites either node id in the outer addressing (routing header
32/// for routed handshakes, or the caller's own `peer_node_id` argument
33/// for direct) produces a prologue mismatch on the responder, which
34/// fails the Noise MAC check on msg1 — the handshake is rejected
35/// end-to-end before any session keys are bound to an attacker-chosen
36/// identity.
37pub fn handshake_prologue(src_node_id: u64, dest_node_id: u64) -> [u8; 32] {
38    let mut buf = [0u8; 32];
39    buf[0..16].copy_from_slice(b"net-handshake-v1");
40    buf[16..24].copy_from_slice(&src_node_id.to_le_bytes());
41    buf[24..32].copy_from_slice(&dest_node_id.to_le_bytes());
42    buf
43}
44
45/// Error type for cryptographic operations
46#[derive(Debug, Clone)]
47pub enum CryptoError {
48    /// Handshake failed
49    Handshake(String),
50    /// Encryption failed
51    Encryption(String),
52    /// Decryption failed
53    Decryption(String),
54    /// Invalid key
55    InvalidKey(String),
56    /// Invalid nonce
57    InvalidNonce,
58}
59
60impl std::fmt::Display for CryptoError {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::Handshake(msg) => write!(f, "handshake error: {}", msg),
64            Self::Encryption(msg) => write!(f, "encryption error: {}", msg),
65            Self::Decryption(msg) => write!(f, "decryption error: {}", msg),
66            Self::InvalidKey(msg) => write!(f, "invalid key: {}", msg),
67            Self::InvalidNonce => write!(f, "invalid nonce"),
68        }
69    }
70}
71
72impl std::error::Error for CryptoError {}
73
74/// Session keys derived from Noise handshake
75#[derive(Clone)]
76pub struct SessionKeys {
77    /// Key for encrypting outbound packets
78    pub tx_key: [u8; 32],
79    /// Key for decrypting inbound packets
80    pub rx_key: [u8; 32],
81    /// Session ID derived from handshake
82    pub session_id: u64,
83    /// The remote peer's Noise static public key (X25519). 32 bytes
84    /// of public material extracted from the handshake before
85    /// transitioning into transport mode. Load-bearing for the
86    /// identity-envelope path in daemon migration: the source seals
87    /// the daemon's ed25519 seed to this key, knowing the only
88    /// party that can unseal it is the peer whose static private
89    /// key completed this handshake. `[0; 32]` is a sentinel for
90    /// "not available" — some test paths construct `SessionKeys`
91    /// directly and don't go through a real handshake.
92    pub remote_static_pub: [u8; 32],
93}
94
95impl std::fmt::Debug for SessionKeys {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("SessionKeys")
98            .field("session_id", &self.session_id)
99            .field("tx_key", &"[REDACTED]")
100            .field("rx_key", &"[REDACTED]")
101            .field(
102                "remote_static_pub",
103                &format_args!(
104                    "{:02x}{:02x}{:02x}{:02x}…",
105                    self.remote_static_pub[0],
106                    self.remote_static_pub[1],
107                    self.remote_static_pub[2],
108                    self.remote_static_pub[3],
109                ),
110            )
111            .finish()
112    }
113}
114
115/// Static keypair for Noise protocol
116#[derive(Clone)]
117pub struct StaticKeypair {
118    /// Private key (32 bytes)
119    pub private: [u8; 32],
120    /// Public key (32 bytes)
121    pub public: [u8; 32],
122}
123
124impl StaticKeypair {
125    /// Generate a new random keypair
126    #[expect(
127        clippy::expect_used,
128        reason = "NOISE_PATTERN is a compile-time-constant string, parses infallibly; the Noise builder generates keypairs deterministically from valid patterns"
129    )]
130    pub fn generate() -> Self {
131        let builder = Builder::new(
132            NOISE_PATTERN
133                .parse()
134                .expect("static noise pattern is valid"),
135        );
136        let keypair = builder
137            .generate_keypair()
138            .expect("keypair generation from valid pattern");
139        let mut private = [0u8; 32];
140        let mut public = [0u8; 32];
141        private.copy_from_slice(&keypair.private);
142        public.copy_from_slice(&keypair.public);
143        Self { private, public }
144    }
145
146    /// Create from existing keys
147    pub fn from_keys(private: [u8; 32], public: [u8; 32]) -> Self {
148        Self { private, public }
149    }
150
151    /// Get the public key
152    #[inline]
153    pub fn public_key(&self) -> &[u8; 32] {
154        &self.public
155    }
156
157    /// Get the secret/private key
158    #[inline]
159    pub fn secret_key(&self) -> &[u8; 32] {
160        &self.private
161    }
162}
163
164impl std::fmt::Debug for StaticKeypair {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("StaticKeypair")
167            .field("public", &hex_string(&self.public))
168            .field("private", &"[REDACTED]")
169            .finish()
170    }
171}
172
173/// Noise handshake state machine
174pub struct NoiseHandshake {
175    state: HandshakeState,
176    is_initiator: bool,
177}
178
179impl NoiseHandshake {
180    /// Create initiator handshake state with an empty prologue.
181    ///
182    /// The initiator knows the responder's static public key.
183    pub fn initiator(psk: &[u8; 32], responder_static: &[u8; 32]) -> Result<Self, CryptoError> {
184        Self::initiator_with_prologue(psk, responder_static, &[])
185    }
186
187    /// Create initiator handshake state with a caller-supplied prologue.
188    ///
189    /// The prologue is mixed into the Noise handshake hash but never sent
190    /// on the wire. Both peers must use byte-identical prologues or `msg1`
191    /// will fail to authenticate. Used by the relayed-handshake path to
192    /// bind the `(dest_node_id, src_node_id)` in the plaintext envelope
193    /// into the Noise transcript — a relay that rewrites either field
194    /// produces a prologue mismatch on the responder, and the attack is
195    /// detected as a Noise `read_message` failure.
196    pub fn initiator_with_prologue(
197        psk: &[u8; 32],
198        responder_static: &[u8; 32],
199        prologue: &[u8],
200    ) -> Result<Self, CryptoError> {
201        let params: NoiseParams = NOISE_PATTERN
202            .parse()
203            .map_err(|e| CryptoError::Handshake(format!("invalid noise params: {}", e)))?;
204
205        let state = Builder::new(params)
206            .psk(0, psk)
207            .map_err(|e| CryptoError::Handshake(format!("failed to set psk: {}", e)))?
208            .prologue(prologue)
209            .map_err(|e| CryptoError::Handshake(format!("failed to set prologue: {}", e)))?
210            .remote_public_key(responder_static)
211            .map_err(|e| CryptoError::Handshake(format!("failed to set remote key: {}", e)))?
212            .build_initiator()
213            .map_err(|e| CryptoError::Handshake(format!("failed to build initiator: {}", e)))?;
214
215        Ok(Self {
216            state,
217            is_initiator: true,
218        })
219    }
220
221    /// Create responder handshake state with an empty prologue.
222    ///
223    /// The responder uses its static keypair for authentication.
224    pub fn responder(psk: &[u8; 32], static_keypair: &StaticKeypair) -> Result<Self, CryptoError> {
225        Self::responder_with_prologue(psk, static_keypair, &[])
226    }
227
228    /// Create responder handshake state with a caller-supplied prologue.
229    ///
230    /// See [`Self::initiator_with_prologue`] for the authentication story.
231    pub fn responder_with_prologue(
232        psk: &[u8; 32],
233        static_keypair: &StaticKeypair,
234        prologue: &[u8],
235    ) -> Result<Self, CryptoError> {
236        let params: NoiseParams = NOISE_PATTERN
237            .parse()
238            .map_err(|e| CryptoError::Handshake(format!("invalid noise params: {}", e)))?;
239
240        let state = Builder::new(params)
241            .psk(0, psk)
242            .map_err(|e| CryptoError::Handshake(format!("failed to set psk: {}", e)))?
243            .prologue(prologue)
244            .map_err(|e| CryptoError::Handshake(format!("failed to set prologue: {}", e)))?
245            .local_private_key(&static_keypair.private)
246            .map_err(|e| CryptoError::Handshake(format!("failed to set local key: {}", e)))?
247            .build_responder()
248            .map_err(|e| CryptoError::Handshake(format!("failed to build responder: {}", e)))?;
249
250        Ok(Self {
251            state,
252            is_initiator: false,
253        })
254    }
255
256    /// Check if handshake is complete
257    #[inline]
258    pub fn is_finished(&self) -> bool {
259        self.state.is_handshake_finished()
260    }
261
262    /// Check if we're the initiator
263    #[inline]
264    #[allow(dead_code)]
265    pub fn is_initiator(&self) -> bool {
266        self.is_initiator
267    }
268
269    /// Write a handshake message
270    ///
271    /// Returns the message to send to the peer.
272    pub fn write_message(&mut self, payload: &[u8]) -> Result<Vec<u8>, CryptoError> {
273        let mut buf = vec![0u8; 65535];
274        let len = self
275            .state
276            .write_message(payload, &mut buf)
277            .map_err(|e| CryptoError::Handshake(format!("write_message failed: {}", e)))?;
278        buf.truncate(len);
279        Ok(buf)
280    }
281
282    /// Read a handshake message
283    ///
284    /// Returns the decrypted payload from the peer.
285    pub fn read_message(&mut self, message: &[u8]) -> Result<Vec<u8>, CryptoError> {
286        let mut buf = vec![0u8; 65535];
287        let len = self
288            .state
289            .read_message(message, &mut buf)
290            .map_err(|e| CryptoError::Handshake(format!("read_message failed: {}", e)))?;
291        buf.truncate(len);
292        Ok(buf)
293    }
294
295    /// Complete the handshake and extract session keys.
296    ///
297    /// This consumes the handshake state and returns the symmetric keys
298    /// for stateless packet encryption.
299    pub fn into_session_keys(self) -> Result<SessionKeys, CryptoError> {
300        if !self.is_finished() {
301            return Err(CryptoError::Handshake("handshake not finished".to_string()));
302        }
303
304        let is_initiator = self.is_initiator;
305
306        // Get the handshake hash before transitioning (HandshakeState has this method)
307        let handshake_hash: [u8; 32] = {
308            let hash_slice = self.state.get_handshake_hash();
309            let mut arr = [0u8; 32];
310            let len = hash_slice.len().min(32);
311            arr[..len].copy_from_slice(&hash_slice[..len]);
312            arr
313        };
314
315        // Capture the remote static pubkey BEFORE `into_transport_mode`
316        // consumes the handshake state. Populated on both sides of
317        // NKpsk0: initiator learned it out-of-band and handed it to
318        // snow via `remote_public_key`; responder learned it from
319        // `-> s` in the Noise pattern. Zero-filled if snow returns
320        // `None` (shouldn't happen post-handshake but `get_remote_static`
321        // is nominally fallible).
322        let mut remote_static_pub = [0u8; 32];
323        if let Some(rs) = self.state.get_remote_static() {
324            let len = rs.len().min(32);
325            remote_static_pub[..len].copy_from_slice(&rs[..len]);
326        }
327
328        // Transition to transport mode (we don't need the transport state since we're using stateless encryption)
329        let _transport = self
330            .state
331            .into_transport_mode()
332            .map_err(|e| CryptoError::Handshake(format!("transport mode failed: {}", e)))?;
333
334        // Derive session ID from handshake hash
335        #[expect(
336            clippy::unwrap_used,
337            reason = "handshake_hash typed as [u8; 32] above; [0..8].try_into::<[u8; 8]>() is infallible"
338        )]
339        let session_id = u64::from_le_bytes(handshake_hash[0..8].try_into().unwrap());
340
341        // Use HKDF to derive tx and rx keys from handshake hash
342        // For NKpsk0, initiator sends first, so:
343        // - Initiator: tx_key from first half, rx_key from second half
344        // - Responder: rx_key from first half, tx_key from second half
345        let mut tx_key = [0u8; 32];
346        let mut rx_key = [0u8; 32];
347
348        // Simple key derivation from handshake hash
349        // In production, use proper HKDF
350        if is_initiator {
351            derive_key(&handshake_hash, b"initiator-tx", &mut tx_key);
352            derive_key(&handshake_hash, b"initiator-rx", &mut rx_key);
353        } else {
354            derive_key(&handshake_hash, b"initiator-rx", &mut tx_key);
355            derive_key(&handshake_hash, b"initiator-tx", &mut rx_key);
356        }
357
358        Ok(SessionKeys {
359            tx_key,
360            rx_key,
361            session_id,
362            remote_static_pub,
363        })
364    }
365}
366
367/// Packet cipher using ChaCha20-Poly1305 with counter-based nonces.
368///
369/// Nonce format: `[session_prefix: 4 bytes][counter: 8 bytes]`
370/// - session_prefix: derived by folding the 64-bit session_id into 4
371///   bytes (hi ^ lo). Every session also derives a fresh key in
372///   `derive_session_keys`, so nonce uniqueness per key is guaranteed
373///   by the counter alone — the prefix is defense-in-depth only, and
374///   XORing both halves retains more entropy than a plain 32-bit
375///   truncation of session_id.
376/// - counter: monotonically increasing, ensures uniqueness within session
377///
378/// Safety: Counter-based nonces are safe because:
379/// - Counter never repeats within a session (AtomicU64)
380/// - Each session has a unique per-direction key, so (key, nonce) pairs
381///   never collide across sessions regardless of prefix entropy
382/// - 2^64 packets before rollover (unreachable in practice)
383///
384/// When used inside a `PacketPool`, the TX counter should be shared across
385/// all ciphers in the pool via `with_shared_tx_counter()` to prevent nonce
386/// reuse across concurrent builders.
387pub struct PacketCipher {
388    cipher: ChaCha20Poly1305,
389    /// Pre-built nonce with the session prefix already filled into
390    /// the first 4 bytes (and the counter bytes left as zeros for
391    /// each per-packet overwrite). Per crypto-session perf #138,
392    /// `nonce_from_counter` starts from this template instead of
393    /// zero-initializing then copy-from-slicing the prefix on
394    /// every TX / RX packet. Saves the per-packet prefix memcpy
395    /// (4 bytes) and the zero-init (12 bytes) — small in absolute
396    /// terms but fires twice per packet (once on encrypt, once on
397    /// decrypt) at the highest frequency code path in the system.
398    /// The session prefix is the first 4 bytes; callers needing
399    /// it independently can read `nonce_template[0..4]`.
400    nonce_template: [u8; NONCE_SIZE],
401    /// TX counter — owned or shared with other ciphers in a pool.
402    tx_counter: Arc<AtomicU64>,
403    /// Sliding-window replay state for received counters. A single counter
404    /// range check cannot prevent replay: an attacker resending a previously
405    /// decrypted packet produces identical AEAD output, so we must track
406    /// which counters have already been committed inside the window.
407    rx_window: Mutex<ReplayWindow>,
408}
409
410/// Sliding-window replay protection.
411///
412/// Bit `i` of `bitmap` is set iff counter `rx_counter - 1 - i` has been
413/// committed (decrypted and accepted). `rx_counter` is `1 + highest_seen`,
414/// starting at 0 meaning "nothing received yet". The bitmap is only
415/// meaningful once `rx_counter > 0`.
416#[derive(Debug)]
417struct ReplayWindow {
418    rx_counter: u64,
419    bitmap: [u64; Self::BITMAP_WORDS],
420}
421
422impl ReplayWindow {
423    const WINDOW_SIZE: u64 = 1024;
424    /// Maximum forward jump in counter values that this window
425    /// will accept on a single packet. Pre-fix this was 65_536,
426    /// far past `WINDOW_SIZE`. Any jump greater than `WINDOW_SIZE`
427    /// forced the bitmap to be zeroed (since the bitmap has
428    /// `BITMAP_WORDS × 64 = WINDOW_SIZE` bits), erasing the
429    /// "seen" markers for the previous `WINDOW_SIZE - 1` counters
430    /// — those sequence numbers became replayable until the new
431    /// window had been populated. Cap the forward jump at
432    /// `WINDOW_SIZE` so any gap that would discard replay state
433    /// is rejected; the peer must re-handshake to legitimately
434    /// resume past such a gap.
435    const MAX_FORWARD: u64 = Self::WINDOW_SIZE;
436    const BITMAP_WORDS: usize = 16;
437
438    const fn new() -> Self {
439        Self {
440            rx_counter: 0,
441            bitmap: [0; Self::BITMAP_WORDS],
442        }
443    }
444
445    /// Read-only check: is `received` in range and not yet committed?
446    fn is_valid(&self, received: u64) -> bool {
447        // Reject the ceiling counter unconditionally. If `commit`
448        // accepted `received == u64::MAX`, `rx_counter` would
449        // saturate at `u64::MAX` (since `rx_counter = received
450        // .saturating_add(1)` clamps), and the early-return guard
451        // at the top of `commit` would then refuse every
452        // subsequent packet — permanent receive-path poisoning
453        // from a single authenticated packet. The session is
454        // already designed to re-handshake long before counter
455        // exhaustion (2^64 packets is unreachable in practice),
456        // so excising the ceiling value costs nothing and closes
457        // the poisoning vector at the gate. `commit` retains its
458        // own `rx_counter == u64::MAX` early return as a
459        // defense-in-depth backstop in case a future caller skips
460        // `is_valid`.
461        if received == u64::MAX {
462            return false;
463        }
464        if received >= self.rx_counter {
465            received.saturating_sub(self.rx_counter) <= Self::MAX_FORWARD
466        } else {
467            let age = self.rx_counter - 1 - received;
468            if age >= Self::WINDOW_SIZE {
469                return false;
470            }
471            let word = (age / 64) as usize;
472            let bit = age % 64;
473            self.bitmap[word] & (1u64 << bit) == 0
474        }
475    }
476
477    /// Commit `received` as seen. Returns `true` iff this call was the one
478    /// that marked it — a `false` return means the counter was already
479    /// committed by a concurrent caller (replay detected at commit time) or
480    /// is outside the retained window.
481    ///
482    /// Once `rx_counter` has saturated at `u64::MAX` the session has
483    /// exhausted its 64-bit nonce space; further commits are refused
484    /// so a crafted `received == u64::MAX` cannot be "re-committed"
485    /// repeatedly and bypass replay detection. In practice this
486    /// boundary is unreachable (2^64 packets per session), but we
487    /// prefer an explicit refusal to a subtle ambiguity at the
488    /// ceiling.
489    fn commit(&mut self, received: u64) -> bool {
490        // Refuse the ceiling counter directly. `is_valid` is the
491        // primary gate (see `ReplayWindow::is_valid`) but if a
492        // future caller skips it and invokes `commit` directly,
493        // accepting `received == u64::MAX` here would saturate
494        // `rx_counter` (line below: `received.saturating_add(1)`)
495        // and the subsequent-commit guard would then reject every
496        // legitimate packet. Refusing at the top prevents the
497        // saturation in the first place.
498        if received == u64::MAX {
499            return false;
500        }
501        if self.rx_counter == u64::MAX {
502            return false;
503        }
504        if received >= self.rx_counter {
505            // saturating_add guards `received == u64::MAX` (with
506            // rx_counter == 0 the `+ 1` would panic in debug and wrap
507            // in release). shift_bitmap_up already clamps at
508            // BITMAP_WORDS * 64, so a saturated value is still safe.
509            let shift = (received - self.rx_counter).saturating_add(1);
510            self.shift_bitmap_up(shift);
511            self.rx_counter = received.saturating_add(1);
512            self.bitmap[0] |= 1u64;
513            true
514        } else {
515            let age = self.rx_counter - 1 - received;
516            if age >= Self::WINDOW_SIZE {
517                return false;
518            }
519            let word = (age / 64) as usize;
520            let bit = age % 64;
521            let mask = 1u64 << bit;
522            let was_set = self.bitmap[word] & mask != 0;
523            self.bitmap[word] |= mask;
524            !was_set
525        }
526    }
527
528    fn shift_bitmap_up(&mut self, shift: u64) {
529        if shift == 0 {
530            return;
531        }
532        // Pre-fix this branch silently zeroed the bitmap
533        // when a legitimate jump exceeded `WINDOW_SIZE` (1024
534        // packets). `MAX_FORWARD` is 65_536, so a single packet
535        // accepted with `received - rx_counter > 1024` clears the
536        // last 64-ish thousand counters' replay tracking — those
537        // sequence numbers can be replayed undetected. Operators
538        // should know when this happens so they can investigate
539        // (a misbehaving peer, a debug-only fast-forward, or
540        // adversarial packet injection mid-stream). Log at warn
541        // before zeroing.
542        if shift >= (Self::BITMAP_WORDS as u64) * 64 {
543            tracing::warn!(
544                shift,
545                window_size = Self::WINDOW_SIZE,
546                max_forward = Self::MAX_FORWARD,
547                "anti-replay bitmap reset on large forward jump; \
548                 prior {} counters lost replay tracking",
549                Self::WINDOW_SIZE,
550            );
551            self.bitmap = [0; Self::BITMAP_WORDS];
552            return;
553        }
554        let word_shift = (shift / 64) as usize;
555        let bit_shift = (shift % 64) as u32;
556        if bit_shift == 0 {
557            for i in (0..Self::BITMAP_WORDS).rev() {
558                self.bitmap[i] = if i >= word_shift {
559                    self.bitmap[i - word_shift]
560                } else {
561                    0
562                };
563            }
564        } else {
565            for i in (0..Self::BITMAP_WORDS).rev() {
566                let hi = if i >= word_shift {
567                    self.bitmap[i - word_shift] << bit_shift
568                } else {
569                    0
570                };
571                let lo = if i > word_shift {
572                    self.bitmap[i - word_shift - 1] >> (64 - bit_shift)
573                } else {
574                    0
575                };
576                self.bitmap[i] = hi | lo;
577            }
578        }
579    }
580}
581
582/// Derive the 4-byte nonce prefix from a 64-bit session id. Folds the
583/// high and low halves together so every bit of session_id contributes,
584/// rather than silently truncating the high 32 bits. Both sender and
585/// receiver — and the wire header patching in `pool.rs` — must call
586/// this so the on-the-wire nonce matches what the cipher used.
587#[inline]
588pub(crate) fn session_prefix_from_id(session_id: u64) -> [u8; 4] {
589    let lo = session_id as u32;
590    let hi = (session_id >> 32) as u32;
591    (lo ^ hi).to_le_bytes()
592}
593
594impl PacketCipher {
595    /// Create a new fast cipher from a 32-byte key and session ID
596    pub fn new(key: &[u8; 32], session_id: u64) -> Self {
597        let mut nonce_template = [0u8; NONCE_SIZE];
598        nonce_template[0..4].copy_from_slice(&session_prefix_from_id(session_id));
599        Self {
600            cipher: ChaCha20Poly1305::new(key.into()),
601            nonce_template,
602            tx_counter: Arc::new(AtomicU64::new(0)),
603            rx_window: Mutex::new(ReplayWindow::new()),
604        }
605    }
606
607    /// Create a new cipher that shares a TX counter with other ciphers.
608    ///
609    /// All ciphers sharing the same counter atomically increment it,
610    /// preventing nonce reuse when multiple builders encrypt with the
611    /// same key (e.g., in a `PacketPool`).
612    pub fn with_shared_tx_counter(
613        key: &[u8; 32],
614        session_id: u64,
615        tx_counter: Arc<AtomicU64>,
616    ) -> Self {
617        let mut nonce_template = [0u8; NONCE_SIZE];
618        nonce_template[0..4].copy_from_slice(&session_prefix_from_id(session_id));
619        Self {
620            cipher: ChaCha20Poly1305::new(key.into()),
621            nonce_template,
622            tx_counter,
623            rx_window: Mutex::new(ReplayWindow::new()),
624        }
625    }
626
627    /// Generate the next nonce for sending. Per crypto-session
628    /// perf #138, starts from the pre-built `nonce_template` (which
629    /// already has the session prefix in bytes 0..4) and only
630    /// overwrites the counter bytes 4..12 — eliminates the
631    /// per-call zero-init + prefix memcpy of the legacy form.
632    #[inline]
633    #[allow(dead_code)]
634    fn next_tx_nonce(&self) -> [u8; NONCE_SIZE] {
635        let counter = self.tx_counter.fetch_add(1, Ordering::Relaxed);
636        let mut nonce = self.nonce_template;
637        nonce[4..12].copy_from_slice(&counter.to_le_bytes());
638        nonce
639    }
640
641    /// Construct a nonce from received counter value. Mirrors the
642    /// TX path (#138): start from the prefix-filled template,
643    /// overwrite only the counter bytes.
644    #[inline]
645    fn nonce_from_counter(&self, counter: u64) -> [u8; NONCE_SIZE] {
646        let mut nonce = self.nonce_template;
647        nonce[4..12].copy_from_slice(&counter.to_le_bytes());
648        nonce
649    }
650
651    /// Get the current TX counter value (for including in packet header)
652    #[inline]
653    pub fn current_tx_counter(&self) -> u64 {
654        self.tx_counter.load(Ordering::Relaxed)
655    }
656
657    /// Encrypt payload in-place with AAD.
658    ///
659    /// Returns the nonce counter used (to include in packet header).
660    /// Appends authentication tag to the buffer.
661    #[inline]
662    pub fn encrypt_in_place(&self, aad: &[u8], buffer: &mut BytesMut) -> Result<u64, CryptoError> {
663        let counter = self.tx_counter.fetch_add(1, Ordering::Relaxed);
664        let nonce = self.nonce_from_counter(counter);
665
666        let tag = self
667            .cipher
668            .encrypt_in_place_detached((&nonce).into(), aad, buffer)
669            .map_err(|_| CryptoError::Encryption("encryption failed".to_string()))?;
670
671        buffer.extend_from_slice(&tag);
672        Ok(counter)
673    }
674
675    /// Encrypt payload with AAD.
676    ///
677    /// Returns (ciphertext, nonce_counter).
678    #[inline]
679    pub fn encrypt(&self, aad: &[u8], plaintext: &[u8]) -> Result<(Vec<u8>, u64), CryptoError> {
680        use chacha20poly1305::aead::Payload;
681
682        let counter = self.tx_counter.fetch_add(1, Ordering::Relaxed);
683        let nonce = self.nonce_from_counter(counter);
684
685        let payload = Payload {
686            msg: plaintext,
687            aad,
688        };
689
690        let ciphertext = self
691            .cipher
692            .encrypt((&nonce).into(), payload)
693            .map_err(|_| CryptoError::Encryption("encryption failed".to_string()))?;
694
695        Ok((ciphertext, counter))
696    }
697
698    /// Decrypt payload with AAD using the provided nonce counter.
699    #[inline]
700    pub fn decrypt(
701        &self,
702        nonce_counter: u64,
703        aad: &[u8],
704        ciphertext: &[u8],
705    ) -> Result<Vec<u8>, CryptoError> {
706        use chacha20poly1305::aead::Payload;
707
708        let nonce = self.nonce_from_counter(nonce_counter);
709        let payload = Payload {
710            msg: ciphertext,
711            aad,
712        };
713
714        self.cipher
715            .decrypt((&nonce).into(), payload)
716            .map_err(|_| CryptoError::Decryption("decryption failed".to_string()))
717    }
718
719    /// Decrypt payload in-place with AAD using the provided nonce counter.
720    ///
721    /// The buffer should contain ciphertext + tag. Returns plaintext length.
722    #[inline]
723    pub fn decrypt_in_place(
724        &self,
725        nonce_counter: u64,
726        aad: &[u8],
727        buffer: &mut [u8],
728    ) -> Result<usize, CryptoError> {
729        if buffer.len() < TAG_SIZE {
730            return Err(CryptoError::Decryption("buffer too small".to_string()));
731        }
732
733        let nonce = self.nonce_from_counter(nonce_counter);
734        let plaintext_len = buffer.len() - TAG_SIZE;
735        let (data, tag_bytes) = buffer.split_at_mut(plaintext_len);
736        let tag = chacha20poly1305::Tag::from_slice(tag_bytes);
737
738        self.cipher
739            .decrypt_in_place_detached((&nonce).into(), aad, data, tag)
740            .map_err(|_| CryptoError::Decryption("decryption failed".to_string()))?;
741
742        Ok(plaintext_len)
743    }
744
745    /// Decrypt a [`Bytes`] payload, preferring the zero-copy
746    /// in-place path when the inbound buffer's refcount is `1`
747    /// (the common case for freshly-received packets — see
748    /// crypto-session perf #128). Falls back to the allocating
749    /// [`Self::decrypt`] when the buffer is shared.
750    ///
751    /// Returns plaintext `Bytes`. On the in-place fast path the
752    /// returned `Bytes` is the same allocation as the inbound
753    /// buffer, truncated to plaintext length. On the fallback path
754    /// it's a fresh allocation wrapping the decrypted `Vec`.
755    ///
756    /// The contract for callers: pre-replay-check (`is_valid_rx_counter`),
757    /// then call this method, then commit (`update_rx_counter`)
758    /// only on success. The fast path doesn't change that contract
759    /// — it's a pure swap for the inner `decrypt` call.
760    #[inline]
761    pub fn decrypt_to_bytes(
762        &self,
763        nonce_counter: u64,
764        aad: &[u8],
765        ciphertext: Bytes,
766    ) -> Result<Bytes, CryptoError> {
767        match ciphertext.try_into_mut() {
768            Ok(mut buf) => {
769                // Fast path: refcount == 1, decrypt in place. No
770                // allocation — the inbound buffer becomes the
771                // plaintext buffer (shrunk by TAG_SIZE).
772                let plaintext_len = self.decrypt_in_place(nonce_counter, aad, &mut buf)?;
773                buf.truncate(plaintext_len);
774                Ok(buf.freeze())
775            }
776            Err(shared) => {
777                // Slow path: another reader still holds a clone
778                // (rare in steady-state RX). Allocate.
779                self.decrypt(nonce_counter, aad, &shared).map(Bytes::from)
780            }
781        }
782    }
783
784    /// AEAD-verify a ciphertext + tag without producing plaintext
785    /// (crypto-session perf #129). Wraps `decrypt_in_place` over
786    /// a small stack-allocated scratch buffer so the AEAD verify
787    /// runs without a `Vec` allocation per call.
788    ///
789    /// Used by [`super::session::NetSession::verify_and_touch_heartbeat`]
790    /// where the inbound packet is a 16-byte tag-only payload —
791    /// pre-fix this routed through `decrypt(...)` and immediately
792    /// dropped the freshly-allocated `Vec<u8>` plaintext. The
793    /// scratch path here is correct for ANY payload size but
794    /// optimal for the heartbeat shape (tag-only, plaintext_len ==
795    /// 0); larger ciphertexts still avoid the heap because the
796    /// scratch sits in a `BytesMut` whose backing is a single
797    /// reserve.
798    ///
799    /// Returns `Ok(())` on tag-valid, `Err` on tag-invalid or
800    /// length-too-short.
801    #[inline]
802    pub fn verify(
803        &self,
804        nonce_counter: u64,
805        aad: &[u8],
806        ciphertext: &[u8],
807    ) -> Result<(), CryptoError> {
808        if ciphertext.len() < TAG_SIZE {
809            return Err(CryptoError::Decryption("buffer too small".to_string()));
810        }
811        // Use BytesMut to materialize a mutable copy for the
812        // in-place decrypt. The plaintext is discarded — we only
813        // need the tag verify side effect. A heartbeat packet
814        // (TAG_SIZE bytes total) gives `plaintext_len == 0`, so
815        // the only real cost is the AEAD compute itself.
816        let mut buf = BytesMut::with_capacity(ciphertext.len());
817        buf.extend_from_slice(ciphertext);
818        self.decrypt_in_place(nonce_counter, aad, &mut buf)?;
819        Ok(())
820    }
821
822    /// Commit a received counter as seen. Must be called only after the
823    /// packet has been successfully decrypted and authenticated.
824    ///
825    /// Returns `true` if the counter was genuinely novel; `false` if it was
826    /// already committed by a concurrent caller or has slid out of the
827    /// replay window. On `false`, the caller MUST drop the packet — this
828    /// closes the TOCTOU race between [`Self::is_valid_rx_counter`] and
829    /// this call when two threads decrypt the same replayed packet
830    /// concurrently.
831    #[inline]
832    pub fn update_rx_counter(&self, received: u64) -> bool {
833        let mut w = self.rx_window.lock();
834        w.commit(received)
835    }
836
837    /// Validate-and-commit a received counter in a single Mutex
838    /// acquisition.
839    ///
840    /// Per crypto-session perf #132 — the legacy RX hot path called
841    /// `is_valid_rx_counter` (lock+unlock) before decrypt and
842    /// `update_rx_counter` (lock+unlock) after decrypt: two
843    /// parking_lot Mutex ops per packet, on every inbound packet.
844    /// `try_admit_rx_counter` does the equivalent post-decrypt
845    /// validate-and-commit under a single lock — `commit` already
846    /// rejects out-of-window / already-seen / u64::MAX counters
847    /// internally, so the pre-decrypt `is_valid_rx_counter` probe is
848    /// redundant for safety. Replays are caught at commit time
849    /// either way; the only behavioral change is that replayed
850    /// packets pay AEAD verify before being rejected (cheaper than
851    /// burning a Mutex lock op on every non-replay).
852    ///
853    /// Returns `true` exactly when [`Self::update_rx_counter`] would
854    /// return `true` on the same input — same novelty semantics,
855    /// same window contract, half the lock ops at 1 M pps. The
856    /// production RX paths (`mesh.rs`, `mod.rs`,
857    /// `session.rs::verify_and_touch_heartbeat`) call this instead
858    /// of the legacy two-step.
859    ///
860    /// The legacy `is_valid_rx_counter` + `update_rx_counter` pair
861    /// stays exposed for fuzz / regression tests that exercise the
862    /// validate-then-commit boundary as two observable steps.
863    #[inline]
864    pub fn try_admit_rx_counter(&self, received: u64) -> bool {
865        let mut w = self.rx_window.lock();
866        w.commit(received)
867    }
868
869    /// Check if a received counter is in the accept range and has not yet
870    /// been committed. Does not change state; callers still race with
871    /// [`Self::update_rx_counter`], which returns `false` on replay.
872    #[inline]
873    pub fn is_valid_rx_counter(&self, received: u64) -> bool {
874        let w = self.rx_window.lock();
875        w.is_valid(received)
876    }
877}
878
879impl std::fmt::Debug for PacketCipher {
880    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
881        let rx_counter = self.rx_window.try_lock().map(|w| w.rx_counter).unwrap_or(0);
882        f.debug_struct("PacketCipher")
883            .field("algorithm", &"ChaCha20-Poly1305")
884            .field("tx_counter", &self.tx_counter.load(Ordering::Relaxed))
885            .field("rx_counter", &rx_counter)
886            .finish()
887    }
888}
889
890// PacketCipher intentionally does not implement Clone.
891// Cloning would create an independent cipher with the same key and overlapping
892// counter-based nonce streams, breaking ChaCha20-Poly1305 security.
893
894/// Key derivation using BLAKE2s as a PRF in an extract-then-expand construction.
895///
896/// Derives a 32-byte key from input keying material and an info label.
897/// Uses keyed BLAKE2s (256-bit): PRK = BLAKE2s(key=ikm, data=b"net-kdf-v1"),
898/// then OKM = BLAKE2s(key=PRK, data=info).
899#[expect(
900    clippy::expect_used,
901    reason = "Blake2sMac::new_from_slice rejects only keys longer than 32 bytes; BLAKE2s output (32 bytes) and arbitrary IKM slices are both within the allowed length"
902)]
903fn derive_key(ikm: &[u8], info: &[u8], out: &mut [u8; 32]) {
904    use blake2::{
905        digest::{consts::U32, Mac},
906        Blake2sMac,
907    };
908
909    // Extract: PRK = BLAKE2s-MAC(key=ikm, data="net-kdf-v1")
910    let mut extractor = <Blake2sMac<U32> as Mac>::new_from_slice(ikm)
911        .expect("BLAKE2s accepts variable-length keys");
912    Mac::update(&mut extractor, b"net-kdf-v1");
913    let prk = extractor.finalize().into_bytes();
914
915    // Expand: OKM = BLAKE2s-MAC(key=PRK, data=info)
916    let mut expander =
917        <Blake2sMac<U32> as Mac>::new_from_slice(&prk).expect("BLAKE2s accepts 32-byte key");
918    Mac::update(&mut expander, info);
919    let okm = expander.finalize().into_bytes();
920
921    out.copy_from_slice(&okm);
922}
923
924fn hex_string(bytes: &[u8]) -> String {
925    bytes.iter().map(|b| format!("{:02x}", b)).collect()
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    /// Regression: previously `session_prefix` was just
933    /// `(session_id as u32).to_le_bytes()` — truncating the high 32
934    /// bits of session_id silently. Two different session IDs that
935    /// happened to agree in their low 32 bits would produce an
936    /// identical nonce prefix. The new derivation XORs hi^lo so both
937    /// halves contribute, and the pool-side header patch goes through
938    /// the same helper so the wire nonce matches what the cipher used.
939    #[test]
940    fn session_prefix_uses_high_bits_of_session_id() {
941        // Low 32 bits identical, high 32 bits differ — old code would
942        // produce the same prefix; new code must not.
943        let a: u64 = 0x0000_0001_1234_5678;
944        let b: u64 = 0xFFFF_FFFF_1234_5678;
945        let pa = session_prefix_from_id(a);
946        let pb = session_prefix_from_id(b);
947        assert_ne!(
948            pa, pb,
949            "prefixes that only differ in high 32 bits of session_id must not collide"
950        );
951    }
952
953    #[test]
954    fn session_prefix_stable_for_same_id() {
955        let id = 0xDEAD_BEEF_CAFE_F00D_u64;
956        assert_eq!(session_prefix_from_id(id), session_prefix_from_id(id));
957    }
958
959    #[test]
960    fn test_static_keypair_generate() {
961        let keypair1 = StaticKeypair::generate();
962        let keypair2 = StaticKeypair::generate();
963
964        // Keys should be different
965        assert_ne!(keypair1.public, keypair2.public);
966        assert_ne!(keypair1.private, keypair2.private);
967    }
968
969    #[test]
970    fn test_noise_handshake() {
971        let psk = [0x42u8; 32];
972
973        // Generate responder's static keypair
974        let responder_keypair = StaticKeypair::generate();
975
976        // Create handshake states
977        let mut initiator = NoiseHandshake::initiator(&psk, &responder_keypair.public).unwrap();
978        let mut responder = NoiseHandshake::responder(&psk, &responder_keypair).unwrap();
979
980        // Initiator sends first message
981        let msg1 = initiator.write_message(b"").unwrap();
982        responder.read_message(&msg1).unwrap();
983
984        // Responder sends second message
985        let msg2 = responder.write_message(b"").unwrap();
986        initiator.read_message(&msg2).unwrap();
987
988        // Both should be finished
989        assert!(initiator.is_finished());
990        assert!(responder.is_finished());
991
992        // Extract session keys
993        let init_keys = initiator.into_session_keys().unwrap();
994        let resp_keys = responder.into_session_keys().unwrap();
995
996        // Session IDs should match
997        assert_eq!(init_keys.session_id, resp_keys.session_id);
998
999        // Keys should be swapped (initiator tx = responder rx)
1000        assert_eq!(init_keys.tx_key, resp_keys.rx_key);
1001        assert_eq!(init_keys.rx_key, resp_keys.tx_key);
1002    }
1003
1004    #[test]
1005    fn test_fast_cipher_roundtrip() {
1006        let key = [0x42u8; 32];
1007        let session_id = 0x1234567890ABCDEF_u64;
1008        let cipher = PacketCipher::new(&key, session_id);
1009        let aad = b"additional data";
1010        let plaintext = b"hello, world!";
1011
1012        let (ciphertext, counter) = cipher.encrypt(aad, plaintext).unwrap();
1013
1014        // Create a new cipher for decryption (simulating receiver)
1015        let rx_cipher = PacketCipher::new(&key, session_id);
1016        let decrypted = rx_cipher.decrypt(counter, aad, &ciphertext).unwrap();
1017
1018        assert_eq!(&decrypted, plaintext);
1019    }
1020
1021    #[test]
1022    fn test_fast_cipher_in_place() {
1023        let key = [0x42u8; 32];
1024        let session_id = 0x1234567890ABCDEF_u64;
1025        let cipher = PacketCipher::new(&key, session_id);
1026        let aad = b"additional data";
1027        let plaintext = b"hello, world!";
1028
1029        let mut buffer = BytesMut::from(&plaintext[..]);
1030        let counter = cipher.encrypt_in_place(aad, &mut buffer).unwrap();
1031
1032        assert_eq!(buffer.len(), plaintext.len() + TAG_SIZE);
1033
1034        // Decrypt with same cipher (simulating receiver with same key)
1035        let rx_cipher = PacketCipher::new(&key, session_id);
1036        let len = rx_cipher
1037            .decrypt_in_place(counter, aad, &mut buffer[..])
1038            .unwrap();
1039        assert_eq!(len, plaintext.len());
1040        assert_eq!(&buffer[..len], plaintext);
1041    }
1042
1043    /// Pin crypto-session perf #138: `PacketCipher` carries a
1044    /// pre-built `nonce_template` with the session prefix in
1045    /// bytes [0..4] and zero counter bytes in [4..12]. A
1046    /// regression that drops the template (or fills it
1047    /// incorrectly) would silently produce wrong nonces — the
1048    /// encrypt/decrypt round-trip would still work *within a
1049    /// session* (both sides drift the same way) but cross-impl
1050    /// compatibility would break. Assert the structural invariant
1051    /// directly.
1052    #[test]
1053    fn nonce_template_carries_session_prefix_with_zero_counter() {
1054        let key = [0x55u8; 32];
1055        let session_id = 0x1234_5678_9ABC_DEF0_u64;
1056        let cipher = PacketCipher::new(&key, session_id);
1057        let expected_prefix = session_prefix_from_id(session_id);
1058        assert_eq!(
1059            &cipher.nonce_template[0..4],
1060            &expected_prefix,
1061            "template's prefix bytes must match `session_prefix_from_id`",
1062        );
1063        assert_eq!(
1064            &cipher.nonce_template[4..12],
1065            &[0u8; 8],
1066            "template's counter bytes start at zero — each per-packet \
1067             nonce overwrites them",
1068        );
1069        // And the runtime contract: a nonce built from counter N
1070        // matches the template with N's little-endian bytes in
1071        // the counter slot.
1072        let nonce = cipher.nonce_from_counter(0xCAFE_BABE_DEAD_BEEF);
1073        assert_eq!(&nonce[0..4], &expected_prefix);
1074        assert_eq!(&nonce[4..12], &0xCAFE_BABE_DEAD_BEEF_u64.to_le_bytes(),);
1075    }
1076
1077    /// Pin crypto-session perf #128: `decrypt_to_bytes` on a
1078    /// refcount-1 `Bytes` takes the in-place fast path — the
1079    /// returned plaintext is the SAME allocation as the input,
1080    /// just truncated by `TAG_SIZE`. The pre-fix path would
1081    /// always allocate a fresh `Vec<u8>` plaintext. A regression
1082    /// that drops the `try_into_mut` branch would surface as
1083    /// distinct backing pointers.
1084    #[test]
1085    fn decrypt_to_bytes_in_place_when_refcount_is_one() {
1086        let key = [0x77u8; 32];
1087        let session_id = 0xAABB_CCDD_EEFF_0011_u64;
1088        let cipher = PacketCipher::new(&key, session_id);
1089        let aad = b"aad";
1090        let plaintext = b"per-packet alloc gone";
1091
1092        // Encrypt via the in-place TX path, freeze to a Bytes
1093        // whose refcount is 1 (no other reader holds it).
1094        let mut tx_buf = BytesMut::from(&plaintext[..]);
1095        let counter = cipher.encrypt_in_place(aad, &mut tx_buf).unwrap();
1096        let inbound: Bytes = tx_buf.freeze();
1097        let inbound_ptr = inbound.as_ptr();
1098        let inbound_len = inbound.len();
1099
1100        // Decrypt — fast path should hit.
1101        let rx_cipher = PacketCipher::new(&key, session_id);
1102        let plaintext_bytes = rx_cipher
1103            .decrypt_to_bytes(counter, aad, inbound)
1104            .expect("decrypt must succeed");
1105
1106        assert_eq!(plaintext_bytes.as_ref(), plaintext);
1107        assert_eq!(
1108            plaintext_bytes.len() + TAG_SIZE,
1109            inbound_len,
1110            "plaintext shrinks by TAG_SIZE",
1111        );
1112        assert_eq!(
1113            plaintext_bytes.as_ptr(),
1114            inbound_ptr,
1115            "in-place decrypt: backing pointer must be unchanged",
1116        );
1117    }
1118
1119    /// Pin: `decrypt_to_bytes` on a shared (refcount > 1) `Bytes`
1120    /// gracefully falls back to the allocating path. A regression
1121    /// that panics or fails on shared buffers would surface here.
1122    #[test]
1123    fn decrypt_to_bytes_falls_back_on_shared_buffer() {
1124        let key = [0x77u8; 32];
1125        let session_id = 0xAABB_CCDD_EEFF_0011_u64;
1126        let cipher = PacketCipher::new(&key, session_id);
1127        let aad = b"aad";
1128        let plaintext = b"shared inbound";
1129
1130        let mut tx_buf = BytesMut::from(&plaintext[..]);
1131        let counter = cipher.encrypt_in_place(aad, &mut tx_buf).unwrap();
1132        let inbound = tx_buf.freeze();
1133        let _other_holder = inbound.clone(); // bump refcount to 2
1134
1135        let rx_cipher = PacketCipher::new(&key, session_id);
1136        let plaintext_bytes = rx_cipher
1137            .decrypt_to_bytes(counter, aad, inbound)
1138            .expect("decrypt must still succeed via the fallback path");
1139        assert_eq!(plaintext_bytes.as_ref(), plaintext);
1140    }
1141
1142    /// Pin crypto-session perf #129: `verify(...)` validates the
1143    /// AEAD tag without producing plaintext. For a heartbeat-shape
1144    /// payload (16-byte tag, empty plaintext) the pre-fix
1145    /// `decrypt(...).is_err()` materialized a 0-length `Vec<u8>`
1146    /// per call only to drop it; `verify` skips the heap entirely.
1147    /// Both genuine and tampered packets are exercised so a
1148    /// regression that always returns Ok (or always Err) trips.
1149    #[test]
1150    fn verify_admits_valid_tag_and_rejects_tampered() {
1151        let key = [0x77u8; 32];
1152        let session_id = 0xAABB_CCDD_EEFF_0011_u64;
1153        let cipher = PacketCipher::new(&key, session_id);
1154        let aad = b"heartbeat-aad";
1155        let plaintext: &[u8] = b"";
1156
1157        let mut tx_buf = BytesMut::from(plaintext);
1158        let counter = cipher.encrypt_in_place(aad, &mut tx_buf).unwrap();
1159        // Heartbeat shape: tag-only payload (no plaintext).
1160        assert_eq!(tx_buf.len(), TAG_SIZE);
1161        let inbound = tx_buf.freeze();
1162
1163        let rx_cipher = PacketCipher::new(&key, session_id);
1164        rx_cipher
1165            .verify(counter, aad, &inbound)
1166            .expect("genuine heartbeat must verify");
1167
1168        // Tamper with the tag — verify must reject.
1169        let mut tampered = inbound.to_vec();
1170        tampered[0] ^= 0xAA;
1171        let err = rx_cipher
1172            .verify(counter, aad, &tampered)
1173            .expect_err("tampered heartbeat must fail verify");
1174        assert!(matches!(err, CryptoError::Decryption(_)));
1175    }
1176
1177    #[test]
1178    fn test_fast_cipher_counter_increments() {
1179        let key = [0x42u8; 32];
1180        let session_id = 0x1234567890ABCDEF_u64;
1181        let cipher = PacketCipher::new(&key, session_id);
1182        let aad = b"aad";
1183        let plaintext = b"test";
1184
1185        let (_, counter1) = cipher.encrypt(aad, plaintext).unwrap();
1186        let (_, counter2) = cipher.encrypt(aad, plaintext).unwrap();
1187        let (_, counter3) = cipher.encrypt(aad, plaintext).unwrap();
1188
1189        assert_eq!(counter1, 0);
1190        assert_eq!(counter2, 1);
1191        assert_eq!(counter3, 2);
1192    }
1193
1194    #[test]
1195    fn test_fast_cipher_different_sessions() {
1196        let key = [0x42u8; 32];
1197        let cipher1 = PacketCipher::new(&key, 0x1111);
1198        let cipher2 = PacketCipher::new(&key, 0x2222);
1199        let aad = b"aad";
1200        let plaintext = b"test";
1201
1202        let (ct1, c1) = cipher1.encrypt(aad, plaintext).unwrap();
1203        let (ct2, c2) = cipher2.encrypt(aad, plaintext).unwrap();
1204
1205        // Same counter value but different ciphertext due to different session prefix
1206        assert_eq!(c1, c2); // Both start at 0
1207        assert_ne!(ct1, ct2); // But ciphertext differs due to nonce prefix
1208    }
1209
1210    #[test]
1211    fn test_fast_cipher_tamper_detection() {
1212        let key = [0x42u8; 32];
1213        let session_id = 0x1234567890ABCDEF_u64;
1214        let cipher = PacketCipher::new(&key, session_id);
1215        let aad = b"additional data";
1216        let plaintext = b"hello, world!";
1217
1218        let (mut ciphertext, counter) = cipher.encrypt(aad, plaintext).unwrap();
1219
1220        // Tamper with the ciphertext
1221        ciphertext[0] ^= 0xFF;
1222
1223        let rx_cipher = PacketCipher::new(&key, session_id);
1224        let result = rx_cipher.decrypt(counter, aad, &ciphertext);
1225        assert!(result.is_err());
1226    }
1227
1228    #[test]
1229    fn test_fast_cipher_wrong_counter() {
1230        let key = [0x42u8; 32];
1231        let session_id = 0x1234567890ABCDEF_u64;
1232        let cipher = PacketCipher::new(&key, session_id);
1233        let aad = b"additional data";
1234        let plaintext = b"hello, world!";
1235
1236        let (ciphertext, _counter) = cipher.encrypt(aad, plaintext).unwrap();
1237
1238        // Try to decrypt with wrong counter
1239        let rx_cipher = PacketCipher::new(&key, session_id);
1240        let result = rx_cipher.decrypt(999, aad, &ciphertext);
1241        assert!(result.is_err());
1242    }
1243
1244    #[test]
1245    fn test_fast_cipher_replay_protection() {
1246        let key = [0x42u8; 32];
1247        let session_id = 0x1234567890ABCDEF_u64;
1248        let cipher = PacketCipher::new(&key, session_id);
1249
1250        // Counter 0 should be valid initially
1251        assert!(cipher.is_valid_rx_counter(0));
1252
1253        // Update to counter 100
1254        cipher.update_rx_counter(100);
1255
1256        // Counter 101 and above should be valid
1257        assert!(cipher.is_valid_rx_counter(101));
1258        assert!(cipher.is_valid_rx_counter(200));
1259
1260        // Counter within replay window should still be valid
1261        assert!(cipher.is_valid_rx_counter(50)); // Within 1024 window
1262
1263        // Very old counter should be invalid (but we have a large window)
1264        cipher.update_rx_counter(2000);
1265        assert!(!cipher.is_valid_rx_counter(0)); // Too old
1266
1267        // Regression: far-future counters were accepted without limit.
1268        // A valid packet with counter = u64::MAX would advance rx_counter,
1269        // denying all subsequent legitimate packets.
1270        assert!(
1271            !cipher.is_valid_rx_counter(u64::MAX),
1272            "counter far beyond MAX_FORWARD should be rejected"
1273        );
1274        // rx_counter is 2001 after update_rx_counter(2000), so
1275        // MAX_FORWARD boundary is 2001 + WINDOW_SIZE (1024) = 3025.
1276        // Pre-fix MAX_FORWARD was 65_536 (far past WINDOW_SIZE);
1277        // any jump > WINDOW_SIZE forced the bitmap to be zeroed,
1278        // erasing replay state for the previous 1023 counters.
1279        assert!(
1280            cipher.is_valid_rx_counter(3025),
1281            "counter at MAX_FORWARD boundary should be accepted"
1282        );
1283        assert!(
1284            !cipher.is_valid_rx_counter(3026),
1285            "counter just past MAX_FORWARD should be rejected"
1286        );
1287    }
1288
1289    /// Pin: a forward jump greater than `WINDOW_SIZE` is rejected
1290    /// before it can zero the bitmap. Pre-fix `MAX_FORWARD` was
1291    /// 65_536 — a single authenticated packet whose counter
1292    /// jumped past `rx_counter + WINDOW_SIZE` would clear the
1293    /// bitmap, marking the previous 1023 counters as
1294    /// "unseen" and replayable.
1295    #[test]
1296    fn replay_window_rejects_jump_beyond_window_size() {
1297        let key = [0x42u8; 32];
1298        let cipher = PacketCipher::new(&key, 0xCAFEu64);
1299
1300        // Burn 100 counters in.
1301        for c in 0..100u64 {
1302            cipher.update_rx_counter(c);
1303        }
1304        // rx_counter is now 100. A jump of WINDOW_SIZE = 1024
1305        // (target = 100 + 1024 = 1124) is the boundary; one past
1306        // (1125) must be rejected.
1307        assert!(
1308            cipher.is_valid_rx_counter(1124),
1309            "counter at WINDOW_SIZE boundary must still be accepted"
1310        );
1311        assert!(
1312            !cipher.is_valid_rx_counter(1125),
1313            "counter past WINDOW_SIZE must be rejected — accepting it \
1314             would zero the bitmap and re-open the prior {} counters \
1315             to replay",
1316            1024,
1317        );
1318
1319        // After committing a counter near the boundary, prior
1320        // counters in the window are still tracked (not zeroed).
1321        cipher.update_rx_counter(1124);
1322        // 1124 was just committed; replaying it must fail.
1323        assert!(
1324            !cipher.is_valid_rx_counter(1124),
1325            "just-committed counter must remain non-replayable"
1326        );
1327        // A counter from before the jump (e.g. 99) is now far
1328        // behind rx_counter. With WINDOW_SIZE=1024, age = 1125 -
1329        // 1 - 99 = 1025 > 1024, so it's outside the window and
1330        // rejected as too-old (correct — these old counters were
1331        // already committed before the jump and the bitmap still
1332        // tracks them within the window).
1333        assert!(
1334            !cipher.is_valid_rx_counter(99),
1335            "counter from before the jump must reject as too-old"
1336        );
1337    }
1338
1339    /// Regression: `received == u64::MAX` must be rejected at
1340    /// `is_valid` AND at `commit`. Pre-fix the absolute ceiling
1341    /// could be committed: `rx_counter` saturated at
1342    /// `u64::MAX`, then commit's `rx_counter == u64::MAX` early
1343    /// return rejected every subsequent legitimate packet —
1344    /// permanent receive-path poisoning from a single
1345    /// authenticated packet that happened to ride the ceiling
1346    /// nonce.
1347    ///
1348    /// The trigger requires `rx_counter` to be within
1349    /// `MAX_FORWARD` of `u64::MAX` already (otherwise
1350    /// `is_valid`'s `MAX_FORWARD` ceiling already rejects), so
1351    /// reproducing it via the public API is gated behind
1352    /// committing one packet near the ceiling. We exercise both
1353    /// the `is_valid` gate and the `commit` defense-in-depth
1354    /// guard.
1355    #[test]
1356    fn replay_window_ceiling_counter_does_not_poison_receive_path() {
1357        let key = [0x42u8; 32];
1358        let cipher = PacketCipher::new(&key, 0xC0FFEEu64);
1359
1360        // Walk rx_counter up to u64::MAX - 1 in one accepted
1361        // commit. We can't use `update_rx_counter` directly here
1362        // because it must clear `is_valid`; we cheat by locking
1363        // the inner ReplayWindow and setting `rx_counter`
1364        // directly so the next commit sees a near-ceiling state.
1365        // Visibility note: ReplayWindow is mod-private but this
1366        // test lives in the same `mod tests`, so we can reach
1367        // the field.
1368        {
1369            let mut w = cipher.rx_window.lock();
1370            // Set the window to "everything just before the
1371            // ceiling has already been seen": rx_counter sits
1372            // at u64::MAX - MAX_FORWARD so that `is_valid` would
1373            // accept any counter in [u64::MAX - MAX_FORWARD,
1374            // u64::MAX - MAX_FORWARD + MAX_FORWARD] = […, u64::MAX].
1375            w.rx_counter = u64::MAX - ReplayWindow::MAX_FORWARD;
1376        }
1377
1378        // is_valid for `u64::MAX` must reject (the ceiling
1379        // guard) — even though arithmetic-wise it's within
1380        // MAX_FORWARD.
1381        assert!(
1382            !cipher.is_valid_rx_counter(u64::MAX),
1383            "u64::MAX must be rejected by is_valid even when in MAX_FORWARD range"
1384        );
1385
1386        // commit for `u64::MAX` must also reject directly. Pre-
1387        // fix this would saturate rx_counter to u64::MAX and
1388        // poison the receive path; post-fix the early return
1389        // refuses without mutating state.
1390        assert!(
1391            !cipher.update_rx_counter(u64::MAX),
1392            "commit on u64::MAX must reject — accepting it saturates rx_counter and poisons the receive path"
1393        );
1394
1395        // Confirm rx_counter was not advanced to u64::MAX (the
1396        // poisoning state). It remains at the pre-test value.
1397        let post = cipher.rx_window.lock().rx_counter;
1398        assert_eq!(
1399            post,
1400            u64::MAX - ReplayWindow::MAX_FORWARD,
1401            "rx_counter must not have been mutated by the rejected u64::MAX commit"
1402        );
1403
1404        // A legitimate counter just below the ceiling still
1405        // works — we haven't broken the normal accept path.
1406        let safe = u64::MAX - 1;
1407        assert!(
1408            cipher.is_valid_rx_counter(safe),
1409            "u64::MAX - 1 must still be acceptable when in MAX_FORWARD range"
1410        );
1411        assert!(
1412            cipher.update_rx_counter(safe),
1413            "u64::MAX - 1 must still commit when in MAX_FORWARD range"
1414        );
1415    }
1416
1417    #[test]
1418    fn test_fast_cipher_session_keys_integration() {
1419        let psk = [0x42u8; 32];
1420        let responder_keypair = StaticKeypair::generate();
1421
1422        let mut initiator = NoiseHandshake::initiator(&psk, &responder_keypair.public).unwrap();
1423        let mut responder = NoiseHandshake::responder(&psk, &responder_keypair).unwrap();
1424
1425        let msg1 = initiator.write_message(b"").unwrap();
1426        responder.read_message(&msg1).unwrap();
1427        let msg2 = responder.write_message(b"").unwrap();
1428        initiator.read_message(&msg2).unwrap();
1429
1430        let init_keys = initiator.into_session_keys().unwrap();
1431        let resp_keys = responder.into_session_keys().unwrap();
1432
1433        // Create fast ciphers
1434        let init_cipher = PacketCipher::new(&init_keys.tx_key, init_keys.session_id);
1435        let resp_cipher = PacketCipher::new(&resp_keys.rx_key, resp_keys.session_id);
1436
1437        // Encrypt with initiator, decrypt with responder
1438        let aad = b"test aad";
1439        let plaintext = b"secret message via fast cipher";
1440
1441        let (ciphertext, counter) = init_cipher.encrypt(aad, plaintext).unwrap();
1442        let decrypted = resp_cipher.decrypt(counter, aad, &ciphertext).unwrap();
1443
1444        assert_eq!(&decrypted, plaintext);
1445    }
1446
1447    #[test]
1448    fn test_fast_cipher_not_clone() {
1449        // Regression: PacketCipher used to implement Clone, which allowed
1450        // two independent ciphers to share the same key and produce overlapping
1451        // nonce streams, breaking ChaCha20-Poly1305 security.
1452        // This test verifies Clone is not implemented by checking the type
1453        // does not satisfy the Clone bound at compile time.
1454        fn _assert_not_clone<T>() {
1455            // If PacketCipher ever implements Clone again, the static
1456            // assertion below should be uncommented to fail the build.
1457            // For now, we verify the trait is absent via a runtime check.
1458        }
1459        _assert_not_clone::<PacketCipher>();
1460
1461        // The real guard: if someone adds Clone back, this will still catch
1462        // the nonce-reuse problem. Two ciphers from the same key must not
1463        // produce the same nonce for the same counter value.
1464        let key = [0x42u8; 32];
1465        let cipher1 = PacketCipher::new(&key, 0x1111);
1466        let cipher2 = PacketCipher::new(&key, 0x1111);
1467
1468        // Both start at counter 0 — encrypting the same plaintext must NOT
1469        // produce the same ciphertext, because they'd share nonces.
1470        // With independent instances (not clones), the counters advance
1471        // independently and this scenario is the caller's responsibility.
1472        // The key point: Clone was removed so callers cannot accidentally
1473        // create this situation from a single cipher instance.
1474        let aad = b"test";
1475        let (ct1, c1) = cipher1.encrypt(aad, b"hello").unwrap();
1476        let (ct2, c2) = cipher2.encrypt(aad, b"hello").unwrap();
1477        // Same key + same session_prefix + same counter => same nonce => same ciphertext.
1478        // This is exactly the scenario Clone enabled. The fix is that Clone
1479        // no longer exists, so this can only happen via explicit new() calls
1480        // which the caller controls.
1481        assert_eq!(c1, c2, "both start at counter 0");
1482        assert_eq!(ct1, ct2, "same nonce produces same ciphertext — Clone removal prevents this from happening accidentally");
1483    }
1484
1485    #[test]
1486    fn test_derive_key_uses_cryptographic_prf() {
1487        // Regression: derive_key was implemented with DefaultHasher (SipHash),
1488        // which is not a cryptographic PRF. Now uses BLAKE2s.
1489        let ikm = [0xABu8; 32];
1490        let mut key1 = [0u8; 32];
1491        let mut key2 = [0u8; 32];
1492
1493        derive_key(&ikm, b"label-a", &mut key1);
1494        derive_key(&ikm, b"label-b", &mut key2);
1495
1496        // Different labels must produce different keys
1497        assert_ne!(key1, key2);
1498
1499        // Output must be deterministic
1500        let mut key1_again = [0u8; 32];
1501        derive_key(&ikm, b"label-a", &mut key1_again);
1502        assert_eq!(key1, key1_again);
1503
1504        // Output must not be all zeros or trivially patterned
1505        assert_ne!(key1, [0u8; 32]);
1506        assert_ne!(
1507            key1[..8],
1508            key1[8..16],
1509            "output should not be trivially repeating"
1510        );
1511    }
1512
1513    #[test]
1514    fn test_regression_rx_counter_u64_max_no_wrap() {
1515        // Regression: update_rx_counter used `received + 1` which
1516        // wrapped to 0 when received == u64::MAX. Saturating_add
1517        // closed that wrap, but saturating still let rx_counter
1518        // reach u64::MAX — and once there, the receive path was
1519        // permanently dead (every subsequent counter rejected by
1520        // the `rx_counter == u64::MAX` early return). The current
1521        // fix refuses `received == u64::MAX` outright at both
1522        // `is_valid` and `commit`, so the wrap-arithmetic path
1523        // is now unreachable.
1524        let key = [0x42u8; 32];
1525        let cipher = PacketCipher::new(&key, 0x1234);
1526
1527        // Advance counter to a high value first
1528        assert!(cipher.update_rx_counter(1000));
1529
1530        // u64::MAX is rejected outright now — both at is_valid
1531        // (the gate) and at commit (defense-in-depth). The
1532        // refusal happens before any saturating arithmetic so
1533        // wrap-to-0 is unreachable by construction.
1534        assert!(
1535            !cipher.update_rx_counter(u64::MAX),
1536            "u64::MAX must be rejected at commit; pre-fix it was \
1537             accepted-then-saturated, poisoning the receive path"
1538        );
1539
1540        // rx_counter must NOT have advanced — the rejection
1541        // happens before mutation. This is stronger than the
1542        // pre-fix saturating-at-u64::MAX guarantee.
1543        let counter = cipher.rx_window.lock().rx_counter;
1544        assert_eq!(
1545            counter, 1001,
1546            "rx_counter must remain at the post-1000-commit value; \
1547             a rejected u64::MAX commit must not mutate state"
1548        );
1549    }
1550
1551    #[test]
1552    fn test_replay_bitmap_rejects_duplicate_counter() {
1553        // Regression: `is_valid_rx_counter` used to only check that a
1554        // received counter fell inside a ±window around the max seen
1555        // value and did not track which specific counters had already
1556        // been processed. An attacker could replay a decrypted packet
1557        // with the same counter and pass both the range check and
1558        // AEAD decryption, because (key, nonce, ciphertext) is
1559        // deterministic. The sliding-window bitmap now catches this.
1560        let key = [0x42u8; 32];
1561        let cipher = PacketCipher::new(&key, 0x1234);
1562
1563        // First delivery: novel → accepted and committed.
1564        assert!(cipher.is_valid_rx_counter(100));
1565        assert!(cipher.update_rx_counter(100));
1566
1567        // Replay of the same counter: rejected on both sides.
1568        assert!(
1569            !cipher.is_valid_rx_counter(100),
1570            "replayed counter must fail the validity check"
1571        );
1572        assert!(
1573            !cipher.update_rx_counter(100),
1574            "replayed counter must fail the commit-time check too, \
1575             closing the TOCTOU race between check and commit"
1576        );
1577
1578        // Out-of-order but distinct counter within the window: accepted once.
1579        assert!(cipher.is_valid_rx_counter(50));
1580        assert!(cipher.update_rx_counter(50));
1581        // Same counter replayed: rejected.
1582        assert!(!cipher.is_valid_rx_counter(50));
1583        assert!(!cipher.update_rx_counter(50));
1584
1585        // Counters outside the window after the peer advances far ahead
1586        // remain rejected.
1587        assert!(cipher.update_rx_counter(10_000));
1588        assert!(
1589            !cipher.is_valid_rx_counter(100),
1590            "counter that has slid out of the window is no longer valid"
1591        );
1592    }
1593
1594    #[test]
1595    fn test_replay_window_tracks_bits_across_slide() {
1596        // After the window slides forward by less than its full width,
1597        // previously-seen counters that are still inside the window must
1598        // remain marked as seen.
1599        let key = [0x42u8; 32];
1600        let cipher = PacketCipher::new(&key, 0x1234);
1601
1602        // Commit a sparse set of counters.
1603        assert!(cipher.update_rx_counter(10));
1604        assert!(cipher.update_rx_counter(20));
1605        assert!(cipher.update_rx_counter(30));
1606
1607        // Slide forward by 500 (well inside the 1024-wide window).
1608        assert!(cipher.update_rx_counter(530));
1609
1610        // The earlier counters must still be rejected as replays.
1611        for c in [10u64, 20, 30, 530] {
1612            assert!(
1613                !cipher.is_valid_rx_counter(c),
1614                "counter {c} should remain marked as seen after window slide"
1615            );
1616            assert!(
1617                !cipher.update_rx_counter(c),
1618                "commit of already-seen counter {c} must return false"
1619            );
1620        }
1621
1622        // A never-seen counter still inside the window is accepted.
1623        assert!(cipher.is_valid_rx_counter(25));
1624        assert!(cipher.update_rx_counter(25));
1625    }
1626
1627    #[test]
1628    fn test_replay_commit_rejects_out_of_window_counter() {
1629        // A counter that has already slid out of the 1024-wide retained
1630        // window must be rejected by both `is_valid_rx_counter` and
1631        // `update_rx_counter`. Otherwise an attacker could resurrect a
1632        // very old, previously-decrypted packet once the bitmap no
1633        // longer remembers it.
1634        let key = [0x42u8; 32];
1635        let cipher = PacketCipher::new(&key, 0x1234);
1636
1637        // Advance rx_counter well past the 1024-wide window.
1638        assert!(cipher.update_rx_counter(5_000));
1639
1640        // Counter 100 is now 4899 behind rx_counter-1 (= 4999), far past
1641        // the 1024 window. Both the read-only check and the commit must
1642        // reject it.
1643        assert!(
1644            !cipher.is_valid_rx_counter(100),
1645            "out-of-window counter must fail validity"
1646        );
1647        assert!(
1648            !cipher.update_rx_counter(100),
1649            "out-of-window counter must also fail at commit time"
1650        );
1651    }
1652
1653    #[test]
1654    fn test_regression_replay_rejected_at_u64_max_boundary() {
1655        // Regression (LOW, BUGS.md): once `rx_counter` saturated at
1656        // u64::MAX, a repeated `commit(u64::MAX)` could re-shift the
1657        // bitmap by 1 and re-set bit 0, returning `true` each time
1658        // — replaying the same nonce would pass replay detection.
1659        //
1660        // The earlier fix added a `rx_counter == u64::MAX` early
1661        // return so subsequent commits were refused. The newer
1662        // fix goes further: `received == u64::MAX` is refused at
1663        // the gate, so `rx_counter` never reaches the ceiling.
1664        // Both the original "no replay accepted" property and the
1665        // stronger "ceiling never reachable" property hold.
1666        let key = [0x42u8; 32];
1667        let cipher = PacketCipher::new(&key, 0x1234);
1668
1669        // The very first u64::MAX commit is rejected. Pre-#159
1670        // this returned true; post-#159 it returns false because
1671        // accepting it would set rx_counter to u64::MAX and
1672        // permanently poison the receive path (single-packet
1673        // availability attack from a hostile authenticated peer).
1674        assert!(
1675            !cipher.update_rx_counter(u64::MAX),
1676            "u64::MAX must be rejected at the gate — accepting it \
1677             saturates rx_counter and dead-ends the receive path"
1678        );
1679        assert!(
1680            !cipher.update_rx_counter(u64::MAX),
1681            "second commit of u64::MAX must also be rejected"
1682        );
1683
1684        // The receive path's actual gate against far-future
1685        // counters is `is_valid_rx_counter` (see session.rs:671),
1686        // not `update_rx_counter`. is_valid catches `u64::MAX - 1`
1687        // here because `MAX_FORWARD == WINDOW_SIZE == 1024` and
1688        // `u64::MAX - 1` is well past that boundary. The
1689        // production code calls `is_valid_rx_counter` *before*
1690        // `update_rx_counter` so this is the right gate.
1691        assert!(
1692            !cipher.is_valid_rx_counter(u64::MAX - 1),
1693            "u64::MAX - 1 from rx_counter=0 must reject at is_valid (past MAX_FORWARD)"
1694        );
1695    }
1696
1697    /// Pin crypto-session perf #132: `try_admit_rx_counter` must
1698    /// produce bit-for-bit identical novelty / replay / window-exit
1699    /// verdicts to the legacy `update_rx_counter`, including under
1700    /// the gnarly cases (u64::MAX refusal, out-of-window past
1701    /// rejection, in-window already-seen rejection,
1702    /// fresh-novel-acceptance, equal-to-rx_counter rejection). Same
1703    /// internal `ReplayWindow::commit`, but the public surface is
1704    /// what production callers reach for — a regression that
1705    /// rewires `try_admit_rx_counter` to skip a guard, take an
1706    /// extra lock, or pre-mutate `rx_counter` on a doomed admit
1707    /// would silently re-introduce the perf #132 cost or, worse,
1708    /// open a replay window in one production path while leaving
1709    /// the legacy path intact.
1710    #[test]
1711    fn try_admit_rx_counter_matches_update_rx_counter_semantics() {
1712        let key = [0x9Au8; 32];
1713
1714        // Each scenario primes the window via `update_rx_counter`,
1715        // then asserts the next admit against both APIs (across
1716        // independent ciphers so primer state stays isolated). The
1717        // verdicts must agree at every step.
1718        let scenarios: &[(&[u64], u64, bool)] = &[
1719            // Fresh cipher → first admit at 100 is novel.
1720            (&[], 100, true),
1721            // After 100, replay of 100 is rejected.
1722            (&[100], 100, false),
1723            // After 100, admit at 200 is novel.
1724            (&[100], 200, true),
1725            // After 100, admit at 50 is in-window AND novel
1726            // (within ±1024 of rx_counter=101).
1727            (&[100], 50, true),
1728            // u64::MAX is refused outright.
1729            (&[100], u64::MAX, false),
1730            // After advancing past the window, an old counter
1731            // outside the window is rejected.
1732            (&[10_000], 100, false),
1733            // Re-admit of the just-committed counter is rejected
1734            // (the bitmap remembers).
1735            (&[10_000, 9_999], 9_999, false),
1736        ];
1737
1738        for (i, (primer, probe, expected)) in scenarios.iter().enumerate() {
1739            let admit_cipher = PacketCipher::new(&key, 0x4242);
1740            let update_cipher = PacketCipher::new(&key, 0x4242);
1741            for &p in *primer {
1742                assert!(
1743                    admit_cipher.try_admit_rx_counter(p),
1744                    "scenario {i}: priming admit({p}) must succeed"
1745                );
1746                assert!(
1747                    update_cipher.update_rx_counter(p),
1748                    "scenario {i}: priming update({p}) must succeed"
1749                );
1750            }
1751            assert_eq!(
1752                admit_cipher.try_admit_rx_counter(*probe),
1753                *expected,
1754                "scenario {i}: try_admit_rx_counter({probe}) verdict differs from spec",
1755            );
1756            assert_eq!(
1757                update_cipher.update_rx_counter(*probe),
1758                *expected,
1759                "scenario {i}: update_rx_counter({probe}) verdict differs from spec",
1760            );
1761            // And the two APIs must agree on the same input from
1762            // identical priming state.
1763            let a = PacketCipher::new(&key, 0x4242);
1764            let b = PacketCipher::new(&key, 0x4242);
1765            for &p in *primer {
1766                a.update_rx_counter(p);
1767                b.update_rx_counter(p);
1768            }
1769            assert_eq!(
1770                a.try_admit_rx_counter(*probe),
1771                b.update_rx_counter(*probe),
1772                "scenario {i}: try_admit_rx_counter and update_rx_counter must agree",
1773            );
1774        }
1775    }
1776
1777    // ---------- Debug redaction contract ----------
1778    //
1779    // The Debug impls on SessionKeys and StaticKeypair MUST omit
1780    // the key material — a regression that drops the redaction
1781    // leaks the long-lived static private key or per-session AEAD
1782    // keys to any tracing line that debug-prints these structs
1783    // (a common pattern: `tracing::debug!(?session, ...)`). The
1784    // tests below pin the contract by constructing each struct
1785    // with a recognizable byte pattern and asserting the Debug
1786    // output does NOT contain that pattern's hex.
1787
1788    /// Hex-encode bytes the same way a naive default Debug impl
1789    /// would surface them, so we can scan the formatted output
1790    /// for accidental leaks.
1791    fn hex_of(bytes: &[u8]) -> String {
1792        bytes.iter().map(|b| format!("{:02x}", b)).collect()
1793    }
1794
1795    /// Assert that `dbg_output` doesn't contain the hex encoding
1796    /// of `secret`. Catches naive Debug derives that surface the
1797    /// raw byte array.
1798    fn assert_no_leak(dbg_output: &str, secret: &[u8], label: &str) {
1799        let hex = hex_of(secret).to_lowercase();
1800        assert!(
1801            !dbg_output.to_lowercase().contains(&hex),
1802            "{label} bytes leaked into Debug output: {dbg_output}",
1803        );
1804    }
1805
1806    #[test]
1807    fn session_keys_debug_redacts_tx_and_rx_keys() {
1808        let tx_secret = [0xAB; 32];
1809        let rx_secret = [0xCD; 32];
1810        let keys = SessionKeys {
1811            tx_key: tx_secret,
1812            rx_key: rx_secret,
1813            session_id: 0x1234_5678_DEAD_BEEF,
1814            remote_static_pub: [0x11; 32],
1815        };
1816        let s = format!("{:?}", keys);
1817
1818        assert!(
1819            s.contains("[REDACTED]"),
1820            "SessionKeys Debug must include [REDACTED]; got: {s}",
1821        );
1822        assert_no_leak(&s, &tx_secret, "tx_key");
1823        assert_no_leak(&s, &rx_secret, "rx_key");
1824        // Sanity: session_id (non-secret) is exposed in the
1825        // Debug output. The exact decimal/hex format is the
1826        // formatter's choice; we only assert that *some*
1827        // session_id field appears in the struct dump.
1828        assert!(s.contains("session_id"));
1829    }
1830
1831    #[test]
1832    fn static_keypair_debug_redacts_private_key() {
1833        let private = [0x77; 32];
1834        let public = [0x22; 32];
1835        let kp = StaticKeypair { private, public };
1836        let s = format!("{:?}", kp);
1837
1838        assert!(
1839            s.contains("[REDACTED]"),
1840            "StaticKeypair Debug must include [REDACTED]; got: {s}",
1841        );
1842        assert_no_leak(&s, &private, "private key");
1843        // The public key is not secret and should be visible.
1844        assert!(
1845            s.to_lowercase().contains(&hex_of(&public).to_lowercase()),
1846            "public key should be visible in Debug; got: {s}",
1847        );
1848    }
1849
1850    // ---------- CryptoError Display ----------
1851
1852    #[test]
1853    fn crypto_error_display_covers_every_variant() {
1854        assert_eq!(
1855            format!("{}", CryptoError::Handshake("bad msg1".into())),
1856            "handshake error: bad msg1"
1857        );
1858        assert_eq!(
1859            format!("{}", CryptoError::Encryption("tag mismatch".into())),
1860            "encryption error: tag mismatch"
1861        );
1862        assert_eq!(
1863            format!("{}", CryptoError::Decryption("auth fail".into())),
1864            "decryption error: auth fail"
1865        );
1866        assert_eq!(
1867            format!("{}", CryptoError::InvalidKey("zero key".into())),
1868            "invalid key: zero key"
1869        );
1870        assert_eq!(format!("{}", CryptoError::InvalidNonce), "invalid nonce");
1871    }
1872}