feat(opaque): add /api/auth/opaque/params

This commit is contained in:
Edouard Vanbelle
2026-07-27 23:38:59 +02:00
parent 7d7621e387
commit 94c6121f3b
4 changed files with 168 additions and 1 deletions
@@ -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
@@ -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<Arc<AppState>> {
.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<Arc<AppState>> {
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<Arc<AppState>>,
) -> 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<AppState>) -> Result<Arc<OpaqueLoginExchange>, AppError> {
+13
View File
@@ -815,6 +815,12 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
"/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)
+33
View File
@@ -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