mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
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:
committed by
Johnathan Corgan
parent
8094a51a82
commit
5cda4a9a55
Generated
+1
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user