feat(opaque): add end to end test hurl + binary

This commit is contained in:
Edouard Vanbelle
2026-08-02 22:10:17 +02:00
parent 903d769e03
commit 5418a5bd34
4 changed files with 385 additions and 0 deletions
+9
View File
@@ -182,6 +182,15 @@ path = "src/bin/opaque-setup.rs"
# must be persisted verbatim (rotating invalidates every user's # must be persisted verbatim (rotating invalidates every user's
# registration — see docs/config/authentication.md §OPAQUE). # registration — see docs/config/authentication.md §OPAQUE).
[[bin]]
name = "opaque-hurl-helper"
path = "src/bin/opaque-hurl-helper.rs"
# Test-suite crypto client — drives a full OPAQUE register + login
# handshake against a running server. Invoked from tests/api/run.sh
# after opaque_substrate.hurl to cover the parts Hurl can't (OPRF
# blinding, AKE nonces are per-attempt-random). Not shipped in the
# release Dockerfile (nothing outside tests/ calls it).
[[bin]] [[bin]]
name = "load-seed" name = "load-seed"
path = "src/bin/load-seed.rs" path = "src/bin/load-seed.rs"
+1
View File
@@ -45,6 +45,7 @@ RUN mkdir -p src/bin && \
echo 'fn main() {}' > src/bin/generate-openapi.rs && \ echo 'fn main() {}' > src/bin/generate-openapi.rs && \
echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \ echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \
echo 'fn main() {}' > src/bin/opaque-setup.rs && \ echo 'fn main() {}' > src/bin/opaque-setup.rs && \
echo 'fn main() {}' > src/bin/opaque-hurl-helper.rs && \
cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin opaque-setup && \ cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin opaque-setup && \
rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-* rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-*
+353
View File
@@ -0,0 +1,353 @@
//! OPAQUE crypto client helper for the api-test suite.
//!
//! Hurl can't drive OPAQUE handshakes: every KE1 / KE3 message
//! contains session-random OPRF blinding + AKE nonces that can't be
//! hardcoded in a .hurl body. `opaque_substrate.hurl` therefore
//! covers only the wire shape (401/400/503 error paths, params publish,
//! login lookup, ciphersuite-mismatch). This binary closes the gap by
//! running a full crypto handshake against a live OxiCloud server, so
//! the api-test suite catches regressions on:
//!
//! * handler happy path (KE1 → 200 with exchange_id + response,
//! KE3 → 200 with AuthResponseDto shape),
//! * middleware composition (public login mount does NOT require
//! CSRF or session cookies — a regression would 403 here),
//! * cookie emission on successful KE3 (session cookies are the
//! mechanism the SPA relies on for subsequent authed calls),
//! * `opaque_migrated_at` stamp landing (asserted transitively by
//! the Phase 4 test suite — this binary just proves the login
//! mints a session, without which mark_migrated wouldn't run).
//!
//! Invocation (from `tests/api/run.sh`):
//!
//! ```bash
//! OPAQUE_HELPER_BASE_URL=$base_url \
//! OPAQUE_HELPER_USERNAME=$username \
//! OPAQUE_HELPER_PASSWORD=$password \
//! ./target/debug/opaque-hurl-helper
//! ```
//!
//! Exit codes:
//! * 0 — full register + login handshake succeeded.
//! * 1 — any HTTP failure or crypto mismatch. Diagnostic printed to
//! stderr; wall-clock cost is under 100 ms with the fast Argon2
//! params `tests/common/server.env` publishes.
//!
//! Design constraints:
//! * Reads the KSF params from `/api/auth/opaque/params` so the
//! client matches the server's config. A hardcoded KSF here would
//! silently break the moment operators changed the env vars.
//! * Registers via the session-authenticated endpoint, so first we
//! legacy-login to obtain the bearer token (mirrors the Phase 2
//! silent-migration bootstrap).
//! * Cleans up NOTHING — the server tears down its DB between
//! `run.sh` invocations, and mutating admin's envelope is safe
//! because subsequent hurl files don't assume `hasOpaque=false`.
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as B64;
use opaque_ke::{
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse,
};
use rand_core::OsRng;
use serde_json::json;
use std::process::ExitCode;
// Re-use the concrete ciphersuite the production `OpaqueService` uses;
// mismatching client + server suites would fail every handshake with a
// confusing error, and reproducing that mismatch by hand would rot.
use oxicloud::infrastructure::services::opaque_service::OxiCloudSuite;
const EXIT_OK: u8 = 0;
const EXIT_FAIL: u8 = 1;
fn env_or_fail(key: &str) -> String {
std::env::var(key).unwrap_or_else(|_| {
eprintln!("opaque-hurl-helper: required env var {key} unset");
std::process::exit(EXIT_FAIL as i32);
})
}
fn fail(msg: impl std::fmt::Display) -> ExitCode {
eprintln!("opaque-hurl-helper: FAIL — {msg}");
ExitCode::from(EXIT_FAIL)
}
#[derive(serde::Deserialize)]
struct ParamsResp {
enabled: bool,
#[serde(rename = "ciphersuiteVersion")]
ciphersuite_version: i16,
ksf: KsfParams,
}
#[derive(serde::Deserialize)]
struct KsfParams {
#[serde(rename = "memoryKib")]
memory_kib: u32,
iterations: u32,
parallelism: u32,
}
#[derive(serde::Deserialize)]
struct LegacyLoginResp {
access_token: String,
}
#[derive(serde::Deserialize)]
struct RegisterStartResp {
#[serde(rename = "registrationResponse")]
registration_response: String,
}
#[derive(serde::Deserialize)]
struct Ke1Resp {
#[serde(rename = "exchangeId")]
exchange_id: String,
#[serde(rename = "loginResponse")]
login_response: String,
}
#[derive(serde::Deserialize)]
struct AuthResp {
access_token: String,
refresh_token: String,
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let base = env_or_fail("OPAQUE_HELPER_BASE_URL");
let username = env_or_fail("OPAQUE_HELPER_USERNAME");
let password = env_or_fail("OPAQUE_HELPER_PASSWORD");
let base = base.trim_end_matches('/');
// reqwest with a modest timeout so a hung server can't hang CI.
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. Fetch server params (must match client-side KSF params) ─────
let params: ParamsResp = match http
.get(format!("{base}/api/auth/opaque/params"))
.send()
.await
{
Ok(r) if r.status().is_success() => match r.json().await {
Ok(p) => p,
Err(e) => return fail(format!("parse /params: {e}")),
},
Ok(r) => return fail(format!("/params HTTP {}", r.status())),
Err(e) => return fail(format!("/params network: {e}")),
};
if !params.enabled {
eprintln!("opaque-hurl-helper: skipped (server reports OPAQUE disabled)");
return ExitCode::from(EXIT_OK);
}
// Build the Argon2id KSF the server is expecting. `argon2::Params::new`
// takes (memory_kib, iterations, parallelism, tag_length) — pass None
// for tag_length to accept opaque-ke's expected length.
let ksf = match argon2::Params::new(
params.ksf.memory_kib,
params.ksf.iterations,
params.ksf.parallelism,
None,
) {
Ok(p) => argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, p),
Err(e) => return fail(format!("build Argon2 params from server: {e}")),
};
// ── 2. Legacy login to grab a session bearer for register/* ────────
// The register endpoints are session-authenticated; we don't yet
// have an envelope, so we bootstrap the session with legacy password
// login — same shape as the Phase 2 silent-migration hook.
let login: LegacyLoginResp = match http
.post(format!("{base}/api/auth/login"))
.json(&json!({ "username": username, "password": password }))
.send()
.await
{
Ok(r) if r.status().is_success() => match r.json().await {
Ok(v) => v,
Err(e) => return fail(format!("parse legacy login response: {e}")),
},
Ok(r) => {
let status = r.status();
let body = r.text().await.unwrap_or_default();
return fail(format!("legacy login HTTP {status}: {body}"));
}
Err(e) => return fail(format!("legacy login network: {e}")),
};
let bearer = format!("Bearer {}", login.access_token);
// ── 3. OPAQUE register (KE1) ───────────────────────────────────────
let mut rng = OsRng;
let client_reg = match ClientRegistration::<OxiCloudSuite>::start(&mut rng, password.as_bytes())
{
Ok(c) => c,
Err(e) => return fail(format!("client_register.start: {e}")),
};
let reg_start_body = json!({
"registrationRequest": B64.encode(client_reg.message.serialize())
});
let reg_start: RegisterStartResp = match http
.post(format!("{base}/api/auth/opaque/register/start"))
.header("Authorization", &bearer)
.json(&reg_start_body)
.send()
.await
{
Ok(r) if r.status().is_success() => match r.json().await {
Ok(v) => v,
Err(e) => return fail(format!("parse register/start: {e}")),
},
Ok(r) => {
let status = r.status();
let body = r.text().await.unwrap_or_default();
return fail(format!("register/start HTTP {status}: {body}"));
}
Err(e) => return fail(format!("register/start network: {e}")),
};
let reg_response_bytes = match B64.decode(reg_start.registration_response.trim()) {
Ok(b) => b,
Err(e) => return fail(format!("decode registration_response: {e}")),
};
let reg_response = match RegistrationResponse::<OxiCloudSuite>::deserialize(&reg_response_bytes)
{
Ok(r) => r,
Err(e) => return fail(format!("deserialize RegistrationResponse: {e}")),
};
// ── 4. OPAQUE register (KE2) ───────────────────────────────────────
let reg_finish = match client_reg.state.finish(
&mut rng,
password.as_bytes(),
reg_response,
ClientRegistrationFinishParameters::new(opaque_ke::Identifiers::default(), Some(&ksf)),
) {
Ok(f) => f,
Err(e) => return fail(format!("client_register.finish: {e}")),
};
let reg_finish_body = json!({
"registrationRecord": B64.encode(reg_finish.message.serialize()),
"ciphersuiteVersion": params.ciphersuite_version,
});
match http
.post(format!("{base}/api/auth/opaque/register/finish"))
.header("Authorization", &bearer)
.json(&reg_finish_body)
.send()
.await
{
Ok(r) if r.status() == 204 => {}
Ok(r) => {
let status = r.status();
let body = r.text().await.unwrap_or_default();
return fail(format!("register/finish HTTP {status}: {body}"));
}
Err(e) => return fail(format!("register/finish network: {e}")),
};
// ── 5. OPAQUE login (KE1) ──────────────────────────────────────────
// Public endpoint — no bearer, no CSRF.
let client_login = match ClientLogin::<OxiCloudSuite>::start(&mut rng, password.as_bytes()) {
Ok(c) => c,
Err(e) => return fail(format!("client_login.start: {e}")),
};
let ke1_body = json!({
"userIdentifier": username,
"startLoginRequest": B64.encode(client_login.message.serialize()),
});
let ke1: Ke1Resp = match http
.post(format!("{base}/api/auth/opaque/login/ke1"))
.json(&ke1_body)
.send()
.await
{
Ok(r) if r.status().is_success() => match r.json().await {
Ok(v) => v,
Err(e) => return fail(format!("parse ke1: {e}")),
},
Ok(r) => {
let status = r.status();
let body = r.text().await.unwrap_or_default();
return fail(format!("login/ke1 HTTP {status}: {body}"));
}
Err(e) => return fail(format!("login/ke1 network: {e}")),
};
let cred_bytes = match B64.decode(ke1.login_response.trim()) {
Ok(b) => b,
Err(e) => return fail(format!("decode loginResponse: {e}")),
};
let cred_response = match CredentialResponse::<OxiCloudSuite>::deserialize(&cred_bytes) {
Ok(c) => c,
Err(e) => return fail(format!("deserialize CredentialResponse: {e}")),
};
// ── 6. OPAQUE login (KE3) ──────────────────────────────────────────
let login_finish = match client_login.state.finish(
password.as_bytes(),
cred_response,
ClientLoginFinishParameters::new(None, opaque_ke::Identifiers::default(), Some(&ksf)),
) {
Ok(f) => f,
Err(e) => {
return fail(format!(
"client_login.finish (server may have rejected): {e}"
));
}
};
let ke3_body = json!({
"exchangeId": ke1.exchange_id,
"finishLoginRequest": B64.encode(login_finish.message.serialize()),
});
let auth: AuthResp = match http
.post(format!("{base}/api/auth/opaque/login/ke3"))
.json(&ke3_body)
.send()
.await
{
Ok(r) if r.status().is_success() => match r.json().await {
Ok(v) => v,
Err(e) => return fail(format!("parse ke3 AuthResponse: {e}")),
},
Ok(r) => {
let status = r.status();
let body = r.text().await.unwrap_or_default();
return fail(format!("login/ke3 HTTP {status}: {body}"));
}
Err(e) => return fail(format!("login/ke3 network: {e}")),
};
if auth.access_token.is_empty() || auth.refresh_token.is_empty() {
return fail("ke3 returned empty tokens");
}
// Sanity-check the token minted by OPAQUE is genuinely usable —
// hit `/api/auth/me` with it. Catches a class of regressions where
// the OPAQUE handler mints a token that's syntactically valid but
// doesn't authenticate the user (e.g. wrong user_id, wrong signing
// key path, missing role claim).
match http
.get(format!("{base}/api/auth/me"))
.header("Authorization", format!("Bearer {}", auth.access_token))
.send()
.await
{
Ok(r) if r.status().is_success() => {}
Ok(r) => {
return fail(format!(
"/api/auth/me with OPAQUE token: HTTP {}",
r.status()
));
}
Err(e) => return fail(format!("/api/auth/me network: {e}")),
}
eprintln!("opaque-hurl-helper: OK — register + login + /me round-trip for '{username}'");
ExitCode::from(EXIT_OK)
}
+22
View File
@@ -226,4 +226,26 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
bash "$API_DIR/storage_cleanup_check.sh" bash "$API_DIR/storage_cleanup_check.sh"
# ── 5. OPAQUE crypto handshake — the parts Hurl can't drive ─────────────
# Full OPAQUE register + login handshake against the running server,
# using the real ciphersuite client-side. Closes the gap left by
# `opaque_substrate.hurl` (which covers only wire shape, not OPRF-
# blinded happy path). See `src/bin/opaque-hurl-helper.rs` for what
# it exercises and why. Skips itself when the server reports OPAQUE
# disabled so an operator running the suite with mode=off doesn't
# get a spurious failure.
OPAQUE_HELPER_BIN="$REPO_ROOT/target/$BUILD_TARGET/opaque-hurl-helper"
if [[ ! -x "$OPAQUE_HELPER_BIN" ]]; then
log "Building opaque-hurl-helper ($BUILD_TARGET)..."
case "$BUILD_TARGET" in
debug) (cd "$REPO_ROOT" && cargo build --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;;
release) (cd "$REPO_ROOT" && cargo build --release --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;;
esac
fi
log "Running OPAQUE crypto handshake helper..."
OPAQUE_HELPER_BASE_URL="$base_url" \
OPAQUE_HELPER_USERNAME="$username" \
OPAQUE_HELPER_PASSWORD="$password" \
"$OPAQUE_HELPER_BIN" || die "OPAQUE crypto handshake failed"
log "All tests passed." log "All tests passed."