/** * OPAQUE aPAKE (RFC 9807) client wrapper. Phase 0 substrate — endpoints * are wired in Phase 1; this module ships the primitives so the login form * can adopt them in a single small change once the handlers land. * * The passphrase never leaves this file. All `password` inputs flow into * the WASM handshake exclusively; nothing serialises them to the network * or logs them. Callers are responsible for clearing their own copy from * component state as soon as the returned promise settles. * * ## Wire shape * * Two HTTP round-trips per operation, matching what the backend expects: * * Registration (only reachable while already authenticated — Phase 1 flow): * ``` * POST /api/auth/opaque/register/start { registrationRequest } * → { registrationResponse } * POST /api/auth/opaque/register/finish { registrationRecord, ciphersuiteVersion } * → 204 No Content * ``` * * Login (unauthenticated): * ``` * POST /api/auth/opaque/login/ke1 { userIdentifier, startLoginRequest } * → { exchangeId, loginResponse } * POST /api/auth/opaque/login/ke3 { exchangeId, finishLoginRequest } * → { user, access_token, refresh_token, ... } // same AuthResponse shape * ``` * * The KSF params are frozen at first-time registration into * `auth.users.opaque_ciphersuite_version` server-side; the client must * always send matching params on login — that's what [`OpaqueKsfConfig`] * carries. In Phase 1 the config is fetched from `/api/health` (or a * dedicated `/api/auth/opaque/params` endpoint) at page load and cached. */ import { ApiError, apiFetch } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import type { AuthResponse } from '$lib/api/types'; /** * Lazily load the `@serenity-kit/opaque` WASM module. A top-level * static import here would trigger `WebAssembly.compile()` the moment * ANYONE imports this file — including the read-only helpers * (`fetchOpaqueParams`, `checkOpaqueAvailable`) that don't need * crypto at all. Under a strict CSP without `'wasm-unsafe-eval'` * (which is the default posture and what Playwright's SPA suite * exercises), that fails at import time and crashes the login page * even in OPAQUE-off deployments where the substrate is disabled. * * Hoisting the import into this async helper defers WASM * compilation to the first crypto call site. The read-only paths * never fire it; only `opaqueRegister` / `opaqueLogin` do — and * those callers already know they need the WASM (they've already * confirmed `params.enabled === true` upstream). * * Awaits `ready` before returning so callers get a fully-initialized * client. `import()` is memoized by the module loader so subsequent * calls hit the same instance. */ async function opaqueWasm(): Promise { const mod = await import('@serenity-kit/opaque'); await mod.ready; return mod.client; } /** * Client-side Argon2id parameters — must match the server's config * ([`OpaqueConfig::ksf_*`] in Rust). Fetched from the server at page load * so a bump in either direction stays in lock-step; hardcoded fallbacks * mirror the Rust defaults for offline dev. */ export interface OpaqueKsfConfig { /** Memory cost in KiB. Server default: 262144 (256 MiB). */ memoryKib: number; /** Iterations. Server default: 3. */ iterations: number; /** Parallelism / lanes. Server default: 4. */ 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; } /** * Ask the server whether `userIdentifier` resolves to a user with an * OPAQUE envelope on file. The SPA login form calls this before * submit to decide between OPAQUE (KE1/KE3) and legacy password * login. The two paths converge to the same session shape, so the * user never notices the branch. * * Returns `false` on any error — network hiccup, disabled substrate, * malformed response — so callers fall back to legacy login rather * than blocking on the OPAQUE probe. The Phase 2 silent-migration * hook will still run after the legacy login and mint the envelope, * so this transient "false" just delays adoption by one login cycle. * * The server-side shape is anti-enum: same `hasOpaque: false` for * both "unknown user" and "user without envelope." Callers must * never assume `hasOpaque: false` implies the user exists. */ /** * Result of `checkOpaqueAvailable`. `has: true` means the user has an * OPAQUE envelope on file — take the OPAQUE login branch. `ksf` is the * server-echoed KSF from the envelope: when present, the client MUST * use these values (not `/params`) on the login handshake, so a KSF * config change on the server side doesn't invalidate historical * envelopes. `ksf === null` means the envelope predates per-envelope * KSF storage — fall back to `/params` values. */ export interface OpaqueLookupResult { has: boolean; ksf: OpaqueKsfConfig | null; } export async function checkOpaqueAvailable(userIdentifier: string): Promise { // Cheap short-circuit: if the substrate isn't enabled server-side, // the endpoint would return 503 anyway. `syncOpaqueEnvelope` // primed the cache after any prior login in this session; this // call reuses it. const params = await fetchOpaqueParams(); if (!params.enabled) return { has: false, ksf: null }; try { const res = await apiFetch('/api/auth/opaque/login/lookup', { method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: JSON.stringify({ userIdentifier }) }); if (!res.ok) return { has: false, ksf: null }; const body = (await res.json()) as { hasOpaque?: boolean; ksf?: OpaqueKsfConfig; }; if (body.hasOpaque !== true) return { has: false, ksf: null }; return { has: true, ksf: body.ksf ?? null }; } catch { return { has: false, ksf: null }; } } /** * 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. */ function ksfOption(cfg: OpaqueKsfConfig) { return { 'argon2id-custom': { iterations: cfg.iterations, memory: cfg.memoryKib, parallelism: cfg.parallelism } } as const; } /** * Best-effort parse of the backend `ErrorResponse` shape into a stable * pair. Never throws; mirrors the same shape `auth.ts` uses. */ async function parseErrorBody(res: Response): Promise<{ errorType?: string; message?: string }> { try { const body = (await res.clone().json()) as { error_type?: unknown; message?: unknown; error?: unknown; }; const errorType = typeof body.error_type === 'string' ? body.error_type : undefined; const rawMessage = (typeof body.message === 'string' ? body.message : undefined) ?? (typeof body.error === 'string' ? body.error : undefined); return { errorType, message: rawMessage }; } catch { return {}; } } /** * Register an OPAQUE envelope for the currently-authenticated caller. * Two HTTP round-trips; the WASM handshake runs entirely client-side. * * Called by the migration hook after any successful legacy-password * login (Phase 2) and by the change-password / password-reset flows * (Phase 1+) so the envelope stays in lock-step with the passphrase. * * Throws [`ApiError`] with the parsed `error_type` on server rejection; * throws a plain [`Error`] on protocol failures. */ export async function opaqueRegister( password: string, ksf: OpaqueKsfConfig, ciphersuiteVersion: number ): Promise { const client = await opaqueWasm(); // ── Round 1 ───────────────────────────────────────────────────────── const { clientRegistrationState, registrationRequest } = client.startRegistration({ password }); const startRes = await apiFetch('/api/auth/opaque/register/start', { method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: JSON.stringify({ registrationRequest }) }); if (!startRes.ok) { const { errorType, message } = await parseErrorBody(startRes); throw new ApiError( startRes.status, startRes.statusText, '/api/auth/opaque/register/start', errorType, message ?? 'opaque register start failed' ); } const { registrationResponse } = (await startRes.json()) as { registrationResponse: string }; // ── Round 2 ───────────────────────────────────────────────────────── const { registrationRecord } = client.finishRegistration({ password, registrationResponse, clientRegistrationState, keyStretching: ksfOption(ksf) }); // Declare the KSF we ACTUALLY used to the server so it persists // them per-envelope. Server falls back to its current config when // omitted (older-client compat), but declaring them ensures the // stored values reflect exactly what this handshake used — future // KSF config changes then won't invalidate this envelope on login. const finishRes = await apiFetch('/api/auth/opaque/register/finish', { method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: JSON.stringify({ registrationRecord, ciphersuiteVersion, ksfMemoryKib: ksf.memoryKib, ksfIterations: ksf.iterations, ksfParallelism: ksf.parallelism }) }); if (!finishRes.ok) { const { errorType, message } = await parseErrorBody(finishRes); throw new ApiError( finishRes.status, finishRes.statusText, '/api/auth/opaque/register/finish', errorType, message ?? 'opaque register finish failed' ); } } /** * Log in via OPAQUE. Two HTTP round-trips; server issues the session * cookies + refresh token on successful `ke3`. * * Returns the [`AuthResponse`] identical in shape to the legacy login * path, so callers (`LoginForm.svelte`) can flow both branches through a * single downstream handler. * * Throws [`ApiError`] with the parsed `error_type` on server rejection * — including the anti-enumeration case where the account has no * envelope (server returns the same `InvalidCredentials` code as a * wrong-passphrase failure to avoid leaking which one it was). */ export async function opaqueLogin( userIdentifier: string, password: string, ksf: OpaqueKsfConfig, /** * DPoP JWK thumbprint (RFC 7638) to bind the resulting session to * this browser's keypair. `null` → session created unbound (fail- * open per `docs/plan/dpop.md`; the caller in `endpoints/auth.ts` * already tried to compute the thumbprint and swallowed failures). */ dpopJkt: string | null ): Promise { const client = await opaqueWasm(); // ── KE1 ───────────────────────────────────────────────────────────── const { clientLoginState, startLoginRequest } = client.startLogin({ password }); const ke1Res = await apiFetch('/api/auth/opaque/login/ke1', { method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: JSON.stringify({ userIdentifier, startLoginRequest }) }); if (!ke1Res.ok) { const { errorType, message } = await parseErrorBody(ke1Res); throw new ApiError( ke1Res.status, ke1Res.statusText, '/api/auth/opaque/login/ke1', errorType, message ?? 'opaque login ke1 failed' ); } const { exchangeId, loginResponse } = (await ke1Res.json()) as { exchangeId: string; loginResponse: string; }; // ── KE3 ───────────────────────────────────────────────────────────── // `finishLogin` returns undefined when the server response is // well-formed but the passphrase is wrong — surface that as a // terminal client-side failure (never reaches the server) rather // than sending garbage to KE3. const finished = client.finishLogin({ clientLoginState, loginResponse, password, keyStretching: ksfOption(ksf) }); if (!finished) { throw new ApiError( 401, 'Unauthorized', '/api/auth/opaque/login/ke1', 'InvalidCredentials', 'invalid credentials' ); } const { finishLoginRequest } = finished; const ke3Res = await apiFetch('/api/auth/opaque/login/ke3', { method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: JSON.stringify({ exchangeId, finishLoginRequest, ...(dpopJkt ? { dpopJkt } : {}) }) }); if (!ke3Res.ok) { const { errorType, message } = await parseErrorBody(ke3Res); throw new ApiError( ke3Res.status, ke3Res.statusText, '/api/auth/opaque/login/ke3', errorType, message ?? 'opaque login ke3 failed' ); } return (await ke3Res.json()) as AuthResponse; }