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