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