feat(opaque): add /api/auth/opaque/params
This commit is contained in:
@@ -112,6 +112,25 @@ impl OpaqueService {
|
|||||||
self.config.ciphersuite_version
|
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`
|
/// The persistent server setup — passed to `ServerRegistration::start`
|
||||||
/// and `ServerLogin::start` in the handler layer. Kept accessible so
|
/// 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
|
/// 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::extract::{Json, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::routing::post;
|
use axum::routing::{get, post};
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
use opaque_ke::{
|
use opaque_ke::{
|
||||||
@@ -112,6 +112,15 @@ pub fn opaque_login_routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/ke3", post(login_ke3))
|
.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
|
/// Client → server on register KE1. `registrationRequest` is the
|
||||||
/// base64-encoded output of the client's
|
/// base64-encoded output of the client's
|
||||||
/// `ClientRegistration::start(...).message`.
|
/// `ClientRegistration::start(...).message`.
|
||||||
@@ -593,6 +602,99 @@ pub async fn login_ke3(
|
|||||||
Ok(Json(session))
|
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 ────────────────────────────────────────────────────
|
// ── Small helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn require_opaque_exchange(state: &Arc<AppState>) -> Result<Arc<OpaqueLoginExchange>, AppError> {
|
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,
|
rate_limit_login,
|
||||||
))
|
))
|
||||||
.with_state(app_state.clone());
|
.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
|
// One-time setup route — public, rate-limited like register
|
||||||
let setup_router = setup_route()
|
let setup_router = setup_route()
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
@@ -934,6 +940,13 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"/api/auth/opaque/login",
|
"/api/auth/opaque/login",
|
||||||
opaque_login_public.layer(access_log!("http::api::auth")),
|
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
|
// One-time setup endpoint — public, rate-limited
|
||||||
.nest("/api", setup_router.layer(access_log!("http::api")))
|
.nest("/api", setup_router.layer(access_log!("http::api")))
|
||||||
// Device Auth Grant public endpoints (authorize + token polling)
|
// Device Auth Grant public endpoints (authorize + token polling)
|
||||||
|
|||||||
@@ -176,3 +176,36 @@ Content-Type: application/json
|
|||||||
HTTP 401
|
HTTP 401
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$.error_type" == "InvalidCredentials"
|
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