feat(DPoP): add verification + X-Forwarded-Host X-Forwarded-Proto

This commit is contained in:
Edouard Vanbelle
2026-08-08 15:08:35 +02:00
parent 514bbc35ab
commit 2e6789e506
12 changed files with 879 additions and 21 deletions
Generated
+1
View File
@@ -4490,6 +4490,7 @@ dependencies = [
"nom-exif",
"opaque-ke",
"ort",
"p256",
"pdf-extract",
"percent-encoding",
"quick-xml 0.41.0",
+4
View File
@@ -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"] }
+1 -5
View File
@@ -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>;
}
// ============================================================================
@@ -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",
+59
View File
@@ -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<Self> {
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::<bool>().unwrap_or(false);
}
+9
View File
@@ -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<crate::infrastructure::services::opaque_login_exchange::OpaqueLoginExchange>,
>,
/// 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<crate::infrastructure::services::dpop_nonce_service::DpopNonceService>,
pub nextcloud: Option<NextcloudServices>,
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
/// WASM plugin management (list/install/toggle/remove), backing the admin
@@ -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<()>;
}
@@ -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)
@@ -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<String>,
/// 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<DpopVerified, DpopVerifyError>;
/// ±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);
}
}
+2
View File
@@ -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;
+302
View File
@@ -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: <fresh>` — 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<Arc<AppState>>,
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::<Arc<CurrentUser>>() 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::<OriginalUri>()
.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())
);
}
}
+1
View File
@@ -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;