From 94c6121f3b305bca4675c3446d7fbe40fc40d6ba Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 27 Jul 2026 23:38:59 +0200 Subject: [PATCH] feat(opaque): add /api/auth/opaque/params --- src/infrastructure/services/opaque_service.rs | 19 ++++ .../api/handlers/opaque_auth_handler.rs | 104 +++++++++++++++++- src/main.rs | 13 +++ tests/api/opaque_substrate.hurl | 33 ++++++ 4 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/services/opaque_service.rs b/src/infrastructure/services/opaque_service.rs index 14fc41b9..8624e7e3 100644 --- a/src/infrastructure/services/opaque_service.rs +++ b/src/infrastructure/services/opaque_service.rs @@ -112,6 +112,25 @@ impl OpaqueService { self.config.ciphersuite_version } + /// Client-side Argon2id memory cost (KiB) — published to the SPA + /// via `GET /api/auth/opaque/params` so both sides configure + /// matching KSF parameters. Values below are read-through from + /// [`OpaqueConfig`]; individual accessors keep handlers from + /// having to plumb the whole config struct. + pub fn config_ksf_memory_kib(&self) -> u32 { + self.config.ksf_memory_kib + } + + /// Client-side Argon2id iterations. See [`config_ksf_memory_kib`]. + pub fn config_ksf_iterations(&self) -> u32 { + self.config.ksf_iterations + } + + /// Client-side Argon2id parallelism. See [`config_ksf_memory_kib`]. + pub fn config_ksf_parallelism(&self) -> u32 { + self.config.ksf_parallelism + } + /// The persistent server setup — passed to `ServerRegistration::start` /// and `ServerLogin::start` in the handler layer. Kept accessible so /// callers can hold their own refs to it if they need to (e.g. inside diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index 01b6005f..d8f08fcc 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -62,7 +62,7 @@ use axum::Router; use axum::extract::{Json, State}; use axum::http::StatusCode; use axum::response::IntoResponse; -use axum::routing::post; +use axum::routing::{get, post}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as B64; use opaque_ke::{ @@ -112,6 +112,15 @@ pub fn opaque_login_routes() -> Router> { .route("/ke3", post(login_ke3)) } +/// Public config-publish endpoint. Mount under `/api/auth/opaque` +/// with NO rate limit — it's a static config read the SPA hits +/// once at page load (cache-friendly). Kept distinct from the +/// login mount to avoid layering the login rate limiter on a read +/// that isn't a login attempt. +pub fn opaque_params_routes() -> Router> { + Router::new().route("/params", get(opaque_params)) +} + /// Client → server on register KE1. `registrationRequest` is the /// base64-encoded output of the client's /// `ClientRegistration::start(...).message`. @@ -593,6 +602,99 @@ pub async fn login_ke3( Ok(Json(session)) } +// ── Params publish ─────────────────────────────────────────────────── + +/// Client-side Argon2id parameters the SPA must feed to +/// `@serenity-kit/opaque` on `finishRegistration` / `finishLogin`. +/// Values MUST match the server's `OpaqueConfig::ksf_*` — the +/// handshake fails to derive matching keys otherwise, so publishing +/// these is what keeps client and server in lock-step across param +/// bumps. +#[derive(Debug, Serialize, ToSchema)] +pub struct OpaqueKsfParams { + #[serde(rename = "memoryKib")] + pub memory_kib: u32, + pub iterations: u32, + pub parallelism: u32, +} + +/// Payload of `GET /api/auth/opaque/params`. `enabled = false` when +/// the OPAQUE substrate is not wired for this deployment (mode=off, +/// or password auth disabled — the same cross-check +/// `OpaqueConfig::effective_mode` runs). SPA gates the OPAQUE code +/// path on this flag; when false, it falls back to legacy password +/// auth as if OPAQUE didn't exist. +/// +/// `ciphersuiteVersion` + `ksf` are ALWAYS populated (safe defaults +/// even when `enabled = false`) so a client that ignored `enabled` +/// wouldn't nil-deref. +#[derive(Debug, Serialize, ToSchema)] +pub struct OpaqueParamsResponse { + pub enabled: bool, + #[serde(rename = "ciphersuiteVersion")] + pub ciphersuite_version: i16, + pub ksf: OpaqueKsfParams, +} + +/// Publish the OPAQUE client config. Safe to call unauthenticated +/// (nothing about individual users is returned) and cache-friendly +/// (the response only changes when the operator rotates env vars). +/// +/// Note: `Cache-Control` is deliberately unset — the SPA fetches +/// this once at page load, and if the operator rotates the KSF +/// params mid-flight, we want the change to be picked up on the +/// next SPA reload rather than lingering behind an intermediary +/// cache. +#[utoipa::path( + get, + path = "/api/auth/opaque/params", + responses( + (status = 200, description = "OPAQUE client config", body = OpaqueParamsResponse), + ), + tag = "auth" +)] +pub async fn opaque_params( + State(state): State>, +) -> impl IntoResponse { + // Reads from OpaqueService when substrate is wired; falls back + // to the OpaqueConfig defaults otherwise so an + // `enabled=false` payload still has plausible-shape numeric + // fields (the SPA logic just short-circuits on the flag). + let (enabled, ciphersuite_version, ksf_memory_kib, ksf_iterations, ksf_parallelism) = + match state.opaque_service.as_ref() { + Some(svc) => ( + true, + svc.ciphersuite_version(), + svc.config_ksf_memory_kib(), + svc.config_ksf_iterations(), + svc.config_ksf_parallelism(), + ), + None => { + // Substrate off — publish safe defaults matching + // `OpaqueConfig::default()` so a curious client can + // still parse the payload cleanly. + let cfg = crate::common::config::OpaqueConfig::default(); + ( + false, + cfg.ciphersuite_version, + cfg.ksf_memory_kib, + cfg.ksf_iterations, + cfg.ksf_parallelism, + ) + } + }; + + Json(OpaqueParamsResponse { + enabled, + ciphersuite_version, + ksf: OpaqueKsfParams { + memory_kib: ksf_memory_kib, + iterations: ksf_iterations, + parallelism: ksf_parallelism, + }, + }) +} + // ── Small helpers ──────────────────────────────────────────────────── fn require_opaque_exchange(state: &Arc) -> Result, AppError> { diff --git a/src/main.rs b/src/main.rs index 317a7dda..bb16ef0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -815,6 +815,12 @@ async fn run() -> Result<(), Box> { rate_limit_login, )) .with_state(app_state.clone()); + // OPAQUE aPAKE — public params (KSF + ciphersuite) — GET, + // no rate limit, SPA fetches once at page load. Distinct + // mount so no login limiter attaches to a non-login read. + let opaque_params_public = + oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_params_routes() + .with_state(app_state.clone()); // One-time setup route — public, rate-limited like register let setup_router = setup_route() .layer(axum::middleware::from_fn_with_state( @@ -934,6 +940,13 @@ async fn run() -> Result<(), Box> { "/api/auth/opaque/login", opaque_login_public.layer(access_log!("http::api::auth")), ) + // OPAQUE aPAKE — public params publish. No rate limit + // (static config read); distinct sub-prefix from login + // for the same middleware-composition reason. + .nest( + "/api/auth/opaque", + opaque_params_public.layer(access_log!("http::api::auth")), + ) // One-time setup endpoint — public, rate-limited .nest("/api", setup_router.layer(access_log!("http::api"))) // Device Auth Grant public endpoints (authorize + token polling) diff --git a/tests/api/opaque_substrate.hurl b/tests/api/opaque_substrate.hurl index c74dc5a2..b74d8920 100644 --- a/tests/api/opaque_substrate.hurl +++ b/tests/api/opaque_substrate.hurl @@ -176,3 +176,36 @@ Content-Type: application/json HTTP 401 [Asserts] jsonpath "$.error_type" == "InvalidCredentials" + + +# ============================================================= +# Phase 1 — Public params publish +# ============================================================= +# GET /api/auth/opaque/params is the SPA's read-only bootstrap: +# fetched once at page load, tells the client whether OPAQUE is +# enabled and (crucially) which Argon2id KSF params to feed to +# `@serenity-kit/opaque` on register/login finish. Mismatched +# params → the handshake derives different keys on the two sides +# and everything fails. This test pins the wire shape. +# ============================================================= + +# ───────────────────────────────────────────────────────────── +# Case 9 — Params publish returns enabled=true under the test +# env (`OXICLOUD_OPAQUE_MODE=migrate`), the current +# ciphersuite version (1 — see `docs/config/env.md`), +# and the fast test-only KSF params +# (memoryKib=8 / iter=1 / lanes=1 from server.env). +# If the test env's KSF values ever drift from the +# handler's, this assertion catches the drift before +# any downstream test tries the crypto and fails +# confusingly. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/opaque/params + +HTTP 200 +[Asserts] +jsonpath "$.enabled" == true +jsonpath "$.ciphersuiteVersion" == 1 +jsonpath "$.ksf.memoryKib" == 8 +jsonpath "$.ksf.iterations" == 1 +jsonpath "$.ksf.parallelism" == 1