diff --git a/.github/workflows/package-openwrt.yml b/.github/workflows/package-openwrt.yml index c7758e4..76a31f9 100644 --- a/.github/workflows/package-openwrt.yml +++ b/.github/workflows/package-openwrt.yml @@ -78,7 +78,9 @@ jobs: rust_target: aarch64-unknown-linux-musl rust_channel: stable # MT3000, MT6000, Flint 2, RPi 3/4/5 - # MIPS disabled: 32-bit MIPS lacks AtomicU64; needs portable-atomic crate + # MIPS disabled: nostr-relay-pool 0.44 uses std::sync::atomic::AtomicU64 + # directly (fips's own atomics already use portable_atomic). Re-enable + # once an upstream portable-atomic patch lands (or via [patch.crates-io]). # - build_arch: mipsel # openwrt_arch: mipsel_24kc # rust_target: mipsel-unknown-linux-musl diff --git a/CHANGELOG.md b/CHANGELOG.md index 3373767..1897160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -338,6 +338,28 @@ with v0.2.x peers. ### Changed +- Noise session ChaCha20-Poly1305 backend switched from RustCrypto's + `chacha20poly1305` to `ring 0.17`. ring wraps BoringSSL's + hand-tuned ChaCha20-Poly1305 implementation, dispatching to NEON + on aarch64 and AVX2 / AVX-512 on x86_64 — typically 3-5 GB/s/core + vs the ~600-800 MB/s/core RustCrypto soft path on the same + hardware. Wire format unchanged: ChaCha20-Poly1305 is + byte-deterministic for a given `(key, nonce, plaintext, aad)`, + so any correct AEAD produces identical ciphertext and a mixed + pre-swap / post-swap mesh interoperates without protocol + awareness. The keyed AEAD is now cached on `CipherState` instead + of being re-derived per packet (the cached Poly1305 key state is + the actual perf win); `EndToEndState` grew from ~600 B to + ~1.5 KB as a consequence and is annotated + `#[allow(clippy::large_enum_variant)]` since boxing would re-add + a per-packet indirection on every encrypt/decrypt. aarch64 + measurements (Apple Silicon docker, two nodes): TCP 1-stream + 437 → 1097 Mbps (~2.5×); UDP at 1000 Mbit goes from + 599 Mbps / 40 % loss to lossless line-rate; 3-node ping under + load 7.68 ms avg / 215 ms max → 0.72 ms / 3.6 ms max as the + relay path stops being crypto-bound + ([#80](https://github.com/jmcorgan/fips/pull/80), + [@mmalmi](https://github.com/mmalmi)) - Linux UDP receive path uses `recvmmsg(2)` with a 32-packet batch in place of single-packet `recvmsg(2)`. A single `readable()` wakeup drains up to 32 datagrams in one syscall before yielding diff --git a/Cargo.lock b/Cargo.lock index f7af6a7..0ee13c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,7 +1056,6 @@ version = "0.4.0-dev" dependencies = [ "bech32", "bluer", - "chacha20poly1305", "clap", "criterion", "dirs", @@ -1070,6 +1069,7 @@ dependencies = [ "procfs", "rand 0.10.1", "ratatui", + "ring", "rtnetlink", "rustables", "secp256k1 0.30.0", diff --git a/Cargo.toml b/Cargo.toml index 3548157..1121da9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ ratatui = "0.30" secp256k1 = { version = "0.30", features = ["rand", "global-context"] } sha2 = "0.10" hkdf = "0.12" -chacha20poly1305 = "0.10" +ring = "0.17" rand = "0.10.1" thiserror = "2.0" bech32 = "0.11" diff --git a/src/node/session.rs b/src/node/session.rs index 93427a5..8058321 100644 --- a/src/node/session.rs +++ b/src/node/session.rs @@ -14,6 +14,15 @@ use crate::noise::{HandshakeState, NoiseSession}; use secp256k1::PublicKey; /// State machine for an end-to-end session. +/// +/// `Established` is intentionally larger than the handshake variants: +/// `NoiseSession` carries ring's `LessSafeKey` (×2, send + recv), each of +/// which embeds the precomputed Poly1305 key + per-implementation AEAD +/// state. That precomputation is exactly the win — it lets the per-packet +/// AEAD skip key derivation and dispatch straight to NEON / AVX. Boxing +/// the variant would add an allocation per session and double-indirection +/// on every encrypt/decrypt, working against that win. +#[allow(clippy::large_enum_variant)] pub(crate) enum EndToEndState { /// We initiated: sent SessionSetup with Noise XX msg1, awaiting SessionAck. Initiating(HandshakeState), diff --git a/src/noise/mod.rs b/src/noise/mod.rs index ff2afd1..4d13036 100644 --- a/src/noise/mod.rs +++ b/src/noise/mod.rs @@ -41,10 +41,7 @@ mod handshake; mod replay; mod session; -use chacha20poly1305::{ - ChaCha20Poly1305, Nonce, - aead::{Aead, KeyInit, Payload}, -}; +use ring::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, Nonce, UnboundKey}; use std::fmt; use thiserror::Error; @@ -170,21 +167,51 @@ impl fmt::Display for HandshakeProgress { } /// Symmetric cipher state for post-handshake encryption. -#[derive(Clone)] +/// +/// AEAD is `ring`'s ChaCha20-Poly1305 (BoringSSL backend), which dispatches +/// to NEON on aarch64 and AVX2/AVX-512 on x86_64. The 32-byte key is +/// retained alongside a cached `LessSafeKey` so the per-packet AEAD skips +/// the keyed-cipher construction (key copy + Poly1305 key derivation). +/// `LessSafeKey` itself doesn't implement `Clone` (deliberate, for safety), +/// so `CipherState`'s manual `Clone` impl rebuilds the keyed AEAD from the +/// retained key bytes — cheap for ChaCha20-Poly1305 since the construction +/// is essentially a key copy plus a constant-time check. pub struct CipherState { - /// Encryption key (32 bytes). + /// Encryption key (32 bytes). Retained so we can rebuild the keyed + /// AEAD on `Clone` and on `initialize_key` (ring's `UnboundKey` / + /// `LessSafeKey` do not implement `Clone`). key: [u8; 32], + /// Cached keyed AEAD, valid iff `has_key`. None for an un-keyed state. + cipher: Option, /// Nonce counter (8 bytes used, 4 bytes zero prefix). pub(super) nonce: u64, /// Whether this cipher has a valid key. has_key: bool, } +impl Clone for CipherState { + fn clone(&self) -> Self { + let cipher = if self.has_key { + Self::build_cipher(&self.key) + } else { + None + }; + Self { + key: self.key, + cipher, + nonce: self.nonce, + has_key: self.has_key, + } + } +} + impl CipherState { /// Create a new cipher state with the given key. pub(crate) fn new(key: [u8; 32]) -> Self { + let cipher = Self::build_cipher(&key); Self { key, + cipher, nonce: 0, has_key: true, } @@ -194,6 +221,7 @@ impl CipherState { pub(super) fn empty() -> Self { Self { key: [0u8; 32], + cipher: None, nonce: 0, has_key: false, } @@ -202,10 +230,20 @@ impl CipherState { /// Initialize with a key. pub(super) fn initialize_key(&mut self, key: [u8; 32]) { self.key = key; + self.cipher = Self::build_cipher(&key); self.nonce = 0; self.has_key = true; } + /// Build a ring `LessSafeKey` from raw key bytes. Centralized so the + /// cipher-cache rebuild paths (`new`, `initialize_key`, `Clone`) all + /// agree on construction. + fn build_cipher(key: &[u8; 32]) -> Option { + UnboundKey::new(&CHACHA20_POLY1305, key) + .ok() + .map(LessSafeKey::new) + } + /// Encrypt plaintext, returning ciphertext with appended tag. pub fn encrypt(&mut self, plaintext: &[u8]) -> Result, NoiseError> { if !self.has_key { @@ -220,15 +258,8 @@ impl CipherState { }); } - let cipher = ChaCha20Poly1305::new_from_slice(&self.key) - .map_err(|_| NoiseError::EncryptionFailed)?; - - let nonce = self.next_nonce()?; - let ciphertext = cipher - .encrypt(&nonce, plaintext) - .map_err(|_| NoiseError::EncryptionFailed)?; - - Ok(ciphertext) + let counter = self.advance_nonce()?; + seal(self.cipher.as_ref(), counter, &[], plaintext) } /// Decrypt ciphertext (with appended tag), returning plaintext. @@ -248,15 +279,8 @@ impl CipherState { }); } - let cipher = ChaCha20Poly1305::new_from_slice(&self.key) - .map_err(|_| NoiseError::DecryptionFailed)?; - - let nonce = self.next_nonce()?; - let plaintext = cipher - .decrypt(&nonce, ciphertext) - .map_err(|_| NoiseError::DecryptionFailed)?; - - Ok(plaintext) + let counter = self.advance_nonce()?; + open(self.cipher.as_ref(), counter, &[], ciphertext) } /// Decrypt with an explicit counter value (for transport phase). @@ -280,15 +304,7 @@ impl CipherState { }); } - let cipher = ChaCha20Poly1305::new_from_slice(&self.key) - .map_err(|_| NoiseError::DecryptionFailed)?; - - let nonce = Self::counter_to_nonce(counter); - let plaintext = cipher - .decrypt(&nonce, ciphertext) - .map_err(|_| NoiseError::DecryptionFailed)?; - - Ok(plaintext) + open(self.cipher.as_ref(), counter, &[], ciphertext) } /// Encrypt plaintext with Additional Authenticated Data (AAD). @@ -312,21 +328,8 @@ impl CipherState { }); } - let cipher = ChaCha20Poly1305::new_from_slice(&self.key) - .map_err(|_| NoiseError::EncryptionFailed)?; - - let nonce = self.next_nonce()?; - let ciphertext = cipher - .encrypt( - &nonce, - Payload { - msg: plaintext, - aad, - }, - ) - .map_err(|_| NoiseError::EncryptionFailed)?; - - Ok(ciphertext) + let counter = self.advance_nonce()?; + seal(self.cipher.as_ref(), counter, aad, plaintext) } /// Decrypt with an explicit counter and AAD (for transport phase). @@ -351,44 +354,25 @@ impl CipherState { }); } - let cipher = ChaCha20Poly1305::new_from_slice(&self.key) - .map_err(|_| NoiseError::DecryptionFailed)?; - - let nonce = Self::counter_to_nonce(counter); - let plaintext = cipher - .decrypt( - &nonce, - Payload { - msg: ciphertext, - aad, - }, - ) - .map_err(|_| NoiseError::DecryptionFailed)?; - - Ok(plaintext) + open(self.cipher.as_ref(), counter, aad, ciphertext) } - /// Convert a counter value to a nonce. + /// Build a ring `Nonce` from a counter value (8-byte LE counter, with + /// 4-byte zero prefix to match the Noise/WireGuard wire format). fn counter_to_nonce(counter: u64) -> Nonce { let mut nonce_bytes = [0u8; 12]; nonce_bytes[4..12].copy_from_slice(&counter.to_le_bytes()); - *Nonce::from_slice(&nonce_bytes) + Nonce::assume_unique_for_key(nonce_bytes) } - /// Get the next nonce, incrementing the counter. - fn next_nonce(&mut self) -> Result { + /// Reserve and return the next nonce, advancing the internal counter. + fn advance_nonce(&mut self) -> Result { if self.nonce == u64::MAX { return Err(NoiseError::NonceOverflow); } - let n = self.nonce; self.nonce += 1; - - // Noise uses 8-byte counter with 4-byte zero prefix - let mut nonce_bytes = [0u8; 12]; - nonce_bytes[4..12].copy_from_slice(&n.to_le_bytes()); - - Ok(*Nonce::from_slice(&nonce_bytes)) + Ok(n) } /// Get the current nonce value (for debugging/testing). @@ -412,5 +396,45 @@ impl fmt::Debug for CipherState { } } +/// Encrypt `plaintext` with the given keyed AEAD, counter, and AAD, +/// returning a `Vec` of `plaintext.len() + TAG_SIZE` bytes. ring's +/// `seal_in_place_append_tag` works on a single buffer; we own it here +/// to keep the public Vec-returning API of `CipherState`. +fn seal( + cipher: Option<&LessSafeKey>, + counter: u64, + aad: &[u8], + plaintext: &[u8], +) -> Result, NoiseError> { + let cipher = cipher.ok_or(NoiseError::EncryptionFailed)?; + let mut buf = Vec::with_capacity(plaintext.len() + TAG_SIZE); + buf.extend_from_slice(plaintext); + let nonce = CipherState::counter_to_nonce(counter); + cipher + .seal_in_place_append_tag(nonce, Aad::from(aad), &mut buf) + .map_err(|_| NoiseError::EncryptionFailed)?; + Ok(buf) +} + +/// Decrypt `ciphertext` (with appended tag) with the given keyed AEAD, +/// counter, and AAD, returning the plaintext as a `Vec`. Truncates +/// in place to drop the AEAD tag. +fn open( + cipher: Option<&LessSafeKey>, + counter: u64, + aad: &[u8], + ciphertext: &[u8], +) -> Result, NoiseError> { + let cipher = cipher.ok_or(NoiseError::DecryptionFailed)?; + let mut buf = ciphertext.to_vec(); + let nonce = CipherState::counter_to_nonce(counter); + let plaintext_len = cipher + .open_in_place(nonce, Aad::from(aad), &mut buf) + .map_err(|_| NoiseError::DecryptionFailed)? + .len(); + buf.truncate(plaintext_len); + Ok(buf) +} + #[cfg(test)] mod tests;