750 lines
26 KiB
Rust
750 lines
26 KiB
Rust
//! 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<Vec<u8>, 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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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,
|
|
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
|
|
// `/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::<OxiCloudSuite>::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::<OxiCloudSuite>::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).
|
|
// 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)
|
|
.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<String>,
|
|
overrides: &ProofOverrides<'_>,
|
|
) -> Result<(reqwest::Response, Option<String>), 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. 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 there IS no legacy path anyway.
|
|
let keys = KeyBundle::fresh();
|
|
let jkt = {
|
|
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 (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
|
|
// → 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: 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_bound_session_required", &res_no_proof, 401) {
|
|
return fail(e);
|
|
}
|
|
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
|
|
}
|