Skip to main content

ppoppo_token/
replay_defense.rs

1//! M35 — jti replay defense port (RFC_2026-05-04_jwt-full-adoption Phase 5).
2//!
3//! `ReplayDefense` is an *engine-facing* port (deep-module discipline):
4//! the engine sees one method (`check_and_record`); concrete adapters
5//! own the substrate-specific composition (KVRocks `SET NX EX`, Postgres
6//! unique-constraint + retention sweeper, etc).
7//!
8//! ── Atomicity contract (TOCTOU defense) ─────────────────────────────────
9//!
10//! `check_and_record` is intentionally a *single* atomic primitive rather
11//! than the split pair `check + record`. A split shape leaves a window
12//! where two simultaneous verifies of the same jti both observe "not
13//! seen" before either records. The atomic primitive maps directly to
14//! the substrate's compare-and-set / set-if-absent operation, eliminating
15//! the race.
16//!
17//! ── Failure-mode contract (fail-closed) ────────────────────────────────
18//!
19//! Adapters MUST NOT translate substrate transient failures into "admit"
20//! — a KVRocks outage during a replay attack would otherwise let every
21//! replay through. The error variants below distinguish the two
22//! conditions so the engine maps each to its own `AuthError` and audit
23//! signal.
24//!
25//! ── Phase 10 split (RFC §6.11) ──────────────────────────────────────────
26//!
27//! This trait moves to `access_token::ReplayDefense` in Phase 10 D1.
28//! id_token does not import — id tokens are not bearers (see
29//! STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO line 225 "M35-M38 inheritance"
30//! exclusion).
31
32use std::time::Duration;
33
34/// Atomic check-and-record over a per-token uniqueness key.
35///
36/// `jti_hash` is the SHA-256 prefix of the raw token (per
37/// STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO §E M35), already hashed by
38/// the engine before this call so adapters never see jti-equivalent
39/// secret material in their substrate logs.
40///
41/// `ttl` is `claims.exp - now` at verify time — the replay window is
42/// bounded by the token's own admissibility window, not a fixed cache
43/// TTL. Adapters set the substrate key's expiry to this value so a key
44/// outliving its token is impossible.
45#[async_trait::async_trait]
46pub trait ReplayDefense: std::fmt::Debug + Send + Sync {
47    async fn check_and_record(
48        &self,
49        jti_hash: &str,
50        ttl: Duration,
51    ) -> Result<(), ReplayDefenseError>;
52}
53
54/// Failure modes from a `ReplayDefense` substrate call.
55///
56/// Two variants — adapters MUST NOT collapse them. `Replayed` is an
57/// *attack signal* (someone presented the same jti twice within TTL);
58/// `Transient` is an *infrastructure signal* (substrate unreachable).
59/// Audit log routing differs (security incident vs ops alert), and
60/// the engine maps each to its own `AuthError` variant.
61#[derive(Debug, thiserror::Error)]
62pub enum ReplayDefenseError {
63    /// The jti hash was already recorded within TTL — replay detected.
64    /// Engine maps to `AuthError::JtiReplayed`.
65    #[error("replay detected for jti hash")]
66    Replayed,
67
68    /// Substrate transient failure — engine fails closed and maps to
69    /// `AuthError::ReplayCacheUnavailable`. The string carries adapter
70    /// context for ops triage (NOT for the audit log; the log keys off
71    /// the variant, not the payload).
72    #[error("replay cache transient failure: {0}")]
73    Transient(String),
74}