Files
fips/src/noise/session.rs
T
Martti MalmiandJohnathan Corgan 0a5c367edc data-plane perf overhaul: off-task encrypt + decrypt, GSO, connected UDP
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>
2026-05-19 20:53:31 +00:00

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()
}
}