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