diff --git a/src/common/di.rs b/src/common/di.rs index f16ded1a..9e89ab0a 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -2105,6 +2105,9 @@ impl AppServiceFactory { dpop_nonce_service: Arc::new( crate::infrastructure::services::dpop_nonce_service::DpopNonceService::new(), ), + dpop_replay_cache: Arc::new( + crate::infrastructure::services::dpop_replay_cache::DpopReplayCache::new(), + ), nextcloud: nextcloud_services, admin_settings_service: None, storage_settings_service: None, @@ -2883,6 +2886,10 @@ pub struct AppState { /// pay only allocation cost at boot. pub dpop_nonce_service: Arc, + /// DPoP replay cache — nonce-scoped `jti` dedup. Same lifecycle + /// as `dpop_nonce_service` (always populated, cheap at boot). + pub dpop_replay_cache: + Arc, pub nextcloud: Option, pub admin_settings_service: Option>, /// WASM plugin management (list/install/toggle/remove), backing the admin diff --git a/src/infrastructure/services/dpop_nonce_service.rs b/src/infrastructure/services/dpop_nonce_service.rs new file mode 100644 index 00000000..38448964 --- /dev/null +++ b/src/infrastructure/services/dpop_nonce_service.rs @@ -0,0 +1,184 @@ +//! DPoP-Nonce service (RFC 9449 §8) — issues and validates the +//! server-generated nonces that eliminate reliance on the client +//! clock for freshness. +//! +//! Model — a **pool of currently-valid nonces**, not a single "last" +//! value. Every nonce we hand out sits in the pool for its full +//! lifetime; multiple can be simultaneously valid (rotation overlap, +//! multi-tab). A proof's `nonce` claim is valid iff the pool still +//! remembers it. +//! +//! Rotation — every request response can carry a `DPoP-Nonce` +//! header pointing at the "current" nonce. When the current nonce +//! is older than [`ROTATION_INTERVAL`], `current_or_rotate` mints +//! a fresh one and returns it (the outgoing one keeps living in +//! the pool until its TTL expires — the overlap window). Clients +//! opportunistically pick up the fresh header and start using it; +//! in-flight requests carrying the previous nonce remain valid +//! throughout the overlap. +//! +//! Storage — in-memory `moka` LRU, no PG persistence. On server +//! restart the pool is empty → every next client request gets a +//! `use_dpop_nonce` challenge (middleware handles this) which +//! transparently rotates the client onto a fresh nonce. That's +//! why the SPA fetch interceptor (Gate 4) has a mandatory +//! challenge-retry loop. +//! +//! Scale — a hard cap on cache size bounds memory under attack. +//! At ~64 bytes/entry and a 100k cap, worst-case ~6 MB. Under +//! normal traffic the pool is far below the cap. +//! +//! **Multi-instance caveat**: each OxiCloud replica has its own +//! pool. A nonce issued by node A + validated by node B will 401 → +//! challenge → retry → one extra round trip, no security impact. +//! Elevate to a shared Redis if the operational impact ever +//! matters; for the common single-instance self-hosted deployment +//! in-memory is correct. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64_URL_NO_PAD; +use moka::sync::Cache; +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +/// How long a nonce is valid after issuance. Rejected outright once +/// past this window (moka TTL enforces it — no manual sweep needed). +pub const NONCE_LIFETIME: Duration = Duration::from_secs(300); // 5 min + +/// Once the "current" nonce is older than this, `current_or_rotate` +/// mints a fresh one on next call. The outgoing nonce stays valid +/// in the pool until its own TTL expires, giving a 3-minute overlap +/// window during which both work. Clients pick up the fresh header +/// in the next response and switch over lazily. +pub const ROTATION_INTERVAL: Duration = Duration::from_secs(120); // 2 min + +/// Max nonce entries — bounds memory under attack. LRU eviction +/// past this cap. +const MAX_POOL_SIZE: u64 = 100_000; + +/// Byte length of a nonce before base64url encoding. 32 bytes → +/// 43-char b64url string, matching JWK-thumbprint dimensions so +/// operator eyes calibrate the same way for both fields. +const NONCE_BYTES: usize = 32; + +/// Currently-active pool of nonces + the freshest one served in +/// `DPoP-Nonce` response headers. +pub struct DpopNonceService { + /// Pool of live nonces. Value is `()` — presence == validity; + /// TTL enforced by moka's `time_to_live`. + pool: Cache, + /// Freshest nonce we've issued + when — used to decide when to + /// rotate. `None` at boot, populated on first `current_or_rotate`. + current: RwLock>, +} + +struct CurrentNonce { + value: String, + issued_at: Instant, +} + +impl Default for DpopNonceService { + fn default() -> Self { + Self::new() + } +} + +impl DpopNonceService { + pub fn new() -> Self { + Self { + pool: Cache::builder() + .max_capacity(MAX_POOL_SIZE) + .time_to_live(NONCE_LIFETIME) + .build(), + current: RwLock::new(None), + } + } + + /// Return the current nonce, minting a fresh one when the last + /// mint is older than [`ROTATION_INTERVAL`] (or on cold start). + /// The returned value is what the middleware stamps into + /// outgoing `DPoP-Nonce` response headers. + pub fn current_or_rotate(&self) -> String { + // Fast path: read lock, current is still fresh → clone the string. + if let Some(cur) = self.current.read().unwrap().as_ref() + && cur.issued_at.elapsed() < ROTATION_INTERVAL + { + return cur.value.clone(); + } + // Slow path: write lock, re-check (someone else may have + // rotated between drop-read and acquire-write), otherwise + // mint fresh. + let mut guard = self.current.write().unwrap(); + if let Some(cur) = guard.as_ref() + && cur.issued_at.elapsed() < ROTATION_INTERVAL + { + return cur.value.clone(); + } + let fresh = mint_nonce(); + self.pool.insert(fresh.clone(), ()); + *guard = Some(CurrentNonce { + value: fresh.clone(), + issued_at: Instant::now(), + }); + fresh + } + + /// Check whether a nonce presented by a client is still valid. + /// Returns `false` for absent-from-pool AND for + /// past-TTL-eviction; both are indistinguishable from the + /// caller's perspective. + pub fn is_valid(&self, nonce: &str) -> bool { + self.pool.contains_key(nonce) + } +} + +fn mint_nonce() -> String { + // Re-use `p256`'s already-transitive `rand_core::OsRng` — no new + // dep, no version alignment risk. `OsRng` reads from the OS + // entropy source; `fill_bytes` panics on RNG failure (unreachable + // outside catastrophic OS state, and safer to crash than mint a + // guessable nonce). + use p256::elliptic_curve::rand_core::{OsRng, RngCore}; + let mut buf = [0u8; NONCE_BYTES]; + OsRng.fill_bytes(&mut buf); + B64_URL_NO_PAD.encode(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn issues_a_nonce_of_expected_shape() { + let svc = DpopNonceService::new(); + let n = svc.current_or_rotate(); + // 43 chars = base64url(SHA-256-equivalent length) + assert_eq!(n.len(), 43); + assert!( + n.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'), + "nonce contains non-base64url chars: {n}" + ); + } + + #[test] + fn issued_nonce_validates_immediately() { + let svc = DpopNonceService::new(); + let n = svc.current_or_rotate(); + assert!(svc.is_valid(&n)); + } + + #[test] + fn returns_same_nonce_within_rotation_window() { + let svc = DpopNonceService::new(); + let a = svc.current_or_rotate(); + let b = svc.current_or_rotate(); + assert_eq!(a, b); + } + + #[test] + fn unknown_nonce_is_rejected() { + let svc = DpopNonceService::new(); + assert!(!svc.is_valid("not-a-real-nonce-value-1234567890abcde")); + } +} diff --git a/src/infrastructure/services/dpop_replay_cache.rs b/src/infrastructure/services/dpop_replay_cache.rs new file mode 100644 index 00000000..a8a1393d --- /dev/null +++ b/src/infrastructure/services/dpop_replay_cache.rs @@ -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")); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index f1e96a52..62f2e0af 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -11,6 +11,7 @@ pub mod consistency_batch_service; pub mod db_pool_monitor; pub mod dedup_service; pub mod dpop_nonce_service; +pub mod dpop_replay_cache; pub mod dpop_verifier; pub mod drives_consistency_service; pub mod encrypted_blob_backend; diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs index 45422d0a..f975070a 100644 --- a/src/interfaces/middleware/dpop.rs +++ b/src/interfaces/middleware/dpop.rs @@ -29,7 +29,11 @@ //! Clients cache it; the next request presents it and skips the //! challenge round trip. //! -//! Replay detection (jti-per-nonce) is Gate 6; not wired yet. +//! Replay detection: after nonce validation succeeds, the +//! `(nonce, jti)` pair is recorded in a moka LRU. A second proof +//! carrying the same `(nonce, jti)` — the classic replay window — +//! fires `dpop.replay_detected` and returns 401 with the standard +//! `invalid_dpop_proof` error shape. use axum::extract::{OriginalUri, Request, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; @@ -87,6 +91,7 @@ pub async fn require_dpop_layer( return next.run(request).await; } let nonce_service = state.dpop_nonce_service.clone(); + let replay_cache = state.dpop_replay_cache.clone(); // No authenticated user → pass through (upstream auth layer // already handled or will handle the 401). We only concern @@ -139,7 +144,7 @@ pub async fn require_dpop_layer( // present, it MUST be in our live pool. Absent → OK on // the bootstrap request, but the challenge below MUST // still fire so the very next request carries a nonce. - match verified.nonce.as_deref() { + let live_nonce = match verified.nonce.as_deref() { Some(n) if !nonce_service.is_valid(n) => { tracing::info!( target: "audit", @@ -159,8 +164,29 @@ pub async fn require_dpop_layer( // the nonce path immediately. return nonce_challenge_response(&nonce_service); } - _ => {} + Some(n) => n, + }; + + // Replay guard — nonce-scoped `jti` dedup. Runs AFTER + // nonce validity so we don't populate the cache with + // entries against a nonce that would 401 anyway (waste + // of pool space; also lets an attacker probe expired + // nonces without pressuring the cache). + if !replay_cache.check_and_record(live_nonce, &verified.jti) { + tracing::info!( + target: "audit", + event = "dpop.replay_detected", + method = %method, + htu = %htu, + jti = %verified.jti, + "👮🏻‍♂️ DPoP proof replayed — same (nonce, jti) seen twice", + ); + return dpop_verification_failed_response( + DpopVerifyError::SignatureInvalid, // shape-only; audit line carries truth + &nonce_service, + ); } + let response = next.run(request).await; stamp_current_nonce(response, &nonce_service) }