From 5cda4a9a55fda74cde169824056b0ddec8e83995 Mon Sep 17 00:00:00 2001 From: Martti Malmi Date: Sat, 9 May 2026 18:11:51 +0300 Subject: [PATCH 1/3] noise: switch ChaCha20-Poly1305 backend to ring (BoringSSL asm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chacha20 crate (RustCrypto) ships SSE2 + soft backends only — on aarch64 (Apple Silicon, ARM Linux servers, Docker on M-series Macs) it falls through to a portable software impl at ~600–800 MB/s/core. ring 0.17 wraps BoringSSL's hand-tuned ChaCha20-Poly1305, which dispatches to NEON on aarch64 and AVX2/AVX-512 on x86_64 — typically 3-5 GB/s/core on the same hardware. Same wire format. ChaCha20-Poly1305 is byte-deterministic for a given (key, nonce, plaintext, aad), so any correct AEAD implementation produces identical ciphertext. The full noise test suite covers this implicitly: IK and XK roundtrip handshakes, replay window correctness, multi-message nonce sequencing, and 100-message stress all pass at 1129/1129 (the lib's full `cargo test` count) — these only succeed if ring's output matches what the receiver's existing replay-window decrypt path expects. Implementation notes: * `LessSafeKey` (and `UnboundKey`) deliberately do not implement Clone for safety. `CipherState`'s manual Clone impl rebuilds it from the retained 32-byte key — cheap for ChaCha20-Poly1305 since construction is essentially a key copy + a constant-time check. * The keyed AEAD is now cached in `CipherState.cipher` instead of being re-derived per packet. This was already a perf win for the chacha20poly1305 backend (`new_from_slice` per packet was hot in profiles); for ring it's a bigger win because `LessSafeKey` construction also derives the Poly1305 key. * Public `Vec`-returning API preserved. New module-private `seal`/`open` helpers wrap ring's `seal_in_place_append_tag` / `open_in_place` so the per-packet allocation pattern is local to one place. * `EndToEndState::Established` triggers `clippy::large_enum_variant` after the swap (`NoiseSession` grew from ~600 to ~1.5 KB because ring precomputes the Poly1305 key state at construction). That precomputation is the win — boxing the variant would re-add an indirection per packet and work against it. `#[allow]`'d at the enum decl with a justifying comment. ring is widely deployed (rustls, hyper-rustls, AWS SDK, …) and a pure-Rust crate (uses BoringSSL's asm via a vendored build). It introduces no new C toolchain requirements that aren't already there for any rustls user. Bench data from a downstream consumer of this crate (Docker e2e, DURATION=10, identical hardware before/after, aarch64 Linux on Apple Silicon): 2-node direct (A↔B): TCP 1-stream 437 → 1097 Mbps (2.51×) TCP 4-stream 439 → 1109 Mbps (2.53×) TCP 8-stream 445 → 1069 Mbps (2.40×) UDP @1000 Mbit 599/40% loss → 1000 Mbps lossless ping under load ~0.6 ms (unchanged) 3-node forced transit (A → C → B): TCP 1-stream 438 → 1019 Mbps (2.33×) TCP 4-stream 421 → 982 Mbps (2.33×) TCP 8-stream 443 → 1031 Mbps (2.33×) UDP @1000 Mbit 475/52% loss → 1000 Mbps lossless ping under load 7.68 ms / 215 ms max → 0.72 ms / 3.6 ms max The relay-path lift is the cleanest tell on the bottleneck: the transit node was crypto-bound (single-threaded soft chacha couldn't keep up with offered rate), so the queue accumulated under load. With NEON the relay isn't crypto-bound and the queue stops accumulating — the 215ms ping-tail collapses to 3.6ms. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/node/session.rs | 9 +++ src/noise/mod.rs | 172 +++++++++++++++++++++++++------------------- 4 files changed, 109 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7fbd3c0..99f29ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,7 +1056,6 @@ version = "0.3.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 26e5c59..4e39005 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 0f63dee..e596cf2 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 XK msg1, awaiting SessionAck. Initiating(HandshakeState), diff --git a/src/noise/mod.rs b/src/noise/mod.rs index 6c71c45..aea19b3 100644 --- a/src/noise/mod.rs +++ b/src/noise/mod.rs @@ -39,10 +39,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; @@ -180,21 +177,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, } @@ -204,6 +231,7 @@ impl CipherState { pub(super) fn empty() -> Self { Self { key: [0u8; 32], + cipher: None, nonce: 0, has_key: false, } @@ -212,10 +240,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 { @@ -230,15 +268,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. @@ -258,15 +289,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). @@ -290,15 +314,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). @@ -322,21 +338,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). @@ -361,44 +364,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). @@ -422,5 +406,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; From 9b1016ffaf62d717fdf6e67ffed727d35377caf4 Mon Sep 17 00:00:00 2001 From: Martti Malmi Date: Sun, 10 May 2026 14:01:03 +0300 Subject: [PATCH 2/3] ci: correct OpenWrt MIPS-disabled comment to locate the actual blocker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated the mipsel-unknown-linux-musl build of this branch on a Linux/x86_64 host. ring 0.17, portable-atomic, and the fips codebase itself all compile cleanly for that target — the AtomicU64 portability work is already done. The actual blocker is in the nostr-relay-pool 0.44 transitive dep, which uses std::sync::atomic::AtomicU64 directly in src/relay/{stats,ping,flags}.rs. Verified fixable with a 6-line portable-atomic patch via a [patch.crates-io] shim during local testing. Updating the comment so the next person looking at this matrix has the right starting point. No functional change. --- .github/workflows/package-openwrt.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 77ecfda1a1f7b91cf76f6b1cf49fde744b5e2a95 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sun, 10 May 2026 16:50:37 +0000 Subject: [PATCH 3/3] changelog: document ring ChaCha20-Poly1305 backend swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Noise-session AEAD swap landed as 5cda4a9 + 9b1016f without a companion CHANGELOG entry; add it under [Unreleased] § Changed ahead of the rest of the perf-win cluster from PR #81 since it's the most operator-visible single change of that class. --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da671be..47ac6c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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