diff --git a/crates/punktfunk-core/benches/pipeline.rs b/crates/punktfunk-core/benches/pipeline.rs index 4fc48bf0..28740ec3 100644 --- a/crates/punktfunk-core/benches/pipeline.rs +++ b/crates/punktfunk-core/benches/pipeline.rs @@ -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(); } diff --git a/crates/punktfunk-core/src/crypto.rs b/crates/punktfunk-core/src/crypto.rs index 08c96003..6814a727 100644 --- a/crates/punktfunk-core/src/crypto.rs +++ b/crates/punktfunk-core/src/crypto.rs @@ -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 { + 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(); diff --git a/crates/punktfunk-core/src/session.rs b/crates/punktfunk-core/src/session.rs index 2644b9c6..3d4412b9 100644 --- a/crates/punktfunk-core/src/session.rs +++ b/crates/punktfunk-core/src/session.rs @@ -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])) { - StatsCounters::add(&self.stats.packets_dropped, 1); - continue; + // 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