noise: switch ChaCha20-Poly1305 backend to ring (BoringSSL asm)

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<u8>`-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.
This commit is contained in:
Martti Malmi
2026-05-10 16:03:02 +00:00
committed by Johnathan Corgan
parent 8094a51a82
commit 5cda4a9a55
4 changed files with 109 additions and 76 deletions
Generated
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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"
+9
View File
@@ -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),
+98 -74
View File
@@ -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<LessSafeKey>,
/// 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<LessSafeKey> {
UnboundKey::new(&CHACHA20_POLY1305, key)
.ok()
.map(LessSafeKey::new)
}
/// Encrypt plaintext, returning ciphertext with appended tag.
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, 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<Nonce, NoiseError> {
/// Reserve and return the next nonce, advancing the internal counter.
fn advance_nonce(&mut self) -> Result<u64, NoiseError> {
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<u8>` 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<Vec<u8>, 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<u8>`. Truncates
/// in place to drop the AEAD tag.
fn open(
cipher: Option<&LessSafeKey>,
counter: u64,
aad: &[u8],
ciphertext: &[u8],
) -> Result<Vec<u8>, 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;