feat(opaque): add lookup identifier (with anti-enum)
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||||
// Mock the OPAQUE WASM client so `login()`'s silent-migration hook can
|
// Mock the OPAQUE WASM client so `login()`'s Phase 2 silent-migration
|
||||||
// exercise the wire path (params + register handshake) without touching
|
// 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.
|
// real WASM in jsdom.
|
||||||
vi.mock('@serenity-kit/opaque', () => ({
|
vi.mock('@serenity-kit/opaque', () => ({
|
||||||
ready: Promise.resolve(),
|
ready: Promise.resolve(),
|
||||||
@@ -15,6 +16,16 @@ vi.mock('@serenity-kit/opaque', () => ({
|
|||||||
registrationRecord: 'RECORD-R',
|
registrationRecord: 'RECORD-R',
|
||||||
exportKey: 'EK',
|
exportKey: 'EK',
|
||||||
serverStaticPublicKey: 'SPK'
|
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);
|
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
|
// `login()` MUST first probe the server for an OPAQUE envelope via
|
||||||
// successful POST /api/auth/login response, so users pick up an OPAQUE
|
// `POST /api/auth/opaque/login/lookup`:
|
||||||
// envelope automatically over time. Verified here by observing the
|
// - if the response says `hasOpaque: true` → dispatch to
|
||||||
// wire calls made after the login POST — a successful login without
|
// `opaqueLogin` (KE1/KE3) and skip the legacy path entirely
|
||||||
// a follow-up `/api/auth/opaque/*` fetch is a regression that would
|
// (Phase 3 cutover);
|
||||||
// silently break Phase 3's cutover assumption ("most active users
|
// - if `false` → fall through to `POST /api/auth/login` (legacy),
|
||||||
// have an envelope on file by the time the SPA flips to OPAQUE").
|
// then run `syncOpaqueEnvelope(password)` to silently mint the
|
||||||
it('login triggers OPAQUE silent-migration on success', async () => {
|
// envelope for the NEXT login (Phase 2 silent migration).
|
||||||
// Login POST → 200 AuthResponse
|
//
|
||||||
// Then syncOpaqueEnvelope runs: GET /params → POST register/start → POST register/finish
|
// 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({
|
f.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
statusText: 'OK',
|
statusText: 'OK',
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
user: { id: 'u1', email: 'a@x.test' },
|
enabled: true,
|
||||||
access_token: 'at',
|
ciphersuiteVersion: 1,
|
||||||
refresh_token: 'rt',
|
ksf: { memoryKib: 8, iterations: 1, parallelism: 1 }
|
||||||
token_type: 'Bearer',
|
|
||||||
expires_in: 3600
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.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({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
statusText: 'OK',
|
statusText: 'OK',
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
enabled: true,
|
user: { id: 'u1', email: 'a@x.test' },
|
||||||
ciphersuiteVersion: 1,
|
access_token: 'at-opaque',
|
||||||
ksf: { memoryKib: 8, iterations: 1, parallelism: 1 }
|
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({
|
.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');
|
const authResponse = await auth.login('alice@example.com', 'correct horse battery staple');
|
||||||
expect(authResponse.access_token).toBe('at');
|
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);
|
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
|
||||||
expect(urls).toContain('/api/auth/login');
|
expect(urls).toEqual([
|
||||||
expect(urls).toContain('/api/auth/opaque/params');
|
'/api/auth/opaque/params',
|
||||||
expect(urls).toContain('/api/auth/opaque/register/start');
|
'/api/auth/opaque/login/lookup',
|
||||||
expect(urls).toContain('/api/auth/opaque/register/finish');
|
'/api/auth/login',
|
||||||
// Login POST must come FIRST — silent migration only runs on a
|
'/api/auth/opaque/register/start',
|
||||||
// session that's already been established.
|
'/api/auth/opaque/register/finish'
|
||||||
expect(urls[0]).toBe('/api/auth/login');
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('login returns AuthResponse even when silent-migration fails (non-fatal)', async () => {
|
it('login skips the OPAQUE branch entirely when the substrate is disabled', async () => {
|
||||||
// Login POST succeeds; the follow-up /params returns 500. Silent
|
// /params replies `enabled: false` (mode=off or misconfig). The
|
||||||
// migration swallows the error (console.warn) — the login itself
|
// lookup POST MUST NOT fire (cheap short-circuit inside
|
||||||
// still returns success to the caller, so the user reaches their
|
// checkOpaqueAvailable), and the legacy silent-migration hook
|
||||||
// session and the envelope gets retried on next login.
|
// MUST also short-circuit — no register/start calls either.
|
||||||
f.mockResolvedValueOnce({
|
f.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: async () => ({
|
||||||
|
enabled: false,
|
||||||
|
ciphersuiteVersion: 0,
|
||||||
|
ksf: { memoryKib: 0, iterations: 0, parallelism: 0 }
|
||||||
|
})
|
||||||
|
}).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
statusText: 'OK',
|
statusText: 'OK',
|
||||||
@@ -138,7 +233,54 @@ it('login returns AuthResponse even when silent-migration fails (non-fatal)', as
|
|||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
expires_in: 3600
|
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 consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
const authResponse = await auth.login('alice@example.com', 'pw');
|
const authResponse = await auth.login('alice@example.com', 'pw');
|
||||||
|
|||||||
@@ -64,6 +64,34 @@ export async function tryRefresh(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function login(emailOrUsername: string, password: string): Promise<AuthResponse> {
|
export async function login(emailOrUsername: string, password: string): Promise<AuthResponse> {
|
||||||
|
// ── 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', {
|
const res = await apiFetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -81,11 +109,8 @@ export async function login(emailOrUsername: string, password: string): Promise<
|
|||||||
|
|
||||||
// ── OPAQUE silent migration (Phase 2) ──────────────────────────────
|
// ── OPAQUE silent migration (Phase 2) ──────────────────────────────
|
||||||
// After a successful legacy password login, transparently mint an
|
// After a successful legacy password login, transparently mint an
|
||||||
// OPAQUE envelope for the same passphrase. Users pick up the new
|
// OPAQUE envelope for the same passphrase. The NEXT login for this
|
||||||
// auth path over time, one login at a time, with zero UX change —
|
// account will take the OPAQUE branch above.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// The freshly-issued session cookie is already active in the
|
// The freshly-issued session cookie is already active in the
|
||||||
// browser at this point (Set-Cookie from the POST response), so
|
// 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
|
// disabled server-side (mode=off) and swallows any error — a
|
||||||
// failure here just leaves the envelope stale, and the NEXT
|
// failure here just leaves the envelope stale, and the NEXT
|
||||||
// legacy login retries the same hook.
|
// 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);
|
await syncOpaqueEnvelope(password);
|
||||||
|
|
||||||
return auth;
|
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 {
|
export interface OidcProviders {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
provider_name?: string;
|
provider_name?: string;
|
||||||
|
|||||||
@@ -112,6 +112,45 @@ export function fetchOpaqueParams(): Promise<OpaqueServerParams> {
|
|||||||
return opaqueParamsInflight;
|
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<boolean> {
|
||||||
|
// 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
|
* Silent OPAQUE registration after a passphrase-touching action
|
||||||
* (signup completion, change-password, silent migration on legacy
|
* (signup completion, change-password, silent migration on legacy
|
||||||
|
|||||||
@@ -104,8 +104,15 @@ pub fn opaque_register_routes() -> Router<Arc<AppState>> {
|
|||||||
/// layer used on legacy `/api/auth/login` so an attacker can't
|
/// layer used on legacy `/api/auth/login` so an attacker can't
|
||||||
/// halve the per-identity budget by spraying both endpoints. See
|
/// halve the per-identity budget by spraying both endpoints. See
|
||||||
/// [`opaque_register_routes`] on why the prefix must be distinct.
|
/// [`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<Arc<AppState>> {
|
pub fn opaque_login_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/lookup", post(login_lookup))
|
||||||
.route("/ke1", post(login_ke1))
|
.route("/ke1", post(login_ke1))
|
||||||
.route("/ke3", post(login_ke3))
|
.route("/ke3", post(login_ke3))
|
||||||
}
|
}
|
||||||
@@ -302,6 +309,84 @@ pub async fn register_finish(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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<Arc<AppState>>,
|
||||||
|
Json(dto): Json<OpaqueLookupDto>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
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 ─────────────────────────────────────────────────
|
// ── Login: KE1 + KE3 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Client → server on KE1. `userIdentifier` is the same string the
|
/// Client → server on KE1. `userIdentifier` is the same string the
|
||||||
|
|||||||
@@ -209,3 +209,70 @@ jsonpath "$.ciphersuiteVersion" == 1
|
|||||||
jsonpath "$.ksf.memoryKib" == 8
|
jsonpath "$.ksf.memoryKib" == 8
|
||||||
jsonpath "$.ksf.iterations" == 1
|
jsonpath "$.ksf.iterations" == 1
|
||||||
jsonpath "$.ksf.parallelism" == 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user