From fac65a7c4d5aae9bd0461fff058663a28d9889a6 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 1 Aug 2026 17:05:22 +0200 Subject: [PATCH] feat(opaque): add lookup identifier (with anti-enum) --- frontend/src/lib/api/endpoints/auth.test.ts | 212 +++++++++++++++--- frontend/src/lib/api/endpoints/auth.ts | 56 ++++- frontend/src/lib/api/endpoints/opaque.ts | 39 ++++ .../api/handlers/opaque_auth_handler.rs | 85 +++++++ tests/api/opaque_substrate.hurl | 67 ++++++ 5 files changed, 414 insertions(+), 45 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.test.ts b/frontend/src/lib/api/endpoints/auth.test.ts index 43570ef3..937113f1 100644 --- a/frontend/src/lib/api/endpoints/auth.test.ts +++ b/frontend/src/lib/api/endpoints/auth.test.ts @@ -1,8 +1,9 @@ import { it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) })); -// Mock the OPAQUE WASM client so `login()`'s silent-migration hook can -// exercise the wire path (params + register handshake) without touching +// Mock the OPAQUE WASM client so `login()`'s Phase 2 silent-migration +// hook and Phase 3 lookup-then-login flip can exercise the wire path +// (params + lookup + register or ke1/ke3 handshake) without touching // real WASM in jsdom. vi.mock('@serenity-kit/opaque', () => ({ ready: Promise.resolve(), @@ -15,6 +16,16 @@ vi.mock('@serenity-kit/opaque', () => ({ registrationRecord: 'RECORD-R', exportKey: 'EK', serverStaticPublicKey: 'SPK' + })), + startLogin: vi.fn(() => ({ + clientLoginState: 'STATE-L', + startLoginRequest: 'REQ-L' + })), + finishLogin: vi.fn(() => ({ + finishLoginRequest: 'REQ-F', + sessionKey: 'SK', + exportKey: 'EK', + serverStaticPublicKey: 'SPK' })) } })); @@ -66,38 +77,114 @@ it('tryRefresh returns false when the refresh fails', async () => { await expect(auth.tryRefresh()).resolves.toBe(false); }); -// ── Phase 2: silent OPAQUE migration on legacy login ───────────────── +// ── Phase 2 + 3: OPAQUE lookup, silent migration, and login flip ───── // -// `login()` MUST trigger `syncOpaqueEnvelope(password)` after a -// successful POST /api/auth/login response, so users pick up an OPAQUE -// envelope automatically over time. Verified here by observing the -// wire calls made after the login POST — a successful login without -// a follow-up `/api/auth/opaque/*` fetch is a regression that would -// silently break Phase 3's cutover assumption ("most active users -// have an envelope on file by the time the SPA flips to OPAQUE"). -it('login triggers OPAQUE silent-migration on success', async () => { - // Login POST → 200 AuthResponse - // Then syncOpaqueEnvelope runs: GET /params → POST register/start → POST register/finish +// `login()` MUST first probe the server for an OPAQUE envelope via +// `POST /api/auth/opaque/login/lookup`: +// - if the response says `hasOpaque: true` → dispatch to +// `opaqueLogin` (KE1/KE3) and skip the legacy path entirely +// (Phase 3 cutover); +// - if `false` → fall through to `POST /api/auth/login` (legacy), +// then run `syncOpaqueEnvelope(password)` to silently mint the +// envelope for the NEXT login (Phase 2 silent migration). +// +// Regression risks these tests guard: +// - The lookup POST goes missing (Phase 3 flip never fires — every +// login stays on legacy forever, defeats the OPAQUE substrate). +// - The silent-migration hook goes missing on the legacy branch +// (envelopes never get minted, so the lookup always returns +// false — same net effect as above). +// - The OPAQUE branch silently falls back to legacy on any WASM +// hiccup (would mask a wrong passphrase as a network error). +it('login flips to OPAQUE when the lookup reports hasOpaque: true', async () => { + // Order on the wire, given the current implementation: + // 1. GET /api/auth/opaque/params (via checkOpaqueAvailable → fetchOpaqueParams) + // 2. POST /api/auth/opaque/login/lookup → { hasOpaque: true } + // 3. POST /api/auth/opaque/login/ke1 → { exchangeId, loginResponse } + // 4. POST /api/auth/opaque/login/ke3 → AuthResponse + // The legacy /api/auth/login POST must NOT fire on this branch. f.mockResolvedValueOnce({ ok: true, status: 200, statusText: 'OK', json: async () => ({ - user: { id: 'u1', email: 'a@x.test' }, - access_token: 'at', - refresh_token: 'rt', - token_type: 'Bearer', - expires_in: 3600 + enabled: true, + ciphersuiteVersion: 1, + ksf: { memoryKib: 8, iterations: 1, parallelism: 1 } }) }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ hasOpaque: true }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ exchangeId: 'ex-1', loginResponse: 'LR' }) + }) .mockResolvedValueOnce({ ok: true, status: 200, statusText: 'OK', json: async () => ({ - enabled: true, - ciphersuiteVersion: 1, - ksf: { memoryKib: 8, iterations: 1, parallelism: 1 } + user: { id: 'u1', email: 'a@x.test' }, + access_token: 'at-opaque', + refresh_token: 'rt-opaque', + token_type: 'Bearer', + expires_in: 3600 + }) + }); + + const authResponse = await auth.login('alice@example.com', 'correct horse battery staple'); + expect(authResponse.access_token).toBe('at-opaque'); + + const urls = f.mock.calls.map((c: unknown[]) => c[0] as string); + expect(urls).toEqual([ + '/api/auth/opaque/params', + '/api/auth/opaque/login/lookup', + '/api/auth/opaque/login/ke1', + '/api/auth/opaque/login/ke3' + ]); + // Legacy MUST NOT run when we took the OPAQUE branch. + expect(urls).not.toContain('/api/auth/login'); +}); + +it('login falls back to legacy + silent-migration when hasOpaque: false', async () => { + // Order on the wire: + // 1. GET /api/auth/opaque/params + // 2. POST /api/auth/opaque/login/lookup → { hasOpaque: false } + // 3. POST /api/auth/login → AuthResponse + // 4. POST /api/auth/opaque/register/start (params is cached — no re-fetch) + // 5. POST /api/auth/opaque/register/finish + f.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + enabled: true, + ciphersuiteVersion: 1, + ksf: { memoryKib: 8, iterations: 1, parallelism: 1 } + }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ hasOpaque: false }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + user: { id: 'u1', email: 'a@x.test' }, + access_token: 'at', + refresh_token: 'rt', + token_type: 'Bearer', + expires_in: 3600 }) }) .mockResolvedValueOnce({ @@ -111,23 +198,31 @@ it('login triggers OPAQUE silent-migration on success', async () => { const authResponse = await auth.login('alice@example.com', 'correct horse battery staple'); expect(authResponse.access_token).toBe('at'); - // 4 apiFetch calls: login + params + register/start + register/finish const urls = f.mock.calls.map((c: unknown[]) => c[0] as string); - expect(urls).toContain('/api/auth/login'); - expect(urls).toContain('/api/auth/opaque/params'); - expect(urls).toContain('/api/auth/opaque/register/start'); - expect(urls).toContain('/api/auth/opaque/register/finish'); - // Login POST must come FIRST — silent migration only runs on a - // session that's already been established. - expect(urls[0]).toBe('/api/auth/login'); + expect(urls).toEqual([ + '/api/auth/opaque/params', + '/api/auth/opaque/login/lookup', + '/api/auth/login', + '/api/auth/opaque/register/start', + '/api/auth/opaque/register/finish' + ]); }); -it('login returns AuthResponse even when silent-migration fails (non-fatal)', async () => { - // Login POST succeeds; the follow-up /params returns 500. Silent - // migration swallows the error (console.warn) — the login itself - // still returns success to the caller, so the user reaches their - // session and the envelope gets retried on next login. +it('login skips the OPAQUE branch entirely when the substrate is disabled', async () => { + // /params replies `enabled: false` (mode=off or misconfig). The + // lookup POST MUST NOT fire (cheap short-circuit inside + // checkOpaqueAvailable), and the legacy silent-migration hook + // MUST also short-circuit — no register/start calls either. f.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + enabled: false, + ciphersuiteVersion: 0, + ksf: { memoryKib: 0, iterations: 0, parallelism: 0 } + }) + }).mockResolvedValueOnce({ ok: true, status: 200, statusText: 'OK', @@ -138,7 +233,54 @@ it('login returns AuthResponse even when silent-migration fails (non-fatal)', as token_type: 'Bearer', expires_in: 3600 }) - }).mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) }); + }); + + const authResponse = await auth.login('alice@example.com', 'pw'); + expect(authResponse.access_token).toBe('at'); + + const urls = f.mock.calls.map((c: unknown[]) => c[0] as string); + expect(urls).toEqual(['/api/auth/opaque/params', '/api/auth/login']); +}); + +it('login returns AuthResponse even when silent-migration fails (non-fatal)', async () => { + // Wire order: /params (enabled=true) → lookup (false) → legacy + // login (200) → /params is cached, skip → register/start fails + // with 500 → syncOpaqueEnvelope logs a console.warn and returns, + // login() still returns the AuthResponse to the caller. + f.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + enabled: true, + ciphersuiteVersion: 1, + ksf: { memoryKib: 8, iterations: 1, parallelism: 1 } + }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ hasOpaque: false }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + user: { id: 'u1', email: 'a@x.test' }, + access_token: 'at', + refresh_token: 'rt', + token_type: 'Bearer', + expires_in: 3600 + }) + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({}) + }); const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const authResponse = await auth.login('alice@example.com', 'pw'); diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index b2c045e3..48d6b81a 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -64,6 +64,34 @@ export async function tryRefresh(): Promise { } export async function login(emailOrUsername: string, password: string): Promise { + // ── OPAQUE lookup (Phase 3) ──────────────────────────────────────── + // Ask the server whether this identifier already has an OPAQUE + // envelope on file. If yes → use OPAQUE login (KE1/KE3). If no → + // fall back to legacy `POST /api/auth/login`, which then silently + // mints the envelope via the Phase 2 hook. + // + // Both paths return the SAME `AuthResponse` shape, so downstream + // callers don't need to know which branch fired. Dynamic import + // keeps the ~200 KiB `@serenity-kit/opaque` WASM bundle out of + // pre-login bundles — the module loads only when a login is + // actually attempted. + // + // `checkOpaqueAvailable` and `opaqueLogin` both fall back + // gracefully: any wire failure inside the OPAQUE branch (503, + // timeout, malformed response) either short-circuits to `false` + // (lookup) or throws with an `InvalidCredentials` shape (login). + // The lookup fallback lands here as `false` → legacy branch + // takes over; a mid-login OPAQUE failure surfaces as a login + // error to the user, same shape as a legacy failure — no silent + // legacy fallback there because it would mask a wrong passphrase + // as a network hiccup. + const { checkOpaqueAvailable, opaqueLogin, syncOpaqueEnvelope } = + await import('$lib/api/endpoints/opaque'); + if (await checkOpaqueAvailable(emailOrUsername)) { + return await opaqueLogin(emailOrUsername, password, await opaqueKsfForClient()); + } + + // ── Legacy login (fallback for users without an envelope yet) ───── const res = await apiFetch('/api/auth/login', { method: 'POST', credentials: 'same-origin', @@ -81,11 +109,8 @@ export async function login(emailOrUsername: string, password: string): Promise< // ── OPAQUE silent migration (Phase 2) ────────────────────────────── // After a successful legacy password login, transparently mint an - // OPAQUE envelope for the same passphrase. Users pick up the new - // auth path over time, one login at a time, with zero UX change — - // by the time Phase 3 flips the SPA to OPAQUE-first and Phase 4 - // refuses legacy for migrated users, most active accounts already - // have an envelope on file. + // OPAQUE envelope for the same passphrase. The NEXT login for this + // account will take the OPAQUE branch above. // // The freshly-issued session cookie is already active in the // browser at this point (Set-Cookie from the POST response), so @@ -94,16 +119,27 @@ export async function login(emailOrUsername: string, password: string): Promise< // disabled server-side (mode=off) and swallows any error — a // failure here just leaves the envelope stale, and the NEXT // legacy login retries the same hook. - // - // Dynamic import keeps the ~200 KiB `@serenity-kit/opaque` WASM - // bundle out of pre-login-page bundles — it loads only for users - // who actually reach a successful login. - const { syncOpaqueEnvelope } = await import('$lib/api/endpoints/opaque'); await syncOpaqueEnvelope(password); return auth; } +/** + * Fetch the server's OPAQUE KSF config to pass to `opaqueLogin`. + * Extracted so `login()` reads more linearly and the module keeps + * one `fetchOpaqueParams` call site regardless of which branch + * (lookup / login / silent-migration) reaches it first. + */ +async function opaqueKsfForClient(): Promise<{ + memoryKib: number; + iterations: number; + parallelism: number; +}> { + const { fetchOpaqueParams } = await import('$lib/api/endpoints/opaque'); + const params = await fetchOpaqueParams(); + return params.ksf; +} + export interface OidcProviders { enabled: boolean; provider_name?: string; diff --git a/frontend/src/lib/api/endpoints/opaque.ts b/frontend/src/lib/api/endpoints/opaque.ts index 3c8c35f9..dae9c7a2 100644 --- a/frontend/src/lib/api/endpoints/opaque.ts +++ b/frontend/src/lib/api/endpoints/opaque.ts @@ -112,6 +112,45 @@ export function fetchOpaqueParams(): Promise { 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. + */ +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 false; + 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 false; + const body = (await res.json()) as { hasOpaque?: boolean }; + return body.hasOpaque === true; + } catch { + return false; + } +} + /** * Silent OPAQUE registration after a passphrase-touching action * (signup completion, change-password, silent migration on legacy diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index bf416984..5ffca4f2 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -104,8 +104,15 @@ pub fn opaque_register_routes() -> Router> { /// 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. +/// +/// `lookup` lives here (not on the params mount) so it shares the +/// login rate limiter — without that, an attacker could use it as a +/// cheaper user-existence probe than legacy login. Anti-enum on the +/// response shape closes the primary information leak; the rate +/// limit closes the secondary "how fast can I ask" leak. pub fn opaque_login_routes() -> Router> { Router::new() + .route("/lookup", post(login_lookup)) .route("/ke1", post(login_ke1)) .route("/ke3", post(login_ke3)) } @@ -302,6 +309,84 @@ pub async fn register_finish( Ok(StatusCode::NO_CONTENT) } +// ── Login: lookup (Phase 3) ────────────────────────────────────────── + +/// Client → server on lookup. Same identifier shape as +/// [`OpaqueLoginKe1Dto::user_identifier`] and legacy +/// `/api/auth/login`'s `username` field: `@` in the input dispatches +/// to email lookup, absence → username lookup. +#[derive(Debug, Deserialize, ToSchema)] +pub struct OpaqueLookupDto { + #[serde(rename = "userIdentifier")] + pub user_identifier: String, +} + +/// Server → client on lookup. The SPA reads `hasOpaque` to decide +/// whether to run OPAQUE login (KE1/KE3) or fall back to legacy +/// `/api/auth/login` (which then silently registers via the Phase 2 +/// hook). +/// +/// **Anti-enum invariant** — this shape MUST be identical whether +/// the user exists or not: +/// +/// * unknown user → `hasOpaque: false` +/// * user, no envelope → `hasOpaque: false` +/// * user with envelope → `hasOpaque: true` +/// +/// A probing attacker cannot distinguish "unknown" from "known but +/// unregistered" from the response body. The only signal an +/// attacker gets is "known with envelope" vs "everything else," +/// which reveals adoption progress but NOT identity existence. +#[derive(Debug, Serialize, ToSchema)] +pub struct OpaqueLookupResponse { + #[serde(rename = "hasOpaque")] + pub has_opaque: bool, +} + +/// Resolve `userIdentifier` → envelope-existence check. Used by the +/// SPA login form to branch between OPAQUE and legacy on submit. +/// +/// Rate-limited via the shared `login_limiter` (mount layer in +/// `main.rs`), so lookup can't be used as a cheap enumeration probe. +#[utoipa::path( + post, + path = "/api/auth/opaque/login/lookup", + request_body = OpaqueLookupDto, + responses( + (status = 200, description = "Whether the identifier resolves to a user with an OPAQUE envelope", body = OpaqueLookupResponse), + (status = 400, description = "Malformed request body"), + (status = 503, description = "OPAQUE service not configured"), + ), + tag = "auth" +)] +pub async fn login_lookup( + State(state): State>, + Json(dto): Json, +) -> Result { + let _svc = require_opaque_service(&state)?; + let repo = require_opaque_repo(&state)?; + let auth = require_auth_application_service(&state)?; + + let identifier = dto.user_identifier.trim(); + if identifier.is_empty() { + return Err(malformed("userIdentifier is empty")); + } + + // Resolve the identifier → user_id → envelope presence. Any miss + // (unknown user, user without envelope, DB blip) collapses to + // `hasOpaque: false` — the anti-enum contract on the wire shape. + // No audit event here: a successful lookup isn't a login attempt, + // and logging every miss would flood the channel without adding + // signal (rate limiter already caps volume; enumeration attempts + // show up in the login-lockout / rate-limit metrics). + let has_opaque = match auth.lookup_user_for_login(identifier).await { + Ok(user) => matches!(repo.read_registration(user.id()).await, Ok(Some(_))), + Err(_) => false, + }; + + Ok(Json(OpaqueLookupResponse { has_opaque })) +} + // ── Login: KE1 + KE3 ───────────────────────────────────────────────── /// Client → server on KE1. `userIdentifier` is the same string the diff --git a/tests/api/opaque_substrate.hurl b/tests/api/opaque_substrate.hurl index b74d8920..1d17cd94 100644 --- a/tests/api/opaque_substrate.hurl +++ b/tests/api/opaque_substrate.hurl @@ -209,3 +209,70 @@ jsonpath "$.ciphersuiteVersion" == 1 jsonpath "$.ksf.memoryKib" == 8 jsonpath "$.ksf.iterations" == 1 jsonpath "$.ksf.parallelism" == 1 + + +# ============================================================= +# Phase 3 — Login lookup (SPA branch selector) +# ============================================================= +# `POST /api/auth/opaque/login/lookup` is what the SPA hits on +# submit to decide between OPAQUE (KE1/KE3) and legacy +# `/api/auth/login`. It's public (no auth required), rate-limited +# via the shared login limiter, and its response body is anti-enum: +# `hasOpaque: false` covers both "user unknown" and "user known but +# unregistered" so an attacker can't use it as a cheaper user- +# existence probe than legacy login. +# +# The seed admin logged in above (line 93) does NOT have an OPAQUE +# envelope yet at this point in the suite — the register cases below +# it sent malformed payloads that were rejected before persistence, +# so no envelope was ever written. Both "seed admin" and "unknown +# user" therefore return the SAME `hasOpaque: false` shape here. +# ============================================================= + +# ───────────────────────────────────────────────────────────── +# Case 10 — Lookup for a KNOWN user with NO envelope → 200 +# with `hasOpaque: false`. Proves the endpoint is +# reachable public, resolves the identifier, and +# reports absence honestly. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/opaque/login/lookup +Content-Type: application/json +{ "userIdentifier": "{{username}}" } + +HTTP 200 +[Asserts] +jsonpath "$.hasOpaque" == false + + +# ───────────────────────────────────────────────────────────── +# Case 11 — Lookup for an UNKNOWN user → same 200 + +# `hasOpaque: false` shape. Anti-enum: an attacker +# probing the endpoint cannot tell "user doesn't +# exist" from "user exists but no envelope yet" from +# this response body. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/opaque/login/lookup +Content-Type: application/json +{ "userIdentifier": "definitely-not-a-user-000@example.test" } + +HTTP 200 +[Asserts] +jsonpath "$.hasOpaque" == false + + +# ───────────────────────────────────────────────────────────── +# Case 12 — Lookup with an empty `userIdentifier` → 400 +# `OpaqueMalformedRequest`. The empty-input guard +# fires before user resolution so we don't waste a +# DB round-trip on a payload that can't identify +# anyone. Response shape reuses the same error_type +# as garbage-base64 above (Case 7) so the SPA has +# one uniform malformed-body error to render. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/opaque/login/lookup +Content-Type: application/json +{ "userIdentifier": "" } + +HTTP 400 +[Asserts] +jsonpath "$.error_type" == "OpaqueMalformedRequest"