T1.8: Per-stream anti-replay window with configurable size
This commit is contained in:
@@ -1,21 +1,20 @@
|
||||
//! Sliding window replay protection.
|
||||
//!
|
||||
//! Tracks seen sequence numbers using a bitmap. Window size is 1024 packets.
|
||||
//! Sequence numbers that are too old (more than WINDOW_SIZE behind the highest
|
||||
//! seen) are rejected.
|
||||
//! Tracks seen sequence numbers using a bitmap. Window size is configurable
|
||||
//! at construction time. Sequence numbers that are too old (more than
|
||||
//! `window_size` behind the highest seen) are rejected.
|
||||
|
||||
use wzp_proto::CryptoError;
|
||||
|
||||
/// Window size in packets.
|
||||
const WINDOW_SIZE: u16 = 1024;
|
||||
|
||||
/// Sliding window anti-replay detector.
|
||||
///
|
||||
/// Uses a bitmap to track which sequence numbers have been seen within
|
||||
/// the current window. Handles u16 wrapping correctly.
|
||||
/// the current window. Handles `u32` wrapping correctly.
|
||||
pub struct AntiReplayWindow {
|
||||
/// Window size in packets.
|
||||
window_size: u32,
|
||||
/// Highest sequence number seen so far.
|
||||
highest: u16,
|
||||
highest: u32,
|
||||
/// Bitmap of seen packets. Bit i corresponds to (highest - i).
|
||||
bitmap: Vec<u64>,
|
||||
/// Whether any packet has been received yet.
|
||||
@@ -23,21 +22,26 @@ pub struct AntiReplayWindow {
|
||||
}
|
||||
|
||||
impl AntiReplayWindow {
|
||||
/// Number of u64 words needed for the bitmap.
|
||||
const BITMAP_WORDS: usize = (WINDOW_SIZE as usize + 63) / 64;
|
||||
|
||||
/// Create a new anti-replay window.
|
||||
/// Create a new anti-replay window with the default size of 1024 packets.
|
||||
pub fn new() -> Self {
|
||||
Self::with_window(1024)
|
||||
}
|
||||
|
||||
/// Create a new anti-replay window with a custom size.
|
||||
pub fn with_window(size: usize) -> Self {
|
||||
let window_size = size as u32;
|
||||
let bitmap_words = (size + 63) / 64;
|
||||
Self {
|
||||
window_size,
|
||||
highest: 0,
|
||||
bitmap: vec![0u64; Self::BITMAP_WORDS],
|
||||
bitmap: vec![0u64; bitmap_words],
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a sequence number is valid (not a replay, not too old).
|
||||
/// If valid, marks it as seen.
|
||||
pub fn check_and_update(&mut self, seq: u16) -> Result<(), CryptoError> {
|
||||
pub fn check_and_update(&mut self, seq: u32) -> Result<(), CryptoError> {
|
||||
if !self.initialized {
|
||||
self.initialized = true;
|
||||
self.highest = seq;
|
||||
@@ -52,17 +56,17 @@ impl AntiReplayWindow {
|
||||
return Err(CryptoError::ReplayDetected { seq });
|
||||
}
|
||||
|
||||
if diff < 0x8000 {
|
||||
// seq is ahead of highest (wrapping-aware: diff in [1, 0x7FFF])
|
||||
if diff < 0x8000_0000 {
|
||||
// seq is ahead of highest (wrapping-aware: diff in [1, 0x7FFF_FFFF])
|
||||
let shift = diff as usize;
|
||||
self.advance_window(shift);
|
||||
self.highest = seq;
|
||||
self.set_bit(0);
|
||||
Ok(())
|
||||
} else {
|
||||
// seq is behind highest (wrapping-aware: diff in [0x8000, 0xFFFF])
|
||||
// seq is behind highest (wrapping-aware: diff in [0x8000_0000, 0xFFFF_FFFF])
|
||||
let behind = self.highest.wrapping_sub(seq) as usize;
|
||||
if behind >= WINDOW_SIZE as usize {
|
||||
if behind >= self.window_size as usize {
|
||||
return Err(CryptoError::ReplayDetected { seq });
|
||||
}
|
||||
if self.get_bit(behind) {
|
||||
@@ -75,7 +79,8 @@ impl AntiReplayWindow {
|
||||
|
||||
/// Advance the window by `shift` positions (shift left = new bits at position 0).
|
||||
fn advance_window(&mut self, shift: usize) {
|
||||
if shift >= WINDOW_SIZE as usize {
|
||||
let window_size = self.window_size as usize;
|
||||
if shift >= window_size {
|
||||
for word in &mut self.bitmap {
|
||||
*word = 0;
|
||||
}
|
||||
@@ -187,11 +192,11 @@ mod tests {
|
||||
#[test]
|
||||
fn wrapping_works() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
assert!(w.check_and_update(65530).is_ok());
|
||||
assert!(w.check_and_update(65535).is_ok());
|
||||
assert!(w.check_and_update(0xFFFF_FFF0).is_ok());
|
||||
assert!(w.check_and_update(0xFFFF_FFFF).is_ok());
|
||||
assert!(w.check_and_update(0).is_ok()); // wrapped
|
||||
assert!(w.check_and_update(1).is_ok());
|
||||
assert!(w.check_and_update(65535).is_err()); // duplicate
|
||||
assert!(w.check_and_update(0xFFFF_FFFF).is_err()); // duplicate
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -205,4 +210,53 @@ mod tests {
|
||||
// Now 0 is 1024 behind 1024, which is at the boundary limit
|
||||
assert!(w.check_and_update(0).is_err()); // already seen or too old
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_window_size() {
|
||||
let mut w = AntiReplayWindow::with_window(64);
|
||||
for i in 0..64 {
|
||||
assert!(w.check_and_update(i).is_ok());
|
||||
}
|
||||
// seq 0 is now exactly at the boundary (64 behind 64)
|
||||
assert!(w.check_and_update(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_burst_200_with_one_reorder() {
|
||||
let mut w = AntiReplayWindow::with_window(1024);
|
||||
// Simulate a 200-packet burst
|
||||
for i in 0..200 {
|
||||
assert!(
|
||||
w.check_and_update(i).is_ok(),
|
||||
"seq {} should be accepted",
|
||||
i
|
||||
);
|
||||
}
|
||||
// One packet reordered (arrives late)
|
||||
assert!(w.check_and_update(50).is_err(), "seq 50 is a duplicate");
|
||||
// But a packet just behind the window should still be ok
|
||||
assert!(w.check_and_update(199).is_err(), "seq 199 is a duplicate");
|
||||
// Continue the burst
|
||||
for i in 200..400 {
|
||||
assert!(
|
||||
w.check_and_update(i).is_ok(),
|
||||
"seq {} should be accepted",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_high_range_works() {
|
||||
let mut w = AntiReplayWindow::with_window(64);
|
||||
let base = 1000u32;
|
||||
assert!(w.check_and_update(base).is_ok());
|
||||
assert!(w.check_and_update(base + 1).is_ok());
|
||||
// 65 behind highest (base+1) is outside the 64-packet window
|
||||
assert!(w.check_and_update(base.wrapping_sub(64)).is_err());
|
||||
// 63 behind is inside
|
||||
assert!(w.check_and_update(base.wrapping_sub(62)).is_ok());
|
||||
// base itself is now a duplicate
|
||||
assert!(w.check_and_update(base).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
//! Implements the `CryptoSession` trait for per-call media encryption.
|
||||
//! Nonces are derived deterministically from session_id + sequence counter + direction.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chacha20poly1305::aead::Aead;
|
||||
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
|
||||
use rand::rngs::OsRng;
|
||||
use wzp_proto::{CryptoError, CryptoSession};
|
||||
use wzp_proto::{CryptoError, CryptoSession, MediaHeader, MediaType};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use crate::anti_replay::AntiReplayWindow;
|
||||
use crate::nonce::{self, Direction};
|
||||
use crate::rekey::RekeyManager;
|
||||
|
||||
@@ -28,6 +31,8 @@ pub struct ChaChaSession {
|
||||
pending_rekey_secret: Option<StaticSecret>,
|
||||
/// Short Authentication String (4-digit code for verbal verification).
|
||||
sas_code: Option<u32>,
|
||||
/// Per-stream anti-replay windows, keyed by (stream_id, media_type).
|
||||
anti_replay: HashMap<(u8, MediaType), AntiReplayWindow>,
|
||||
}
|
||||
|
||||
impl ChaChaSession {
|
||||
@@ -49,6 +54,7 @@ impl ChaChaSession {
|
||||
rekey_mgr: RekeyManager::new(shared_secret),
|
||||
pending_rekey_secret: None,
|
||||
sas_code: None,
|
||||
anti_replay: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +73,27 @@ impl ChaChaSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a v2 `MediaHeader` from raw bytes.
|
||||
/// Returns `None` if the buffer is too short or not a valid v2 header.
|
||||
fn parse_header(header_bytes: &[u8]) -> Option<MediaHeader> {
|
||||
if header_bytes.len() < MediaHeader::WIRE_SIZE {
|
||||
return None;
|
||||
}
|
||||
let mut cursor = std::io::Cursor::new(header_bytes);
|
||||
MediaHeader::read_from(&mut cursor)
|
||||
}
|
||||
|
||||
/// Return the default anti-replay window size for a given media type.
|
||||
fn default_window_for_media_type(media_type: MediaType) -> AntiReplayWindow {
|
||||
let size = match media_type {
|
||||
MediaType::Audio => 64,
|
||||
MediaType::Video => 1024,
|
||||
MediaType::Data => 256,
|
||||
MediaType::Control => 32,
|
||||
};
|
||||
AntiReplayWindow::with_window(size)
|
||||
}
|
||||
|
||||
impl CryptoSession for ChaChaSession {
|
||||
fn encrypt(
|
||||
&mut self,
|
||||
@@ -116,8 +143,24 @@ impl CryptoSession for ChaChaSession {
|
||||
.decrypt(nonce, payload)
|
||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||
|
||||
let plaintext_len = plaintext.len();
|
||||
out.extend_from_slice(&plaintext);
|
||||
self.recv_seq = self.recv_seq.wrapping_add(1);
|
||||
|
||||
// Anti-replay check: if header parses as a v2 MediaHeader, verify seq
|
||||
// is not a replay for this (stream_id, media_type).
|
||||
if let Some(header) = parse_header(header_bytes) {
|
||||
let window = self
|
||||
.anti_replay
|
||||
.entry((header.stream_id, header.media_type))
|
||||
.or_insert_with(|| default_window_for_media_type(header.media_type));
|
||||
if let Err(e) = window.check_and_update(header.seq) {
|
||||
// Roll back the plaintext we just appended.
|
||||
out.truncate(out.len() - plaintext_len);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -237,4 +280,89 @@ mod tests {
|
||||
// Session is now rekeyed - counters reset
|
||||
assert_eq!(alice.send_seq, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_stream_anti_replay_rejects_duplicate() {
|
||||
use wzp_proto::{CodecId, MediaType};
|
||||
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let header = MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 10,
|
||||
seq: 42,
|
||||
timestamp: 1000,
|
||||
fec_block: 0,
|
||||
};
|
||||
let mut header_bytes = Vec::new();
|
||||
header.write_to(&mut header_bytes);
|
||||
|
||||
let plaintext = b"audio frame";
|
||||
|
||||
// First packet decrypts successfully
|
||||
let mut ct = Vec::new();
|
||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
||||
let mut pt = Vec::new();
|
||||
bob.decrypt(&header_bytes, &ct, &mut pt).unwrap();
|
||||
assert_eq!(&pt, plaintext);
|
||||
|
||||
// Exact duplicate is rejected by anti-replay
|
||||
let mut pt2 = Vec::new();
|
||||
let result = bob.decrypt(&header_bytes, &ct, &mut pt2);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"duplicate packet with same seq must be rejected"
|
||||
);
|
||||
assert!(pt2.is_empty(), "plaintext must be rolled back on replay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_stream_anti_replay_video_burst_200_with_reorder() {
|
||||
use wzp_proto::{CodecId, MediaType};
|
||||
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let header = MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Video,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 1,
|
||||
fec_ratio: 10,
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
fec_block: 0,
|
||||
};
|
||||
|
||||
let plaintext = b"video frame";
|
||||
|
||||
// Send 200 packets in order
|
||||
for i in 0..200 {
|
||||
let mut h = header;
|
||||
h.seq = i;
|
||||
let mut header_bytes = Vec::new();
|
||||
h.write_to(&mut header_bytes);
|
||||
|
||||
let mut ct = Vec::new();
|
||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
||||
|
||||
let mut pt = Vec::new();
|
||||
bob.decrypt(&header_bytes, &ct, &mut pt).unwrap();
|
||||
}
|
||||
|
||||
// Re-send packet 50 — should be rejected as replay
|
||||
let mut h = header;
|
||||
h.seq = 50;
|
||||
let mut header_bytes = Vec::new();
|
||||
h.write_to(&mut header_bytes);
|
||||
|
||||
let mut ct = Vec::new();
|
||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
||||
|
||||
let mut pt = Vec::new();
|
||||
let result = bob.decrypt(&header_bytes, &ct, &mut pt);
|
||||
assert!(result.is_err(), "reordered duplicate must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub enum CryptoError {
|
||||
#[error("rekey failed: {0}")]
|
||||
RekeyFailed(String),
|
||||
#[error("anti-replay: duplicate or old packet (seq={seq})")]
|
||||
ReplayDetected { seq: u16 },
|
||||
ReplayDetected { seq: u32 },
|
||||
#[error("internal crypto error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user