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::{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;