stygian_charon/token_lifecycle/nonce.rs
1//! Per-issuance nonce bookkeeping for token lifecycle contracts (T91).
2//!
3//! The [`NonceBook`] is a capacity-bounded LRU+TTL store that
4//! tracks every nonce the validator has **seen**, along with
5//! the vendor family, challenge class, and observation count.
6//! It reuses the same `LruTtlStore`
7//! primitive the [`ChallengeMemory`][crate::challenge_feedback::ChallengeMemory]
8//! uses (T83) — that keeps eviction + expiry semantics
9//! consistent across both short-horizon stores and satisfies
10//! the "no new cache store" constraint.
11
12use std::num::NonZeroUsize;
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17use crate::cache::LruTtlStore;
18use crate::token_lifecycle::contract::ChallengeClass;
19use crate::vendor_classifier::VendorId;
20
21/// Default TTL for nonce observations: **10 minutes**.
22///
23/// Aligned with the
24/// `DEFAULT_CHALLENGE_TTL`
25/// default so the two stores share an "after ten minutes we
26/// forget" horizon. Long enough to span a typical scraping
27/// session, short enough that an evicted nonce can be re-issued
28/// without false-positive replay detection.
29pub const DEFAULT_NONCE_TTL: Duration = Duration::from_mins(10);
30
31/// Default capacity (in nonce entries) for the
32/// [`NonceBook`]. Conservative default — most workflows
33/// observe a few hundred nonces per session.
34#[allow(clippy::unwrap_used)]
35pub const DEFAULT_NONCE_BOOK_CAPACITY: NonZeroUsize = match NonZeroUsize::new(256) {
36 Some(value) => value,
37 None => NonZeroUsize::MIN,
38};
39
40/// Build a stable, lower-cased cache key for a
41/// `(vendor_family, nonce)` tuple.
42///
43/// # Example
44///
45/// ```
46/// use stygian_charon::token_lifecycle::nonce_book_key;
47/// use stygian_charon::vendor_classifier::VendorId;
48///
49/// let key = nonce_book_key(VendorId::Cloudflare, "NONCE-XYZ");
50/// assert!(key.starts_with("charon:token_nonce:cloudflare:"));
51/// ```
52#[must_use]
53pub fn nonce_book_key(vendor: VendorId, nonce: &str) -> String {
54 format!("charon:token_nonce:{}:{}", vendor.label(), nonce)
55}
56
57/// One observation row in the [`NonceBook`].
58///
59/// The row records the vendor family and challenge class the
60/// observation was tagged with (so a stale nonce re-entry from
61/// a different vendor still surfaces the right audit context),
62/// along with the observation count for monotonic accounting.
63/// The TTL is owned by the `LruTtlStore`
64/// backing the [`NonceBook`].
65///
66/// # Example
67///
68/// ```
69/// use stygian_charon::token_lifecycle::{ChallengeClass, NonceObservation};
70/// use stygian_charon::vendor_classifier::VendorId;
71///
72/// let obs = NonceObservation {
73/// vendor: VendorId::Akamai,
74/// challenge_class: ChallengeClass::ProofOfWork,
75/// observation_count: 1,
76/// };
77/// assert_eq!(obs.vendor, VendorId::Akamai);
78/// ```
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct NonceObservation {
81 /// Vendor family the observation was tagged with.
82 pub vendor: VendorId,
83 /// Challenge class the observation was tagged with.
84 pub challenge_class: ChallengeClass,
85 /// Number of times the nonce has been observed (saturating
86 /// on overflow).
87 pub observation_count: u32,
88}
89
90/// Capacity-bounded LRU+TTL store of
91/// [`NonceObservation`][crate::token_lifecycle::NonceObservation]s.
92///
93/// The store reuses the same
94/// `LruTtlStore` primitive the
95/// [`ChallengeMemory`][crate::challenge_feedback::ChallengeMemory]
96/// uses (T83). That keeps eviction + expiry semantics
97/// consistent across both short-horizon stores and satisfies
98/// the "no new cache store" requirement.
99///
100/// # Example
101///
102/// ```
103/// use stygian_charon::token_lifecycle::{ChallengeClass, NonceBook};
104/// use stygian_charon::vendor_classifier::VendorId;
105/// use std::num::NonZeroUsize;
106/// use std::time::Duration;
107///
108/// let book = NonceBook::with_defaults();
109/// book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "nonce-1");
110/// assert_eq!(book.observation_count(VendorId::Cloudflare, "nonce-1"), Some(1));
111/// ```
112pub struct NonceBook {
113 store: LruTtlStore<NonceObservation>,
114}
115
116impl std::fmt::Debug for NonceBook {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 f.debug_struct("NonceBook")
119 .field("ttl", &self.store.ttl())
120 .field("len", &self.store.len())
121 .finish()
122 }
123}
124
125impl NonceBook {
126 /// Create a new nonce book with explicit capacity and TTL.
127 ///
128 /// # Example
129 ///
130 /// ```
131 /// use stygian_charon::token_lifecycle::NonceBook;
132 /// use std::num::NonZeroUsize;
133 /// use std::time::Duration;
134 ///
135 /// let book = NonceBook::new(NonZeroUsize::new(8).expect("non-zero"), Duration::from_mins(1));
136 /// assert!(book.is_empty());
137 /// ```
138 #[must_use]
139 pub fn new(capacity: NonZeroUsize, ttl: Duration) -> Self {
140 Self {
141 store: LruTtlStore::new(capacity, ttl),
142 }
143 }
144
145 /// Capacity-bounded [`NonceBook`] with [`DEFAULT_NONCE_TTL`].
146 #[must_use]
147 pub fn with_default_ttl(capacity: NonZeroUsize) -> Self {
148 Self::new(capacity, DEFAULT_NONCE_TTL)
149 }
150
151 /// Capacity-bounded [`NonceBook`] with [`DEFAULT_NONCE_BOOK_CAPACITY`]
152 /// and [`DEFAULT_NONCE_TTL`].
153 ///
154 /// # Example
155 ///
156 /// ```
157 /// use stygian_charon::token_lifecycle::NonceBook;
158 ///
159 /// let book = NonceBook::with_defaults();
160 /// assert_eq!(book.ttl(), stygian_charon::token_lifecycle::DEFAULT_NONCE_TTL);
161 /// ```
162 #[must_use]
163 pub fn with_defaults() -> Self {
164 Self::new(DEFAULT_NONCE_BOOK_CAPACITY, DEFAULT_NONCE_TTL)
165 }
166
167 /// Configured TTL for the backing store.
168 #[must_use]
169 pub const fn ttl(&self) -> Duration {
170 self.store.ttl()
171 }
172
173 /// Record an observation for a `(vendor, nonce)` tuple. The
174 /// observation count is incremented atomically with the
175 /// read-modify-write sequence; the LRU recency is **not**
176 /// bumped on the read so a high-volume key does not crowd
177 /// out less common keys.
178 ///
179 /// # Example
180 ///
181 /// ```
182 /// use stygian_charon::token_lifecycle::{ChallengeClass, NonceBook};
183 /// use stygian_charon::vendor_classifier::VendorId;
184 ///
185 /// let book = NonceBook::with_defaults();
186 /// book.record(VendorId::PerimeterX, ChallengeClass::IntegrityCheck, "n");
187 /// book.record(VendorId::PerimeterX, ChallengeClass::IntegrityCheck, "n");
188 /// assert_eq!(book.observation_count(VendorId::PerimeterX, "n"), Some(2));
189 /// ```
190 pub fn record(&self, vendor: VendorId, challenge_class: ChallengeClass, nonce: &str) {
191 let key = nonce_book_key(vendor, nonce);
192 // Read-modify-write must be atomic under concurrency;
193 // otherwise two simultaneous `record` calls would both
194 // observe count=N, both compute N+1, and both write N+1,
195 // losing one increment.
196 self.store.mutate(key, |existing| {
197 let next_count = existing.map_or(1, |prev| prev.observation_count.saturating_add(1));
198 NonceObservation {
199 vendor,
200 challenge_class,
201 observation_count: next_count,
202 }
203 });
204 }
205
206 /// Look up the current observation count for a `(vendor,
207 /// nonce)` tuple. Returns `None` when the key is absent or
208 /// has expired.
209 ///
210 /// # Example
211 ///
212 /// ```
213 /// use stygian_charon::token_lifecycle::NonceBook;
214 /// use stygian_charon::vendor_classifier::VendorId;
215 ///
216 /// let book = NonceBook::with_defaults();
217 /// assert!(book.observation_count(VendorId::Unknown, "nope").is_none());
218 /// ```
219 #[must_use]
220 pub fn observation_count(&self, vendor: VendorId, nonce: &str) -> Option<u32> {
221 self.store
222 .get(&nonce_book_key(vendor, nonce))
223 .map(|o| o.observation_count)
224 }
225
226 /// Look up the full [`NonceObservation`] for a `(vendor,
227 /// nonce)` tuple.
228 #[must_use]
229 pub fn lookup(&self, vendor: VendorId, nonce: &str) -> Option<NonceObservation> {
230 self.store.get(&nonce_book_key(vendor, nonce))
231 }
232
233 /// Number of entries currently retained.
234 #[must_use]
235 pub fn len(&self) -> usize {
236 self.store.len()
237 }
238
239 /// `true` when the book has zero entries.
240 #[must_use]
241 pub fn is_empty(&self) -> bool {
242 self.store.is_empty()
243 }
244
245 /// Remove all entries.
246 pub fn clear(&self) {
247 self.store.clear();
248 }
249
250 /// Invalidate a single `(vendor, nonce)` key.
251 pub fn invalidate(&self, vendor: VendorId, nonce: &str) {
252 self.store.invalidate(&nonce_book_key(vendor, nonce));
253 }
254}
255
256#[cfg(test)]
257#[allow(
258 clippy::unwrap_used,
259 clippy::expect_used,
260 clippy::panic,
261 clippy::indexing_slicing
262)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn record_increments_observation_count() {
268 let book = NonceBook::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
269 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "n");
270 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "n");
271 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "n");
272 assert_eq!(book.observation_count(VendorId::Cloudflare, "n"), Some(3));
273 }
274
275 #[test]
276 fn distinct_vendors_keep_distinct_entries() {
277 let book = NonceBook::new(NonZeroUsize::new(8).unwrap(), Duration::from_mins(1));
278 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "n");
279 book.record(VendorId::Akamai, ChallengeClass::ProofOfWork, "n");
280 assert_eq!(book.observation_count(VendorId::Cloudflare, "n"), Some(1));
281 assert_eq!(book.observation_count(VendorId::Akamai, "n"), Some(1));
282 }
283
284 #[test]
285 fn entries_decay_after_ttl() {
286 let book = NonceBook::new(NonZeroUsize::new(4).unwrap(), Duration::from_millis(1));
287 book.record(VendorId::Unknown, ChallengeClass::None, "n");
288 std::thread::sleep(Duration::from_millis(5));
289 assert!(book.observation_count(VendorId::Unknown, "n").is_none());
290 }
291
292 #[test]
293 fn clear_drops_everything() {
294 let book = NonceBook::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
295 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "a");
296 book.record(VendorId::DataDome, ChallengeClass::Captcha, "b");
297 assert_eq!(book.len(), 2);
298 book.clear();
299 assert!(book.is_empty());
300 }
301
302 #[test]
303 fn invalidate_drops_single_key() {
304 let book = NonceBook::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
305 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "a");
306 book.record(VendorId::DataDome, ChallengeClass::Captcha, "b");
307 book.invalidate(VendorId::Cloudflare, "a");
308 assert!(book.observation_count(VendorId::Cloudflare, "a").is_none());
309 assert_eq!(book.observation_count(VendorId::DataDome, "b"), Some(1));
310 }
311
312 #[test]
313 fn nonce_book_key_is_stable_and_lower_case() {
314 let key = nonce_book_key(VendorId::Cloudflare, "NONCE-XYZ");
315 assert_eq!(key, "charon:token_nonce:cloudflare:NONCE-XYZ");
316 }
317
318 #[test]
319 fn observation_count_for_unknown_nonce_is_none() {
320 let book = NonceBook::with_defaults();
321 assert!(book.observation_count(VendorId::Unknown, "nope").is_none());
322 }
323
324 #[test]
325 fn lru_capacity_is_respected() {
326 let book = NonceBook::new(NonZeroUsize::new(2).unwrap(), Duration::from_mins(1));
327 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "a");
328 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "b");
329 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "c");
330 assert!(book.len() <= 2);
331 }
332}