stygian_charon/token_lifecycle/mod.rs
1//! Challenge-token lifecycle contracts (T91).
2//!
3//! ## What this module does
4//!
5//! Defines strict lifecycle contracts for **challenge tokens**
6//! — short-lived, vendor-issued artefacts (e.g. `cf-chl-bypass`,
7//! `_px3`, `_abck`, `datadome=…`) that the scraper must present
8//! alongside its request to convince the vendor the challenge has
9//! been solved.
10//!
11//! Each [`TokenContract`] captures four invariants:
12//!
13//! 1. **Time-to-live**: tokens older than their TTL are rejected
14//! as stale. The TTL is **vendor-aware** (see the
15//! [vendor policy table](#vendor-policy-table) below).
16//! 2. **Nonce binding**: the contract carries a per-issuance
17//! nonce. The validator enforces that any subsequent
18//! submission carries the same nonce — a mismatched nonce is
19//! always rejected.
20//! 3. **Single-use**: contracts with `single_use = true` may only
21//! be submitted once. Subsequent submissions trip the
22//! [replay-defense][crate::challenge_feedback] path with
23//! [`InvalidationReason::NonceReplayed`].
24//! 4. **Vendor family + challenge class**: every contract is
25//! stamped with a [`VendorId`] family (e.g. `Cloudflare`,
26//! `PerimeterX`, `Akamai`, `DataDome`) and a
27//! [`ChallengeClass`] (interstitial, captcha, proof-of-work,
28//! integrity check, cookie refresh, none). The two together
29//! drive **diagnostic routing** — operators can wire
30//! [`InvalidationReason`] events into the per-family audit
31//! log without re-running the classifier.
32//!
33//! ## Why a contract?
34//!
35//! A naïve scraper that caches a token for hours and replays it
36//! across sessions trains the vendor to escalate its posture
37//! (rotating nonces more aggressively, shortening TTLs,
38//! eventually locking the scraper out entirely). The contract
39//! pins **when** a token may be used, **how often**, and **which**
40//! vendor issued it so the policy planner can refresh the token
41//! before the vendor invalidates it server-side.
42//!
43//! ## Nonce bookkeeping (T83 integration)
44//!
45//! Per-issuance nonces are tracked by a [`NonceBook`] — a
46//! capacity-bounded LRU+TTL store that reuses the same
47//! `LruTtlStore` primitive the
48//! [`ChallengeMemory`][crate::challenge_feedback::ChallengeMemory]
49//! uses (T83). That keeps eviction + expiry semantics consistent
50//! across both short-horizon stores and satisfies the
51//! "no new cache store" constraint.
52//!
53//! A [`TokenValidator`] consumes a [`TokenContract`] and a
54//! present-time clock, looks up the nonce in the [`NonceBook`],
55//! evaluates the four invariants, and returns a
56//! [`ValidationOutcome`] the runner can act on. The validator
57//! also integrates with the T83 feedback loop by emitting a
58//! structured [`InvalidationReason`] (with vendor family +
59//! challenge class) so the diagnostic payload can route
60//! invalidations to the correct per-family audit log.
61//!
62//! ## Vendor policy table
63//!
64//! The defaults are tuned for the Tier 1 vendor catalogue
65//! shipped with T89 and the Tier 1 / Tier 2 playbooks shipped
66//! with T85:
67//!
68//! | Vendor family | Default TTL | Max TTL | Nonce required | Single-use | Session binding |
69//! |------------------------|-------------|---------|----------------|------------|-----------------|
70//! | [`VendorId::Cloudflare`] | 30 minutes | 45 minutes | yes | yes | optional |
71//! | [`VendorId::Akamai`] | 15 minutes | 30 minutes | yes | yes | required |
72//! | [`VendorId::DataDome`] | 10 minutes | 20 minutes | yes | yes | required |
73//! | [`VendorId::PerimeterX`] | 15 minutes | 30 minutes | yes | yes | required |
74//! | [`VendorId::Hcaptcha`] | 5 minutes | 10 minutes | yes | yes | optional |
75//! | [`VendorId::Recaptcha`] | 5 minutes | 10 minutes | yes | yes | optional |
76//! | [`VendorId::Kasada`] | 5 minutes | 10 minutes | yes | yes | required |
77//! | [`VendorId::FingerprintCom`] | 1 hour | 2 hours | yes | no | optional |
78//! | [`VendorId::ShapeSecurity`] | 10 minutes | 20 minutes | yes | yes | required |
79//! | [`VendorId::Imperva`] | 15 minutes | 30 minutes | yes | yes | required |
80//! | [`VendorId::Unknown`] | 5 minutes | 10 minutes | yes | yes | optional |
81//!
82//! Operators can override per-family defaults via
83//! [`TokenPolicyTable::with_policy`]; the validator consults the
84//! table before applying the contract's own `ttl` field, so an
85//! over-long contract is **clamped to `policy.max_ttl`** at
86//! validation time.
87//!
88//! ## Feature flag
89//!
90//! The module is **default-on** (gated behind the
91//! `caching` feature, which is part of the `stygian-charon`
92//! default feature set, so the module is always compiled).
93//! It is purely additive — no existing public type gains a new
94//! field, no existing behaviour changes, and no new feature gate
95//! is introduced. Operators who want the strict lifecycle
96//! validation call [`TokenValidator::validate`] on every
97//! submission; callers that ignore it see no behaviour change.
98//!
99//! # Example
100//!
101//! ```
102//! use std::time::Duration;
103//! use stygian_charon::token_lifecycle::{
104//! ChallengeClass, TokenContract, TokenPolicyTable, TokenValidator,
105//! ValidationOutcome,
106//! };
107//! use stygian_charon::vendor_classifier::VendorId;
108//!
109//! // Build a policy table seeded with the per-vendor defaults
110//! // and a 256-entry nonce book with a 10-minute TTL.
111//! let policy = TokenPolicyTable::with_builtin_defaults();
112//! let validator = TokenValidator::with_defaults(policy);
113//!
114//! // A Cloudflare interstitial token issued 1 minute ago.
115//! let contract = TokenContract {
116//! token_id: "cf-chl-bypass-abc".to_string(),
117//! issued_at_unix_secs: 1_700_000_000,
118//! ttl: Duration::from_mins(30),
119//! nonce: "nonce-xyz".to_string(),
120//! vendor_family: VendorId::Cloudflare,
121//! challenge_class: ChallengeClass::Interstitial,
122//! single_use: true,
123//! bound_session: None,
124//! description: "Cloudflare turnstile bypass token".to_string(),
125//! };
126//!
127//! // First submission passes: the nonce has not been seen.
128//! let outcome = validator.validate(&contract, Some("session-1"), 1_700_000_060);
129//! assert!(matches!(outcome, ValidationOutcome::Ok { .. }));
130//! ```
131
132mod contract;
133mod error;
134mod invalidation;
135mod nonce;
136mod policy;
137mod validator;
138
139pub use contract::{ChallengeClass, TokenContract};
140pub use error::TokenLifecycleError;
141pub use invalidation::{InvalidationKind, InvalidationReason};
142pub use nonce::{
143 DEFAULT_NONCE_BOOK_CAPACITY, DEFAULT_NONCE_TTL, NonceBook, NonceObservation, nonce_book_key,
144};
145pub use policy::{TokenPolicy, TokenPolicyTable, builtin_token_policies};
146pub use validator::{TokenValidator, ValidationOutcome};