Skip to main content

stygian_charon/token_lifecycle/
contract.rs

1//! Challenge-token contract model (T91).
2
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7use crate::vendor_classifier::VendorId;
8
9/// Stable label for the kind of challenge a token is bound to.
10///
11/// The taxonomy mirrors the surfaces a challenge token can be
12/// issued for. It is intentionally **smaller** than the
13/// [`ChallengeOutcome`][crate::challenge_feedback::ChallengeOutcome]
14/// enum (T83) because outcomes describe what the runner
15/// observed on the wire, while `ChallengeClass` describes what
16/// the token is **for**. A `Captcha` outcome and a
17/// `Captcha` token challenge class are paired by construction —
18/// but a `Captcha` token can also be observed against an
19/// `IntegrityCheck` outcome when the vendor re-checks the
20/// captcha solution during a follow-up request.
21///
22/// # Example
23///
24/// ```
25/// use stygian_charon::token_lifecycle::ChallengeClass;
26///
27/// let c = ChallengeClass::Captcha;
28/// assert_eq!(c.label(), "captcha");
29/// assert!(c.requires_nonce());
30/// ```
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ChallengeClass {
34    /// No challenge — token is a session cookie or
35    /// bearer-style bearer that does not gate a specific
36    /// challenge artefact.
37    None,
38    /// Soft / interstitial challenge (e.g. `cf-chl-bypass` for
39    /// Cloudflare's "Just a moment…" page).
40    Interstitial,
41    /// Captcha challenge (reCAPTCHA, hCaptcha, `DataDome`
42    /// captcha-delivery, etc.).
43    Captcha,
44    /// Proof-of-work challenge (e.g. `Akamai` `_abck`
45    /// derivation).
46    ProofOfWork,
47    /// JS integrity check (e.g. `PerimeterX` `_px3` payload).
48    IntegrityCheck,
49    /// Cookie refresh / sticky session roll-over.
50    CookieRefresh,
51    /// Catch-all when the challenge class cannot be classified.
52    Unknown,
53}
54
55impl ChallengeClass {
56    /// Stable, lower-case wire label.
57    ///
58    /// # Example
59    ///
60    /// ```
61    /// use stygian_charon::token_lifecycle::ChallengeClass;
62    ///
63    /// assert_eq!(ChallengeClass::Interstitial.label(), "interstitial");
64    /// assert_eq!(ChallengeClass::Captcha.label(), "captcha");
65    /// assert_eq!(ChallengeClass::ProofOfWork.label(), "proof_of_work");
66    /// assert_eq!(ChallengeClass::IntegrityCheck.label(), "integrity_check");
67    /// assert_eq!(ChallengeClass::CookieRefresh.label(), "cookie_refresh");
68    /// assert_eq!(ChallengeClass::None.label(), "none");
69    /// assert_eq!(ChallengeClass::Unknown.label(), "unknown");
70    /// ```
71    #[must_use]
72    pub const fn label(self) -> &'static str {
73        match self {
74            Self::None => "none",
75            Self::Interstitial => "interstitial",
76            Self::Captcha => "captcha",
77            Self::ProofOfWork => "proof_of_work",
78            Self::IntegrityCheck => "integrity_check",
79            Self::CookieRefresh => "cookie_refresh",
80            Self::Unknown => "unknown",
81        }
82    }
83
84    /// Whether the validator must enforce nonce binding for
85    /// tokens of this challenge class.
86    ///
87    /// Every class except [`None`][Self::None] requires a
88    /// nonce. `None` is the "session cookie" path — the cookie
89    /// itself is the contract and nonce binding is meaningless.
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use stygian_charon::token_lifecycle::ChallengeClass;
95    ///
96    /// assert!(!ChallengeClass::None.requires_nonce());
97    /// assert!(ChallengeClass::Interstitial.requires_nonce());
98    /// assert!(ChallengeClass::Captcha.requires_nonce());
99    /// ```
100    #[must_use]
101    pub const fn requires_nonce(self) -> bool {
102        !matches!(self, Self::None)
103    }
104
105    /// Whether the validator must enforce session binding for
106    /// tokens of this challenge class.
107    ///
108    /// Only the cookie-refresh / sticky-session class is
109    /// sensitive to session binding by default; all other
110    /// classes are session-agnostic. Per-vendor policy overrides
111    /// can still require session binding for any class.
112    ///
113    /// # Example
114    ///
115    /// ```
116    /// use stygian_charon::token_lifecycle::ChallengeClass;
117    ///
118    /// assert!(ChallengeClass::CookieRefresh.requires_session_binding());
119    /// assert!(!ChallengeClass::Interstitial.requires_session_binding());
120    /// ```
121    #[must_use]
122    pub const fn requires_session_binding(self) -> bool {
123        matches!(self, Self::CookieRefresh)
124    }
125}
126
127/// Lifecycle contract for a single challenge token.
128///
129/// The contract is the **wire-level schema** for "what the
130/// scraper is allowed to do with this token". It is a pure
131/// data structure: validation logic lives in
132/// [`TokenValidator`][crate::token_lifecycle::TokenValidator],
133/// nonce bookkeeping lives in
134/// [`NonceBook`][crate::token_lifecycle::NonceBook].
135///
136/// # Example
137///
138/// ```
139/// use std::time::Duration;
140/// use stygian_charon::token_lifecycle::{ChallengeClass, TokenContract};
141/// use stygian_charon::vendor_classifier::VendorId;
142///
143/// let contract = TokenContract {
144///     token_id: "cf-bypass-xyz".to_string(),
145///     issued_at_unix_secs: 1_700_000_000,
146///     ttl: Duration::from_mins(30),
147///     nonce: "n-001".to_string(),
148///     vendor_family: VendorId::Cloudflare,
149///     challenge_class: ChallengeClass::Interstitial,
150///     single_use: true,
151///     bound_session: Some("session-abc".to_string()),
152///     description: "Cloudflare interstitial bypass token".to_string(),
153/// };
154/// assert_eq!(contract.vendor_family, VendorId::Cloudflare);
155/// assert!(contract.single_use);
156/// ```
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct TokenContract {
159    /// Stable token identifier (vendor-issued or
160    /// scraper-derived). Used as a stable key in audit logs.
161    pub token_id: String,
162    /// Unix epoch seconds when the token was issued. The
163    /// validator uses this with the supplied `now_unix_secs`
164    /// to compute the token's age.
165    pub issued_at_unix_secs: u64,
166    /// Time-to-live the token was issued with. The validator
167    /// clamps this against the per-vendor
168    /// [`TokenPolicy::max_ttl`][crate::token_lifecycle::TokenPolicy::max_ttl]
169    /// ceiling before applying it.
170    pub ttl: Duration,
171    /// Per-issuance nonce. The validator enforces that every
172    /// submission carries the same nonce and that a single-use
173    /// nonce cannot be submitted twice.
174    pub nonce: String,
175    /// Vendor family the token was issued for. Used for both
176    /// the per-vendor policy lookup and the diagnostic
177    /// invalidation routing.
178    pub vendor_family: VendorId,
179    /// Challenge class the token is bound to. Used for both
180    /// the default per-class policy and the diagnostic
181    /// invalidation routing.
182    pub challenge_class: ChallengeClass,
183    /// `true` when the token may only be submitted once. The
184    /// validator marks the nonce as consumed on first
185    /// successful validation.
186    pub single_use: bool,
187    /// Optional sticky-session identifier the token is bound
188    /// to. When `Some`, the validator rejects submissions
189    /// whose `session_id` does not match.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub bound_session: Option<String>,
192    /// Short human-readable description (operator log / audit).
193    #[serde(default)]
194    pub description: String,
195}
196
197impl TokenContract {
198    /// Effective age in seconds at the supplied `now_unix_secs`.
199    ///
200    /// Returns `0` when `now < issued_at_unix_secs` (clock skew
201    /// or test fixtures where the supplied clock is before the
202    /// issuance timestamp). The validator still rejects the
203    /// token when the TTL check fires — clock skew is a
204    /// different invalidation path that the policy planner
205    /// surfaces via
206    /// [`InvalidationKind::Expired`][crate::token_lifecycle::InvalidationKind::Expired].
207    ///
208    /// # Example
209    ///
210    /// ```
211    /// use std::time::Duration;
212    /// use stygian_charon::token_lifecycle::{ChallengeClass, TokenContract};
213    /// use stygian_charon::vendor_classifier::VendorId;
214    ///
215    /// let contract = TokenContract {
216    ///     token_id: "x".to_string(),
217    ///     issued_at_unix_secs: 100,
218    ///     ttl: Duration::from_mins(5),
219    ///     nonce: "n".to_string(),
220    ///     vendor_family: VendorId::Unknown,
221    ///     challenge_class: ChallengeClass::None,
222    ///     single_use: false,
223    ///     bound_session: None,
224    ///     description: String::new(),
225    /// };
226    /// assert_eq!(contract.age_secs(160), 60);
227    /// assert_eq!(contract.age_secs(50), 0);
228    /// ```
229    #[must_use]
230    pub const fn age_secs(&self, now_unix_secs: u64) -> u64 {
231        now_unix_secs.saturating_sub(self.issued_at_unix_secs)
232    }
233
234    /// `true` when the token's age at `now_unix_secs` exceeds
235    /// `ttl`.
236    ///
237    /// This is the **raw TTL check** — the validator applies
238    /// the per-vendor `max_ttl` clamp **before** calling this
239    /// helper. Callers that want to validate on their own
240    /// (e.g. doctests) should respect the policy table the same
241    /// way the validator does.
242    ///
243    /// # Example
244    ///
245    /// ```
246    /// use std::time::Duration;
247    /// use stygian_charon::token_lifecycle::{ChallengeClass, TokenContract};
248    /// use stygian_charon::vendor_classifier::VendorId;
249    ///
250    /// let contract = TokenContract {
251    ///     token_id: "x".to_string(),
252    ///     issued_at_unix_secs: 0,
253    ///     ttl: Duration::from_mins(1),
254    ///     nonce: "n".to_string(),
255    ///     vendor_family: VendorId::Unknown,
256    ///     challenge_class: ChallengeClass::None,
257    ///     single_use: false,
258    ///     bound_session: None,
259    ///     description: String::new(),
260    /// };
261    /// assert!(!contract.is_expired(30));
262    /// assert!(contract.is_expired(120));
263    /// ```
264    #[must_use]
265    pub const fn is_expired(&self, now_unix_secs: u64) -> bool {
266        self.age_secs(now_unix_secs) >= self.ttl.as_secs()
267    }
268}
269
270#[cfg(test)]
271#[allow(
272    clippy::unwrap_used,
273    clippy::expect_used,
274    clippy::panic,
275    clippy::indexing_slicing
276)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn challenge_class_labels_are_stable() {
282        for (variant, label) in [
283            (ChallengeClass::None, "none"),
284            (ChallengeClass::Interstitial, "interstitial"),
285            (ChallengeClass::Captcha, "captcha"),
286            (ChallengeClass::ProofOfWork, "proof_of_work"),
287            (ChallengeClass::IntegrityCheck, "integrity_check"),
288            (ChallengeClass::CookieRefresh, "cookie_refresh"),
289            (ChallengeClass::Unknown, "unknown"),
290        ] {
291            assert_eq!(variant.label(), label);
292        }
293    }
294
295    #[test]
296    fn challenge_class_requires_nonce_except_none() {
297        assert!(!ChallengeClass::None.requires_nonce());
298        assert!(ChallengeClass::Interstitial.requires_nonce());
299        assert!(ChallengeClass::Captcha.requires_nonce());
300        assert!(ChallengeClass::ProofOfWork.requires_nonce());
301        assert!(ChallengeClass::IntegrityCheck.requires_nonce());
302        assert!(ChallengeClass::CookieRefresh.requires_nonce());
303        assert!(ChallengeClass::Unknown.requires_nonce());
304    }
305
306    #[test]
307    fn challenge_class_requires_session_binding_only_for_cookie_refresh() {
308        assert!(ChallengeClass::CookieRefresh.requires_session_binding());
309        for variant in [
310            ChallengeClass::None,
311            ChallengeClass::Interstitial,
312            ChallengeClass::Captcha,
313            ChallengeClass::ProofOfWork,
314            ChallengeClass::IntegrityCheck,
315            ChallengeClass::Unknown,
316        ] {
317            assert!(
318                !variant.requires_session_binding(),
319                "{variant:?} should not require session binding"
320            );
321        }
322    }
323
324    #[test]
325    fn token_contract_age_secs_clamps_clock_skew_to_zero() {
326        // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
327        let contract = TokenContract {
328            token_id: "x".to_string(),
329            issued_at_unix_secs: 100,
330            ttl: Duration::from_mins(1),
331            nonce: "n".to_string(),
332            vendor_family: VendorId::Unknown,
333            challenge_class: ChallengeClass::None,
334            single_use: false,
335            bound_session: None,
336            description: String::new(),
337        };
338        assert_eq!(contract.age_secs(50), 0);
339        assert_eq!(contract.age_secs(100), 0);
340        assert_eq!(contract.age_secs(160), 60);
341    }
342
343    #[test]
344    fn token_contract_is_expired_returns_true_after_ttl() {
345        // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
346        let contract = TokenContract {
347            token_id: "x".to_string(),
348            issued_at_unix_secs: 0,
349            ttl: Duration::from_mins(1),
350            nonce: "n".to_string(),
351            vendor_family: VendorId::Unknown,
352            challenge_class: ChallengeClass::None,
353            single_use: false,
354            bound_session: None,
355            description: String::new(),
356        };
357        assert!(!contract.is_expired(0));
358        assert!(!contract.is_expired(30));
359        assert!(!contract.is_expired(59));
360        assert!(contract.is_expired(60));
361        assert!(contract.is_expired(120));
362    }
363
364    #[test]
365    fn token_contract_serializes_round_trip() {
366        // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
367        let contract = TokenContract {
368            token_id: "x".to_string(),
369            issued_at_unix_secs: 100,
370            ttl: Duration::from_mins(1),
371            nonce: "n".to_string(),
372            vendor_family: VendorId::DataDome,
373            challenge_class: ChallengeClass::Captcha,
374            single_use: true,
375            bound_session: Some("session-1".to_string()),
376            description: "x".to_string(),
377        };
378        let json = serde_json::to_string(&contract).expect("serialize");
379        let back: TokenContract = serde_json::from_str(&json).expect("deserialize");
380        assert_eq!(contract, back);
381    }
382}