Skip to main content

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}