mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 00:26:59 +00:00
Moves both AEAD layers (ChaCha20-Poly1305, one round per layer per packet) plus the sendmsg syscall off the rx_loop task onto a per-shard worker pool, adds per-peer connect(2)-ed UDP with SO_REUSEPORT, and uses Linux UDP GSO (sendmsg+UDP_SEGMENT — kernel splits one super-skb into N on-the-wire datagrams in a single TX-stack walk) when packets in a batch are uniform-size. Same kernel primitive WireGuard's in-kernel module and BoringTun use to hit 2.5–3.2 Gbps single-stream. Single TCP stream on a 5-node docker-bridge mesh, 5 x 15 s x P=1: A→D: 1379 → 2708 Mbps (1.96x, RTT +0.12 ms) A→E: 1394 → 2663 Mbps (1.91x, RTT +0.11 ms) E→A: 1406 → 2624 Mbps (1.87x, RTT +0.19 ms) Static-peer pairs only — every CoV under 3%, 0 outliers, 0% ICMP loss. The ~+100 µs RTT is the worker queue handoff cost; AEAD + sendmmsg now run on a separate core in exchange. What lands: - src/node/encrypt_worker.rs: std::thread + crossbeam_channel workers; hash-by-destination dispatch pins a TCP flow to one worker so wire ordering is preserved; per-worker sendmmsg(2) batching up to 32; Linux uses sendmsg(2)+UDP_SEGMENT when packets in a group are uniform-size. - src/node/decrypt_worker.rs: receive-side mirror. Each shard owns its session's recv cipher + replay window in a thread-local HashMap (no shared RwLock/Mutex). Sessions are handed off at promote_connection and re-registered on K-bit flip / rekey cutover. - src/node/handlers/session.rs try_send_session_data_pipelined: FSP+FMP both seal in-place in the worker on one wire-buffer alloc; no intermediate inner_plaintext / fsp_payload Vecs. - src/transport/udp/connected_peer.rs + peer_drain.rs: per-peer connect(2)-ed UDP socket with SO_REUSEPORT (set on the listen socket too — without that, EADDRINUSE on activation and every packet falls back to the wildcard path); the worker sends with msg_name=NULL and the kernel uses its cached 5-tuple. Tick- driven activation in handlers/connected_udp.rs, idempotent. - src/transport/udp/mod.rs: mem::replace the recvmmsg backing buffer instead of buf.to_vec() per packet — single pointer swap, no MTU-sized memcpy. - src/protocol/link.rs SessionDatagramRef: zero-copy borrowed view used by handle_session_datagram for the bulk local-delivery path; handle_session_payload takes the borrowed payload directly (no payload[35..].to_vec()). - src/transport/mod.rs TransportAddr::from_socket_addr: collapses the two-alloc from_string(addr.to_string()) pattern to one. - src/node/handlers/rx_loop.rs: decrypt-fallback drain promoted ahead of packet_rx in the select! (TCP ACK starvation fix); interleaved fallback drain every 32 packets inside the rx burst loop. - noise::Session: send_cipher_clone / recv_cipher_clone / recv_replay_snapshot_owned / take_send_counter / accept_replay so off-task workers can hold a cloned cipher + reserved counter while the dispatcher keeps replay/counter sequencing serial. CipherState::cipher_clone returns a refcount-bumped LessSafeKey. AsyncUdpSocket: AsRawFd so workers issue raw sendmmsg / sendmsg without going through the tokio reactor. - Worker pool sizing: both default to num_cpus, overridable via FIPS_ENCRYPT_WORKERS=N / FIPS_DECRYPT_WORKERS=N. Per-peer connected UDP can be disabled via FIPS_CONNECTED_UDP=0. - src/perf_profile.rs: optional per-stage timing reporter under FIPS_PERF=1 (or FIPS_PIPELINE_TRACE=1). Off by default; zero overhead when disabled. - All cfg(unix)-gated. Windows continues on the existing tokio- based send/recv. Decrypt worker session lifecycle: - Node::unregister_decrypt_worker_session mirrors the existing register helper. Wired at the two natural sites that already iterate peers_by_index: the rekey drain-completion block in handlers/rekey.rs (drops the worker entry for the old our_index once the drain window has expired and the cache_key is unreachable to any in-flight OLD-K packet), and remove_active_peer in handlers/dispatch.rs (drops the worker entry for each of the four index slots: current, rekey, pending, previous). Only our_index is normally registered; unregister_session is fire- and-forget for missing entries, so calling unconditionally on all four slots is correct and bounds the cleanup without per- slot accounting. Without these callers the per-worker sessions HashMap and the Node's decrypt_registered_sessions set would grow monotonically per rekey on long-lived peers. Testing: - testing/static/scripts/bench-multirun.sh: multi-run iperf3 + ping bench. N reruns (default 5), median / min / max / CoV % / per-run outlier flag, avg ping RTT, ICMP loss %, TCP retransmit total. Plain client→dest labels + topology header. Pre-bench peer-convergence check (FIPS_BENCH_CONVERGE_SECS, default 15); per-path route verification via stats.bytes_sent deltas — fails fast if traffic exits via a non-static-peer link. - testing/static/docker-compose.yml: passes FIPS_ENCRYPT_WORKERS / FIPS_DECRYPT_WORKERS / FIPS_PERF through to containers for A/B benchmarking without rebuilds. - testing/static/scripts/iperf-test.sh: same plain client→dest labels + topology header (was multihop/direct/N hop, which conflated topology distance with on-wire path). - .config/nextest.toml: synthetic UDP node tests serialized through a max-threads=1 test group. Localhost handshakes drop on shared CI runners under parallel load; one-at-a-time keeps assertions reliable. - src/node/tests/spanning_tree.rs: repair_missing_edge_handshakes — retries up to 5 times for synthetic edges whose msg1 was dropped, with a drain after each edge retry instead of after each attempt's full burst. - src/node/decrypt_worker.rs::tests: two unit tests asserting WorkerMsg::UnregisterSession removes the worker-thread session HashMap entry (handle_msg_unregister_session_removes_entry) and is a no-op for never-seen cache_keys (handle_msg_unregister_session_idempotent_on_unknown_key), which is the safety invariant the unconditional unregister calls at the four index slots in remove_active_peer rely on. - src/node/encrypt_worker.rs::unix_tests pipelined_send_wire_layout_roundtrips_canonical_decoders: mirrors the encoder geometry of try_send_session_data_pipelined (no coords, the common established-session path), runs the worker's real seal + send via flush_direct_batch_sync, and decodes the resulting wire packet using only canonical receive-side decoders (EncryptedHeader::parse, SessionDatagramRef::decode, FSP header parse, noise::open). Any divergence between the hand-rolled encoder offsets (fsp_aad_offset, fsp_plaintext_offset) and the decoders fails at one of the parse / open / decode steps before the inner-plaintext assertion fires. Complements the existing fsp_preseal_runs_before_outer_fmp_seal test which covers the seal-ordering invariant with synthetic headers but does not exercise the wire-layout invariant. CHANGELOG.md [Unreleased] # Changed entry added describing the worker-pool threading model, hash-by-destination dispatch, sendmmsg/UDP_GSO, per-peer connected UDP, the operator-facing env vars, and the bench numbers above. Cherry-picks from mmalmi/master (paths translated from crates/fips-core/src/ to src/): 9b7c723, 0deb5cb, 13f7339, e036c0e, 3740a68, 3792f83, 8510193, 4910b07, e53f545, e4e2896, 5fe4af5, 1d01ada, 8c37008, e12469e, 6eb2860. Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
234 lines
7.9 KiB
Rust
234 lines
7.9 KiB
Rust
use super::{CipherState, HandshakeRole, NoiseError, ReplayWindow};
|
|
use secp256k1::{PublicKey, XOnlyPublicKey};
|
|
use std::fmt;
|
|
|
|
/// Completed Noise session for transport encryption.
|
|
///
|
|
/// Provides bidirectional authenticated encryption with replay protection.
|
|
/// The send counter is monotonically incremented; received counters are
|
|
/// validated against a sliding window to prevent replay attacks.
|
|
pub struct NoiseSession {
|
|
/// Our role in the original handshake.
|
|
role: HandshakeRole,
|
|
/// Cipher for sending.
|
|
send_cipher: CipherState,
|
|
/// Cipher for receiving.
|
|
recv_cipher: CipherState,
|
|
/// Handshake hash for channel binding.
|
|
handshake_hash: [u8; 32],
|
|
/// Remote peer's static public key.
|
|
remote_static: PublicKey,
|
|
/// Replay window for received packets.
|
|
replay_window: ReplayWindow,
|
|
}
|
|
|
|
impl NoiseSession {
|
|
/// Create a new session from completed handshake data.
|
|
pub(super) fn from_handshake(
|
|
role: HandshakeRole,
|
|
send_cipher: CipherState,
|
|
recv_cipher: CipherState,
|
|
handshake_hash: [u8; 32],
|
|
remote_static: PublicKey,
|
|
) -> Self {
|
|
Self {
|
|
role,
|
|
send_cipher,
|
|
recv_cipher,
|
|
handshake_hash,
|
|
remote_static,
|
|
replay_window: ReplayWindow::new(),
|
|
}
|
|
}
|
|
|
|
/// Encrypt a message for sending (using internal counter).
|
|
///
|
|
/// Returns the ciphertext. The current send counter should be included
|
|
/// in the wire format before calling this method.
|
|
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, NoiseError> {
|
|
self.send_cipher.encrypt(plaintext)
|
|
}
|
|
|
|
/// Get the current send counter (before incrementing).
|
|
///
|
|
/// Use this to get the counter to include in the wire format.
|
|
/// The counter will be incremented when `encrypt` is called.
|
|
pub fn current_send_counter(&self) -> u64 {
|
|
self.send_cipher.nonce
|
|
}
|
|
|
|
/// Decrypt a received message (using internal counter).
|
|
///
|
|
/// This is for handshake-phase decryption. For transport phase with
|
|
/// explicit counters, use `decrypt_with_replay_check` instead.
|
|
pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, NoiseError> {
|
|
self.recv_cipher.decrypt(ciphertext)
|
|
}
|
|
|
|
/// Check if a counter passes the replay window.
|
|
///
|
|
/// Returns Ok(()) if the counter is acceptable, Err if it should be rejected.
|
|
/// Call this before attempting decryption to avoid wasting CPU on replay attacks.
|
|
pub fn check_replay(&self, counter: u64) -> Result<(), NoiseError> {
|
|
if self.replay_window.check(counter) {
|
|
Ok(())
|
|
} else {
|
|
Err(NoiseError::ReplayDetected(counter))
|
|
}
|
|
}
|
|
|
|
/// Decrypt with explicit counter and replay protection.
|
|
///
|
|
/// This is the primary decryption method for transport phase.
|
|
/// The counter comes from the wire format and is validated against
|
|
/// the replay window before and after decryption.
|
|
///
|
|
/// On success, the counter is accepted into the replay window.
|
|
pub fn decrypt_with_replay_check(
|
|
&mut self,
|
|
ciphertext: &[u8],
|
|
counter: u64,
|
|
) -> Result<Vec<u8>, NoiseError> {
|
|
// Check replay window first (cheap)
|
|
if !self.replay_window.check(counter) {
|
|
return Err(NoiseError::ReplayDetected(counter));
|
|
}
|
|
|
|
// Attempt decryption (expensive)
|
|
let plaintext = self.recv_cipher.decrypt_with_counter(ciphertext, counter)?;
|
|
|
|
// Only accept into window after successful decryption
|
|
// This prevents DoS attacks that exhaust the window
|
|
self.replay_window.accept(counter);
|
|
|
|
Ok(plaintext)
|
|
}
|
|
|
|
/// Encrypt a message with Additional Authenticated Data (AAD).
|
|
///
|
|
/// Returns the ciphertext. The current send counter should be included
|
|
/// in the wire format before calling this method.
|
|
pub fn encrypt_with_aad(
|
|
&mut self,
|
|
plaintext: &[u8],
|
|
aad: &[u8],
|
|
) -> Result<Vec<u8>, NoiseError> {
|
|
self.send_cipher.encrypt_with_aad(plaintext, aad)
|
|
}
|
|
|
|
/// Decrypt with explicit counter, replay protection, and AAD.
|
|
///
|
|
/// This is the primary decryption method for the FMP transport phase
|
|
/// with AAD binding. The AAD (typically the 16-byte outer header) must
|
|
/// match what was used during encryption.
|
|
pub fn decrypt_with_replay_check_and_aad(
|
|
&mut self,
|
|
ciphertext: &[u8],
|
|
counter: u64,
|
|
aad: &[u8],
|
|
) -> Result<Vec<u8>, NoiseError> {
|
|
// Check replay window first (cheap)
|
|
if !self.replay_window.check(counter) {
|
|
return Err(NoiseError::ReplayDetected(counter));
|
|
}
|
|
|
|
// Attempt decryption with AAD (expensive)
|
|
let plaintext = self
|
|
.recv_cipher
|
|
.decrypt_with_counter_and_aad(ciphertext, counter, aad)?;
|
|
|
|
// Only accept into window after successful decryption
|
|
self.replay_window.accept(counter);
|
|
|
|
Ok(plaintext)
|
|
}
|
|
|
|
/// Get the highest received counter.
|
|
pub fn highest_received_counter(&self) -> u64 {
|
|
self.replay_window.highest()
|
|
}
|
|
|
|
/// Clone the recv-side AEAD instance, for off-task decrypt workers.
|
|
pub fn recv_cipher_clone(&self) -> Option<ring::aead::LessSafeKey> {
|
|
self.recv_cipher.cipher_clone()
|
|
}
|
|
|
|
/// Snapshot the current replay-window state as an **owned**
|
|
/// `ReplayWindow`, for hand-off to a shard-owning decrypt worker.
|
|
/// After this snapshot, the worker becomes the sole authority for
|
|
/// replay protection on the session.
|
|
pub fn recv_replay_snapshot_owned(&self) -> ReplayWindow {
|
|
self.replay_window.clone()
|
|
}
|
|
|
|
/// Clone the send-side AEAD instance, for off-task encrypt workers.
|
|
/// Pair with `take_send_counter` to keep counter assignment serial
|
|
/// under the session's `&mut`.
|
|
pub fn send_cipher_clone(&self) -> Option<ring::aead::LessSafeKey> {
|
|
self.send_cipher.cipher_clone()
|
|
}
|
|
|
|
/// Reserve and return the next send counter, advancing the internal
|
|
/// nonce. For pipelined encrypt paths.
|
|
pub fn take_send_counter(&mut self) -> Result<u64, NoiseError> {
|
|
if self.send_cipher.nonce == u64::MAX {
|
|
return Err(NoiseError::NonceOverflow);
|
|
}
|
|
let counter = self.send_cipher.nonce;
|
|
self.send_cipher.nonce += 1;
|
|
Ok(counter)
|
|
}
|
|
|
|
/// Accept a counter into the replay window after a successful out-of-task
|
|
/// decrypt. Caller is responsible for verifying decrypt success first.
|
|
pub fn accept_replay(&mut self, counter: u64) {
|
|
self.replay_window.accept(counter);
|
|
}
|
|
|
|
/// Reset the replay window (use when rekeying).
|
|
pub fn reset_replay_window(&mut self) {
|
|
self.replay_window.reset();
|
|
}
|
|
|
|
/// Get the handshake hash for channel binding.
|
|
pub fn handshake_hash(&self) -> &[u8; 32] {
|
|
&self.handshake_hash
|
|
}
|
|
|
|
/// Get the remote peer's static public key.
|
|
pub fn remote_static(&self) -> &PublicKey {
|
|
&self.remote_static
|
|
}
|
|
|
|
/// Get the remote peer's x-only public key.
|
|
pub fn remote_static_xonly(&self) -> XOnlyPublicKey {
|
|
self.remote_static.x_only_public_key().0
|
|
}
|
|
|
|
/// Get our role in the handshake.
|
|
pub fn role(&self) -> HandshakeRole {
|
|
self.role
|
|
}
|
|
|
|
/// Get the send nonce (for debugging).
|
|
pub fn send_nonce(&self) -> u64 {
|
|
self.send_cipher.nonce()
|
|
}
|
|
|
|
/// Get the receive nonce (for debugging).
|
|
pub fn recv_nonce(&self) -> u64 {
|
|
self.recv_cipher.nonce()
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for NoiseSession {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("NoiseSession")
|
|
.field("role", &self.role)
|
|
.field("send_nonce", &self.send_cipher.nonce())
|
|
.field("recv_nonce", &self.recv_cipher.nonce())
|
|
.field("handshake_hash", &hex::encode(&self.handshake_hash[..8]))
|
|
.finish()
|
|
}
|
|
}
|