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}