diff --git a/Cargo.lock b/Cargo.lock index db5d4d9c..b3d44d27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4490,6 +4490,7 @@ dependencies = [ "nom-exif", "opaque-ke", "ort", + "p256", "pdf-extract", "percent-encoding", "quick-xml 0.41.0", diff --git a/Cargo.toml b/Cargo.toml index a1cf19fb..9806461b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,10 @@ rand_core = { version = "0.6", features = ["std", "getrandom"] } # changing it invalidates every user's registration record, plan a # migration before touching. `argon2` feature gates the memory-hard KSF. opaque-ke = { version = "3", features = ["argon2"] } +# DPoP (RFC 9449) proof verification — ECDSA P-256 signatures. Transitive +# via jsonwebtoken's rust_crypto feature; declared here for direct use in +# `infrastructure::services::dpop_verifier`. +p256 = { version = "0.13", features = ["ecdsa"] } quick-xml = "0.41.0" dotenvy = "0.15.7" moka = { version = "0.12.15", features = ["future", "sync"] } diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index a32aca41..414de2d6 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -479,11 +479,7 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// redemption). Fails with `AlreadyExists` if the session already /// carries a thumbprint (anti-downgrade invariant, see /// `docs/plan/dpop.md`). - async fn bind_dpop_jkt( - &self, - session_id: Uuid, - dpop_jkt: &str, - ) -> Result<(), DomainError>; + async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> Result<(), DomainError>; } // ============================================================================ diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 47638a50..5ba0d7c7 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -2263,7 +2263,11 @@ impl AuthApplicationService { "dpop_jkt must be a 43-character base64url SHA-256 thumbprint (RFC 7638)", ) })?; - match self.session_storage.bind_dpop_jkt(session_id, &validated).await { + match self + .session_storage + .bind_dpop_jkt(session_id, &validated) + .await + { Ok(()) => { tracing::info!( target: "audit", diff --git a/src/common/config.rs b/src/common/config.rs index 129c4f20..a9b62229 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -1448,6 +1448,55 @@ pub struct AuthConfig { /// /// Env: `OXICLOUD_REQUIRE_VERIFIED_EMAIL` (default `false`). pub require_verified_email: bool, + + /// DPoP session-binding enforcement (RFC 9449). Bound sessions — + /// those created with a `dpop_jkt` supplied at login — carry a + /// browser-held keypair thumbprint; the middleware verifies a + /// per-request signed proof so that stealing the session cookie + /// alone is useless without the private key. + /// + /// Modes (see `DpopMode` enum): + /// * `Off` (default) — middleware is a pass-through; no + /// verification even when a proof is present. Ship-safe + /// default while the client rollout catches up. + /// * `Opportunistic` — verify when a proof is present, reject + /// mismatches; skip when absent. Warn on + /// `dpop.header_missing_but_session_bound`. Rollout mode. + /// * `Required` — bound sessions MUST present a valid proof. + /// Unbound sessions (`dpop_jkt IS NULL` — app passwords, + /// Nextcloud clients, legacy) remain exempt at the + /// middleware level. + /// + /// Env: `OXICLOUD_DPOP_MODE` in `{off,opportunistic,required}` + /// (default `off`). + pub dpop_mode: DpopMode, +} + +/// DPoP session-binding enforcement mode. See `AuthConfig::dpop_mode` +/// and `docs/plan/dpop.md` for the rollout strategy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DpopMode { + /// Middleware pass-through — DPoP header is neither required nor + /// verified. Default: safe while clients roll out proof-signing. + #[default] + Off, + /// Verify when present, allow when absent. Bound sessions still + /// get a warning audit line when they arrive without a proof. + Opportunistic, + /// Bound sessions (`dpop_jkt IS NOT NULL`) MUST present a valid + /// proof or 401. Unbound sessions remain exempt. + Required, +} + +impl DpopMode { + pub fn from_env_str(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "off" => Some(Self::Off), + "opportunistic" => Some(Self::Opportunistic), + "required" => Some(Self::Required), + _ => None, + } + } } /// Self-service auth method. Exposed as `AuthConfig::allowed_auth_methods` @@ -1583,6 +1632,7 @@ impl Default for AuthConfig { auth_policies: Vec::new(), allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink], require_verified_email: false, + dpop_mode: DpopMode::Off, } } } @@ -2978,6 +3028,15 @@ impl AppConfig { } } + if let Ok(v) = env::var("OXICLOUD_DPOP_MODE") { + match DpopMode::from_env_str(&v) { + Some(mode) => config.auth.dpop_mode = mode, + None => panic!( + "OXICLOUD_DPOP_MODE={v:?} — expected one of off / opportunistic / required" + ), + } + } + if let Ok(v) = env::var("OXICLOUD_REQUIRE_VERIFIED_EMAIL") { config.auth.require_verified_email = v.parse::().unwrap_or(false); } diff --git a/src/common/di.rs b/src/common/di.rs index f57c805b..f16ded1a 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -2102,6 +2102,9 @@ impl AppServiceFactory { opaque_service, opaque_repo, opaque_login_exchange, + dpop_nonce_service: Arc::new( + crate::infrastructure::services::dpop_nonce_service::DpopNonceService::new(), + ), nextcloud: nextcloud_services, admin_settings_service: None, storage_settings_service: None, @@ -2874,6 +2877,12 @@ pub struct AppState { pub opaque_login_exchange: Option< Arc, >, + /// DPoP nonce pool. Always populated (even in `dpop_mode = off`) + /// so switching mode via env-flip needs no restart-time wiring + /// change. Cheap-to-construct in-memory moka cache; unused paths + /// pay only allocation cost at boot. + pub dpop_nonce_service: + Arc, pub nextcloud: Option, pub admin_settings_service: Option>, /// WASM plugin management (list/install/toggle/remove), backing the admin diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index f6ae88ef..48589d4e 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -121,9 +121,5 @@ pub trait SessionRepository: Send + Sync + 'static { /// downgrade guard from `docs/plan/dpop.md` — an attacker who has /// stolen the cookie of a bound session cannot re-bind to their /// own key. - async fn bind_dpop_jkt( - &self, - session_id: Uuid, - dpop_jkt: &str, - ) -> SessionRepositoryResult<()>; + async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> SessionRepositoryResult<()>; } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 04d936a3..4da55abe 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -469,11 +469,7 @@ impl SessionRepository for SessionPgRepository { Ok(result.rows_affected()) } - async fn bind_dpop_jkt( - &self, - session_id: Uuid, - dpop_jkt: &str, - ) -> SessionRepositoryResult<()> { + async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> SessionRepositoryResult<()> { // `WHERE dpop_jkt IS NULL` enforces the immutability invariant // at the SQL level — a bound session's UPDATE affects 0 rows // and we surface `DpopAlreadyBound`. Also guards against a @@ -648,11 +644,7 @@ impl SessionStoragePort for SessionPgRepository { .map_err(DomainError::from) } - async fn bind_dpop_jkt( - &self, - session_id: Uuid, - dpop_jkt: &str, - ) -> Result<(), DomainError> { + async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> Result<(), DomainError> { SessionRepository::bind_dpop_jkt(self, session_id, dpop_jkt) .await .map_err(DomainError::from) diff --git a/src/infrastructure/services/dpop_verifier.rs b/src/infrastructure/services/dpop_verifier.rs new file mode 100644 index 00000000..d620cfe1 --- /dev/null +++ b/src/infrastructure/services/dpop_verifier.rs @@ -0,0 +1,492 @@ +//! DPoP proof verifier (RFC 9449) — pure functions over a compact JWS. +//! +//! Consumes a DPoP header value produced by +//! `frontend/src/lib/auth/dpop-proof.ts` (or the `dpop-hurl-helper` +//! test binary — Gate 6b) and returns a typed verdict. +//! +//! Nothing here touches the DB or the request extractor pipeline — +//! that's the middleware's job (see `src/interfaces/middleware/dpop.rs`). +//! Keeping the verifier pure makes the failure-mode matrix trivially +//! unit-testable: `verify(proof, method, htu, expected_jkt, now)`. +//! +//! **Nonce validation is a caller responsibility for now** — the +//! nonce claim is extracted and returned as part of the OK verdict, +//! but the caller (middleware) validates it against the nonce +//! service. Wiring lands in Gate 5b; at Gate 5 the middleware +//! ignores the nonce field (opportunistic path). +//! +//! Ciphersuite: **ES256 ONLY** (ECDSA P-256 + SHA-256). Any other +//! `alg` or `crv` is a hard reject — RFC 9449 §4 mandates support +//! for ES256 and we don't accept anything looser. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64_URL_NO_PAD; +use sha2::{Digest, Sha256}; + +/// Per-request context the verifier compares proof claims against. +#[derive(Debug, Clone)] +pub struct DpopRequestContext<'a> { + /// Uppercase HTTP method (e.g. `"POST"`). + pub htm: &'a str, + /// Canonical target URL: `scheme://authority/path` — NO query, NO + /// fragment. Middleware builds this from the external scheme + + /// host (`X-Forwarded-*`-aware) + `OriginalUri` path. + pub htu: &'a str, + /// Server clock (unix seconds). Injected so tests can pin it + /// deterministically. + pub now_secs: i64, + /// Session's stored thumbprint — set at login (`session.dpop_jkt`). + /// When present, the proof's JWK thumbprint MUST match. + pub expected_jkt: Option<&'a str>, +} + +/// Successful verify outcome — the middleware may still need to +/// validate the nonce (Gate 5b) and jti (Gate 6, replay cache). +#[derive(Debug, Clone)] +pub struct DpopVerified { + /// RFC 7638 JWK thumbprint of the proof's public key. + /// Middleware compares to `session.dpop_jkt` (already done here + /// when `expected_jkt` was set) and may audit-log this value. + pub jkt: String, + /// Nonce claim from the proof, if any. Bootstrap-only branch has + /// `None` — the very first request per session precedes the + /// server-issued nonce, so the client can't include it. + pub nonce: Option, + /// Unique proof id — the replay cache in Gate 6 keys off this. + pub jti: String, + /// Claimed issue time (unix seconds) — informational when nonce + /// is present (server clock is authoritative via nonce validity), + /// bounded ±30s when no nonce yet (bootstrap branch). + pub iat: i64, +} + +/// Machine-readable failure reasons. Stringly matched by the middleware +/// for the audit `reason=` field — DO NOT rename variants without +/// coordinating with dashboards. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpopVerifyError { + /// JWS isn't three base64url segments, or a segment fails to + /// decode, or the JSON header/claims fail to parse. + Malformed, + /// `typ` header field is not `"dpop+jwt"`. + WrongTyp, + /// `alg` header field is not `"ES256"`. + WrongAlg, + /// `jwk` header member is missing or not an EC/P-256 public key. + WrongJwk, + /// ECDSA signature does not verify over `header.payload`. + SignatureInvalid, + /// `htm` claim doesn't match the request method. + WrongHtm, + /// `htu` claim doesn't match the canonical request URL. + WrongHtu, + /// `iat` claim is missing / non-numeric. + IatMissing, + /// `iat` claim is outside the ±30s bootstrap window (no nonce yet). + IatOutOfWindow, + /// `jti` claim is missing / empty. + JtiMissing, + /// Proof's JWK thumbprint doesn't match `expected_jkt`. + JktMismatch, +} + +impl DpopVerifyError { + /// Stable machine-readable reason string for audit lines. + pub fn reason(&self) -> &'static str { + match self { + Self::Malformed => "malformed_jws", + Self::WrongTyp => "wrong_typ", + Self::WrongAlg => "wrong_alg", + Self::WrongJwk => "wrong_jwk", + Self::SignatureInvalid => "signature_invalid", + Self::WrongHtm => "wrong_htm", + Self::WrongHtu => "wrong_htu", + Self::IatMissing => "iat_missing", + Self::IatOutOfWindow => "iat_out_of_window", + Self::JtiMissing => "jti_missing", + Self::JktMismatch => "jkt_mismatch", + } + } +} + +pub type DpopVerifyResult = Result; + +/// ±30s tolerance on the `iat` claim when NO nonce is present +/// (bootstrap branch). Once Gate 5b lands, requests carrying a +/// server-issued nonce bypass this check — nonce validity acts as +/// the authoritative freshness signal. +const IAT_BOOTSTRAP_TOLERANCE_SECS: i64 = 30; + +/// Verify a DPoP proof against a request context. See file doc for +/// scope: nonce/jti/replay checks are the caller's responsibility. +pub fn verify(proof: &str, ctx: &DpopRequestContext<'_>) -> DpopVerifyResult { + // ── 1. Split the compact JWS into three segments ───────────── + let mut parts = proof.split('.'); + let (h_b64, p_b64, s_b64) = match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s), None) => (h, p, s), + _ => return Err(DpopVerifyError::Malformed), + }; + + let header_bytes = B64_URL_NO_PAD + .decode(h_b64) + .map_err(|_| DpopVerifyError::Malformed)?; + let payload_bytes = B64_URL_NO_PAD + .decode(p_b64) + .map_err(|_| DpopVerifyError::Malformed)?; + let signature = B64_URL_NO_PAD + .decode(s_b64) + .map_err(|_| DpopVerifyError::Malformed)?; + + // ── 2. Parse header + validate typ/alg/jwk ──────────────────── + let header: serde_json::Value = + serde_json::from_slice(&header_bytes).map_err(|_| DpopVerifyError::Malformed)?; + + if header.get("typ").and_then(|v| v.as_str()) != Some("dpop+jwt") { + return Err(DpopVerifyError::WrongTyp); + } + if header.get("alg").and_then(|v| v.as_str()) != Some("ES256") { + return Err(DpopVerifyError::WrongAlg); + } + let jwk = header.get("jwk").ok_or(DpopVerifyError::WrongJwk)?; + if jwk.get("kty").and_then(|v| v.as_str()) != Some("EC") { + return Err(DpopVerifyError::WrongJwk); + } + if jwk.get("crv").and_then(|v| v.as_str()) != Some("P-256") { + return Err(DpopVerifyError::WrongJwk); + } + let x_b64 = jwk + .get("x") + .and_then(|v| v.as_str()) + .ok_or(DpopVerifyError::WrongJwk)?; + let y_b64 = jwk + .get("y") + .and_then(|v| v.as_str()) + .ok_or(DpopVerifyError::WrongJwk)?; + + // ── 3. Verify the ECDSA signature ───────────────────────────── + // JWS ES256 signature is raw R||S (64 bytes for P-256), NOT DER + // — RFC 7515 A.3. `p256::ecdsa::Signature::from_slice` accepts + // exactly that layout. + let x_bytes = B64_URL_NO_PAD + .decode(x_b64) + .map_err(|_| DpopVerifyError::WrongJwk)?; + let y_bytes = B64_URL_NO_PAD + .decode(y_b64) + .map_err(|_| DpopVerifyError::WrongJwk)?; + if x_bytes.len() != 32 || y_bytes.len() != 32 { + return Err(DpopVerifyError::WrongJwk); + } + // SEC1 uncompressed point: 0x04 || X || Y. + let mut sec1 = Vec::with_capacity(65); + sec1.push(0x04); + sec1.extend_from_slice(&x_bytes); + sec1.extend_from_slice(&y_bytes); + + use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; + let vkey = VerifyingKey::from_sec1_bytes(&sec1).map_err(|_| DpopVerifyError::WrongJwk)?; + let sig = Signature::from_slice(&signature).map_err(|_| DpopVerifyError::SignatureInvalid)?; + + // Signing input is EXACT bytes: base64url(header) || '.' || + // base64url(payload). Preserve the caller's encoding — do NOT + // re-encode, since serde_json re-serialisation may reorder + // members and break signature. + let signing_input = format!("{h_b64}.{p_b64}"); + vkey.verify(signing_input.as_bytes(), &sig) + .map_err(|_| DpopVerifyError::SignatureInvalid)?; + + // ── 4. Parse claims + validate htm/htu/iat/jti ──────────────── + let claims: serde_json::Value = + serde_json::from_slice(&payload_bytes).map_err(|_| DpopVerifyError::Malformed)?; + + let htm = claims.get("htm").and_then(|v| v.as_str()).unwrap_or(""); + if !htm.eq_ignore_ascii_case(ctx.htm) { + return Err(DpopVerifyError::WrongHtm); + } + let htu = claims.get("htu").and_then(|v| v.as_str()).unwrap_or(""); + if htu != ctx.htu { + return Err(DpopVerifyError::WrongHtu); + } + let iat = claims + .get("iat") + .and_then(|v| v.as_i64()) + .ok_or(DpopVerifyError::IatMissing)?; + let jti = claims + .get("jti") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or(DpopVerifyError::JtiMissing)? + .to_string(); + let nonce = claims + .get("nonce") + .and_then(|v| v.as_str()) + .map(str::to_owned); + + // iat freshness check: authoritative only when NO nonce is + // present (bootstrap branch). Gate 5b will bypass this when a + // nonce is available — nonce validity is server-clock-based, so + // it moots any client-clock skew. + if nonce.is_none() && (iat - ctx.now_secs).abs() > IAT_BOOTSTRAP_TOLERANCE_SECS { + return Err(DpopVerifyError::IatOutOfWindow); + } + + // ── 5. Compute JWK thumbprint (RFC 7638 §3.2 EC members) ────── + let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x_b64}","y":"{y_b64}"}}"#,); + let jkt = B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes())); + + if let Some(expected) = ctx.expected_jkt + && expected != jkt + { + return Err(DpopVerifyError::JktMismatch); + } + + Ok(DpopVerified { + jkt, + nonce, + jti, + iat, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; + + /// Deterministic-ish signing key for tests — derives 32 bytes from + /// a seed byte so each test can hold its own without pulling in + /// `rand` as a dev-dep. Any value 1..=127 works (P-256 scalar + /// must be non-zero and < curve order); we spread bytes over the + /// buffer so keys with adjacent seeds don't share high bits. + fn test_key(seed: u8) -> SigningKey { + let mut bytes = [0u8; 32]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = seed.wrapping_add(i as u8).wrapping_add(1); + } + SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar") + } + + /// Build a signed DPoP proof for testing — mirrors what + /// `frontend/src/lib/auth/dpop-proof.ts` produces on the client. + #[allow(clippy::too_many_arguments)] + fn make_proof( + signing_key: &SigningKey, + htm: &str, + htu: &str, + iat: i64, + jti: &str, + nonce: Option<&str>, + override_alg: Option<&str>, + override_typ: Option<&str>, + ) -> (String, String) { + let vkey = signing_key.verifying_key(); + let encoded = vkey.to_encoded_point(false); // uncompressed + let x = encoded.x().unwrap(); + let y = encoded.y().unwrap(); + let x_b64 = B64_URL_NO_PAD.encode(x); + let y_b64 = B64_URL_NO_PAD.encode(y); + + let header = serde_json::json!({ + "typ": override_typ.unwrap_or("dpop+jwt"), + "alg": override_alg.unwrap_or("ES256"), + "jwk": { "crv": "P-256", "kty": "EC", "x": x_b64, "y": y_b64 }, + }); + let mut claims = serde_json::json!({ + "htm": htm, + "htu": htu, + "iat": iat, + "jti": jti, + }); + if let Some(n) = nonce { + claims.as_object_mut().unwrap().insert( + "nonce".to_string(), + serde_json::Value::String(n.to_string()), + ); + } + + let h_b64 = B64_URL_NO_PAD.encode(header.to_string()); + let p_b64 = B64_URL_NO_PAD.encode(claims.to_string()); + let signing_input = format!("{h_b64}.{p_b64}"); + let sig: Signature = signing_key.sign(signing_input.as_bytes()); + let s_b64 = B64_URL_NO_PAD.encode(sig.to_bytes()); + let proof = format!("{h_b64}.{p_b64}.{s_b64}"); + + // Compute canonical thumbprint the same way verify() does + let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x_b64}","y":"{y_b64}"}}"#,); + let jkt = B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes())); + + (proof, jkt) + } + + fn ctx<'a>( + htm: &'a str, + htu: &'a str, + now: i64, + expected_jkt: Option<&'a str>, + ) -> DpopRequestContext<'a> { + DpopRequestContext { + htm, + htu, + now_secs: now, + expected_jkt, + } + } + + #[test] + fn accepts_happy_path() { + let sk = test_key(1); + let (proof, jkt) = make_proof( + &sk, + "GET", + "https://oxi.example/api/me", + 1_000_000, + "jti-1", + None, + None, + None, + ); + let out = verify( + &proof, + &ctx("GET", "https://oxi.example/api/me", 1_000_000, Some(&jkt)), + ) + .unwrap(); + assert_eq!(out.jkt, jkt); + assert_eq!(out.jti, "jti-1"); + assert_eq!(out.nonce, None); + } + + #[test] + fn rejects_wrong_htm() { + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "POST", "https://x/a", 1_000_000, "j", None, None, None); + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::WrongHtm); + assert_eq!(err.reason(), "wrong_htm"); + } + + #[test] + fn rejects_wrong_htu() { + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None); + let err = verify(&proof, &ctx("GET", "https://x/b", 1_000_000, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::WrongHtu); + } + + #[test] + fn rejects_wrong_alg() { + let sk = test_key(1); + let (proof, _jkt) = make_proof( + &sk, + "GET", + "https://x/a", + 1_000_000, + "j", + None, + Some("RS256"), + None, + ); + // Wrong alg reject fires BEFORE signature verify (alg is a + // header field we check first). No expected_jkt needed — + // we don't get that far. + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::WrongAlg); + } + + #[test] + fn rejects_wrong_typ() { + let sk = test_key(1); + let (proof, _jkt) = make_proof( + &sk, + "GET", + "https://x/a", + 1_000_000, + "j", + None, + None, + Some("jwt"), + ); + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::WrongTyp); + } + + #[test] + fn rejects_expired_iat_when_no_nonce() { + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None); + // now = iat + 60 → outside ±30s tolerance + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_060, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::IatOutOfWindow); + } + + #[test] + fn accepts_stale_iat_when_nonce_present() { + let sk = test_key(1); + let (proof, jkt) = make_proof( + &sk, + "GET", + "https://x/a", + 1_000_000, + "j", + Some("srv-nonce"), + None, + None, + ); + // now = iat + 10 minutes → would fail bootstrap check, but + // nonce is present → freshness check delegated to nonce + // validity (Gate 5b), so we accept here. + let out = verify(&proof, &ctx("GET", "https://x/a", 1_000_600, Some(&jkt))).unwrap(); + assert_eq!(out.nonce.as_deref(), Some("srv-nonce")); + } + + #[test] + fn rejects_jkt_mismatch() { + let sk = test_key(1); + let (proof, _jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None); + let err = verify( + &proof, + &ctx( + "GET", + "https://x/a", + 1_000_000, + Some("some-other-thumbprint-value"), + ), + ) + .unwrap_err(); + assert_eq!(err, DpopVerifyError::JktMismatch); + } + + #[test] + fn rejects_bad_signature_when_payload_tampered() { + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None); + // Corrupt the middle segment (payload) — signature will no + // longer verify against the tampered signing-input bytes. + let mut parts: Vec<&str> = proof.split('.').collect(); + parts[1] = "bm90LWEtcmVhbC1wYXlsb2Fk"; // "not-a-real-payload" + let tampered = parts.join("."); + let err = verify(&tampered, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::SignatureInvalid); + } + + #[test] + fn rejects_malformed_jws() { + // Only 2 segments + let err = verify("aa.bb", &ctx("GET", "https://x/a", 0, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::Malformed); + // 4 segments + let err = verify("a.b.c.d", &ctx("GET", "https://x/a", 0, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::Malformed); + // Bad base64 + let err = verify("!!.??.@@", &ctx("GET", "https://x/a", 0, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::Malformed); + } + + #[test] + fn missing_jti_is_rejected() { + // Build a proof with an empty jti — verify() rejects because + // it's essential for the replay cache to key on. + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "", None, None, None); + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::JtiMissing); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index e912d60f..f1e96a52 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -10,6 +10,8 @@ pub mod compression_service; pub mod consistency_batch_service; pub mod db_pool_monitor; pub mod dedup_service; +pub mod dpop_nonce_service; +pub mod dpop_verifier; pub mod drives_consistency_service; pub mod encrypted_blob_backend; pub mod entry_backend; diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs new file mode 100644 index 00000000..45422d0a --- /dev/null +++ b/src/interfaces/middleware/dpop.rs @@ -0,0 +1,302 @@ +//! DPoP proof enforcement middleware (RFC 9449). +//! +//! Runs AFTER the auth middleware — reads the `CurrentUser` +//! extension to know the caller has an authenticated session, and +//! validates the `DPoP` header against the session's stored JWK +//! thumbprint (`session.dpop_jkt`). +//! +//! Mode dispatch (from `OXICLOUD_DPOP_MODE`): +//! * **Off** — pass-through, no work. Safe default. +//! * **Opportunistic** — verify when present, allow when absent. +//! Rollout mode: catches client bugs before enforcement. +//! * **Required** — bound sessions MUST present a valid proof. +//! Unbound sessions (`dpop_jkt IS NULL`) remain exempt (app +//! passwords, legacy). +//! +//! Failure response shape mirrors RFC 9449 §7.1: +//! * generic bad proof → `401` + `WWW-Authenticate: DPoP +//! error="invalid_dpop_proof"` +//! * nonce missing / stale (Gate 5b) → `401` + +//! `WWW-Authenticate: DPoP error="use_dpop_nonce"` + +//! `DPoP-Nonce: ` — client retries once, transparently. +//! +//! Body is JSON `{"error_type": "DpopVerificationFailed"}` in both +//! cases (anti-enumeration: same shape regardless of reason; the +//! audit line carries the machine-readable reason). +//! +//! Every response — success OR failure — also gets a `DPoP-Nonce` +//! header pointing at the currently-fresh server-issued nonce. +//! 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. + +use axum::extract::{OriginalUri, Request, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use std::sync::Arc; + +use crate::common::config::DpopMode; +use crate::common::di::AppState; +use crate::infrastructure::services::dpop_verifier::{ + DpopRequestContext, DpopVerifyError, verify as verify_proof, +}; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUser; + +/// Resolve the request's external `(scheme, host)` — what the client +/// sees the URL as, which is what its DPoP proof's `htu` was built +/// from. Behind a reverse proxy, the internal request scheme + +/// authority differ from the external ones; without normalising here +/// the verifier fires `wrong_htu` on every request. +/// +/// Priority chain (RFC 7239-adjacent — mirror what oxicloud audit +/// spans use for `client_ip`): +/// 1. `X-Forwarded-Proto` + `X-Forwarded-Host` +/// 2. `Host` header with scheme inferred from `is_https` request +/// 3. Fallback (`http` + `localhost`) — dev-only, unrepresentative +/// +/// NB: no trust-boundary check here. If your deployment lets +/// arbitrary clients set `X-Forwarded-*`, they can already forge +/// audit-log IPs everywhere else — that's an operator responsibility +/// solved by the trusted-proxy config, not this helper. +fn external_scheme_host(headers: &HeaderMap) -> (String, String) { + let scheme = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim().to_owned()) + .unwrap_or_else(|| "http".to_owned()); + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host")) + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim().to_owned()) + .unwrap_or_else(|| "localhost".to_owned()); + (scheme, host) +} + +/// Middleware entry point mounted on authenticated `/api/*` subtrees. +pub async fn require_dpop_layer( + State(state): State>, + request: Request, + next: Next, +) -> Response { + let mode = state.core.config.auth.dpop_mode; + if mode == DpopMode::Off { + return next.run(request).await; + } + let nonce_service = state.dpop_nonce_service.clone(); + + // No authenticated user → pass through (upstream auth layer + // already handled or will handle the 401). We only concern + // ourselves with proof-carrying requests on authenticated paths. + let Some(_current_user) = request.extensions().get::>() else { + return next.run(request).await; + }; + + let dpop_header = request + .headers() + .get("DPoP") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + + // GATE 5 SCOPE: session-level `dpop_jkt` lookup is deferred (see + // module docstring). For opportunistic mode: + // - proof present → verify with expected_jkt=None (accept any + // jkt shape; still catches malformed/bad-sig/wrong-htu bugs) + // - proof absent → pass through, no warning yet + // For required mode: same for now — Gate 9 flips to real + // per-session enforcement once session context is wired. + let Some(proof) = dpop_header else { + let response = next.run(request).await; + return stamp_current_nonce(response, &nonce_service); + }; + + // Build canonical htu — external scheme + host (`X-Forwarded-*` + // aware) + OriginalUri path (nest-strip-safe). Query stripped + // per RFC 9449 §4.2. + let (scheme, host) = external_scheme_host(request.headers()); + let path = request + .extensions() + .get::() + .map(|u| u.0.path().to_owned()) + .unwrap_or_else(|| request.uri().path().to_owned()); + let htu = format!("{scheme}://{host}{path}"); + + let method = request.method().as_str().to_owned(); + let now_secs = chrono::Utc::now().timestamp(); + + let ctx = DpopRequestContext { + htm: &method, + htu: &htu, + now_secs, + expected_jkt: None, // Session-level pin comes in a later gate + }; + match verify_proof(&proof, &ctx) { + Ok(verified) => { + // Nonce validation: the verifier extracted the claim; if + // 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() { + Some(n) if !nonce_service.is_valid(n) => { + tracing::info!( + target: "audit", + event = "dpop.verify_failed", + reason = "nonce_stale", + method = %method, + htu = %htu, + "👮🏻‍♂️ DPoP nonce stale — issuing challenge", + ); + return nonce_challenge_response(&nonce_service); + } + None => { + // No nonce presented at all → challenge so the + // NEXT request carries one. The ±30s bootstrap + // window at the verifier means this one still + // succeeded, but we still want the client onto + // the nonce path immediately. + return nonce_challenge_response(&nonce_service); + } + _ => {} + } + let response = next.run(request).await; + stamp_current_nonce(response, &nonce_service) + } + Err(err) => { + tracing::info!( + target: "audit", + event = "dpop.verify_failed", + reason = err.reason(), + method = %method, + htu = %htu, + "👮🏻‍♂️ DPoP proof rejected", + ); + dpop_verification_failed_response(err, &nonce_service) + } + } +} + +/// Stamp the currently-fresh nonce onto the outgoing response so +/// the client sees it and caches it for its next request. Called +/// on EVERY successful passthrough — the client's fetch interceptor +/// keeps its cached nonce in sync automatically. +fn stamp_current_nonce( + mut response: Response, + nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService, +) -> Response { + let fresh = nonce_service.current_or_rotate(); + if let Ok(hv) = HeaderValue::from_str(&fresh) { + response.headers_mut().insert("DPoP-Nonce", hv); + } + response +} + +/// Build a `use_dpop_nonce` challenge response — 401 + +/// WWW-Authenticate + DPoP-Nonce carrying a fresh nonce. The SPA +/// fetch interceptor (Gate 4) auto-retries once with the new nonce +/// so users don't experience a visible failure. +fn nonce_challenge_response( + nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService, +) -> Response { + let mut resp = AppError::new( + StatusCode::UNAUTHORIZED, + "DPoP nonce required", + "DpopVerificationFailed", + ) + .into_response(); + resp.headers_mut().insert( + "WWW-Authenticate", + HeaderValue::from_static(r#"DPoP error="use_dpop_nonce""#), + ); + let fresh = nonce_service.current_or_rotate(); + if let Ok(hv) = HeaderValue::from_str(&fresh) { + resp.headers_mut().insert("DPoP-Nonce", hv); + } + resp +} + +/// Build the standardised 401 response for a rejected DPoP proof. +/// Response shape: RFC 9449 §7.1 `WWW-Authenticate: DPoP error="…"` +/// plus OxiCloud's `error_type` JSON body so the SPA can key off it. +/// Also carries a fresh `DPoP-Nonce` so a client whose failure was +/// nonce-shaped (rare after this refactor, but future error paths +/// might need it) can retry immediately. +fn dpop_verification_failed_response( + err: DpopVerifyError, + nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService, +) -> Response { + let mut resp = AppError::new( + StatusCode::UNAUTHORIZED, + "DPoP proof verification failed", + "DpopVerificationFailed", + ) + .into_response(); + // Static: `WWW-Authenticate` schemes stay stable across errors. + // Only the audit `reason` field varies (already emitted). + let www_auth = HeaderValue::from_static(r#"DPoP error="invalid_dpop_proof""#); + resp.headers_mut().insert("WWW-Authenticate", www_auth); + let fresh = nonce_service.current_or_rotate(); + if let Ok(hv) = HeaderValue::from_str(&fresh) { + resp.headers_mut().insert("DPoP-Nonce", hv); + } + // Silence unused-parameter lint — err is captured in the audit + // line at the callsite; this fn intentionally maps ALL failures + // to the same client-facing shape (anti-enumeration). + let _ = err; + resp +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderMap; + + #[test] + fn external_scheme_host_prefers_forwarded_headers() { + let mut h = HeaderMap::new(); + h.insert("x-forwarded-proto", HeaderValue::from_static("https")); + h.insert("x-forwarded-host", HeaderValue::from_static("oxi.example")); + h.insert("host", HeaderValue::from_static("internal:8086")); + assert_eq!( + external_scheme_host(&h), + ("https".to_owned(), "oxi.example".to_owned()) + ); + } + + #[test] + fn external_scheme_host_falls_back_to_host_header() { + let mut h = HeaderMap::new(); + h.insert("host", HeaderValue::from_static("localhost:5173")); + assert_eq!( + external_scheme_host(&h), + ("http".to_owned(), "localhost:5173".to_owned()) + ); + } + + #[test] + fn external_scheme_host_takes_leftmost_of_forwarded_chain() { + // Multiple hops → `X-Forwarded-*` becomes a comma-separated + // list. RFC 7239 says the leftmost is the original client. + let mut h = HeaderMap::new(); + h.insert("x-forwarded-proto", HeaderValue::from_static("https, http")); + h.insert( + "x-forwarded-host", + HeaderValue::from_static("oxi.example, internal"), + ); + assert_eq!( + external_scheme_host(&h), + ("https".to_owned(), "oxi.example".to_owned()) + ); + } + + #[test] + fn external_scheme_host_defaults_when_empty() { + let h = HeaderMap::new(); + assert_eq!( + external_scheme_host(&h), + ("http".to_owned(), "localhost".to_owned()) + ); + } +} diff --git a/src/interfaces/middleware/mod.rs b/src/interfaces/middleware/mod.rs index 8dbe66e3..6d4de627 100644 --- a/src/interfaces/middleware/mod.rs +++ b/src/interfaces/middleware/mod.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod auth; pub mod csrf; +pub mod dpop; pub mod locale; pub mod rate_limit; pub mod server_status;