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