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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user