feat(DPoP): add anti replay cache
This commit is contained in:
@@ -2105,6 +2105,9 @@ impl AppServiceFactory {
|
|||||||
dpop_nonce_service: Arc::new(
|
dpop_nonce_service: Arc::new(
|
||||||
crate::infrastructure::services::dpop_nonce_service::DpopNonceService::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,
|
nextcloud: nextcloud_services,
|
||||||
admin_settings_service: None,
|
admin_settings_service: None,
|
||||||
storage_settings_service: None,
|
storage_settings_service: None,
|
||||||
@@ -2883,6 +2886,10 @@ pub struct AppState {
|
|||||||
/// pay only allocation cost at boot.
|
/// pay only allocation cost at boot.
|
||||||
pub dpop_nonce_service:
|
pub dpop_nonce_service:
|
||||||
Arc<crate::infrastructure::services::dpop_nonce_service::DpopNonceService>,
|
Arc<crate::infrastructure::services::dpop_nonce_service::DpopNonceService>,
|
||||||
|
/// DPoP replay cache — nonce-scoped `jti` dedup. Same lifecycle
|
||||||
|
/// as `dpop_nonce_service` (always populated, cheap at boot).
|
||||||
|
pub dpop_replay_cache:
|
||||||
|
Arc<crate::infrastructure::services::dpop_replay_cache::DpopReplayCache>,
|
||||||
pub nextcloud: Option<NextcloudServices>,
|
pub nextcloud: Option<NextcloudServices>,
|
||||||
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
||||||
/// WASM plugin management (list/install/toggle/remove), backing the admin
|
/// WASM plugin management (list/install/toggle/remove), backing the admin
|
||||||
|
|||||||
@@ -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<String, ()>,
|
||||||
|
/// Freshest nonce we've issued + when — used to decide when to
|
||||||
|
/// rotate. `None` at boot, populated on first `current_or_rotate`.
|
||||||
|
current: RwLock<Option<CurrentNonce>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod consistency_batch_service;
|
|||||||
pub mod db_pool_monitor;
|
pub mod db_pool_monitor;
|
||||||
pub mod dedup_service;
|
pub mod dedup_service;
|
||||||
pub mod dpop_nonce_service;
|
pub mod dpop_nonce_service;
|
||||||
|
pub mod dpop_replay_cache;
|
||||||
pub mod dpop_verifier;
|
pub mod dpop_verifier;
|
||||||
pub mod drives_consistency_service;
|
pub mod drives_consistency_service;
|
||||||
pub mod encrypted_blob_backend;
|
pub mod encrypted_blob_backend;
|
||||||
|
|||||||
@@ -29,7 +29,11 @@
|
|||||||
//! Clients cache it; the next request presents it and skips the
|
//! Clients cache it; the next request presents it and skips the
|
||||||
//! challenge round trip.
|
//! 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::extract::{OriginalUri, Request, State};
|
||||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||||
@@ -87,6 +91,7 @@ pub async fn require_dpop_layer(
|
|||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
let nonce_service = state.dpop_nonce_service.clone();
|
let nonce_service = state.dpop_nonce_service.clone();
|
||||||
|
let replay_cache = state.dpop_replay_cache.clone();
|
||||||
|
|
||||||
// No authenticated user → pass through (upstream auth layer
|
// No authenticated user → pass through (upstream auth layer
|
||||||
// already handled or will handle the 401). We only concern
|
// 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
|
// present, it MUST be in our live pool. Absent → OK on
|
||||||
// the bootstrap request, but the challenge below MUST
|
// the bootstrap request, but the challenge below MUST
|
||||||
// still fire so the very next request carries a nonce.
|
// 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) => {
|
Some(n) if !nonce_service.is_valid(n) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "audit",
|
target: "audit",
|
||||||
@@ -159,8 +164,29 @@ pub async fn require_dpop_layer(
|
|||||||
// the nonce path immediately.
|
// the nonce path immediately.
|
||||||
return nonce_challenge_response(&nonce_service);
|
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;
|
let response = next.run(request).await;
|
||||||
stamp_current_nonce(response, &nonce_service)
|
stamp_current_nonce(response, &nonce_service)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user