ppoppo-token 0.3.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
Documentation
//! M35 — jti replay-cache enforcement (RFC_2026-05-04_jwt-full-adoption Phase 5).
//!
//! Runs after `check_domain` (Phase 4) so cheaper structural rejects fire
//! first. Skips when `cfg.replay` is `None` — opt-in port matches the
//! "gradual rollout" pattern Phase 2 established for cfg-driven gates.
//!
//! ── jti hash discipline (STANDARDS §E M35) ─────────────────────────────
//!
//! The substrate key is `sha256(token)[:16]` — *not* the raw jti. The
//! standard: "The hash (not raw jti) prevents log-leak of jti-equivalent
//! secret material if jti is ever sensitive." Hashing the *full token*
//! (signature included) means even a forger who guesses jti cannot
//! pre-poison the cache without also signing — the hash binds to the
//! signed envelope, not the claim.
//!
//! ── TTL bound (STANDARDS §E M35) ───────────────────────────────────────
//!
//! TTL = `claims.exp - now`. The replay window is the token's own
//! admissibility window — never longer (substrate entry outliving the
//! token wastes substrate budget) and never shorter (would let a
//! mid-window replayer slip through).
//!
//! ── Fail-closed mapping ─────────────────────────────────────────────────
//!
//! `ReplayDefenseError::Replayed` → `AuthError::JtiReplayed` (attack
//! signal). `ReplayDefenseError::Transient(_)` →
//! `AuthError::ReplayCacheUnavailable` (infrastructure signal). Audit
//! log routing differs (security incident vs ops alert), so the variants
//! stay distinct.

use std::time::Duration;

use sha2::{Digest, Sha256};

use crate::access_token::replay_defense::ReplayDefenseError;
use crate::access_token::{AuthError, Claims, VerifyConfig};

pub(crate) async fn run(
    token: &str,
    claims: &Claims,
    cfg: &VerifyConfig,
    now: i64,
) -> Result<(), AuthError> {
    let Some(port) = cfg.replay.as_ref() else {
        // Port not wired — gate is opt-in. Legacy / sibling-test
        // VerifyConfigs admit (they explicitly chose not to enforce).
        return Ok(());
    };

    let ttl_secs = claims.exp.saturating_sub(now);
    if ttl_secs <= 0 {
        // Token already expired. `check_claims::run` should have rejected
        // upstream via M18 — reaching here means clock advanced between
        // check_claims and check_replay (rare, but possible). Skip the
        // record to avoid poisoning the substrate with a zero-TTL entry.
        return Ok(());
    }
    // Cast is safe: `ttl_secs > 0` invariant just established.
    let ttl = Duration::from_secs(ttl_secs as u64);

    let hash = jti_hash(token);

    // M37 observability: emit `revocation.checked` event per port call
    // so ops dashboards can measure the propagation SLA (≤5min from
    // PAS-side write to consumer-side reject). Field shape:
    // `port = "replay" | "session" | "epoch"`, `outcome = "admit" |
    // "reject" | "transient"`, `sub` for per-account correlation.
    // jti_hash is *not* emitted — STANDARDS §E mandates the hash exists
    // precisely to keep jti-equivalent secret material out of logs.
    match port.check_and_record(&hash, ttl).await {
        Ok(()) => {
            tracing::trace!(
                target: "ppoppo_token::revocation",
                port = "replay",
                outcome = "admit",
                sub = %claims.sub,
                "revocation.checked",
            );
            Ok(())
        }
        Err(ReplayDefenseError::Replayed) => {
            tracing::warn!(
                target: "ppoppo_token::revocation",
                port = "replay",
                outcome = "reject",
                reason = "replayed",
                sub = %claims.sub,
                "revocation.checked",
            );
            Err(AuthError::JtiReplayed)
        }
        Err(ReplayDefenseError::Transient(detail)) => {
            tracing::warn!(
                target: "ppoppo_token::revocation",
                port = "replay",
                outcome = "transient",
                sub = %claims.sub,
                detail = %detail,
                "revocation.checked",
            );
            Err(AuthError::ReplayCacheUnavailable)
        }
    }
}

/// Hex-encoded SHA-256 prefix of the raw token bytes (16 bytes = 32
/// hex chars). Matches STANDARDS_JWT_DETAILS_MITIGATION §E key shape
/// `jti:{sha256(token)[:16]}`.
fn jti_hash(token: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(token.as_bytes());
    let digest = hasher.finalize();
    hex::encode(&digest[..16])
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn jti_hash_is_deterministic_and_short() {
        let h1 = jti_hash("eyJ.payload.sig");
        let h2 = jti_hash("eyJ.payload.sig");
        assert_eq!(h1, h2, "same input must produce same hash");
        assert_eq!(h1.len(), 32, "16 bytes = 32 hex chars (STANDARDS §E)");
    }

    #[test]
    fn jti_hash_differs_for_different_inputs() {
        let h1 = jti_hash("eyJ.payload.sig1");
        let h2 = jti_hash("eyJ.payload.sig2");
        assert_ne!(h1, h2, "different inputs must produce different hashes");
    }
}