From 7d7621e3878a7831b28a1532d27d38b48cd615ed Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 27 Jul 2026 22:23:15 +0200 Subject: [PATCH] feat(opaque): wire API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/auth/opaque/login/ke1 (public) — takes {userIdentifier, startLoginRequest}, resolves the identifier via the same @-dispatch as legacy login (AuthApplicationService::lookup_user_for_login, factored out), fetches envelope, runs ServerLogin::start (real branch for known users, dummy branch for anti-enum on unknown/unregistered), stashes state under a random exchange_id in the moka cache, returns {exchangeId, loginResponse}. - POST /api/auth/opaque/login/ke3 (public) — atomic take from the cache FIRST (anti-enum + anti-replay), then decodes the payload, runs ServerLogin::finish, stamps opaque_migrated_at (Phase 3 signal), and mints a session via the new AuthApplicationService::mint_session_for_authenticated_user helper — returns the same AuthResponseDto shape as legacy login so the SPA has one downstream handler. - Session mint factored: mint_session_for_authenticated_user(User) extracted from login() so both the legacy password path and OPAQUE KE3 converge through one implementation. - OpaqueRepositoryPort::mark_migrated with COALESCE-preserving idempotent stamp of opaque_migrated_at. - opaque-setup CLI + Dockerfile wiring already shipped (Step 0 hygiene). - Routing fix: sub-prefix split (/api/auth/opaque/register vs /api/auth/opaque/login) — axum composes middleware between sibling nests at the same prefix, which was cross-applying auth+CSRF to my public login routes. Distinct prefixes side-step that cleanly. Documented in both main.rs and the router builder doc. - Rate-limit sharing: login KE1/KE3 layered with the SAME login_limiter instance as legacy POST /api/auth/login, so an attacker can't halve the per-IP budget by spraying both endpoints. Anti-enum + anti-replay hardening in KE3: take runs BEFORE payload parse so: - Unknown / expired / already-consumed exchange_id → 401 InvalidCredentials (same shape as wrong-passphrase, no payload-shape leak) - Consumed handle can't be re-used to spam parse attempts --- src/application/ports/opaque_ports.rs | 10 + .../services/auth_application_service.rs | 54 ++- .../repositories/pg/opaque_pg_repository.rs | 73 ++++ .../services/opaque_login_exchange.rs | 99 ++++- .../api/handlers/opaque_auth_handler.rs | 407 +++++++++++++++++- src/main.rs | 35 +- tests/api/opaque_substrate.hurl | 67 ++- 7 files changed, 696 insertions(+), 49 deletions(-) diff --git a/src/application/ports/opaque_ports.rs b/src/application/ports/opaque_ports.rs index a6daaab1..220b97db 100644 --- a/src/application/ports/opaque_ports.rs +++ b/src/application/ports/opaque_ports.rs @@ -92,4 +92,14 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static { /// on the envelope columns but STILL sets the force-change flag /// (that's the point of the admin call). async fn clear_registration(&self, user_id: Uuid) -> Result<()>; + + /// Stamp `opaque_migrated_at` on `user_id` if it isn't set yet. + /// Called by the login-KE3 handler after a successful OPAQUE + /// handshake — the presence of this timestamp is the Phase 3+ + /// signal that legacy `POST /api/auth/login` should refuse for + /// this user. + /// + /// Idempotent (COALESCE preserves the first-migration timestamp + /// so a later login doesn't rewrite the operational signal). + async fn mark_migrated(&self, user_id: Uuid) -> Result<()>; } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 1061623a..3dfd4bb4 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -712,7 +712,7 @@ impl AuthApplicationService { } else { self.user_storage.get_user_by_username(&dto.username).await }; - let mut user = lookup.map_err(|_| { + let user = lookup.map_err(|_| { // Audit: unknown-identifier login attempt. Reason key kept // stable so log search can aggregate without parsing the // human-readable message. Caller's client IP + request id @@ -827,6 +827,38 @@ impl AuthApplicationService { )); } + // Mint the session — factored so the OPAQUE login handler + // can reuse the exact same shape after a successful OPAQUE + // handshake (Phase 1, `login/ke3`). Both paths converge here + // so lifecycle + token + session-family semantics stay in + // one place. + self.mint_session_for_authenticated_user(user).await + } + + /// Emit a fresh session for a user who has ALREADY been + /// authenticated by a mechanism the caller trusts (legacy + /// password verify, OPAQUE KE3 success, magic-link redemption). + /// + /// This method does NOT verify any credential — the caller must + /// have proven identity before invoking it. What it DOES do: + /// + /// * Dispatch `on_user_login` lifecycle (so + /// `PersonalDriveLifecycleHook` can safety-net first-login + /// provisioning). + /// * Update `last_login_at` (in-memory; `create_session` + /// persists it as a side effect via its own transaction). + /// * Mint access + refresh tokens under a fresh token family. + /// * Persist the session row. + /// * Return the shared [`AuthResponseDto`] shape. + /// + /// Callers: `login()` (after password verify), + /// `redeem_magic_link()` (after token redemption), + /// `interfaces::api::handlers::opaque_auth_handler::login_ke3` + /// (after OPAQUE handshake). + pub async fn mint_session_for_authenticated_user( + &self, + mut user: crate::domain::entities::user::User, + ) -> Result { // Lifecycle: dispatch login BEFORE register_login() so hooks // observing `last_login_at().is_none()` see "first ever login" // correctly. See tip #1 in user_lifecycle.rs. @@ -2073,6 +2105,26 @@ impl AuthApplicationService { UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await } + /// Login-style identifier lookup: dispatches on `@` in the input + /// (email path when present, username path when not), identical + /// to `login()`'s dispatch. Exposed so the OPAQUE login handler + /// (`opaque_auth_handler::login_ke1`) can resolve the same + /// identifier shape without duplicating the `@` heuristic. + /// + /// Returns the raw DB error on miss — callers are responsible + /// for the anti-enum shape (do NOT surface the DomainError kind + /// distinction to unauthenticated clients). + pub async fn lookup_user_for_login( + &self, + identifier: &str, + ) -> Result { + if identifier.contains('@') { + self.user_storage.get_user_by_email(identifier).await + } else { + self.user_storage.get_user_by_username(identifier).await + } + } + /// Visibility-checked profile lookup for `GET /api/users/{id}`. /// /// Returns `NotFound` (not `AccessDenied`) when the caller has no diff --git a/src/infrastructure/repositories/pg/opaque_pg_repository.rs b/src/infrastructure/repositories/pg/opaque_pg_repository.rs index 0aeb76c6..617edd94 100644 --- a/src/infrastructure/repositories/pg/opaque_pg_repository.rs +++ b/src/infrastructure/repositories/pg/opaque_pg_repository.rs @@ -125,6 +125,30 @@ impl OpaqueRepositoryPort for OpaquePgRepository { } } + async fn mark_migrated(&self, user_id: Uuid) -> Result<()> { + // COALESCE preserves the first-migration timestamp — same + // pattern as write_registration preserves opaque_registered_at. + // Ops dashboards read this to answer "what fraction of users + // have completed the OPAQUE cutover", so overwriting on every + // login would erase the signal. + let res = sqlx::query( + r#" + UPDATE auth.users + SET opaque_migrated_at = COALESCE(opaque_migrated_at, NOW()) + WHERE id = $1 + "#, + ) + .bind(user_id) + .execute(self.pool()) + .await + .map_err(|e| DomainError::internal_error("OpaquePg", format!("mark_migrated: {e}")))?; + + if res.rows_affected() == 0 { + return Err(DomainError::not_found("User", user_id.to_string())); + } + Ok(()) + } + async fn clear_registration(&self, user_id: Uuid) -> Result<()> { // One UPDATE writes both the envelope invalidation AND the // force-change flag — matches the atomicity we promise in the @@ -438,6 +462,54 @@ mod integration_tests { ); } + /// Mark_migrated is idempotent — a second call preserves the + /// first-migration timestamp, matching the ops-dashboard contract + /// ("when did this user first complete OPAQUE?"). + #[tokio::test] + async fn mark_migrated_stamps_once_and_is_idempotent() { + let repo = test_repo().await; + let user = + seed_user(&repo, &format!("opaque-mig-{}@example.invalid", Uuid::new_v4())).await; + + // Read the initial NULL state + let initial: (Option>,) = sqlx::query_as( + "SELECT opaque_migrated_at FROM auth.users WHERE id = $1", + ) + .bind(user) + .fetch_one(repo.pool()) + .await + .unwrap(); + assert!( + initial.0.is_none(), + "new user starts with no opaque_migrated_at" + ); + + repo.mark_migrated(user).await.expect("first mark"); + let first: (Option>,) = sqlx::query_as( + "SELECT opaque_migrated_at FROM auth.users WHERE id = $1", + ) + .bind(user) + .fetch_one(repo.pool()) + .await + .unwrap(); + let first_ts = first.0.expect("timestamp set after first mark"); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + repo.mark_migrated(user).await.expect("second mark"); + let second: (Option>,) = sqlx::query_as( + "SELECT opaque_migrated_at FROM auth.users WHERE id = $1", + ) + .bind(user) + .fetch_one(repo.pool()) + .await + .unwrap(); + assert_eq!( + second.0.unwrap(), + first_ts, + "second mark_migrated preserves the first timestamp (COALESCE)" + ); + } + #[tokio::test] async fn missing_user_surfaces_notfound_on_write_and_read_and_clear() { let repo = test_repo().await; @@ -449,6 +521,7 @@ mod integration_tests { .unwrap_err(), repo.read_registration(ghost).await.unwrap_err(), repo.clear_registration(ghost).await.unwrap_err(), + repo.mark_migrated(ghost).await.unwrap_err(), ] { assert_eq!( err.kind, diff --git a/src/infrastructure/services/opaque_login_exchange.rs b/src/infrastructure/services/opaque_login_exchange.rs index deb29656..37ff3e5d 100644 --- a/src/infrastructure/services/opaque_login_exchange.rs +++ b/src/infrastructure/services/opaque_login_exchange.rs @@ -71,10 +71,39 @@ pub const DEFAULT_MAX_INFLIGHT: u64 = 10_000; /// correct passphrase). pub type ExchangeId = Uuid; +/// Server-side state stashed between KE1 and KE3. +/// +/// Holds the [`ServerLogin`] (opaque-ke handshake continuation) plus +/// the `user_id` KE1 resolved from the client's identifier — `Some` +/// for a real user, `None` for the anti-enum dummy branch (unknown +/// user or user without an envelope). KE3 uses this to know which +/// account to mint the session for on success; the dummy branch's +/// KE3 will fail the AKE check inside `ServerLogin::finish` and +/// never reach the session mint, so the `None` variant is a +/// belt-and-braces guard we never rely on for correctness. +/// +/// `state` lives in a `Mutex>` because moka's `Cache::remove` +/// returns a *clone* of the stored `Arc` (not the original). That means +/// unwrapping the Arc to consume `ServerLogin` (which isn't `Clone`) +/// would race the briefly-lingering cache-side ref. `Mutex::lock().take()` +/// gives us ownership of the inner value regardless of how many Arc +/// clones exist. +struct StashedExchange { + state: std::sync::Mutex>>, + user_id: Option, +} + +/// Exchange state handed back to the KE3 handler — a plain owned +/// tuple so consumers work with the concrete types, not Arc>. +pub struct TakenExchange { + pub state: ServerLogin, + pub user_id: Option, +} + /// In-memory cache holding server-side login state between KE1 and KE3. #[derive(Clone)] pub struct OpaqueLoginExchange { - inner: Arc>>, + inner: Arc>>, } impl OpaqueLoginExchange { @@ -97,25 +126,43 @@ impl OpaqueLoginExchange { } } - /// Stash a fresh `ServerLogin` state and return the handle to - /// hand back to the client. The exchange_id is generated here so - /// callers can't accidentally reuse one — every KE1 gets its own. - pub fn store(&self, state: ServerLogin) -> ExchangeId { + /// Stash a fresh exchange and return the handle to hand back to + /// the client. The exchange_id is generated here so callers + /// can't accidentally reuse one — every KE1 gets its own. + pub fn store(&self, state: ServerLogin, user_id: Option) -> ExchangeId { let id = Uuid::new_v4(); - self.inner.insert(id, state); + self.inner.insert( + id, + Arc::new(StashedExchange { + state: std::sync::Mutex::new(Some(state)), + user_id, + }), + ); id } - /// Atomically consume the state for `exchange_id`. Returns `None` - /// if the id is unknown, already consumed, or expired. Callers - /// must treat those three cases identically (anti-enum): a KE3 - /// with a bad id, a replay, and a timeout should all surface as - /// the same `InvalidCredentials` shape to the client. + /// Atomically consume the exchange for `exchange_id`. Returns + /// `None` if the id is unknown, already consumed, or expired. + /// Callers must treat those three cases identically (anti-enum): + /// a KE3 with a bad id, a replay, and a timeout should all + /// surface as the same `InvalidCredentials` shape to the client. /// - /// Uses moka's atomic `remove` (verified single get-and-invalidate - /// in moka 0.12+, no race window between the two operations). - pub fn take(&self, exchange_id: ExchangeId) -> Option> { - self.inner.remove(&exchange_id) + /// Combines two atomicity guarantees: + /// + /// * `moka::Cache::remove` is a single get-and-invalidate on + /// the cache side (verified in moka 0.12+). + /// * `Mutex::lock().take()` gives us ownership of the inner + /// `ServerLogin` even when moka returns a *clone* of the + /// stored `Arc` (which it does — `remove` yields a clone, + /// not the original), and prevents two concurrent takers + /// from both seeing `Some`. + pub fn take(&self, exchange_id: ExchangeId) -> Option { + let arc = self.inner.remove(&exchange_id)?; + let state = arc.state.lock().ok()?.take()?; + Some(TakenExchange { + state, + user_id: arc.user_id, + }) } /// Force runtime maintenance (LRU eviction + TTL sweep). Moka runs @@ -206,9 +253,10 @@ mod tests { let cache = OpaqueLoginExchange::with_params(Duration::from_secs(60), 100); let state = build_server_login_state(); - let id = cache.store(state); + let id = cache.store(state, Some(Uuid::new_v4())); // Second call after take must miss — single-use semantic. - assert!(cache.take(id).is_some(), "first take retrieves the state"); + let taken = cache.take(id).expect("first take retrieves the state"); + assert!(taken.user_id.is_some(), "user_id round-trips through the stash"); assert!( cache.take(id).is_none(), "second take must miss — exchange_id is single-use" @@ -228,7 +276,7 @@ mod tests { // (it runs pending tasks lazily), so `run_pending_tasks` // forces a deterministic sweep. let cache = OpaqueLoginExchange::with_params(Duration::from_millis(100), 100); - let id = cache.store(build_server_login_state()); + let id = cache.store(build_server_login_state(), None); std::thread::sleep(Duration::from_millis(150)); cache.run_pending_tasks(); @@ -245,8 +293,19 @@ mod tests { // — reusing one would enable a KE3 to consume the wrong // exchange's state. let cache = OpaqueLoginExchange::new(); - let a = cache.store(build_server_login_state()); - let b = cache.store(build_server_login_state()); + let a = cache.store(build_server_login_state(), None); + let b = cache.store(build_server_login_state(), None); assert_ne!(a, b, "each store() must mint a fresh UUID"); } + + #[test] + fn dummy_branch_stash_carries_no_user_id() { + // KE1 for an unknown user stashes `user_id: None` (anti-enum + // — dummy branch). KE3 for the dummy will fail at AKE, but + // the stash contract is: real user → Some(id), unknown → None. + let cache = OpaqueLoginExchange::new(); + let id = cache.store(build_server_login_state(), None); + let taken = cache.take(id).expect("take dummy stash"); + assert!(taken.user_id.is_none(), "dummy-branch stash carries no user_id"); + } } diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index cb6fa5bf..01b6005f 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -65,22 +65,51 @@ use axum::response::IntoResponse; use axum::routing::post; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as B64; -use opaque_ke::{RegistrationRequest, RegistrationUpload, ServerRegistration}; +use opaque_ke::{ + CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, + ServerLoginStartParameters, ServerRegistration, +}; +use rand_core::OsRng; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; +use crate::application::dtos::user_dto::AuthResponseDto; use crate::common::di::AppState; +use crate::infrastructure::services::opaque_login_exchange::{ + ExchangeId, OpaqueLoginExchange, +}; use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite}; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUserId; /// Session-required OPAQUE routes. Callers layer the auth + CSRF /// middlewares in `main.rs` (mirrors [`auth_protected_routes`]). +/// Session-required OPAQUE register routes. Mount under +/// `/api/auth/opaque/register` in `main.rs` with the auth + CSRF +/// layer stack (mirrors [`auth_protected_routes`]). +/// +/// **The mount prefix MUST be distinct from `opaque_login_routes`'s +/// prefix.** Nesting both routers at the same `/api/auth` node +/// causes axum's `.layer()` to cross-apply between siblings on +/// shared prefixes — the login endpoints would then inherit +/// register's auth+CSRF middleware and 403 every unauthenticated +/// KE1. Separate prefixes side-step the composition rule cleanly. pub fn opaque_register_routes() -> Router> { Router::new() - .route("/opaque/register/start", post(register_start)) - .route("/opaque/register/finish", post(register_finish)) + .route("/start", post(register_start)) + .route("/finish", post(register_finish)) +} + +/// Public (unauthenticated) OPAQUE login routes. Mount under +/// `/api/auth/opaque/login` with the same `rate_limit_login` +/// layer used on legacy `/api/auth/login` so an attacker can't +/// halve the per-identity budget by spraying both endpoints. See +/// [`opaque_register_routes`] on why the prefix must be distinct. +pub fn opaque_login_routes() -> Router> { + Router::new() + .route("/ke1", post(login_ke1)) + .route("/ke3", post(login_ke3)) } /// Client → server on register KE1. `registrationRequest` is the @@ -266,8 +295,380 @@ pub async fn register_finish( Ok(StatusCode::NO_CONTENT) } +// ── Login: KE1 + KE3 ───────────────────────────────────────────────── + +/// Client → server on KE1. `userIdentifier` is the same string the +/// SPA sends to legacy `/api/auth/login` — email if it contains `@`, +/// username otherwise (server dispatches on `@`, same as legacy). +/// `startLoginRequest` is the base64-encoded output of +/// `ClientLogin::start(...).message`. +#[derive(Debug, Deserialize, ToSchema)] +pub struct OpaqueLoginKe1Dto { + #[serde(rename = "userIdentifier")] + pub user_identifier: String, + #[serde(rename = "startLoginRequest")] + pub start_login_request: String, +} + +/// Server → client on KE1. `exchangeId` is an opaque handle the +/// client MUST echo back on KE3 (single-use, 60 s TTL); the client +/// can't derive server state from it. `loginResponse` is the +/// base64-encoded output of `ServerLogin::start(...).message`. +#[derive(Debug, Serialize, ToSchema)] +pub struct OpaqueLoginKe1Response { + #[serde(rename = "exchangeId")] + #[schema(value_type = String, format = "uuid")] + pub exchange_id: ExchangeId, + #[serde(rename = "loginResponse")] + pub login_response: String, +} + +/// Client → server on KE3. `exchangeId` matches what KE1 handed +/// back; `finishLoginRequest` is the base64-encoded output of +/// `ClientLogin::finish(...).message`. +#[derive(Debug, Deserialize, ToSchema)] +pub struct OpaqueLoginKe3Dto { + #[serde(rename = "exchangeId")] + #[schema(value_type = String, format = "uuid")] + pub exchange_id: ExchangeId, + #[serde(rename = "finishLoginRequest")] + pub finish_login_request: String, +} + +/// KE1: user lookup → envelope fetch → `ServerLogin::start` → stash +/// state under a fresh exchange_id → return handle + response bytes. +/// +/// ## Anti-enumeration +/// +/// A KE1 that CAN'T be honestly answered (unknown user, or user +/// exists but has no OPAQUE envelope yet) MUST look identical to a +/// KE1 that CAN. opaque-ke's `ServerLogin::start` supports a "dummy" +/// branch (`password_file = None`) that generates a well-formed +/// KE2 response indistinguishable from the real branch. The +/// dummy-branch KE3 will fail at the client-side `ClientLogin::finish` +/// (wrong MAC), never reaching the server — so KE3 for the +/// non-existent user is symmetric with KE3 for a wrong passphrase. +/// +/// User is NOT looked up via `_with_perms` — this is the auth +/// bootstrap; there's no caller identity yet. We use the same +/// dispatch as legacy `/api/auth/login`. +#[utoipa::path( + post, + path = "/api/auth/opaque/login/ke1", + request_body = OpaqueLoginKe1Dto, + responses( + (status = 200, description = "Login response + single-use exchange handle", + body = OpaqueLoginKe1Response), + (status = 400, description = "Malformed request payload"), + (status = 503, description = "OPAQUE service not configured"), + ), + tag = "auth" +)] +pub async fn login_ke1( + State(state): State>, + Json(dto): Json, +) -> Result { + let svc = require_opaque_service(&state)?; + let repo = require_opaque_repo(&state)?; + let exchange = require_opaque_exchange(&state)?; + + let cred_bytes = B64.decode(dto.start_login_request.trim()).map_err(|_| { + malformed("startLoginRequest is not valid base64") + })?; + let cred_request = CredentialRequest::::deserialize(&cred_bytes) + .map_err(|_| malformed("startLoginRequest failed to deserialize"))?; + + // Resolve identifier → user_id, then fetch the envelope. Both + // steps can fail (unknown user, no envelope) — collapse into a + // single Option so `ServerLogin::start` sees the anti-enum + // shape cleanly. + let (user_bytes, password_file) = resolve_user_and_envelope(&state, &dto.user_identifier) + .await + .unwrap_or_else(|| (dto.user_identifier.as_bytes().to_vec(), None)); + + // `ServerLogin::start(..., Some(file), ...)` runs the real + // handshake; `None` runs the dummy branch that still produces a + // well-formed KE2 tied to the same suite so a probing attacker + // can't distinguish the two paths from response shape or timing + // (opaque-ke pads the dummy branch to match). + let mut server_rng = OsRng; + let started = opaque_ke::ServerLogin::start( + &mut server_rng, + svc.setup(), + password_file, + cred_request, + &user_bytes, + ServerLoginStartParameters::default(), + ) + .map_err(|e| { + // A start error at this stage is a genuine protocol failure + // (bad KE1 payload, ciphersuite mismatch in the wire bytes). + // We STILL respond 400 rather than 200-with-dummy — 200 + // response would let the attacker distinguish "protocol + // error" from "unknown user"; the caller getting a 400 has + // to try a different KE1 anyway. + tracing::info!( + target: "audit", + event = "opaque.login_ke1_rejected", + reason = "server_start_error", + attempted_identifier = %dto.user_identifier, + error = %e, + "👮🏻‍♂️ OPAQUE KE1 rejected: protocol error" + ); + AppError::new( + StatusCode::BAD_REQUEST, + "OPAQUE login start failed", + "OpaqueMalformedRequest", + ) + })?; + + // Silence unused-warning for repo when the branch above returns + // None — repo IS used inside `resolve_user_and_envelope` (via + // `state.opaque_repo`) but the closure hides that from the + // compiler's flow analysis. + let _ = &repo; + + // Stash user_id alongside ServerLogin so KE3 knows which account + // just proved possession of the passphrase without having to + // re-parse the AKE payload (opaque-ke doesn't hand the identifier + // back on `finish` — it's checked implicitly against the KE1 + // state's expected identifier). + let user_id = user_id_from_bytes(&user_bytes); + let response_b64 = B64.encode(started.message.serialize()); + let exchange_id = exchange.store(started.state, user_id); + Ok(Json(OpaqueLoginKe1Response { + exchange_id, + login_response: response_b64, + })) +} + +/// KE3: consume exchange state → `ServerLogin::finish` → mint session. +/// +/// On success: +/// +/// * Stamp `opaque_migrated_at` (Phase 3 signal that this user +/// has completed at least one OPAQUE login — future legacy +/// login attempts will be refused once Phase 4 flips to +/// `opaque_only`). +/// * Mint access + refresh tokens under a fresh session family, +/// via `AuthApplicationService::mint_session_for_authenticated_user`. +/// * Return the shared `AuthResponseDto` shape identical to +/// `POST /api/auth/login` — the SPA consumes both paths through +/// one downstream handler. +/// +/// On failure (bad passphrase, replay, expired exchange, unknown +/// exchange_id): 401 `InvalidCredentials` — the SAME shape for every +/// failure branch so attackers can't distinguish "expired" from +/// "wrong password" from "id already consumed". +#[utoipa::path( + post, + path = "/api/auth/opaque/login/ke3", + request_body = OpaqueLoginKe3Dto, + responses( + (status = 200, description = "Session issued", body = AuthResponseDto), + (status = 400, description = "Malformed request payload"), + (status = 401, description = "Invalid credentials"), + (status = 503, description = "OPAQUE service not configured"), + ), + tag = "auth" +)] +pub async fn login_ke3( + State(state): State>, + Json(dto): Json, +) -> Result { + let _svc = require_opaque_service(&state)?; + let repo = require_opaque_repo(&state)?; + let exchange = require_opaque_exchange(&state)?; + let auth = require_auth_application_service(&state)?; + + // Atomic take FIRST — before touching the payload. Two reasons: + // + // 1. Anti-enum: an unknown / expired / already-consumed + // exchange_id returns 401 `InvalidCredentials` regardless + // of payload shape, matching the wrong-passphrase case + // exactly. If we parsed the payload first, a malformed + // body would 400 EVEN for an unknown id — leaking the fact + // that some KE3 shapes are valid vs invalid. + // 2. Anti-replay: consuming the exchange first guarantees the + // handle is single-use even if the caller sends garbage + // afterwards — an attacker with a stolen id can't spam + // "try shapes until one parses" against the same handle. + let stash = exchange.take(dto.exchange_id).ok_or_else(|| { + tracing::info!( + target: "audit", + event = "opaque.login_ke3_rejected", + reason = "unknown_or_expired_exchange", + exchange_id = %dto.exchange_id, + "👮🏻‍♂️ OPAQUE KE3 rejected: unknown/expired/replayed exchange_id" + ); + invalid_credentials() + })?; + + let cred_bytes = B64.decode(dto.finish_login_request.trim()).map_err(|_| { + malformed("finishLoginRequest is not valid base64") + })?; + let cred_final = CredentialFinalization::::deserialize(&cred_bytes) + .map_err(|_| malformed("finishLoginRequest failed to deserialize"))?; + + // If the AKE integrity check fails (wrong passphrase, dummy-branch + // KE1 for an unknown user, tampered bytes), `finish` errors and + // we return the same 401 shape as an unknown exchange_id above. + let _finished = stash.state.finish(cred_final).map_err(|e| { + tracing::info!( + target: "audit", + event = "opaque.login_ke3_rejected", + reason = "ake_check_failed", + exchange_id = %dto.exchange_id, + error = %e, + "👮🏻‍♂️ OPAQUE KE3 rejected: AKE / passphrase check failed" + ); + invalid_credentials() + })?; + + // Both sides now agree on a shared session_key. The current + // implementation does NOT derive the bearer token from it — + // reusing the existing token minter keeps the session shape + // identical to legacy login. Cryptographically tying the + // access_token to `session_key` via HKDF is a follow-up (see + // docs/plan/opaque.md §Step 5 notes). + // + // The user_id comes from the KE1-side stash (resolved from the + // client's identifier before the dummy-vs-real branch), not from + // the AKE payload. Anti-enum: the dummy branch has + // `stash.user_id = None`; a dummy KE3 that somehow reached this + // point (should not — it fails at `finish` above) returns the + // same InvalidCredentials shape. + let user_id = stash.user_id.ok_or_else(invalid_credentials)?; + + // Fetch the user entity — needed by mint_session_for_authenticated_user + // (it calls dispatch_login + register_login + generates tokens + // from the user's role/email/etc.). + let user = auth + .get_user_entity(user_id) + .await + .map_err(|_| { + // User row vanished between KE1's envelope fetch and now + // (delete race). Same shape as bad passphrase — never + // leak "you passed the crypto but the account is gone." + tracing::warn!( + target: "audit", + event = "opaque.login_ke3_rejected", + reason = "user_gone_after_ke3", + user_id = %user_id, + "👮🏻‍♂️ OPAQUE KE3: user disappeared between KE1 and KE3" + ); + invalid_credentials() + })?; + + // Mint the session BEFORE stamping opaque_migrated_at — if the + // session mint fails (rare, but not impossible under DB failure), + // we don't want to have flipped the migration flag for a user + // whose login didn't actually complete. + let session = auth + .mint_session_for_authenticated_user(user) + .await + .map_err(AppError::from)?; + + if let Err(e) = repo.mark_migrated(user_id).await { + // Non-fatal: session is already issued, user is logged in. + // Log at warn so ops sees any consistent trend, but don't + // fail the response — the mark is idempotent so a later + // login will retry. + tracing::warn!( + target: "audit", + event = "opaque.mark_migrated_deferred", + user_id = %user_id, + error = %e, + "OPAQUE login succeeded but mark_migrated failed — will retry on next login" + ); + } + + tracing::info!( + target: "audit", + event = "opaque.login_ok", + user_id = %user_id, + "OPAQUE login completed" + ); + + Ok(Json(session)) +} + // ── Small helpers ──────────────────────────────────────────────────── +fn require_opaque_exchange(state: &Arc) -> Result, AppError> { + state.opaque_login_exchange.clone().ok_or_else(|| { + AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "OPAQUE login-exchange cache is not wired", + "OpaqueDisabled", + ) + }) +} + +fn require_auth_application_service( + state: &Arc, +) -> Result< + Arc, + AppError, +> { + Ok(state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Authentication service not configured"))? + .auth_application_service + .clone()) +} + +/// Resolve KE1's `userIdentifier` to the OPAQUE server-side user +/// identifier + the stored envelope. Returns `None` (silently) +/// when either the user doesn't exist or has no envelope on file — +/// both branches converge to the anti-enum dummy-KE1 path. +/// +/// The tuple's first element (`Vec`) is what we pass to +/// `ServerLogin::start` as the OPAQUE user identifier. For real users +/// we use `user_id.as_bytes()` (matches the register handler); for +/// the unknown-user branch we hash the CLAIMED identifier so the +/// dummy branch's identifier bytes are still deterministic +/// per-attempt (opaque-ke uses this in the AKE derivation). +async fn resolve_user_and_envelope( + state: &Arc, + identifier: &str, +) -> Option<(Vec, Option>)> { + let auth = state.auth_service.as_ref()?; + let repo = state.opaque_repo.as_ref()?; + + let user = auth + .auth_application_service + .lookup_user_for_login(identifier) + .await + .ok()?; + + let stored = repo.read_registration(user.id()).await.ok().flatten(); + let password_file = + stored.and_then(|s| ServerRegistration::::deserialize(&s.envelope).ok()); + + Some((user.id().as_bytes().to_vec(), password_file)) +} + +/// Recover a UUID from the OPAQUE user identifier bytes IF they're +/// the 16-byte shape written by `resolve_user_and_envelope`. For the +/// dummy branch (identifier = raw caller-supplied string), the bytes +/// are almost never 16 long, so this returns `None` — that's the +/// correct signal to KE1's callers ("don't stamp a user_id in the +/// stash for the dummy branch"). +fn user_id_from_bytes(bytes: &[u8]) -> Option { + let arr: [u8; 16] = bytes.try_into().ok()?; + Some(Uuid::from_bytes(arr)) +} + +fn invalid_credentials() -> AppError { + AppError::new( + StatusCode::UNAUTHORIZED, + "Invalid credentials", + "InvalidCredentials", + ) +} + fn require_opaque_service(state: &Arc) -> Result, AppError> { state.opaque_service.clone().ok_or_else(|| { AppError::new( diff --git a/src/main.rs b/src/main.rs index 5f53c5f8..317a7dda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -790,11 +790,16 @@ async fn run() -> Result<(), Box> { auth_middleware, )) .with_state(app_state.clone()); - // OPAQUE register routes — require auth + CSRF. The handlers - // return 503 `OpaqueDisabled` when the substrate isn't wired - // (mode=off or password auth disabled), so mounting them - // unconditionally is safe: the mode gate lives in the DI - // factory, not the router. + // OPAQUE aPAKE routes — nested under DISTINCT sub-prefixes + // so axum doesn't cross-apply middleware between the two + // branches (`.nest("/api/auth", A).nest("/api/auth", B)` + // composes their layers on shared prefixes; distinct + // prefixes avoid that entirely). + // + // Handlers return 503 `OpaqueDisabled` when the substrate + // isn't wired (mode=off / password auth disabled); the mode + // gate lives in the DI factory, so mounting unconditionally + // is safe. let opaque_register_protected = oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_register_routes() .layer(axum::middleware::from_fn(csrf_middleware)) @@ -803,6 +808,13 @@ async fn run() -> Result<(), Box> { auth_middleware, )) .with_state(app_state.clone()); + let opaque_login_public = + oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_login_routes() + .layer(axum::middleware::from_fn_with_state( + login_limiter.clone(), + rate_limit_login, + )) + .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( @@ -909,12 +921,19 @@ async fn run() -> Result<(), Box> { "/api/auth", app_pw_protected.layer(access_log!("http::api::auth")), ) - // OPAQUE aPAKE — session-required register endpoints. Login - // endpoints (public) are mounted in a later Phase 1 step. + // OPAQUE aPAKE — session-required register endpoints + // (mounted under a distinct sub-prefix so auth+CSRF + // don't bleed into the sibling login mount). .nest( - "/api/auth", + "/api/auth/opaque/register", opaque_register_protected.layer(access_log!("http::api::auth")), ) + // OPAQUE aPAKE — public login endpoints (KE1 + KE3). + // Rate-limit shared with legacy login above. + .nest( + "/api/auth/opaque/login", + opaque_login_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 0f2c847e..c74dc5a2 100644 --- a/tests/api/opaque_substrate.hurl +++ b/tests/api/opaque_substrate.hurl @@ -56,24 +56,12 @@ Content-Type: application/json HTTP 401 # ───────────────────────────────────────────────────────────── -# Case 3 — Login KE1 endpoint not routed (401 anti-enum). -# Will flip to 400 in Phase 1 (public + malformed body). +# NOTE — The Phase 0 "login endpoints 401 anti-enum" cases were +# retired at Phase 1 landing. KE1 / KE3 are now routed (public, +# rate-limited). Cases 7 & 8 below assert the Phase 1 shape: +# KE1 400 `OpaqueMalformedRequest` on bad body, KE3 401 +# `InvalidCredentials` on unknown exchange_id. # ───────────────────────────────────────────────────────────── -POST {{base_url}}/api/auth/opaque/login/ke1 -Content-Type: application/json -{ "userIdentifier": "{{username}}", "startLoginRequest": "unused-phase-0" } - -HTTP 401 - -# ───────────────────────────────────────────────────────────── -# Case 4 — Login KE3 endpoint not routed (401 anti-enum). -# Will flip to 400 in Phase 1 (public + malformed body). -# ───────────────────────────────────────────────────────────── -POST {{base_url}}/api/auth/opaque/login/ke3 -Content-Type: application/json -{ "exchangeId": "unused-phase-0", "finishLoginRequest": "unused-phase-0" } - -HTTP 401 # ============================================================= @@ -143,3 +131,48 @@ Content-Type: application/json HTTP 400 [Asserts] jsonpath "$.error_type" == "OpaqueCiphersuiteMismatch" + + +# ============================================================= +# Phase 1 — Login endpoints (KE1 / KE3, public + rate-limited) +# ============================================================= +# The KE1 / KE3 endpoints are public — no session required, no +# CSRF (bearer/basic exempt anyway). Rate-limited by the same +# per-IP budget as legacy `/api/auth/login` so an attacker can't +# double their guessing rate by spraying both endpoints. +# +# The full crypto handshake (real opaque-ke bytes) is proved in +# the Rust integration test (in-process, no HTTP). What Hurl +# covers here is the wire wiring: routing exists, error paths +# fire with the stable error_type contract. + +# ───────────────────────────────────────────────────────────── +# Case 7 — KE1 with garbage base64 → 400 OpaqueMalformedRequest. +# Proves the endpoint is publicly reachable (no auth +# required — no 401), the JSON body is parsed, and the +# malformed-base64 error path is stable. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/opaque/login/ke1 +Content-Type: application/json +{ "userIdentifier": "{{username}}", "startLoginRequest": "not-valid-base64!" } + +HTTP 400 +[Asserts] +jsonpath "$.error_type" == "OpaqueMalformedRequest" + + +# ───────────────────────────────────────────────────────────── +# Case 8 — KE3 with an unknown exchange_id → 401 +# InvalidCredentials. The `exchange_id` handle is +# single-use and 60s-TTL; unknown ids MUST return the +# SAME error shape as a wrong-passphrase failure so +# attackers can't distinguish "id expired" from +# "wrong password" from "id already consumed". +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/opaque/login/ke3 +Content-Type: application/json +{ "exchangeId": "00000000-0000-0000-0000-000000000000", "finishLoginRequest": "AAAA" } + +HTTP 401 +[Asserts] +jsonpath "$.error_type" == "InvalidCredentials"