phantom_protocol/security/replay_window.rs
1//! Bitmap-based sliding-window replay detection (RFC 4303 § 3.4.3 / IPsec ESP).
2//!
3//! Per-stream replay protection for the data plane. Tracks the highest accepted
4//! sequence number plus a 1024-bit bitmap covering `[high - 1024 + 1, high]`,
5//! rejecting duplicates and out-of-window-old packets.
6//!
7//! ## Why this exists if the AEAD already prevents replay
8//!
9//! The current AEAD construction (per-direction monotonic `AtomicU64` counter
10//! folded into the nonce) cryptographically rejects replays — a duplicated
11//! packet carries an old counter, so the receiver-derived nonce no longer
12//! matches and decryption fails. This module is **defense in depth**:
13//!
14//! - Catches the replay *before* AEAD work (saves CPU under attack).
15//! - Yields an explicit, observable `ReplayDetected` error variant for metrics.
16//! - Survives future AEAD redesigns (e.g. moving the nonce derivation off the
17//! strict counter onto `header.sequence` to support out-of-order delivery).
18//!
19//! ## Window semantics
20//!
21//! - `WINDOW_BITS = 1024`. Bit 0 represents `high_watermark`; bit `i` represents
22//! `high_watermark - i`.
23//! - Sequence numbers strictly greater than `high_watermark` advance the window
24//! (left-shift the bitmap, mark bit 0).
25//! - Sequence numbers within the window: check the bit; reject if set, accept
26//! and set the bit otherwise.
27//! - Sequence numbers below `high_watermark - WINDOW_BITS + 1`: too old, reject.
28//!
29//! The struct is small (128-byte bitmap + a couple of u32 words) and intended
30//! to be held per-stream under a `parking_lot::Mutex`.
31
32/// Width of the sliding window in bits. 1024 matches RFC 4303's recommended
33/// upper bound and gives us comfortable headroom for severely reordered
34/// transports.
35pub const WINDOW_BITS: usize = 1024;
36const WINDOW_WORDS: usize = WINDOW_BITS / 64;
37
38/// Sliding-window replay tracker for a single sequence-numbered stream.
39#[derive(Debug)]
40pub struct ReplayWindow {
41 /// Bitmap. Bit `i` (counting from LSB of word 0) corresponds to
42 /// `high_watermark - i`. Bit 0 is the high-watermark itself.
43 bitmap: [u64; WINDOW_WORDS],
44 /// Highest accepted sequence number seen so far.
45 high_watermark: u32,
46 /// Whether any packet has been accepted yet. Until the first packet, every
47 /// sequence number is novel.
48 initialized: bool,
49}
50
51impl Default for ReplayWindow {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl ReplayWindow {
58 /// Create an empty window. The first `accept` call is always accepted.
59 pub const fn new() -> Self {
60 Self {
61 bitmap: [0u64; WINDOW_WORDS],
62 high_watermark: 0,
63 initialized: false,
64 }
65 }
66
67 /// Attempt to accept a new sequence number.
68 ///
69 /// Returns `true` if the sequence is novel (never seen before AND within
70 /// the window). Updates internal state to record the acceptance.
71 ///
72 /// Returns `false` for:
73 /// - Exact duplicate of a previously-accepted sequence within the window.
74 /// - Sequence below the window (too old).
75 pub fn accept(&mut self, seq: u32) -> bool {
76 if !self.initialized {
77 self.high_watermark = seq;
78 self.bitmap[0] = 1; // bit 0 set: high_watermark itself
79 self.initialized = true;
80 return true;
81 }
82
83 if seq > self.high_watermark {
84 let shift = (seq - self.high_watermark) as usize;
85 if shift >= WINDOW_BITS {
86 // Big forward jump — entire window beyond the old high. Reset.
87 self.bitmap = [0u64; WINDOW_WORDS];
88 } else {
89 Self::shift_left(&mut self.bitmap, shift);
90 }
91 self.bitmap[0] |= 1; // mark new high
92 self.high_watermark = seq;
93 true
94 } else {
95 let offset = (self.high_watermark - seq) as usize;
96 if offset >= WINDOW_BITS {
97 return false; // too old, beyond the window
98 }
99 let word = offset / 64;
100 let bit = offset % 64;
101 let mask = 1u64 << bit;
102 if self.bitmap[word] & mask != 0 {
103 false // duplicate
104 } else {
105 self.bitmap[word] |= mask;
106 true
107 }
108 }
109 }
110
111 /// Highest accepted sequence number. Returns 0 if no packets have been
112 /// accepted yet (caller should check `is_initialized` to disambiguate).
113 pub fn high_watermark(&self) -> u32 {
114 self.high_watermark
115 }
116
117 /// Whether any packet has been accepted yet.
118 pub fn is_initialized(&self) -> bool {
119 self.initialized
120 }
121
122 /// Left-shift the bitmap by `shift` bits (`shift < WINDOW_BITS`).
123 ///
124 /// "Left" in the conceptual layout: bit at position `i` moves to position
125 /// `i + shift`. In multi-word storage with bit 0 at LSB of word 0, this
126 /// is a high-word shift in big-endian-bit ordering.
127 fn shift_left(bitmap: &mut [u64; WINDOW_WORDS], shift: usize) {
128 debug_assert!(shift < WINDOW_BITS);
129 let word_shift = shift / 64;
130 let bit_shift = shift % 64;
131
132 if word_shift > 0 {
133 // Move each word `word_shift` positions toward higher indices.
134 // The lowest `word_shift` words become 0.
135 for i in (word_shift..WINDOW_WORDS).rev() {
136 bitmap[i] = bitmap[i - word_shift];
137 }
138 for word in bitmap[..word_shift].iter_mut() {
139 *word = 0;
140 }
141 }
142
143 if bit_shift > 0 {
144 let inv = 64 - bit_shift;
145 // From high-index to low: shift within each word and OR in the
146 // carry from the next-lower word.
147 for i in (1..WINDOW_WORDS).rev() {
148 bitmap[i] = (bitmap[i] << bit_shift) | (bitmap[i - 1] >> inv);
149 }
150 bitmap[0] <<= bit_shift;
151 }
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn first_packet_always_accepted() {
161 let mut w = ReplayWindow::new();
162 assert!(w.accept(42));
163 assert!(w.is_initialized());
164 assert_eq!(w.high_watermark(), 42);
165 }
166
167 #[test]
168 fn duplicate_is_rejected() {
169 let mut w = ReplayWindow::new();
170 assert!(w.accept(10));
171 assert!(!w.accept(10));
172 }
173
174 #[test]
175 fn monotonic_sequence_accepted() {
176 let mut w = ReplayWindow::new();
177 for seq in 1..=2000u32 {
178 assert!(w.accept(seq), "seq {} must be accepted", seq);
179 }
180 assert_eq!(w.high_watermark(), 2000);
181 }
182
183 #[test]
184 fn out_of_order_within_window_accepted_once() {
185 let mut w = ReplayWindow::new();
186 assert!(w.accept(100));
187 assert!(w.accept(90)); // within window
188 assert!(!w.accept(90)); // duplicate
189 assert!(w.accept(80));
190 assert!(!w.accept(80));
191 assert!(w.accept(99));
192 assert!(!w.accept(99));
193 // High watermark unchanged after older packets.
194 assert_eq!(w.high_watermark(), 100);
195 }
196
197 #[test]
198 fn ancient_packet_rejected() {
199 let mut w = ReplayWindow::new();
200 assert!(w.accept(2000));
201 // Window covers [high - WINDOW_BITS + 1, high] inclusive
202 // = [2000 - 1024 + 1, 2000]
203 // = [977, 2000].
204 // 977 is the lowest sequence still in-window.
205 assert!(w.accept(977));
206 // 976 is below the window edge.
207 assert!(!w.accept(976));
208 assert!(!w.accept(100));
209 assert!(!w.accept(0));
210 }
211
212 #[test]
213 fn large_forward_jump_resets_window() {
214 let mut w = ReplayWindow::new();
215 assert!(w.accept(1));
216 assert!(w.accept(10_000)); // huge jump > WINDOW_BITS
217 assert_eq!(w.high_watermark(), 10_000);
218 // Old in-window-of-1 packet is now far outside the new window.
219 assert!(!w.accept(1));
220 // But 9_000 (within new window) is fine.
221 assert!(w.accept(9_000));
222 assert!(!w.accept(9_000));
223 }
224
225 #[test]
226 fn shift_within_word() {
227 let mut w = ReplayWindow::new();
228 assert!(w.accept(5));
229 assert!(w.accept(10)); // shifts by 5
230 // 5 should still be marked (bit 5 of word 0).
231 assert!(!w.accept(5));
232 assert!(!w.accept(10));
233 }
234
235 #[test]
236 fn shift_across_word_boundary() {
237 let mut w = ReplayWindow::new();
238 assert!(w.accept(0));
239 assert!(w.accept(64)); // crosses word boundary on shift
240 assert!(!w.accept(64));
241 assert!(!w.accept(0));
242 assert!(w.accept(32));
243 assert!(!w.accept(32));
244 }
245
246 #[test]
247 fn shift_multiple_words() {
248 let mut w = ReplayWindow::new();
249 assert!(w.accept(0));
250 assert!(w.accept(100)); // shifts by 100 = 1 word + 36 bits
251 assert!(!w.accept(0));
252 assert!(!w.accept(100));
253 assert!(w.accept(50));
254 assert!(!w.accept(50));
255 }
256
257 #[test]
258 fn boundary_exactly_at_window_edge() {
259 let mut w = ReplayWindow::new();
260 assert!(w.accept(WINDOW_BITS as u32));
261 // The edge: high_watermark - WINDOW_BITS + 1 = 1 → accepted.
262 assert!(w.accept(1));
263 // One below the edge: 0 → too old.
264 assert!(!w.accept(0));
265 }
266}