fips_core/noise/mod.rs
1//! Noise Protocol Implementations for FIPS
2//!
3//! Implements Noise Protocol Framework patterns using secp256k1:
4//!
5//! - **IK pattern**: Used by FMP (link layer) for hop-by-hop peer authentication.
6//! The initiator knows the responder's static key and sends its encrypted
7//! static in msg1. Two-message handshake.
8//!
9//! - **XK pattern**: Used by FSP (session layer) for end-to-end sessions.
10//! The initiator knows the responder's static key but defers revealing its
11//! own identity until msg3, providing stronger identity hiding. Three-message
12//! handshake.
13//!
14//! ## IK Handshake Pattern (Link Layer)
15//!
16//! ```text
17//! <- s (pre-message: responder's static known)
18//! -> e, es, s, ss (msg1: ephemeral + encrypted static)
19//! <- e, ee, se (msg2: ephemeral)
20//! ```
21//!
22//! ## XK Handshake Pattern (Session Layer)
23//!
24//! ```text
25//! <- s (pre-message: responder's static known)
26//! -> e, es (msg1: ephemeral + DH with responder's static)
27//! <- e, ee (msg2: ephemeral + DH)
28//! -> s, se (msg3: encrypted static + DH)
29//! ```
30//!
31//! ## Separation of Concerns
32//!
33//! The IK pattern handles **link-layer peer authentication** — securing the
34//! direct link between neighboring nodes. The XK pattern handles **session-layer
35//! end-to-end encryption** between arbitrary network addresses, with stronger
36//! initiator identity protection.
37
38mod handshake;
39mod replay;
40mod session;
41
42use ring::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, Nonce, UnboundKey};
43use std::fmt;
44use thiserror::Error;
45
46pub use handshake::HandshakeState;
47pub use replay::{ReplayRejection, ReplayWindow};
48pub use session::NoiseSession;
49pub(crate) use session::SendCounterAuthority;
50
51/// Protocol name for Noise IK with secp256k1 (link layer).
52/// Format: Noise_IK_secp256k1_ChaChaPoly_SHA256
53pub(crate) const PROTOCOL_NAME_IK: &[u8] = b"Noise_IK_secp256k1_ChaChaPoly_SHA256";
54
55/// Protocol name for Noise XK with secp256k1 (session layer).
56/// Format: Noise_XK_secp256k1_ChaChaPoly_SHA256
57pub(crate) const PROTOCOL_NAME_XK: &[u8] = b"Noise_XK_secp256k1_ChaChaPoly_SHA256";
58
59/// Maximum message size for noise transport messages.
60pub const MAX_MESSAGE_SIZE: usize = 65535;
61
62/// Size of the AEAD tag.
63pub const TAG_SIZE: usize = 16;
64
65/// Size of a public key (compressed secp256k1).
66pub const PUBKEY_SIZE: usize = 33;
67
68/// Size of the startup epoch (random bytes for restart detection).
69pub const EPOCH_SIZE: usize = 8;
70
71/// Size of encrypted epoch (epoch + AEAD tag).
72pub const EPOCH_ENCRYPTED_SIZE: usize = EPOCH_SIZE + TAG_SIZE;
73
74/// Size of IK handshake message 1: ephemeral (33) + encrypted static (33 + 16 tag) + encrypted epoch (8 + 16 tag).
75pub const HANDSHAKE_MSG1_SIZE: usize = PUBKEY_SIZE + PUBKEY_SIZE + TAG_SIZE + EPOCH_ENCRYPTED_SIZE;
76
77/// Size of IK handshake message 2: ephemeral (33) + encrypted epoch (8 + 16 tag).
78pub const HANDSHAKE_MSG2_SIZE: usize = PUBKEY_SIZE + EPOCH_ENCRYPTED_SIZE;
79
80/// XK msg1: ephemeral only (33 bytes).
81pub const XK_HANDSHAKE_MSG1_SIZE: usize = PUBKEY_SIZE;
82
83/// XK msg2: ephemeral (33) + encrypted epoch (8 + 16 tag) = 57 bytes.
84pub const XK_HANDSHAKE_MSG2_SIZE: usize = PUBKEY_SIZE + EPOCH_ENCRYPTED_SIZE;
85
86/// XK msg3: encrypted static (33 + 16 tag) + encrypted epoch (8 + 16 tag) = 73 bytes.
87pub const XK_HANDSHAKE_MSG3_SIZE: usize = PUBKEY_SIZE + TAG_SIZE + EPOCH_ENCRYPTED_SIZE;
88
89/// Replay window size in packets.
90///
91/// This is local receiver state, not a wire-format value. Keep it wide enough
92/// to tolerate bounded userspace queue/reorder tails under CPU pressure.
93pub const REPLAY_WINDOW_SIZE: usize = 8192;
94
95/// Errors from Noise protocol operations.
96#[derive(Debug, Error)]
97pub enum NoiseError {
98 #[error("handshake not complete")]
99 HandshakeNotComplete,
100
101 #[error("handshake already complete")]
102 HandshakeAlreadyComplete,
103
104 #[error("wrong handshake state: expected {expected}, got {got}")]
105 WrongState { expected: String, got: String },
106
107 #[error("invalid public key")]
108 InvalidPublicKey,
109
110 #[error("decryption failed")]
111 DecryptionFailed,
112
113 #[error("encryption failed")]
114 EncryptionFailed,
115
116 #[error("message too large: {size} > {max}")]
117 MessageTooLarge { size: usize, max: usize },
118
119 #[error("message too short: expected at least {expected}, got {got}")]
120 MessageTooShort { expected: usize, got: usize },
121
122 #[error("nonce overflow")]
123 NonceOverflow,
124
125 #[error("replay detected: counter {0} already seen or too old")]
126 ReplayDetected(u64),
127
128 #[error("secp256k1 error: {0}")]
129 Secp256k1(#[from] secp256k1::Error),
130}
131
132/// Role in the handshake.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum HandshakeRole {
135 /// We initiated the connection.
136 Initiator,
137 /// They initiated the connection.
138 Responder,
139}
140
141impl fmt::Display for HandshakeRole {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 match self {
144 HandshakeRole::Initiator => write!(f, "initiator"),
145 HandshakeRole::Responder => write!(f, "responder"),
146 }
147 }
148}
149
150/// Which Noise pattern is being used for this handshake.
151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
152pub enum NoisePattern {
153 /// Noise IK: two-message handshake (link layer).
154 Ik,
155 /// Noise XK: three-message handshake (session layer).
156 Xk,
157}
158
159/// Handshake state machine states.
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum HandshakeProgress {
162 /// Initial state, ready to send/receive message 1.
163 Initial,
164 /// Message 1 sent/received, ready for message 2.
165 Message1Done,
166 /// Message 2 sent/received, ready for message 3 (XK only).
167 Message2Done,
168 /// Handshake complete, ready for transport.
169 Complete,
170}
171
172impl fmt::Display for HandshakeProgress {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match self {
175 HandshakeProgress::Initial => write!(f, "initial"),
176 HandshakeProgress::Message1Done => write!(f, "message1_done"),
177 HandshakeProgress::Message2Done => write!(f, "message2_done"),
178 HandshakeProgress::Complete => write!(f, "complete"),
179 }
180 }
181}
182
183/// Symmetric cipher state for post-handshake encryption.
184///
185/// AEAD is `ring`'s ChaCha20-Poly1305 (BoringSSL backend), which dispatches
186/// to NEON on aarch64 and AVX-512/AVX2 on x86_64. The `cipher` field caches
187/// a constructed `LessSafeKey` so we don't re-derive it per packet.
188/// `LessSafeKey` itself isn't `Clone`, so `CipherState`'s `Clone` impl
189/// rebuilds it from the retained 32-byte key on demand — for the
190/// off-task-decrypt path see `cipher_clone`.
191pub struct CipherState {
192 /// Encryption key (32 bytes). Retained so we can rebuild the keyed
193 /// AEAD on `Clone` and `cipher_clone()` (ring's `UnboundKey`/`LessSafeKey`
194 /// don't implement `Clone` deliberately for safety).
195 key: [u8; 32],
196 /// Cached keyed AEAD, valid iff `has_key`. None for an un-keyed state.
197 cipher: Option<LessSafeKey>,
198 /// Nonce counter (8 bytes used, 4 bytes zero prefix).
199 pub(super) nonce: u64,
200 /// Whether this cipher has a valid key.
201 has_key: bool,
202}
203
204impl Clone for CipherState {
205 fn clone(&self) -> Self {
206 let cipher = if self.has_key {
207 Self::build_cipher(&self.key)
208 } else {
209 None
210 };
211 Self {
212 key: self.key,
213 cipher,
214 nonce: self.nonce,
215 has_key: self.has_key,
216 }
217 }
218}
219
220impl CipherState {
221 /// Create a new cipher state with the given key.
222 pub(crate) fn new(key: [u8; 32]) -> Self {
223 let cipher = Self::build_cipher(&key);
224 Self {
225 key,
226 cipher,
227 nonce: 0,
228 has_key: true,
229 }
230 }
231
232 /// Create an empty cipher state (no key yet).
233 pub(super) fn empty() -> Self {
234 Self {
235 key: [0u8; 32],
236 cipher: None,
237 nonce: 0,
238 has_key: false,
239 }
240 }
241
242 /// Initialize with a key.
243 pub(super) fn initialize_key(&mut self, key: [u8; 32]) {
244 self.key = key;
245 self.cipher = Self::build_cipher(&key);
246 self.nonce = 0;
247 self.has_key = true;
248 }
249
250 /// Build a ring `LessSafeKey` from raw key bytes. Centralized so the
251 /// cipher-cache rebuild paths (`new`, `initialize_key`, `Clone`,
252 /// `cipher_clone`) all agree on construction.
253 fn build_cipher(key: &[u8; 32]) -> Option<LessSafeKey> {
254 UnboundKey::new(&CHACHA20_POLY1305, key)
255 .ok()
256 .map(LessSafeKey::new)
257 }
258
259 /// Encrypt plaintext, returning ciphertext with appended tag.
260 pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, NoiseError> {
261 if !self.has_key {
262 // No key means no encryption (shouldn't happen in transport phase)
263 return Ok(plaintext.to_vec());
264 }
265
266 if plaintext.len() > MAX_MESSAGE_SIZE - TAG_SIZE {
267 return Err(NoiseError::MessageTooLarge {
268 size: plaintext.len(),
269 max: MAX_MESSAGE_SIZE - TAG_SIZE,
270 });
271 }
272
273 let counter = self.advance_nonce()?;
274 seal(self.cipher.as_ref(), counter, &[], plaintext)
275 }
276
277 /// Decrypt ciphertext (with appended tag), returning plaintext.
278 ///
279 /// Uses the internal nonce counter. For transport phase with explicit
280 /// counters from the wire format, use `decrypt_with_counter` instead.
281 pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, NoiseError> {
282 if !self.has_key {
283 // No key means no encryption
284 return Ok(ciphertext.to_vec());
285 }
286
287 if ciphertext.len() < TAG_SIZE {
288 return Err(NoiseError::MessageTooShort {
289 expected: TAG_SIZE,
290 got: ciphertext.len(),
291 });
292 }
293
294 let counter = self.advance_nonce()?;
295 open(self.cipher.as_ref(), counter, &[], ciphertext)
296 }
297
298 /// Decrypt with an explicit counter value (for transport phase).
299 ///
300 /// This is used when the counter comes from the wire format rather than
301 /// an internal counter. The counter must be validated by a replay window
302 /// before calling this method.
303 pub fn decrypt_with_counter(
304 &self,
305 ciphertext: &[u8],
306 counter: u64,
307 ) -> Result<Vec<u8>, NoiseError> {
308 if !self.has_key {
309 return Ok(ciphertext.to_vec());
310 }
311
312 if ciphertext.len() < TAG_SIZE {
313 return Err(NoiseError::MessageTooShort {
314 expected: TAG_SIZE,
315 got: ciphertext.len(),
316 });
317 }
318
319 open(self.cipher.as_ref(), counter, &[], ciphertext)
320 }
321
322 /// Encrypt plaintext with Additional Authenticated Data (AAD).
323 ///
324 /// The AAD is authenticated but not encrypted. Used for the FMP
325 /// established frame format where the 16-byte outer header is
326 /// bound to the AEAD tag.
327 pub fn encrypt_with_aad(
328 &mut self,
329 plaintext: &[u8],
330 aad: &[u8],
331 ) -> Result<Vec<u8>, NoiseError> {
332 if !self.has_key {
333 return Ok(plaintext.to_vec());
334 }
335
336 if plaintext.len() > MAX_MESSAGE_SIZE - TAG_SIZE {
337 return Err(NoiseError::MessageTooLarge {
338 size: plaintext.len(),
339 max: MAX_MESSAGE_SIZE - TAG_SIZE,
340 });
341 }
342
343 let counter = self.advance_nonce()?;
344 seal(self.cipher.as_ref(), counter, aad, plaintext)
345 }
346
347 /// Encrypt plaintext with an explicit counter (no AAD).
348 ///
349 /// Symmetric to `decrypt_with_counter`: takes `&self` and a caller-
350 /// supplied counter rather than mutating the internal nonce. Intended
351 /// for pipelined encrypt paths where a dispatcher pre-assigns counters
352 /// and fans the AEAD work out across worker threads. Callers are
353 /// responsible for ensuring counter uniqueness — typically by holding
354 /// the cipher behind a lock or queue that hands out counters in order.
355 pub fn encrypt_with_counter(
356 &self,
357 plaintext: &[u8],
358 counter: u64,
359 ) -> Result<Vec<u8>, NoiseError> {
360 if !self.has_key {
361 return Ok(plaintext.to_vec());
362 }
363
364 if plaintext.len() > MAX_MESSAGE_SIZE - TAG_SIZE {
365 return Err(NoiseError::MessageTooLarge {
366 size: plaintext.len(),
367 max: MAX_MESSAGE_SIZE - TAG_SIZE,
368 });
369 }
370
371 seal(self.cipher.as_ref(), counter, &[], plaintext)
372 }
373
374 /// Encrypt plaintext with an explicit counter and AAD.
375 ///
376 /// Symmetric to `decrypt_with_counter_and_aad`: takes `&self` and a
377 /// caller-supplied counter rather than mutating the internal nonce.
378 /// Same uniqueness contract as `encrypt_with_counter`.
379 pub fn encrypt_with_counter_and_aad(
380 &self,
381 plaintext: &[u8],
382 counter: u64,
383 aad: &[u8],
384 ) -> Result<Vec<u8>, NoiseError> {
385 if !self.has_key {
386 return Ok(plaintext.to_vec());
387 }
388
389 if plaintext.len() > MAX_MESSAGE_SIZE - TAG_SIZE {
390 return Err(NoiseError::MessageTooLarge {
391 size: plaintext.len(),
392 max: MAX_MESSAGE_SIZE - TAG_SIZE,
393 });
394 }
395
396 seal(self.cipher.as_ref(), counter, aad, plaintext)
397 }
398
399 /// Construct an independent keyed AEAD pinned to this cipher's key.
400 ///
401 /// Returns `None` for an empty (un-keyed) state. The returned key is
402 /// freshly built from the retained 32-byte key material — ring's
403 /// `LessSafeKey` doesn't implement `Clone` deliberately, but for
404 /// ChaCha20-Poly1305 the construction is essentially a key copy plus
405 /// a constant-time check, so this is cheap. Combined with
406 /// `decrypt_with_counter[_and_aad]` (which already takes `&self`),
407 /// this lets a dispatcher offload the AEAD rounds to a worker pool
408 /// while the main task keeps the replay window and counter
409 /// assignment sequential.
410 pub fn cipher_clone(&self) -> Option<LessSafeKey> {
411 if self.has_key {
412 Self::build_cipher(&self.key)
413 } else {
414 None
415 }
416 }
417
418 /// Decrypt with an explicit counter and AAD (for transport phase).
419 ///
420 /// Combines explicit counter (from wire format) with AAD verification.
421 /// The AAD must match exactly what was used during encryption or the
422 /// AEAD tag verification will fail.
423 pub fn decrypt_with_counter_and_aad(
424 &self,
425 ciphertext: &[u8],
426 counter: u64,
427 aad: &[u8],
428 ) -> Result<Vec<u8>, NoiseError> {
429 if !self.has_key {
430 return Ok(ciphertext.to_vec());
431 }
432
433 if ciphertext.len() < TAG_SIZE {
434 return Err(NoiseError::MessageTooShort {
435 expected: TAG_SIZE,
436 got: ciphertext.len(),
437 });
438 }
439
440 open(self.cipher.as_ref(), counter, aad, ciphertext)
441 }
442
443 /// In-place variant of [`Self::decrypt_with_counter_and_aad`].
444 ///
445 /// On entry, `buf` holds `ciphertext + 16-byte AEAD tag`. On
446 /// successful return, `buf[..returned_len]` holds the plaintext.
447 /// Saves one heap alloc + memcpy per packet versus the by-value
448 /// variant — at multi-Gbps that's a real chunk of the rx_loop's
449 /// per-packet cost.
450 ///
451 /// If the cipher has no key (handshake-not-yet-complete fallback),
452 /// `buf` is treated as already-plaintext and the full length is
453 /// returned unchanged.
454 pub fn decrypt_with_counter_and_aad_in_place(
455 &self,
456 buf: &mut [u8],
457 counter: u64,
458 aad: &[u8],
459 ) -> Result<usize, NoiseError> {
460 if !self.has_key {
461 return Ok(buf.len());
462 }
463 open_in_place(self.cipher.as_ref(), counter, aad, buf)
464 }
465
466 /// Build a ring `Nonce` from a counter value (8-byte LE counter, with
467 /// 4-byte zero prefix to match the Noise/WireGuard wire format).
468 /// Public-in-crate helper so the off-task encrypt/decrypt path on
469 /// callers (e.g. `recv_cipher_clone`) can produce a matching nonce.
470 pub(crate) fn counter_to_nonce(counter: u64) -> Nonce {
471 let mut nonce_bytes = [0u8; 12];
472 nonce_bytes[4..12].copy_from_slice(&counter.to_le_bytes());
473 Nonce::assume_unique_for_key(nonce_bytes)
474 }
475
476 /// Reserve and return the next nonce, advancing the internal counter.
477 fn advance_nonce(&mut self) -> Result<u64, NoiseError> {
478 if self.nonce == u64::MAX {
479 return Err(NoiseError::NonceOverflow);
480 }
481 let n = self.nonce;
482 self.nonce += 1;
483 Ok(n)
484 }
485
486 /// Get the current nonce value (for debugging/testing).
487 pub fn nonce(&self) -> u64 {
488 self.nonce
489 }
490
491 /// Check if cipher has a key.
492 pub fn has_key(&self) -> bool {
493 self.has_key
494 }
495}
496
497impl fmt::Debug for CipherState {
498 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
499 f.debug_struct("CipherState")
500 .field("nonce", &self.nonce)
501 .field("has_key", &self.has_key)
502 .field("key", &"[redacted]")
503 .finish()
504 }
505}
506
507/// Encrypt `plaintext` with the given keyed AEAD, counter, and AAD,
508/// returning a `Vec<u8>` of `plaintext.len() + TAG_SIZE` bytes (ring's
509/// `seal_in_place_append_tag` works on a single buffer; we own it here
510/// to keep the public Vec-returning API of `CipherState`).
511///
512/// Module-private so other paths inside `noise` (e.g. a future pipelined
513/// dispatcher consuming `cipher_clone`) can reuse the exact same
514/// allocation + AEAD pattern.
515pub(crate) fn seal(
516 cipher: Option<&LessSafeKey>,
517 counter: u64,
518 aad: &[u8],
519 plaintext: &[u8],
520) -> Result<Vec<u8>, NoiseError> {
521 let cipher = cipher.ok_or(NoiseError::EncryptionFailed)?;
522 let mut buf = Vec::with_capacity(plaintext.len() + TAG_SIZE);
523 buf.extend_from_slice(plaintext);
524 let nonce = CipherState::counter_to_nonce(counter);
525 cipher
526 .seal_in_place_append_tag(nonce, Aad::from(aad), &mut buf)
527 .map_err(|_| NoiseError::EncryptionFailed)?;
528 Ok(buf)
529}
530
531/// Decrypt `ciphertext` (with appended tag) with the given keyed AEAD,
532/// counter, and AAD, returning the plaintext as a `Vec<u8>`. Truncates
533/// in place to drop the AEAD tag.
534pub(crate) fn open(
535 cipher: Option<&LessSafeKey>,
536 counter: u64,
537 aad: &[u8],
538 ciphertext: &[u8],
539) -> Result<Vec<u8>, NoiseError> {
540 let cipher = cipher.ok_or(NoiseError::DecryptionFailed)?;
541 let mut buf = ciphertext.to_vec();
542 let nonce = CipherState::counter_to_nonce(counter);
543 let plaintext_len = cipher
544 .open_in_place(nonce, Aad::from(aad), &mut buf)
545 .map_err(|_| NoiseError::DecryptionFailed)?
546 .len();
547 buf.truncate(plaintext_len);
548 Ok(buf)
549}
550
551/// In-place variant of [`open`] — decrypts `buf` (which on entry holds
552/// `ciphertext + 16-byte AEAD tag`) into the same buffer, returning the
553/// plaintext length. The caller can then slice `&buf[..plaintext_len]`
554/// without any heap allocation.
555///
556/// Saves one ~1.4 KB heap alloc + memcpy per packet on the FMP / FSP
557/// receive hot path versus the by-value [`open`] variant (which
558/// internally does `ciphertext.to_vec()` before calling
559/// `open_in_place`). At 113 kpps that's ~150 MB/s of memory traffic
560/// dropped per AEAD step, and a meaningful chunk of the rx_loop's
561/// per-packet cost.
562///
563/// Returns `NoiseError::DecryptionFailed` if the AEAD tag check fails,
564/// the cipher has no key, or the buffer is shorter than the tag.
565pub(crate) fn open_in_place(
566 cipher: Option<&LessSafeKey>,
567 counter: u64,
568 aad: &[u8],
569 buf: &mut [u8],
570) -> Result<usize, NoiseError> {
571 let cipher = cipher.ok_or(NoiseError::DecryptionFailed)?;
572 if buf.len() < TAG_SIZE {
573 return Err(NoiseError::MessageTooShort {
574 expected: TAG_SIZE,
575 got: buf.len(),
576 });
577 }
578 let nonce = CipherState::counter_to_nonce(counter);
579 let plaintext = cipher
580 .open_in_place(nonce, Aad::from(aad), buf)
581 .map_err(|_| NoiseError::DecryptionFailed)?;
582 Ok(plaintext.len())
583}
584
585#[cfg(test)]
586mod tests;