fix(core): receive path — replay window covers the loss window, zero-alloc open

Two receive-path findings from the networking audit:

1. The anti-replay window (4096 seqs) silently re-tightened the "late ≠ lost"
   fix: at 1 Gbps (~125k pkt/s) it spans only ~33 ms, so a Wi-Fi-retry-delayed
   shard the reassembler's 120 ms loss window would still use was dropped HERE
   first as "older than the window" — recreating the false-loss → recovery-IDR
   churn the time-based loss window was built to kill, exactly on the high-rate
   links punktfunk targets. Widened to 32768 (covers 120 ms up to ~270k pkt/s,
   ≈2 Gbps+); the bitmap costs 4 KiB per session and the replay-hiding bound
   stays finite.

2. Every received datagram still paid one Vec allocation in the AES-GCM open
   (and a to_vec on the plaintext probe path) — ~125k allocs/s of cross-thread
   allocator churn at line rate, the same class of overhead that was the
   documented single-core wall on the macOS receive path. New
   `SessionCrypto::open_in_place` (mirror of seal_in_place; GCM verifies the
   tag BEFORE decrypting, so a forged packet never yields plaintext) lets
   `poll_frame` decrypt inside the recv ring and hand the reassembler a slice.
   Byte-identical semantics, unit-tested against `open` incl. tamper/runt
   cases; criterion entry added next to seal_in_place.

Tests: 94 core unit + loopback/c_abi suites green; clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 14:55:06 +02:00
parent cca5008805
commit c7b8007ce7
3 changed files with 101 additions and 12 deletions
@@ -71,6 +71,15 @@ fn bench_crypto(c: &mut Criterion) {
g.bench_function("open", |b| {
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
});
g.bench_function("open_in_place", |b| {
// In-place open consumes the buffer, so each iteration restores the ciphertext first —
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot.
let mut buf = sealed.clone();
b.iter(|| {
buf.copy_from_slice(black_box(&sealed));
black_box(client.open_in_place(0, &mut buf).unwrap());
})
});
g.finish();
}
+58
View File
@@ -90,6 +90,31 @@ impl SessionCrypto {
)
.map_err(|_| PunktfunkError::Crypto)
}
/// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and
/// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length)
/// — byte-identical to `open`, just written in place. GCM verifies the tag *before*
/// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet
/// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec`
/// that `open`'s convenience API allocates for every datagram at line rate — the receive
/// mirror of [`seal_in_place`](Self::seal_in_place).
pub fn open_in_place(&self, seq: u64, buf: &mut [u8]) -> Result<usize> {
if buf.len() < TAG_LEN {
return Err(PunktfunkError::BadPacket);
}
let nonce = nonce(self.recv_salt, seq);
let split = buf.len() - TAG_LEN;
let (ciphertext, tag) = buf.split_at_mut(split);
self.cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce),
&seq.to_be_bytes(),
ciphertext,
aes_gcm::Tag::from_slice(tag),
)
.map_err(|_| PunktfunkError::Crypto)?;
Ok(split)
}
}
fn direction(role: Role) -> u8 {
@@ -164,6 +189,39 @@ mod tests {
);
}
#[test]
fn open_in_place_matches_open_and_rejects_tampering() {
let key = random_key();
let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client);
for msg in [
&b""[..],
b"x",
b"the quick brown fox jumps over 13 lazy dogs!!",
] {
let sealed = host.seal(9, msg).unwrap();
let mut buf = sealed.clone();
let n = client.open_in_place(9, &mut buf).unwrap();
assert_eq!(
&buf[..n],
msg,
"in-place open must be byte-identical to open"
);
// Wrong sequence (nonce + AAD) → authentication failure, like `open`.
let mut buf = sealed.clone();
assert!(client.open_in_place(8, &mut buf).is_err());
// A flipped ciphertext/tag bit → authentication failure.
let mut buf = sealed.clone();
let last = buf.len() - 1;
buf[last] ^= 1;
assert!(client.open_in_place(9, &mut buf).is_err());
}
// Shorter than a tag can't be a sealed packet at all.
let mut runt = vec![0u8; TAG_LEN - 1];
assert!(client.open_in_place(0, &mut runt).is_err());
}
#[test]
fn seal_in_place_matches_seal_and_opens() {
let key = random_key();
+32 -10
View File
@@ -296,24 +296,42 @@ impl Session {
if len > MAX_DATAGRAM_BYTES {
continue;
}
let pkt = match self.open_from_wire(&self.recv_scratch[i][..len]) {
Ok(p) => p,
Err(_) => continue,
// Open in place inside the ring buffer — no per-datagram allocation at line rate
// (~125k pkt/s at 1 Gbps; the recv ring killed the recv alloc, this kills the decrypt
// one). The plaintext lands at [8..8+n] of the sealed wire (behind the seq prefix); an
// unencrypted (probe) datagram IS the packet. Field-precise borrows keep the slice into
// `recv_scratch` alive across the replay/reassembler calls below.
let (pkt_range, seq) = match &self.crypto {
Some(c) => {
// A sealed datagram is at least seq prefix + tag; anything shorter is noise.
if len < 8 + crate::crypto::TAG_LEN {
continue;
}
let seq = u64::from_be_bytes(self.recv_scratch[i][..8].try_into().unwrap());
match c.open_in_place(seq, &mut self.recv_scratch[i][8..len]) {
Ok(n) => (8..8 + n, Some(seq)),
Err(_) => continue, // undecryptable noise — drop, keep draining
}
}
None => (0..len, None),
};
// Anti-replay (same rationale as poll_input): reject a datagram whose authenticated
// sequence was already seen. Video also dedups per-frame downstream, but filtering here
// is uniform and cheap. `len >= 8` because the sealed-path open above succeeded.
if self.replay.is_some() && !self.accept_seq(seq_of(&self.recv_scratch[i][..len])) {
// is uniform and cheap.
if let (Some(w), Some(seq)) = (self.replay.as_mut(), seq) {
if !w.accept(seq) {
StatsCounters::add(&self.stats.packets_dropped, 1);
continue;
}
}
let pkt = &self.recv_scratch[i][pkt_range];
StatsCounters::add(&self.stats.packets_received, 1);
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
// The reassembler validates the packet via its parsed header (`magic`),
// ignoring anything that isn't a well-formed video packet.
if let Some(frame) = self
.reassembler
.push(&pkt, self.coder.as_ref(), &self.stats)?
.push(pkt, self.coder.as_ref(), &self.stats)?
{
StatsCounters::add(&self.stats.frames_completed, 1);
return Ok(frame);
@@ -387,10 +405,14 @@ fn seq_of(wire: &[u8]) -> u64 {
}
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
/// datagram, so at the data plane's packet rate 4096 is roughly 33 ms of reorder tolerance for the
/// video stream (well beyond any reordering still useful for a live frame) and effectively unbounded
/// for the sparse input stream — while bounding how far back a replay could hide.
const REPLAY_WINDOW: u64 = 4096;
/// datagram, so this must cover the reassembler's 120 ms loss window
/// ([`LOSS_WINDOW_NS`](crate::packet)) at line-rate packet rates — otherwise the replay filter
/// silently re-tightens the "late ≠ lost" fix: a Wi-Fi-retry-delayed shard the reassembler would
/// still use gets dropped here as "older than the window" first (4096 was only ~33 ms at the
/// ~125k pkt/s of a 1 Gbps stream). 32768 covers 120 ms up to ~270k pkt/s (≈2 Gbps+) and is
/// effectively unbounded for the sparse input stream, while still bounding how far back a replay
/// could hide; the bitmap costs 4 KiB per session.
const REPLAY_WINDOW: u64 = 32768;
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts