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