feat(DPoP): check requests and 401 on failure

This commit is contained in:
Edouard Vanbelle
2026-08-08 19:55:18 +02:00
parent ed99b08e62
commit 8d6e03a4bb
11 changed files with 283 additions and 63 deletions
+82 -22
View File
@@ -226,6 +226,7 @@ async fn opaque_login(
base: &str,
username: &str,
password: &str,
dpop_jkt: Option<&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
@@ -295,10 +296,19 @@ async fn opaque_login(
// 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!({
// Include `dpopJkt` when set so the server binds the session
// to the browser-held (well, test-held) keypair via Gate 3.
// Downstream scenarios can then exercise bound-session paths.
let mut ke3_body = json!({
"exchangeId": ke1.exchange_id,
"finishLoginRequest": B64_URL_NO_PAD.encode(login_finish.message.serialize()),
});
if let Some(jkt) = dpop_jkt {
ke3_body
.as_object_mut()
.unwrap()
.insert("dpopJkt".to_string(), json!(jkt));
}
let ke3_res = http
.post(format!("{base}/api/auth/opaque/login/ke3"))
.json(&ke3_body)
@@ -412,26 +422,19 @@ async fn main() -> ExitCode {
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.
// ── 1. Mint the persistent keypair FIRST, compute its JWK
// thumbprint, then log in via OPAQUE passing that
// thumbprint so the resulting session is bound (Gate 3).
// That way subsequent scenarios exercise the bound-path
// enforcement Gate 9 lit up: bound session + missing
// proof → 401, wrong-jkt proof → 401 `jkt_mismatch`.
//
// 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),
};
// OPAQUE-only mode ships there IS no legacy path anyway.
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
@@ -440,6 +443,12 @@ async fn main() -> ExitCode {
};
eprintln!("dpop-hurl-helper: keypair jkt={jkt}");
let (access, _refresh) = match opaque_login(&http, base, &username, &password, Some(&jkt)).await
{
Ok(t) => t,
Err(e) => return fail(e),
};
let mut nonce: Option<String> = None;
// ── Scenario 1: happy path — bootstrap (no nonce) → challenge
@@ -670,19 +679,70 @@ async fn main() -> ExitCode {
}
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.
// ── Scenario 9: bound session with NO proof — Gate 9
// enforcement. Under `required` mode this must 401 with a
// `use_dpop_nonce` challenge (server treats missing proof
// on a bound session the same shape as a nonce-challenge
// to nudge the client back onto the DPoP path). Under
// `opportunistic` mode this would 200 with only a warning
// audit line. Test env pins `required` (see
// `tests/common/server.env`).
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) {
if let Err(e) = expect_status("no_proof_bound_session_required", &res_no_proof, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 9 (no proof, unbound session → pass) ✓");
eprintln!("dpop-hurl-helper: scenario 9 (no proof, bound session, required → 401) ✓");
// ── Scenario 10: bound session, valid proof shape but signed
// by a DIFFERENT keypair than the session was bound to.
// Verifier fires `jkt_mismatch` → 401. The classic attacker
// scenario: cookie stolen, attacker mints their own DPoP
// keypair, valid proof shape but wrong key.
let rogue_keys = KeyBundle {
signing_key: {
let mut bytes = [0u8; 32];
for (i, b) in bytes.iter_mut().enumerate() {
*b = ((i as u8).wrapping_mul(41)).wrapping_add(7);
}
SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar")
},
jwk_x_b64: String::new(),
jwk_y_b64: String::new(),
};
// Rebuild x/y for the rogue key.
let rogue_enc = rogue_keys
.signing_key
.verifying_key()
.to_encoded_point(false);
let rogue_keys = KeyBundle {
signing_key: rogue_keys.signing_key,
jwk_x_b64: B64_URL_NO_PAD.encode(rogue_enc.x().unwrap()),
jwk_y_b64: B64_URL_NO_PAD.encode(rogue_enc.y().unwrap()),
};
let rogue_proof = build_proof(
&rogue_keys,
"GET",
&url,
nonce.as_deref(),
&ProofOverrides::default(),
);
let res_rogue = match http
.get(&url)
.bearer_auth(&access)
.header("DPoP", rogue_proof)
.send()
.await
{
Ok(r) => r,
Err(e) => return fail(format!("rogue-key send: {e}")),
};
if let Err(e) = expect_status("wrong_jkt_bound_session", &res_rogue, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 10 (bound session + wrong-jkt proof → 401) ✓");
eprintln!("dpop-hurl-helper: all scenarios passed");
ExitCode::SUCCESS