diff --git a/frontend/src/lib/api/endpoints/opaque.test.ts b/frontend/src/lib/api/endpoints/opaque.test.ts index b4f2e8a0..45517822 100644 --- a/frontend/src/lib/api/endpoints/opaque.test.ts +++ b/frontend/src/lib/api/endpoints/opaque.test.ts @@ -54,7 +54,13 @@ vi.mock('@serenity-kit/opaque', () => ({ import { apiFetch, ApiError } from '$lib/api/client'; import * as opaque from '@serenity-kit/opaque'; -import { opaqueLogin, opaqueRegister } from './opaque'; +import { + __resetOpaqueParamsCache, + fetchOpaqueParams, + opaqueLogin, + opaqueRegister, + syncOpaqueEnvelope +} from './opaque'; const f = apiFetch as unknown as ReturnType; const fin = opaque.client.finishLogin as unknown as ReturnType; @@ -87,6 +93,10 @@ function errJson(status: number, body: unknown) { beforeEach(() => { vi.clearAllMocks(); + // Reset the params-fetch cache — tests below assert first-call + // behaviour, and the singleton cache would carry a prior test's + // resolved params into the next test if left alone. + __resetOpaqueParamsCache(); // Reset finishLogin to the truthy default; individual tests override. fin.mockReturnValue({ finishLoginRequest: 'FIN-L', @@ -193,3 +203,61 @@ describe('opaqueLogin', () => { expect(err).toMatchObject({ status: 429, errorType: 'RateLimited' }); }); }); + +describe('fetchOpaqueParams', () => { + it('returns the payload and caches it (second call = no HTTP)', async () => { + f.mockResolvedValueOnce(okJson({ enabled: true, ciphersuiteVersion: 1, ksf: KSF })); + const first = await fetchOpaqueParams(); + expect(first.enabled).toBe(true); + expect(first.ciphersuiteVersion).toBe(1); + expect(first.ksf).toEqual(KSF); + + // Second call MUST NOT hit the wire — the operator contract is + // that params change requires a page reload, so caching is safe. + const second = await fetchOpaqueParams(); + expect(second).toEqual(first); + expect(f).toHaveBeenCalledTimes(1); + }); + + it('degrades to enabled=false on a broken /params (no crash)', async () => { + f.mockResolvedValueOnce(errJson(500, {})); + const params = await fetchOpaqueParams(); + expect(params.enabled).toBe(false); + }); +}); + +describe('syncOpaqueEnvelope', () => { + it('is a no-op when params.enabled=false', async () => { + f.mockResolvedValueOnce(okJson({ enabled: false, ciphersuiteVersion: 0, ksf: KSF })); + await syncOpaqueEnvelope('any-password'); + // Only the /params fetch — no register/start or register/finish. + expect(f).toHaveBeenCalledTimes(1); + expect(f.mock.calls[0][0]).toBe('/api/auth/opaque/params'); + }); + + it('runs the register handshake when params.enabled=true', async () => { + f.mockResolvedValueOnce(okJson({ enabled: true, ciphersuiteVersion: 1, ksf: KSF })) + .mockResolvedValueOnce(okJson({ registrationResponse: 'RESP-R' })) + .mockResolvedValueOnce(okJson({}, 204)); + await syncOpaqueEnvelope('correct horse battery staple'); + // /params + /register/start + /register/finish + expect(f).toHaveBeenCalledTimes(3); + expect(f.mock.calls[0][0]).toBe('/api/auth/opaque/params'); + expect(f.mock.calls[1][0]).toBe('/api/auth/opaque/register/start'); + expect(f.mock.calls[2][0]).toBe('/api/auth/opaque/register/finish'); + }); + + it('swallows opaqueRegister errors — silent-migration retry recovers', async () => { + // /params succeeds, register/start returns a server error. The + // contract is "swallow, log, don't throw" so the caller (change- + // password success handler) doesn't surface a user-facing toast + // for a migration-hint step they didn't ask for. + f.mockResolvedValueOnce( + okJson({ enabled: true, ciphersuiteVersion: 1, ksf: KSF }) + ).mockResolvedValueOnce(errJson(500, { error_type: 'InternalError' })); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await expect(syncOpaqueEnvelope('pw')).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); diff --git a/frontend/src/lib/api/endpoints/opaque.ts b/frontend/src/lib/api/endpoints/opaque.ts index b10ee71d..3c8c35f9 100644 --- a/frontend/src/lib/api/endpoints/opaque.ts +++ b/frontend/src/lib/api/endpoints/opaque.ts @@ -54,6 +54,95 @@ export interface OpaqueKsfConfig { parallelism: number; } +/** + * Wire shape of `GET /api/auth/opaque/params`. `enabled: false` means + * the server's OPAQUE substrate is off — the SPA should short-circuit + * all `syncOpaqueEnvelope` / `opaqueLogin` calls and stay on the legacy + * password path. Numeric fields carry safe defaults regardless so a + * client that ignored the flag wouldn't nil-deref. + */ +export interface OpaqueServerParams { + enabled: boolean; + ciphersuiteVersion: number; + ksf: OpaqueKsfConfig; +} + +/** + * In-memory cache of the params response. Fetched once per page load + * (per SPA runtime), invalidated only by a hard refresh — this matches + * the operator contract that changing OPAQUE env vars requires a server + * restart, and the SPA reload that follows picks up the new values. + * + * Unresolved `null` = we haven't tried yet. A settled promise (or a + * thrown one) is what subsequent callers await, so concurrent first + * touches collapse into ONE `GET /params` round-trip. + */ +let opaqueParamsInflight: Promise | null = null; + +/** + * Test-only: drop the params cache so the next call re-fetches. + * Exposed as `__resetOpaqueParamsCache` to signal "internal — call + * from tests only." Runtime code MUST NOT use this; the operator + * contract is that params change requires a page reload. + */ +export function __resetOpaqueParamsCache(): void { + opaqueParamsInflight = null; +} + +/** Fetch (and cache) the server's OPAQUE params. See [`OpaqueServerParams`]. */ +export function fetchOpaqueParams(): Promise { + if (opaqueParamsInflight) return opaqueParamsInflight; + opaqueParamsInflight = (async () => { + const res = await apiFetch('/api/auth/opaque/params', { + credentials: 'same-origin' + }); + if (!res.ok) { + // Treat a broken /params as "OPAQUE not available" rather + // than propagating an error — the SPA should degrade to + // legacy password auth, not crash. Cache the negative + // result so we don't hammer a broken endpoint. + return { + enabled: false, + ciphersuiteVersion: 0, + ksf: { memoryKib: 0, iterations: 0, parallelism: 0 } + }; + } + return (await res.json()) as OpaqueServerParams; + })(); + return opaqueParamsInflight; +} + +/** + * Silent OPAQUE registration after a passphrase-touching action + * (signup completion, change-password, silent migration on legacy + * login). Fetches params on demand, runs the two-round OPAQUE + * register handshake with `password`, and swallows errors — a + * failure here leaves the envelope stale, but a subsequent legacy + * login will retry via the silent-migration hook. Callers should + * clear their local copy of `password` from memory as soon as this + * settles (either await or catch — the promise resolves in both + * paths so `.finally(() => clearPw())` is the idiomatic wire). + * + * Callers MUST hold a valid session — the register endpoints are + * session-authenticated (they bind the envelope to the current + * user_id). Post-signup / post-change-password sessions qualify. + */ +export async function syncOpaqueEnvelope(password: string): Promise { + const params = await fetchOpaqueParams(); + if (!params.enabled) return; // Substrate off — no-op. + try { + await opaqueRegister(password, params.ksf, params.ciphersuiteVersion); + } catch (err) { + // Non-fatal: legacy login still works, silent migration will + // retry on next legacy /api/auth/login. Log to console so a + // developer poking at DevTools sees the failure but the user + // doesn't get a confusing toast for something they didn't ask + // for. Reset the cache so the next call re-probes /params — + // the failure might have been a transient outage. + console.warn('OPAQUE envelope sync failed (silent migration will retry):', err); + } +} + const JSON_HEADERS = { 'Content-Type': 'application/json' }; /** Build the shape `@serenity-kit/opaque` expects for its `keyStretching` opt. */ diff --git a/frontend/src/lib/api/endpoints/profile.ts b/frontend/src/lib/api/endpoints/profile.ts index 0360168b..cca916ae 100644 --- a/frontend/src/lib/api/endpoints/profile.ts +++ b/frontend/src/lib/api/endpoints/profile.ts @@ -68,6 +68,19 @@ export async function changePassword(currentPw: string, newPw: string): Promise< body: JSON.stringify({ current_password: currentPw, new_password: newPw }) }); if (!res.ok) throw new Error(`password change failed: ${res.status}`); + // Re-mint the OPAQUE envelope under the new passphrase — session + // stays valid across change-password (backend doesn't invalidate), + // so the session-authenticated register endpoints are reachable + // straight away. Non-fatal on failure: silent migration on next + // legacy login recovers the envelope. See + // `$lib/api/endpoints/opaque.ts::syncOpaqueEnvelope`. + // + // Dynamic import keeps the ~200 KiB `@serenity-kit/opaque` WASM + // bundle out of the profile route's initial chunk — the module + // only loads for users who actually reach the change-password + // success path. + const { syncOpaqueEnvelope } = await import('$lib/api/endpoints/opaque'); + await syncOpaqueEnvelope(newPw); } export async function updateAvatar(image: string | null): Promise { diff --git a/src/infrastructure/repositories/pg/opaque_pg_repository.rs b/src/infrastructure/repositories/pg/opaque_pg_repository.rs index 617edd94..7797a900 100644 --- a/src/infrastructure/repositories/pg/opaque_pg_repository.rs +++ b/src/infrastructure/repositories/pg/opaque_pg_repository.rs @@ -468,41 +468,41 @@ mod integration_tests { #[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; + 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(); + 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: (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(); + 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, diff --git a/src/infrastructure/services/opaque_login_exchange.rs b/src/infrastructure/services/opaque_login_exchange.rs index 37ff3e5d..9034527b 100644 --- a/src/infrastructure/services/opaque_login_exchange.rs +++ b/src/infrastructure/services/opaque_login_exchange.rs @@ -256,7 +256,10 @@ mod tests { let id = cache.store(state, Some(Uuid::new_v4())); // Second call after take must miss — single-use semantic. 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!( + 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" @@ -306,6 +309,9 @@ mod tests { 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"); + 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 d8f08fcc..bf416984 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -76,9 +76,7 @@ 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_login_exchange::{ExchangeId, OpaqueLoginExchange}; use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite}; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUserId; @@ -381,9 +379,9 @@ pub async fn login_ke1( 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_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"))?; @@ -513,9 +511,9 @@ pub async fn login_ke3( invalid_credentials() })?; - let cred_bytes = B64.decode(dto.finish_login_request.trim()).map_err(|_| { - malformed("finishLoginRequest is not valid base64") - })?; + 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"))?; @@ -552,22 +550,19 @@ pub async fn login_ke3( // 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() - })?; + 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), @@ -653,9 +648,7 @@ pub struct OpaqueParamsResponse { ), tag = "auth" )] -pub async fn opaque_params( - State(state): State>, -) -> impl IntoResponse { +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