Skip to main content

soap_server/wssec/
nonce_cache.rs

1// Nonce replay cache — prevents replay attacks
2use crate::fault::SoapFault;
3use std::collections::HashSet;
4use std::time::Instant;
5
6/// Default maximum number of nonces in a single bucket before forced rotation.
7/// Limits memory usage: at most `2 × DEFAULT_MAX_ENTRIES` nonces are held at once.
8const DEFAULT_MAX_ENTRIES: usize = 100_000;
9
10/// Two-bucket rotating nonce cache for WS-Security replay detection.
11///
12/// Buckets rotate every `half_window` seconds (default: 150 s → 300 s total window).
13/// A nonce present in **either** bucket is considered a replay and rejected.
14///
15/// When the current bucket reaches `max_entries` a forced rotation occurs so the
16/// bucket is replaced by an empty one — legitimate traffic continues but further
17/// nonces from the previous bucket will no longer be detected as replays.  This
18/// is preferable to unbounded growth; the trade-off is documented in `check_and_insert`.
19///
20/// # Thread-safety
21///
22/// `check_and_insert` takes `&mut self`, so `RotatingNonceCache` itself is **not**
23/// thread-safe. Wrap it in a `tokio::sync::Mutex` (or `std::sync::Mutex` for sync
24/// contexts) before sharing across async tasks:
25///
26/// ```rust,ignore
27/// use tokio::sync::Mutex;
28/// use soap_server::RotatingNonceCache;
29///
30/// let cache = Arc::new(Mutex::new(RotatingNonceCache::new(150)));
31/// // Inside an async handler:
32/// let mut cache = cache.lock().await;
33/// cache.check_and_insert(&nonce)?;
34/// ```
35///
36/// The soap-server [`SoapService`](crate::SoapService) handles this internally — consumers
37/// using [`validate_username_token`](crate::validate_username_token) via the server builder
38/// do not need to manage the cache directly. Consider interior-mutability refactoring in v0.2.
39pub struct RotatingNonceCache {
40    current: HashSet<String>,
41    previous: HashSet<String>,
42    bucket_start: Instant,
43    half_window_secs: u64,
44    max_entries: usize,
45}
46
47impl RotatingNonceCache {
48    /// Create a new cache with the given time-window and default entry cap (100 000).
49    pub fn new(half_window_secs: u64) -> Self {
50        Self::with_max_entries(half_window_secs, DEFAULT_MAX_ENTRIES)
51    }
52
53    /// Create a new cache with explicit time-window and per-bucket entry cap.
54    pub fn with_max_entries(half_window_secs: u64, max_entries: usize) -> Self {
55        Self {
56            current: HashSet::new(),
57            previous: HashSet::new(),
58            bucket_start: Instant::now(),
59            half_window_secs,
60            max_entries,
61        }
62    }
63
64    /// Check nonce for replay and insert if not seen. Returns Err on replay.
65    ///
66    /// If the current bucket is at capacity (`max_entries`), a **forced rotation**
67    /// is performed before inserting: the current bucket becomes the previous bucket
68    /// and a fresh empty bucket is started.  This bounds memory use at the cost of
69    /// a narrow window where very recently-rotated nonces are no longer tracked —
70    /// a DoS flood that exhausts the cap is still bounded, and normal traffic is
71    /// not disrupted.
72    pub fn check_and_insert(&mut self, nonce: &str) -> Result<(), SoapFault> {
73        self.rotate_if_needed();
74
75        // Check for replay before potentially rotating due to capacity.
76        if self.current.contains(nonce) || self.previous.contains(nonce) {
77            return Err(SoapFault::sender("WS-Security nonce replay detected"));
78        }
79
80        // If current bucket is full, force a rotation to bound memory use.
81        if self.current.len() >= self.max_entries {
82            self.previous = std::mem::take(&mut self.current);
83            self.bucket_start = Instant::now();
84        }
85
86        self.current.insert(nonce.to_string());
87        Ok(())
88    }
89
90    fn rotate_if_needed(&mut self) {
91        // Use a while loop so that 2+ elapsed windows fully evict stale nonces.
92        // Without this loop, a server idle for 2× half_window would retain the
93        // previous bucket, causing legitimate clients to get spurious replay faults.
94        while self.bucket_start.elapsed().as_secs() >= self.half_window_secs {
95            self.previous = std::mem::take(&mut self.current);
96            self.bucket_start += std::time::Duration::from_secs(self.half_window_secs);
97        }
98    }
99
100    /// For testing: force a bucket rotation by resetting the bucket_start to the past.
101    #[cfg(test)]
102    pub fn force_rotate(&mut self) {
103        self.previous = std::mem::take(&mut self.current);
104        self.bucket_start = Instant::now();
105    }
106
107    /// For testing: rewind bucket_start by `secs` seconds to simulate elapsed time.
108    #[cfg(test)]
109    pub fn rewind_bucket_start(&mut self, secs: u64) {
110        self.bucket_start -= std::time::Duration::from_secs(secs);
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn fresh_cache_accepts_new_nonce() {
120        let mut cache = RotatingNonceCache::new(150);
121        assert!(cache.check_and_insert("abc").is_ok());
122    }
123
124    #[test]
125    fn same_nonce_rejected_on_second_call() {
126        let mut cache = RotatingNonceCache::new(150);
127        cache.check_and_insert("abc").unwrap();
128        let result = cache.check_and_insert("abc");
129        assert!(result.is_err());
130        let fault = result.unwrap_err();
131        assert!(fault.reason.contains("replay"), "got: {}", fault.reason);
132    }
133
134    #[test]
135    fn different_nonce_accepted() {
136        let mut cache = RotatingNonceCache::new(150);
137        cache.check_and_insert("abc").unwrap();
138        assert!(cache.check_and_insert("xyz").is_ok());
139    }
140
141    #[test]
142    fn nonce_in_previous_bucket_still_detected_as_replay() {
143        let mut cache = RotatingNonceCache::new(150);
144        // Insert nonce into current bucket
145        cache.check_and_insert("abc").unwrap();
146        // Force rotation — "abc" moves to previous bucket
147        cache.force_rotate();
148        // Should still be rejected because it's in previous
149        let result = cache.check_and_insert("abc");
150        assert!(result.is_err(), "Expected replay error after rotation");
151    }
152
153    #[test]
154    fn nonce_dropped_after_two_rotations() {
155        let mut cache = RotatingNonceCache::new(150);
156        // Insert nonce
157        cache.check_and_insert("abc").unwrap();
158        // First rotation — "abc" moves to previous
159        cache.force_rotate();
160        // Second rotation — "abc" is dropped (previous is replaced)
161        cache.force_rotate();
162        // Now "abc" should be accepted again
163        assert!(
164            cache.check_and_insert("abc").is_ok(),
165            "Expected nonce to be accepted after two rotations"
166        );
167    }
168
169    /// Regression test for BLOCK-SS-C02: idle-gap bug.
170    /// If the server is idle for 2+ half_window periods, rotate_if_needed() must
171    /// rotate enough times so that stale nonces are fully evicted.
172    /// Without the while-loop fix, this test would fail with a spurious replay fault.
173    #[test]
174    fn idle_gap_regression_nonce_accepted_after_two_window_gap() {
175        let half_window = 5u64;
176        let mut cache = RotatingNonceCache::new(half_window);
177        // Insert nonce into current bucket.
178        cache.check_and_insert("gap_nonce").unwrap();
179        // Simulate server being idle for 2× half_window + 1 second.
180        cache.rewind_bucket_start(half_window * 2 + 1);
181        // The nonce was inserted >2 windows ago — it must be fully evicted.
182        // Without the while-loop fix, only one rotation would happen, leaving
183        // "gap_nonce" in `previous` and causing a spurious replay rejection.
184        assert!(
185            cache.check_and_insert("gap_nonce").is_ok(),
186            "Nonce must be accepted after a 2× half_window idle gap (BLOCK-SS-C02 regression)"
187        );
188    }
189
190    // ── Finding #10: per-bucket cardinality cap ───────────────────────────────
191
192    #[test]
193    fn nonce_cache_enforces_max_entries_cap() {
194        // Use a small cap so we can test quickly.
195        let cap = 10usize;
196        let mut cache = RotatingNonceCache::with_max_entries(150, cap);
197
198        // Insert `cap` nonces to fill the bucket.
199        for i in 0..cap {
200            cache.check_and_insert(&format!("nonce_{i}")).unwrap();
201        }
202
203        // The current bucket is full.  Insert one more — this triggers a forced
204        // rotation and the bucket size resets.
205        cache.check_and_insert("nonce_overflow").unwrap();
206
207        // After the forced rotation + new insert, total live entries are:
208        //   current: 1 ("nonce_overflow")
209        //   previous: cap nonces
210        // The combined size must not grow without bound.
211        let total = cache.current.len() + cache.previous.len();
212        assert!(
213            total <= cap + 1,
214            "Total live nonces ({total}) must not exceed cap+1 ({}) after overflow insert",
215            cap + 1
216        );
217    }
218
219    #[test]
220    fn nonce_cache_cap_does_not_reject_new_nonces_after_rotation() {
221        let cap = 5usize;
222        let mut cache = RotatingNonceCache::with_max_entries(150, cap);
223
224        // Fill to capacity.
225        for i in 0..cap {
226            cache.check_and_insert(&format!("n{i}")).unwrap();
227        }
228
229        // Insert beyond cap — triggers rotation.  The new nonce must be accepted.
230        let result = cache.check_and_insert("new_nonce_after_cap");
231        assert!(
232            result.is_ok(),
233            "New nonce must be accepted after forced rotation, got: {:?}",
234            result.err()
235        );
236    }
237
238    #[test]
239    fn nonce_in_overflow_bucket_still_rejected_as_replay() {
240        let cap = 3usize;
241        let mut cache = RotatingNonceCache::with_max_entries(150, cap);
242
243        // Fill to capacity with nonces including "target".
244        cache.check_and_insert("target").unwrap();
245        cache.check_and_insert("n1").unwrap();
246        cache.check_and_insert("n2").unwrap();
247
248        // Insert a 4th nonce — forces rotation: "target", "n1", "n2" move to previous.
249        cache.check_and_insert("n3").unwrap();
250
251        // "target" is now in the previous bucket — must still be detected as replay.
252        let result = cache.check_and_insert("target");
253        assert!(
254            result.is_err(),
255            "Nonce in previous bucket must still be detected as replay after forced rotation"
256        );
257    }
258}