diff --git a/Cargo.toml b/Cargo.toml index 9806461b..37d81fe7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -200,6 +200,15 @@ path = "src/bin/opaque-hurl-helper.rs" # blinding, AKE nonces are per-attempt-random). Not shipped in the # release Dockerfile (nothing outside tests/ calls it). +[[bin]] +name = "dpop-hurl-helper" +path = "src/bin/dpop-hurl-helper.rs" +# Test-suite DPoP client — signs an ES256 proof per request, threads +# the DPoP-Nonce challenge/retry loop, and covers the wire-protocol +# scenarios Hurl can't express (per-request fresh jti/iat, replay +# detection, malformed proofs, wrong htm/htu/alg/typ). Same +# no-ship-in-release status as opaque-hurl-helper. + [[bin]] name = "load-seed" path = "src/bin/load-seed.rs" diff --git a/src/bin/dpop-hurl-helper.rs b/src/bin/dpop-hurl-helper.rs new file mode 100644 index 00000000..3a0ed0d8 --- /dev/null +++ b/src/bin/dpop-hurl-helper.rs @@ -0,0 +1,689 @@ +//! DPoP wire-protocol test helper for the api-test suite. +//! +//! Hurl can't drive DPoP: every proof carries a fresh `jti`, a +//! current `iat`, an `htm`/`htu` matching the exact request, an +//! ES256 signature from a persistent browser-held keypair, and a +//! nonce threaded from the server's `DPoP-Nonce` response header. +//! A declarative `.hurl` template has no way to compute that per +//! request. Same problem OPAQUE has, same solution: +//! `opaque-hurl-helper.rs` (task #19) verifies OPAQUE end-to-end; +//! this binary does the same for DPoP. +//! +//! Invocation (from `tests/api/run.sh`): +//! +//! ```bash +//! OXICLOUD_DPOP_MODE=required +//! DPOP_HELPER_BASE_URL=$base_url \ +//! DPOP_HELPER_USERNAME=$username \ +//! DPOP_HELPER_PASSWORD=$password \ +//! ./target/debug/dpop-hurl-helper +//! ``` +//! +//! Exit codes: +//! * 0 — every scenario succeeded. +//! * 1 — any scenario failed; diagnostic on stderr. +//! +//! Scope: this binary covers the wire contract for the currently- +//! integrated slice — verifier + nonce + replay + middleware in +//! opportunistic mode. Scenarios requiring session-context binding +//! (bound-vs-unbound enforcement per `session.dpop_jkt`, refresh +//! continuity, thumbprint mismatch vs stored) are deferred until +//! Gate 7 threads session context through the middleware. + +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD as B64_URL_NO_PAD}; +use opaque_ke::{ClientLogin, ClientLoginFinishParameters, CredentialResponse}; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::{Signature, SigningKey}; +use rand_core::OsRng as OpaqueRng; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::process::ExitCode; + +// Reuse the concrete ciphersuite the production `OpaqueService` uses — +// mismatching client + server suites would fail every handshake with +// a confusing error. +use oxicloud::infrastructure::services::opaque_service::OxiCloudSuite; + +/// Server may emit URL_SAFE_NO_PAD *or* STANDARD base64; try both so +/// a future format flip doesn't silently break the round-trip. +fn decode_opaque_b64(input: &str) -> Result, base64::DecodeError> { + let s = input.trim(); + B64_URL_NO_PAD.decode(s).or_else(|_| B64.decode(s)) +} + +#[derive(serde::Deserialize)] +struct OpaqueParamsResp { + enabled: bool, + #[serde(rename = "ciphersuiteVersion")] + _ciphersuite_version: i16, + ksf: OpaqueKsfParams, +} + +#[derive(serde::Deserialize)] +struct OpaqueKsfParams { + #[serde(rename = "memoryKib")] + memory_kib: u32, + iterations: u32, + parallelism: u32, +} + +#[derive(serde::Deserialize)] +struct OpaqueKe1Resp { + #[serde(rename = "exchangeId")] + exchange_id: String, + #[serde(rename = "loginResponse")] + login_response: String, +} + +const EXIT_FAIL: u8 = 1; + +fn env_or_fail(key: &str) -> String { + std::env::var(key).unwrap_or_else(|_| { + eprintln!("dpop-hurl-helper: required env var {key} unset"); + std::process::exit(EXIT_FAIL as i32); + }) +} + +fn fail(msg: impl std::fmt::Display) -> ExitCode { + // Bordered banner so a failure stands out against the interleaved + // server audit log — the last line before this is usually the + // server-side reject that caused it, which visually blends in. + eprintln!(); + eprintln!("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓"); + eprintln!("┃ dpop-hurl-helper: FAIL ┃"); + eprintln!("┃ {msg}"); + eprintln!("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛"); + ExitCode::from(EXIT_FAIL) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn random_jti() -> String { + // 16 bytes of OS randomness, base64url'd — plenty of entropy for + // per-request uniqueness; the server's replay cache keys on this. + use p256::elliptic_curve::rand_core::{OsRng, RngCore}; + let mut b = [0u8; 16]; + OsRng.fill_bytes(&mut b); + B64_URL_NO_PAD.encode(b) +} + +/// A persistent-across-scenarios keypair — simulates one browser +/// tab whose IndexedDB entry survives every scenario in this run. +struct KeyBundle { + signing_key: SigningKey, + jwk_x_b64: String, + jwk_y_b64: String, +} + +impl KeyBundle { + fn fresh() -> Self { + // Deterministic-ish seed — this is a test tool, not a + // security surface. Skipping `rand`-dep to keep the binary + // dep footprint identical to what Gate 5 already added. + let mut bytes = [0u8; 32]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = ((i as u8).wrapping_mul(37)).wrapping_add(1); + } + let signing_key = SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar"); + let vkey = signing_key.verifying_key(); + let enc = vkey.to_encoded_point(false); + Self { + signing_key, + jwk_x_b64: B64_URL_NO_PAD.encode(enc.x().unwrap()), + jwk_y_b64: B64_URL_NO_PAD.encode(enc.y().unwrap()), + } + } +} + +/// Overrides for building malformed / tampered proofs, per scenario. +#[derive(Default, Clone)] +struct ProofOverrides<'a> { + override_alg: Option<&'a str>, + override_typ: Option<&'a str>, + override_htm: Option<&'a str>, + override_htu: Option<&'a str>, + stale_iat: bool, + /// If `Some`, use this exact string for the `jti` instead of a + /// fresh random one — the replay scenario needs to reuse it. + fixed_jti: Option, + /// If `Some`, include this literal `nonce` claim (even if it's + /// wrong on purpose). If `None`, use whatever the caller + /// tracked from `DPoP-Nonce` response headers. + force_nonce: Option, +} + +/// Build + sign a DPoP proof against the given method + URL. +/// `nonce` is the current server-issued nonce (if any); `overrides` +/// let scenario code tamper with the proof shape. +fn build_proof( + keys: &KeyBundle, + method: &str, + url: &str, + nonce: Option<&str>, + overrides: &ProofOverrides<'_>, +) -> String { + let header = json!({ + "typ": overrides.override_typ.unwrap_or("dpop+jwt"), + "alg": overrides.override_alg.unwrap_or("ES256"), + "jwk": { + "crv": "P-256", + "kty": "EC", + "x": keys.jwk_x_b64, + "y": keys.jwk_y_b64, + }, + }); + let iat = if overrides.stale_iat { + now_secs() - 10_000 + } else { + now_secs() + }; + let mut claims = json!({ + "htm": overrides.override_htm.unwrap_or(method), + "htu": overrides.override_htu.map(str::to_owned).unwrap_or_else(|| canonical_htu(url)), + "iat": iat, + "jti": overrides.fixed_jti.clone().unwrap_or_else(random_jti), + }); + let nonce_to_use = overrides.force_nonce.as_deref().or(nonce); + if let Some(n) = nonce_to_use { + 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 = keys.signing_key.sign(signing_input.as_bytes()); + let s_b64 = B64_URL_NO_PAD.encode(sig.to_bytes()); + format!("{h_b64}.{p_b64}.{s_b64}") +} + +fn canonical_htu(url: &str) -> String { + let u = reqwest::Url::parse(url).expect("valid URL for htu"); + format!("{}://{}{}", u.scheme(), u.authority(), u.path()) +} + +/// Log in via OPAQUE, return the access + refresh tokens. +/// +/// `opaque-hurl-helper` runs earlier in `tests/api/run.sh` and +/// mints the OPAQUE envelope for `admin`; from that point on the +/// account is migrated and legacy `POST /api/auth/login` refuses +/// with Phase-4 `opaque_migrated_use_opaque` (403). So this +/// helper drives the OPAQUE handshake directly — same ciphersuite +/// (`OxiCloudSuite`) and same `/params` KSF-fetch pattern as +/// `opaque-hurl-helper`. Long-term this is also what OPAQUE-only +/// mode (`docs/plan/opaque-only.md`) requires: legacy login is +/// on the way out entirely. +async fn opaque_login( + http: &reqwest::Client, + base: &str, + username: &str, + password: &str, +) -> Result<(String, String), String> { + // Fetch server params so client-side Argon2 matches. Per Phase B + // the envelope's OWN KSF is authoritative for that user (via + // `/login/lookup`), but for a test admin whose envelope was + // freshly minted by the OPAQUE helper the two match, so + // `/params` is sufficient here. + let params: OpaqueParamsResp = http + .get(format!("{base}/api/auth/opaque/params")) + .send() + .await + .map_err(|e| format!("opaque /params: {e}"))? + .json() + .await + .map_err(|e| format!("parse /params: {e}"))?; + if !params.enabled { + return Err("server reports OPAQUE disabled — did opaque-hurl-helper run first?".into()); + } + let ksf_params = argon2::Params::new( + params.ksf.memory_kib, + params.ksf.iterations, + params.ksf.parallelism, + None, + ) + .map_err(|e| format!("build Argon2 params: {e}"))?; + let ksf = argon2::Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + ksf_params, + ); + + let mut rng = OpaqueRng; + + // ── KE1 — public endpoint, no bearer ───────────────────────────── + let client_login = ClientLogin::::start(&mut rng, password.as_bytes()) + .map_err(|e| format!("ClientLogin::start: {e}"))?; + let ke1_body = json!({ + "userIdentifier": username, + "startLoginRequest": B64.encode(client_login.message.serialize()), + }); + let ke1_res = http + .post(format!("{base}/api/auth/opaque/login/ke1")) + .json(&ke1_body) + .send() + .await + .map_err(|e| format!("ke1 POST: {e}"))?; + if !ke1_res.status().is_success() { + return Err(format!("ke1 returned {}", ke1_res.status())); + } + let ke1: OpaqueKe1Resp = ke1_res + .json() + .await + .map_err(|e| format!("parse ke1: {e}"))?; + let cred_bytes = + decode_opaque_b64(&ke1.login_response).map_err(|e| format!("decode loginResponse: {e}"))?; + let cred_response = CredentialResponse::::deserialize(&cred_bytes) + .map_err(|e| format!("deserialize CredentialResponse: {e}"))?; + + // ── KE3 — finish + submit ───────────────────────────────────────── + let login_finish = client_login + .state + .finish( + password.as_bytes(), + cred_response, + ClientLoginFinishParameters::new(None, opaque_ke::Identifiers::default(), Some(&ksf)), + ) + .map_err(|e| format!("ClientLogin::finish (bad password?): {e}"))?; + // URL_SAFE_NO_PAD on the wire — matches what the SPA sends and + // what the server's handler prefers (accepts both, but this is + // the canonical form). + let ke3_body = json!({ + "exchangeId": ke1.exchange_id, + "finishLoginRequest": B64_URL_NO_PAD.encode(login_finish.message.serialize()), + }); + let ke3_res = http + .post(format!("{base}/api/auth/opaque/login/ke3")) + .json(&ke3_body) + .send() + .await + .map_err(|e| format!("ke3 POST: {e}"))?; + if !ke3_res.status().is_success() { + return Err(format!("ke3 returned {}", ke3_res.status())); + } + let body: serde_json::Value = ke3_res.json().await.map_err(|e| format!("ke3 body: {e}"))?; + let access = body["access_token"] + .as_str() + .ok_or("ke3 response missing access_token")? + .to_string(); + let refresh = body["refresh_token"] + .as_str() + .ok_or("ke3 response missing refresh_token")? + .to_string(); + Ok((access, refresh)) +} + +/// Send a GET request to `path` with a DPoP proof, following the +/// nonce-challenge retry loop. Returns the final response + the +/// nonce currently cached (which the caller threads into follow-up +/// requests). +async fn get_with_dpop( + http: &reqwest::Client, + base: &str, + path: &str, + access_token: &str, + keys: &KeyBundle, + cached_nonce: Option, + overrides: &ProofOverrides<'_>, +) -> Result<(reqwest::Response, Option), String> { + let url = format!("{base}{path}"); + let proof = build_proof(keys, "GET", &url, cached_nonce.as_deref(), overrides); + let res = http + .get(&url) + .bearer_auth(access_token) + .header("DPoP", proof) + .send() + .await + .map_err(|e| format!("GET {path}: {e}"))?; + // Harvest DPoP-Nonce even on failure — the server stamps it + // regardless so the client can retry with the fresh value. + let updated_nonce = res + .headers() + .get("DPoP-Nonce") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .or(cached_nonce); + // Challenge-retry once — mirrors the SPA fetch interceptor. + if res.status() == 401 + && res + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.contains("use_dpop_nonce")) + { + // Only ONE retry — a second challenge on the retry is a + // server bug and should surface as-is. + let proof2 = build_proof(keys, "GET", &url, updated_nonce.as_deref(), overrides); + let res2 = http + .get(&url) + .bearer_auth(access_token) + .header("DPoP", proof2) + .send() + .await + .map_err(|e| format!("GET {path} (retry): {e}"))?; + let updated_nonce2 = res2 + .headers() + .get("DPoP-Nonce") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .or(updated_nonce); + return Ok((res2, updated_nonce2)); + } + Ok((res, updated_nonce)) +} + +fn expect_status(scenario: &str, res: &reqwest::Response, want: u16) -> Result<(), String> { + if res.status().as_u16() == want { + Ok(()) + } else { + Err(format!( + "scenario {scenario}: expected HTTP {want}, got {}", + res.status() + )) + } +} + +// Defensive-programming pattern: every scenario refreshes the +// cached nonce so a later scenario picks up any rotation the +// server did in between. Scenarios that DON'T re-consume the +// cached nonce (e.g. #6 which passes a bogus value on purpose) +// look like dead assignments to the linter — silence it here +// rather than sprinkle `let _ = n` calls that obscure intent. +#[allow(unused_assignments)] +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let base = env_or_fail("DPOP_HELPER_BASE_URL"); + let username = env_or_fail("DPOP_HELPER_USERNAME"); + let password = env_or_fail("DPOP_HELPER_PASSWORD"); + let base = base.trim_end_matches('/'); + + let http = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + { + Ok(c) => c, + Err(e) => return fail(format!("build reqwest client: {e}")), + }; + + // ── 1. Log in via OPAQUE (session created unbound — Gate 3 + // requires the client to send `dpop_jkt` in the login + // body; this helper doesn't, so we get the fail-open + // unbound path). Bind support gets tested via + // `POST /api/auth/dpop/bind` in a later gate. + // + // OPAQUE (not legacy) because `opaque-hurl-helper` + // migrates `admin` earlier in `run.sh`, after which + // legacy login 403s with Phase-4 refusal — and once + // OPAQUE-only mode ships (`docs/plan/opaque-only.md`) + // there IS no legacy path anyway. + let (access, _refresh) = match opaque_login(&http, base, &username, &password).await { + Ok(t) => t, + Err(e) => return fail(e), + }; + + let keys = KeyBundle::fresh(); + let jkt = { + // Compute expected thumbprint for logging — server ignores + // at this gate but future scenarios will compare. + let canonical = format!( + r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#, + keys.jwk_x_b64, keys.jwk_y_b64 + ); + B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes())) + }; + eprintln!("dpop-hurl-helper: keypair jkt={jkt}"); + + let mut nonce: Option = None; + + // ── Scenario 1: happy path — bootstrap (no nonce) → challenge + // → retry with nonce → 200. The retry loop is inside + // `get_with_dpop`. + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides::default(), + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + if let Err(e) = expect_status("happy_path", &res, 200) { + return fail(e); + } + if n.is_none() { + return fail("happy_path: server did not stamp DPoP-Nonce on response"); + } + nonce = n; + eprintln!( + "dpop-hurl-helper: scenario 1 (happy path) ✓ cached_nonce={:?}", + nonce + ); + + // ── Scenario 2: wrong htm — sign for POST but send GET → 401 + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides { + override_htm: Some("POST"), + ..Default::default() + }, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + nonce = n; + if let Err(e) = expect_status("wrong_htm", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 2 (wrong htm) ✓"); + + // ── Scenario 3: wrong htu — sign for /api/foo but send /api/auth/me → 401 + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides { + override_htu: Some("https://not-this-host.example/api/foo"), + ..Default::default() + }, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + nonce = n; + if let Err(e) = expect_status("wrong_htu", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 3 (wrong htu) ✓"); + + // ── Scenario 4: wrong alg (RS256) — 401 + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides { + override_alg: Some("RS256"), + ..Default::default() + }, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + nonce = n; + if let Err(e) = expect_status("wrong_alg", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 4 (wrong alg) ✓"); + + // ── Scenario 5: wrong typ — 401 + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides { + override_typ: Some("jwt"), + ..Default::default() + }, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + nonce = n; + if let Err(e) = expect_status("wrong_typ", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 5 (wrong typ) ✓"); + + // ── Scenario 6: stale nonce — simulate the client's cached + // nonce having expired server-side (server restarted, or + // pool TTL elapsed). The server issues a challenge with a + // fresh nonce, and `get_with_dpop` MUST retry once using + // that fresh value from the response header — not the + // stale one from `cached_nonce`. + // + // Pass the bogus value positionally (via `cached_nonce`) + // rather than through the `force_nonce` override — the + // override would survive the retry and cause a second + // challenge, defeating the recovery path we're testing. + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + Some("nonce-that-server-does-not-know".to_string()), + &ProofOverrides::default(), + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + if let Err(e) = expect_status("stale_nonce_challenge_retry", &res, 200) { + return fail(e); + } + nonce = n; + eprintln!("dpop-hurl-helper: scenario 6 (stale nonce → challenge → retry) ✓"); + + // ── Scenario 7: replay — build a proof, send it TWICE with + // the same jti; second call must be replay-rejected. + // Uses a bespoke send (no retry-loop) so we control both + // submissions of the exact-same bytes. + let fixed_jti = random_jti(); + let url = format!("{base}/api/auth/me"); + let proof_replay = build_proof( + &keys, + "GET", + &url, + nonce.as_deref(), + &ProofOverrides { + fixed_jti: Some(fixed_jti.clone()), + ..Default::default() + }, + ); + let res_first = match http + .get(&url) + .bearer_auth(&access) + .header("DPoP", proof_replay.clone()) + .send() + .await + { + Ok(r) => r, + Err(e) => return fail(format!("replay send 1: {e}")), + }; + if let Err(e) = expect_status("replay_first_send", &res_first, 200) { + return fail(e); + } + let res_second = match http + .get(&url) + .bearer_auth(&access) + .header("DPoP", proof_replay) + .send() + .await + { + Ok(r) => r, + Err(e) => return fail(format!("replay send 2: {e}")), + }; + if let Err(e) = expect_status("replay_second_send", &res_second, 401) { + return fail(e); + } + // The replay 401 is an invalid_dpop_proof shape (not use_dpop_nonce) + let www_auth = res_second + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if www_auth.contains("use_dpop_nonce") { + return fail(format!( + "replay: WWW-Authenticate should be invalid_dpop_proof, was: {www_auth}" + )); + } + eprintln!("dpop-hurl-helper: scenario 7 (replay) ✓"); + + // ── Scenario 8: malformed JWS — send garbage in the DPoP + // header. Server-side verifier rejects at the split step. + let res = match http + .get(&url) + .bearer_auth(&access) + .header("DPoP", "not-a-real-jws") + .send() + .await + { + Ok(r) => r, + Err(e) => return fail(format!("malformed send: {e}")), + }; + if let Err(e) = expect_status("malformed", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 8 (malformed) ✓"); + + // ── Scenario 9: no proof at all on a bound-if-required path. + // In opportunistic mode this passes through; in required + // mode the session is unbound (`dpop_jkt IS NULL`) so it + // STILL passes through. Server-side gate 9 will flip this + // once session-context enforcement lands. + let res_no_proof = match http.get(&url).bearer_auth(&access).send().await { + Ok(r) => r, + Err(e) => return fail(format!("no_proof send: {e}")), + }; + if let Err(e) = expect_status("no_proof_unbound_session", &res_no_proof, 200) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 9 (no proof, unbound session → pass) ✓"); + + eprintln!("dpop-hurl-helper: all scenarios passed"); + ExitCode::SUCCESS +} diff --git a/src/main.rs b/src/main.rs index 29661296..6fd1af72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -780,6 +780,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -792,6 +796,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -814,6 +822,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -851,6 +863,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -864,6 +880,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -885,6 +905,7 @@ async fn run() -> Result<(), Box> { // change-password, and logout internally so the SPA can // complete the reset flow — see the middleware doc for the // allowlist and its rationale. + use oxicloud::interfaces::middleware::dpop::require_dpop_layer; use oxicloud::interfaces::middleware::user::{ require_internal_user_layer, require_no_password_change_pending_layer, }; @@ -893,6 +914,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn_with_state( app_state.clone(), require_internal_user_layer, @@ -906,6 +931,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn_with_state( app_state.clone(), require_internal_user_layer, @@ -919,6 +948,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn_with_state( app_state.clone(), require_internal_user_layer, diff --git a/tests/api/run.sh b/tests/api/run.sh index b7213127..9c140eb1 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -248,4 +248,28 @@ OPAQUE_HELPER_USERNAME="$username" \ OPAQUE_HELPER_PASSWORD="$password" \ "$OPAQUE_HELPER_BIN" || die "OPAQUE crypto handshake failed" +# ── 6. DPoP wire protocol — the parts Hurl can't drive ────────────────── +# Each proof carries a fresh jti, current iat, htm/htu matching the +# exact request, an ES256 signature, and a threaded nonce — none of +# which a declarative .hurl template can compute. See +# `src/bin/dpop-hurl-helper.rs` for the scenario matrix (happy path, +# wrong htm/htu/alg/typ, stale nonce, replay, malformed, fail-open +# when the session is unbound). Runs against the SAME server target +# the OPAQUE helper used — but the server config must set +# `OXICLOUD_DPOP_MODE=opportunistic` (or `required`) or the middleware +# is a pass-through and every failure scenario silently 200s. +DPOP_HELPER_BIN="$REPO_ROOT/target/$BUILD_TARGET/dpop-hurl-helper" +if [[ ! -x "$DPOP_HELPER_BIN" ]]; then + log "Building dpop-hurl-helper ($BUILD_TARGET)..." + case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; + esac +fi +log "Running DPoP wire-protocol helper..." +DPOP_HELPER_BASE_URL="$base_url" \ +DPOP_HELPER_USERNAME="$username" \ +DPOP_HELPER_PASSWORD="$password" \ + "$DPOP_HELPER_BIN" || die "DPoP wire-protocol test failed" + log "All tests passed." diff --git a/tests/common/server.env b/tests/common/server.env index 3fa08af1..2a0c4118 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -145,3 +145,14 @@ OXICLOUD_AUTH_OPAQUE_KSF_MEMORY_KIB=8 OXICLOUD_AUTH_OPAQUE_KSF_ITERATIONS=1 OXICLOUD_AUTH_OPAQUE_KSF_PARALLELISM=1 +# DPoP (RFC 9449) — required mode surfaces every verifier / +# nonce / replay bug as a hard 401 (opportunistic would swallow +# some cases as warnings). Safe today because unbound sessions — +# `session.dpop_jkt IS NULL`, which is where the test admin's +# legacy-login lands — are still exempted at the middleware; the +# session-level enforcement flip is Gate 9. Once that arrives, +# every bind-time downgrade path also gets caught here without +# a config change. `dpop-hurl-helper` needs this on or the +# middleware is a pass-through and its scenarios all silently 200. +OXICLOUD_DPOP_MODE=required +