feat(DPoP): add anti replay cache
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
//! DPoP replay cache — remembers `(nonce, jti)` tuples we've
|
||||
//! already verified, and rejects duplicates as replays.
|
||||
//!
|
||||
//! Nonce-scoped by design (see `docs/plan/dpop.md` Gate 6). A `jti`
|
||||
//! is only guaranteed unique WITHIN the lifetime of a nonce; a
|
||||
//! naive global-`jti` cache would falsely reject the second use of
|
||||
//! a `jti` value the client happened to reuse across two nonces
|
||||
//! (statistically negligible for 128-bit UUIDs but semantically
|
||||
//! wrong per the spec).
|
||||
//!
|
||||
//! Two-scope invariant, tested below:
|
||||
//! * same `jti` under DIFFERENT nonces → both accepted
|
||||
//! * same `(nonce, jti)` seen twice → second is a replay
|
||||
//!
|
||||
//! TTL is aligned with [`super::dpop_nonce_service::NONCE_LIFETIME`]
|
||||
//! (5 minutes) — once a nonce ages out of the nonce pool it cannot
|
||||
//! validate anyway, so replay-cache entries against that nonce are
|
||||
//! moot the moment the outer freshness check fires. Both caches
|
||||
//! bounded at ~100k entries.
|
||||
|
||||
use moka::sync::Cache;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Same as `NONCE_LIFETIME` — see file doc.
|
||||
const REPLAY_ENTRY_TTL: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Cap. Roughly 100 bytes/entry (two short strings + moka
|
||||
/// bookkeeping) → ~10 MB ceiling under sustained attack, per plan.
|
||||
const MAX_ENTRIES: u64 = 100_000;
|
||||
|
||||
/// In-memory nonce-scoped replay tracker.
|
||||
pub struct DpopReplayCache {
|
||||
seen: Cache<(String, String), ()>,
|
||||
}
|
||||
|
||||
impl Default for DpopReplayCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DpopReplayCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
seen: Cache::builder()
|
||||
.max_capacity(MAX_ENTRIES)
|
||||
.time_to_live(REPLAY_ENTRY_TTL)
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a fresh `(nonce, jti)` pair, returning `true` if this
|
||||
/// is the first time we've seen it (accept the proof) and
|
||||
/// `false` if we've already recorded it (replay — reject).
|
||||
///
|
||||
/// Uses moka's atomic `entry` API so two racing verify calls
|
||||
/// for the same `(nonce, jti)` — the pathological concurrent-
|
||||
/// replay window — resolve to exactly one `true` and one
|
||||
/// `false`, never both `true`.
|
||||
pub fn check_and_record(&self, nonce: &str, jti: &str) -> bool {
|
||||
let key = (nonce.to_string(), jti.to_string());
|
||||
// `entry().or_insert_with(...)` is atomic across concurrent
|
||||
// callers; the returned `Entry` exposes `is_fresh()` to
|
||||
// distinguish "we just wrote this" from "already existed".
|
||||
let entry = self.seen.entry(key).or_insert_with(|| ());
|
||||
entry.is_fresh()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_seen_is_accepted() {
|
||||
let c = DpopReplayCache::new();
|
||||
assert!(c.check_and_record("nonce-A", "jti-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_same_scope_is_replay() {
|
||||
let c = DpopReplayCache::new();
|
||||
assert!(c.check_and_record("nonce-A", "jti-1"));
|
||||
assert!(
|
||||
!c.check_and_record("nonce-A", "jti-1"),
|
||||
"second insert of same (nonce, jti) must be flagged as replay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_jti_different_nonces_both_accepted() {
|
||||
// Nonce-scoped invariant: `jti` uniqueness is only meaningful
|
||||
// within a single nonce lifetime. Reusing a `jti` across
|
||||
// different nonces is legitimate (the second nonce is a
|
||||
// fresh replay scope) and MUST NOT trip replay detection.
|
||||
let c = DpopReplayCache::new();
|
||||
assert!(c.check_and_record("nonce-A", "jti-1"));
|
||||
assert!(c.check_and_record("nonce-B", "jti-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_jtis_same_nonce_both_accepted() {
|
||||
let c = DpopReplayCache::new();
|
||||
assert!(c.check_and_record("nonce-A", "jti-1"));
|
||||
assert!(c.check_and_record("nonce-A", "jti-2"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user