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 let next_count = self
193 .store
194 .peek(&key)
195 .map_or(1, |existing| existing.observation_count.saturating_add(1));
196 let obs = NonceObservation {
197 vendor,
198 challenge_class,
199 observation_count: next_count,
200 };
201 self.store.put(key, obs);
202 }
203
204 /// Look up the current observation count for a `(vendor,
205 /// nonce)` tuple. Returns `None` when the key is absent or
206 /// has expired.
207 ///
208 /// # Example
209 ///
210 /// ```
211 /// use stygian_charon::token_lifecycle::NonceBook;
212 /// use stygian_charon::vendor_classifier::VendorId;
213 ///
214 /// let book = NonceBook::with_defaults();
215 /// assert!(book.observation_count(VendorId::Unknown, "nope").is_none());
216 /// ```
217 #[must_use]
218 pub fn observation_count(&self, vendor: VendorId, nonce: &str) -> Option<u32> {
219 self.store
220 .get(&nonce_book_key(vendor, nonce))
221 .map(|o| o.observation_count)
222 }
223
224 /// Look up the full [`NonceObservation`] for a `(vendor,
225 /// nonce)` tuple.
226 #[must_use]
227 pub fn lookup(&self, vendor: VendorId, nonce: &str) -> Option<NonceObservation> {
228 self.store.get(&nonce_book_key(vendor, nonce))
229 }
230
231 /// Number of entries currently retained.
232 #[must_use]
233 pub fn len(&self) -> usize {
234 self.store.len()
235 }
236
237 /// `true` when the book has zero entries.
238 #[must_use]
239 pub fn is_empty(&self) -> bool {
240 self.store.is_empty()
241 }
242
243 /// Remove all entries.
244 pub fn clear(&self) {
245 self.store.clear();
246 }
247
248 /// Invalidate a single `(vendor, nonce)` key.
249 pub fn invalidate(&self, vendor: VendorId, nonce: &str) {
250 self.store.invalidate(&nonce_book_key(vendor, nonce));
251 }
252}
253
254#[cfg(test)]
255#[allow(
256 clippy::unwrap_used,
257 clippy::expect_used,
258 clippy::panic,
259 clippy::indexing_slicing
260)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn record_increments_observation_count() {
266 let book = NonceBook::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
267 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "n");
268 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "n");
269 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "n");
270 assert_eq!(book.observation_count(VendorId::Cloudflare, "n"), Some(3));
271 }
272
273 #[test]
274 fn distinct_vendors_keep_distinct_entries() {
275 let book = NonceBook::new(NonZeroUsize::new(8).unwrap(), Duration::from_mins(1));
276 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "n");
277 book.record(VendorId::Akamai, ChallengeClass::ProofOfWork, "n");
278 assert_eq!(book.observation_count(VendorId::Cloudflare, "n"), Some(1));
279 assert_eq!(book.observation_count(VendorId::Akamai, "n"), Some(1));
280 }
281
282 #[test]
283 fn entries_decay_after_ttl() {
284 let book = NonceBook::new(NonZeroUsize::new(4).unwrap(), Duration::from_millis(1));
285 book.record(VendorId::Unknown, ChallengeClass::None, "n");
286 std::thread::sleep(Duration::from_millis(5));
287 assert!(book.observation_count(VendorId::Unknown, "n").is_none());
288 }
289
290 #[test]
291 fn clear_drops_everything() {
292 let book = NonceBook::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
293 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "a");
294 book.record(VendorId::DataDome, ChallengeClass::Captcha, "b");
295 assert_eq!(book.len(), 2);
296 book.clear();
297 assert!(book.is_empty());
298 }
299
300 #[test]
301 fn invalidate_drops_single_key() {
302 let book = NonceBook::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
303 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "a");
304 book.record(VendorId::DataDome, ChallengeClass::Captcha, "b");
305 book.invalidate(VendorId::Cloudflare, "a");
306 assert!(book.observation_count(VendorId::Cloudflare, "a").is_none());
307 assert_eq!(book.observation_count(VendorId::DataDome, "b"), Some(1));
308 }
309
310 #[test]
311 fn nonce_book_key_is_stable_and_lower_case() {
312 let key = nonce_book_key(VendorId::Cloudflare, "NONCE-XYZ");
313 assert_eq!(key, "charon:token_nonce:cloudflare:NONCE-XYZ");
314 }
315
316 #[test]
317 fn observation_count_for_unknown_nonce_is_none() {
318 let book = NonceBook::with_defaults();
319 assert!(book.observation_count(VendorId::Unknown, "nope").is_none());
320 }
321
322 #[test]
323 fn lru_capacity_is_respected() {
324 let book = NonceBook::new(NonZeroUsize::new(2).unwrap(), Duration::from_mins(1));
325 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "a");
326 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "b");
327 book.record(VendorId::Cloudflare, ChallengeClass::Interstitial, "c");
328 assert!(book.len() <= 2);
329 }
330}