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        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}