Skip to main content

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}