feat(opaque): add change password

This commit is contained in:
Edouard Vanbelle
2026-07-28 00:18:53 +02:00
parent 94c6121f3b
commit bd8384c9b5
6 changed files with 223 additions and 54 deletions
+69 -1
View File
@@ -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<typeof vi.fn>;
const fin = opaque.client.finishLogin as unknown as ReturnType<typeof vi.fn>;
@@ -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();
});
});
+89
View File
@@ -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<OpaqueServerParams> | 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<OpaqueServerParams> {
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<void> {
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. */
+13
View File
@@ -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<void> {