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-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}