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}