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}