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