Files
Oxicloud/frontend/src/lib/api/endpoints/opaque.ts
T
2026-08-09 01:56:07 +02:00

430 lines
16 KiB
TypeScript

/**
* 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<typeof import('@serenity-kit/opaque').client> {
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<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;
}
/**
* 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<OpaqueLookupResult> {
// 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<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. */
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<void> {
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<AuthResponse> {
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;
}