Skip to main content

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;