Skip to main content

stygian_charon/token_lifecycle/
invalidation.rs

1//! Structured invalidation reasons for token lifecycle failures (T91).
2
3use serde::{Deserialize, Serialize};
4
5use crate::token_lifecycle::contract::ChallengeClass;
6use crate::vendor_classifier::VendorId;
7
8/// Coarse-grained kind tag for [`InvalidationReason`].
9///
10/// The kind is the **stable, wire-level enum** (`snake_case`)
11/// that downstream consumers (alerting, dashboards, audit log
12/// routers) can switch on without inspecting the full
13/// [`InvalidationReason`]. The full reason carries the
14/// diagnostic context (vendor family, challenge class,
15/// supplied vs. expected values); the kind is the routing key.
16///
17/// # Example
18///
19/// ```
20/// use stygian_charon::token_lifecycle::InvalidationKind;
21///
22/// let k = InvalidationKind::NonceReplayed;
23/// assert_eq!(k.label(), "nonce_replayed");
24/// ```
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum InvalidationKind {
28    /// Token TTL elapsed. The
29    /// [`InvalidationReason::Expired`][crate::token_lifecycle::InvalidationReason::Expired]
30    /// variant carries the vendor family, challenge class,
31    /// observed age, and the TTL that was crossed.
32    Expired,
33    /// Submission's nonce does not match the contract's nonce.
34    /// The
35    /// [`InvalidationReason::NonceMismatch`][crate::token_lifecycle::InvalidationReason::NonceMismatch]
36    /// variant carries both nonces.
37    NonceMismatch,
38    /// A single-use nonce was observed more than once. The
39    /// [`InvalidationReason::NonceReplayed`][crate::token_lifecycle::InvalidationReason::NonceReplayed]
40    /// variant carries the observation count from the nonce
41    /// book.
42    NonceReplayed,
43    /// Submission's session id does not match the contract's
44    /// `bound_session`.
45    SessionBindingMiss,
46    /// No contract was supplied alongside the token. The
47    /// validator returns this when the caller bypasses the
48    /// contract path.
49    ContractMissing,
50    /// The token's challenge class is not applicable for the
51    /// supplied vendor family (e.g. `Cloudflare` is asked to
52    /// validate a `ProofOfWork` token — Cloudflare does not
53    /// issue `PoW` tokens).
54    NotApplicable,
55}
56
57impl InvalidationKind {
58    /// Stable, lower-case wire label.
59    ///
60    /// # Example
61    ///
62    /// ```
63    /// use stygian_charon::token_lifecycle::InvalidationKind;
64    ///
65    /// assert_eq!(InvalidationKind::Expired.label(), "expired");
66    /// assert_eq!(InvalidationKind::NonceMismatch.label(), "nonce_mismatch");
67    /// assert_eq!(InvalidationKind::NonceReplayed.label(), "nonce_replayed");
68    /// assert_eq!(InvalidationKind::SessionBindingMiss.label(), "session_binding_miss");
69    /// assert_eq!(InvalidationKind::ContractMissing.label(), "contract_missing");
70    /// assert_eq!(InvalidationKind::NotApplicable.label(), "not_applicable");
71    /// ```
72    #[must_use]
73    pub const fn label(self) -> &'static str {
74        match self {
75            Self::Expired => "expired",
76            Self::NonceMismatch => "nonce_mismatch",
77            Self::NonceReplayed => "nonce_replayed",
78            Self::SessionBindingMiss => "session_binding_miss",
79            Self::ContractMissing => "contract_missing",
80            Self::NotApplicable => "not_applicable",
81        }
82    }
83}
84
85/// Structured reason a [`TokenValidator`][crate::token_lifecycle::TokenValidator]
86/// rejected a token submission.
87///
88/// Every variant carries the **vendor family** and
89/// **challenge class** so the diagnostic payload can route the
90/// invalidation to the per-family audit log without inspecting
91/// the rest of the report. The kind tag is the routing key
92/// (see [`InvalidationKind`]); the variant embeds the
93/// diagnostic context (expected vs. observed values, age,
94/// observation count, etc.).
95///
96/// # Example
97///
98/// ```
99/// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationKind, InvalidationReason};
100/// use stygian_charon::vendor_classifier::VendorId;
101///
102/// let reason = InvalidationReason::Expired {
103///     vendor: VendorId::Cloudflare,
104///     challenge_class: ChallengeClass::Interstitial,
105///     age_secs: 1900,
106///     ttl_secs: 1800,
107/// };
108/// assert_eq!(reason.kind(), InvalidationKind::Expired);
109/// assert_eq!(reason.vendor_family(), VendorId::Cloudflare);
110/// ```
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(tag = "kind", rename_all = "snake_case")]
113pub enum InvalidationReason {
114    /// Token TTL elapsed before submission.
115    Expired {
116        /// Vendor family that issued the token.
117        vendor: VendorId,
118        /// Challenge class the token is bound to.
119        challenge_class: ChallengeClass,
120        /// Observed age at submission time, in seconds.
121        age_secs: u64,
122        /// TTL the contract was issued with, in seconds.
123        ttl_secs: u64,
124    },
125    /// Submission's nonce does not match the contract's nonce.
126    NonceMismatch {
127        /// Vendor family the contract was issued for.
128        vendor: VendorId,
129        /// Challenge class the contract is bound to.
130        challenge_class: ChallengeClass,
131        /// Nonce the contract was issued with.
132        expected: String,
133        /// Nonce the submission carried.
134        observed: String,
135    },
136    /// A single-use nonce was observed more than once.
137    NonceReplayed {
138        /// Vendor family the contract was issued for.
139        vendor: VendorId,
140        /// Challenge class the contract is bound to.
141        challenge_class: ChallengeClass,
142        /// Number of times the nonce has now been observed
143        /// (capped at `u32::MAX` for monotonic counters).
144        observation_count: u32,
145    },
146    /// Submission's session id does not match the contract's
147    /// `bound_session`.
148    SessionBindingMiss {
149        /// Vendor family the contract was issued for.
150        vendor: VendorId,
151        /// Challenge class the contract is bound to.
152        challenge_class: ChallengeClass,
153        /// Session id the contract was bound to (`None` when
154        /// the contract carried no binding).
155        expected: Option<String>,
156        /// Session id the submission carried.
157        observed: Option<String>,
158    },
159    /// No contract was supplied alongside the token.
160    ContractMissing {
161        /// Vendor family the token was nominally issued for
162        /// (when the caller had partial evidence).
163        vendor: VendorId,
164        /// Challenge class the token was nominally bound to.
165        challenge_class: ChallengeClass,
166    },
167    /// The token's challenge class is not applicable for the
168    /// supplied vendor family.
169    NotApplicable {
170        /// Vendor family the contract was issued for.
171        vendor: VendorId,
172        /// Challenge class the contract claimed to cover.
173        challenge_class: ChallengeClass,
174    },
175}
176
177impl InvalidationReason {
178    /// Routing kind for this invalidation.
179    ///
180    /// # Example
181    ///
182    /// ```
183    /// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationKind, InvalidationReason};
184    /// use stygian_charon::vendor_classifier::VendorId;
185    ///
186    /// let reason = InvalidationReason::NonceReplayed {
187    ///     vendor: VendorId::Akamai,
188    ///     challenge_class: ChallengeClass::ProofOfWork,
189    ///     observation_count: 2,
190    /// };
191    /// assert_eq!(reason.kind(), InvalidationKind::NonceReplayed);
192    /// ```
193    #[must_use]
194    pub const fn kind(&self) -> InvalidationKind {
195        match self {
196            Self::Expired { .. } => InvalidationKind::Expired,
197            Self::NonceMismatch { .. } => InvalidationKind::NonceMismatch,
198            Self::NonceReplayed { .. } => InvalidationKind::NonceReplayed,
199            Self::SessionBindingMiss { .. } => InvalidationKind::SessionBindingMiss,
200            Self::ContractMissing { .. } => InvalidationKind::ContractMissing,
201            Self::NotApplicable { .. } => InvalidationKind::NotApplicable,
202        }
203    }
204
205    /// Vendor family the invalidation is attributed to.
206    ///
207    /// # Example
208    ///
209    /// ```
210    /// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationReason};
211    /// use stygian_charon::vendor_classifier::VendorId;
212    ///
213    /// let reason = InvalidationReason::NonceMismatch {
214    ///     vendor: VendorId::PerimeterX,
215    ///     challenge_class: ChallengeClass::IntegrityCheck,
216    ///     expected: "n".to_string(),
217    ///     observed: "m".to_string(),
218    /// };
219    /// assert_eq!(reason.vendor_family(), VendorId::PerimeterX);
220    /// ```
221    #[must_use]
222    pub const fn vendor_family(&self) -> VendorId {
223        match self {
224            Self::Expired { vendor, .. }
225            | Self::NonceMismatch { vendor, .. }
226            | Self::NonceReplayed { vendor, .. }
227            | Self::SessionBindingMiss { vendor, .. }
228            | Self::ContractMissing { vendor, .. }
229            | Self::NotApplicable { vendor, .. } => *vendor,
230        }
231    }
232
233    /// Challenge class the invalidation is attributed to.
234    ///
235    /// # Example
236    ///
237    /// ```
238    /// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationReason};
239    /// use stygian_charon::vendor_classifier::VendorId;
240    ///
241    /// let reason = InvalidationReason::SessionBindingMiss {
242    ///     vendor: VendorId::DataDome,
243    ///     challenge_class: ChallengeClass::Captcha,
244    ///     expected: Some("s1".to_string()),
245    ///     observed: Some("s2".to_string()),
246    /// };
247    /// assert_eq!(reason.challenge_class(), ChallengeClass::Captcha);
248    /// ```
249    #[must_use]
250    pub const fn challenge_class(&self) -> ChallengeClass {
251        match self {
252            Self::Expired {
253                challenge_class, ..
254            }
255            | Self::NonceMismatch {
256                challenge_class, ..
257            }
258            | Self::NonceReplayed {
259                challenge_class, ..
260            }
261            | Self::SessionBindingMiss {
262                challenge_class, ..
263            }
264            | Self::ContractMissing {
265                challenge_class, ..
266            }
267            | Self::NotApplicable {
268                challenge_class, ..
269            } => *challenge_class,
270        }
271    }
272
273    /// Stable wire label — equivalent to
274    /// [`kind().label()`][InvalidationKind::label]. Useful for
275    /// the JSON field that the diagnostic payload exposes.
276    ///
277    /// # Example
278    ///
279    /// ```
280    /// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationReason};
281    /// use stygian_charon::vendor_classifier::VendorId;
282    ///
283    /// let reason = InvalidationReason::ContractMissing {
284    ///     vendor: VendorId::Unknown,
285    ///     challenge_class: ChallengeClass::Unknown,
286    /// };
287    /// assert_eq!(reason.label(), "contract_missing");
288    /// ```
289    #[must_use]
290    pub const fn label(&self) -> &'static str {
291        self.kind().label()
292    }
293}
294
295#[cfg(test)]
296#[allow(
297    clippy::unwrap_used,
298    clippy::expect_used,
299    clippy::panic,
300    clippy::indexing_slicing
301)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn invalidation_kind_labels_are_stable() {
307        for (kind, label) in [
308            (InvalidationKind::Expired, "expired"),
309            (InvalidationKind::NonceMismatch, "nonce_mismatch"),
310            (InvalidationKind::NonceReplayed, "nonce_replayed"),
311            (InvalidationKind::SessionBindingMiss, "session_binding_miss"),
312            (InvalidationKind::ContractMissing, "contract_missing"),
313            (InvalidationKind::NotApplicable, "not_applicable"),
314        ] {
315            assert_eq!(kind.label(), label);
316        }
317    }
318
319    #[test]
320    fn invalidation_reason_kind_vendor_and_class_match_variant() {
321        let expired = InvalidationReason::Expired {
322            vendor: VendorId::Cloudflare,
323            challenge_class: ChallengeClass::Interstitial,
324            age_secs: 100,
325            ttl_secs: 60,
326        };
327        assert_eq!(expired.kind(), InvalidationKind::Expired);
328        assert_eq!(expired.vendor_family(), VendorId::Cloudflare);
329        assert_eq!(expired.challenge_class(), ChallengeClass::Interstitial);
330        assert_eq!(expired.label(), "expired");
331
332        let mismatch = InvalidationReason::NonceMismatch {
333            vendor: VendorId::PerimeterX,
334            challenge_class: ChallengeClass::IntegrityCheck,
335            expected: "a".to_string(),
336            observed: "b".to_string(),
337        };
338        assert_eq!(mismatch.kind(), InvalidationKind::NonceMismatch);
339        assert_eq!(mismatch.vendor_family(), VendorId::PerimeterX);
340        assert_eq!(mismatch.label(), "nonce_mismatch");
341
342        let replayed = InvalidationReason::NonceReplayed {
343            vendor: VendorId::Akamai,
344            challenge_class: ChallengeClass::ProofOfWork,
345            observation_count: 3,
346        };
347        assert_eq!(replayed.kind(), InvalidationKind::NonceReplayed);
348        assert_eq!(replayed.vendor_family(), VendorId::Akamai);
349        assert_eq!(replayed.label(), "nonce_replayed");
350
351        let binding = InvalidationReason::SessionBindingMiss {
352            vendor: VendorId::DataDome,
353            challenge_class: ChallengeClass::Captcha,
354            expected: Some("s1".to_string()),
355            observed: Some("s2".to_string()),
356        };
357        assert_eq!(binding.kind(), InvalidationKind::SessionBindingMiss);
358        assert_eq!(binding.vendor_family(), VendorId::DataDome);
359        assert_eq!(binding.label(), "session_binding_miss");
360
361        let missing = InvalidationReason::ContractMissing {
362            vendor: VendorId::Unknown,
363            challenge_class: ChallengeClass::Unknown,
364        };
365        assert_eq!(missing.kind(), InvalidationKind::ContractMissing);
366        assert_eq!(missing.vendor_family(), VendorId::Unknown);
367        assert_eq!(missing.label(), "contract_missing");
368
369        let not_applicable = InvalidationReason::NotApplicable {
370            vendor: VendorId::Cloudflare,
371            challenge_class: ChallengeClass::ProofOfWork,
372        };
373        assert_eq!(not_applicable.kind(), InvalidationKind::NotApplicable);
374        assert_eq!(not_applicable.vendor_family(), VendorId::Cloudflare);
375        assert_eq!(not_applicable.label(), "not_applicable");
376    }
377
378    #[test]
379    fn invalidation_reason_serializes_with_tag() {
380        let reason = InvalidationReason::NonceReplayed {
381            vendor: VendorId::Akamai,
382            challenge_class: ChallengeClass::ProofOfWork,
383            observation_count: 2,
384        };
385        let json = serde_json::to_string(&reason).expect("serialize");
386        assert!(json.contains("\"kind\":\"nonce_replayed\""));
387        assert!(json.contains("\"vendor\":\"akamai\""));
388        assert!(json.contains("\"observation_count\":2"));
389    }
390}