feat(opaque): add /api/auth/opaque/params
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user