feat(auth): bring opaque (RFC 9807) auth

OPAQUE (RFC 9807) implementation (using `opaque-ke` crate)

    with opaque authentfication, server will never receive the password (in the auth=password mode)
    this is a must have to create trust with users to permit end to end encryption in the future
    (we cannot know if user use the same password/passphrase for his asymetric key or his oxicloud auth,
    this is why server must never have the password)

    pass1: prepare server
This commit is contained in:
Edouard Vanbelle
2026-07-26 15:04:31 +02:00
parent d76803f602
commit 0e395ae15f
19 changed files with 1570 additions and 7 deletions
+12
View File
@@ -7,6 +7,9 @@
"": {
"name": "oxicloud-frontend",
"version": "0.0.0",
"dependencies": {
"@serenity-kit/opaque": "^1.1.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.10",
@@ -1492,6 +1495,15 @@
"win32"
]
},
"node_modules/@serenity-kit/opaque": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@serenity-kit/opaque/-/opaque-1.1.0.tgz",
"integrity": "sha512-Y6v/+hRMn0MdMEk5+/ArM0vPIiFfFEbdTZc7oAx+cWyvGODGezAQ/sMjXBVogf7NsS9z9EV0Ve5paZCVULuedw==",
"license": "MIT",
"bin": {
"opaque": "bin/index.js"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+3
View File
@@ -45,5 +45,8 @@
"vite": "^6.4.3",
"vite-plugin-istanbul": "^8.0.0",
"vitest": "^4.1.9"
},
"dependencies": {
"@serenity-kit/opaque": "^1.1.0"
}
}
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the transport layer + CSRF headers so the test asserts on the wire
// shape we send to the backend, not the actual network. WASM handshake
// results are captured by mocking `@serenity-kit/opaque`'s client namespace.
vi.mock('$lib/api/client', () => ({
apiFetch: vi.fn(),
ApiError: class ApiError extends Error {
readonly status: number;
readonly statusText: string;
readonly errorType?: string;
constructor(
status: number,
statusText: string,
_resource: unknown,
errorType?: string,
message?: string
) {
super(message ?? `${status} ${statusText}`);
this.status = status;
this.statusText = statusText;
this.errorType = errorType;
}
}
}));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({ 'x-csrf-token': 'test' }) }));
// Stub the WASM handshake with deterministic strings so the test can
// assert what the wire body contains without pulling in the real WASM.
vi.mock('@serenity-kit/opaque', () => ({
ready: Promise.resolve(),
client: {
startRegistration: vi.fn(() => ({
clientRegistrationState: 'STATE-R',
registrationRequest: 'REQ-R'
})),
finishRegistration: vi.fn(() => ({
registrationRecord: 'RECORD-R',
exportKey: 'EK',
serverStaticPublicKey: 'SPK'
})),
startLogin: vi.fn(() => ({
clientLoginState: 'STATE-L',
startLoginRequest: 'REQ-L'
})),
finishLogin: vi.fn(() => ({
finishLoginRequest: 'FIN-L',
sessionKey: 'SK',
exportKey: 'EK',
serverStaticPublicKey: 'SPK'
}))
}
}));
import { apiFetch, ApiError } from '$lib/api/client';
import * as opaque from '@serenity-kit/opaque';
import { opaqueLogin, opaqueRegister } from './opaque';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const fin = opaque.client.finishLogin as unknown as ReturnType<typeof vi.fn>;
const KSF = { memoryKib: 8, iterations: 1, parallelism: 1 };
function okJson(body: unknown, status = 200) {
return {
ok: true,
status,
statusText: 'OK',
clone() {
return this;
},
json: async () => body
};
}
function errJson(status: number, body: unknown) {
return {
ok: false,
status,
statusText: 'Bad',
clone() {
return this;
},
json: async () => body
};
}
beforeEach(() => {
vi.clearAllMocks();
// Reset finishLogin to the truthy default; individual tests override.
fin.mockReturnValue({
finishLoginRequest: 'FIN-L',
sessionKey: 'SK',
exportKey: 'EK',
serverStaticPublicKey: 'SPK'
});
});
describe('opaqueRegister', () => {
it('POSTs both rounds with the WASM-produced payloads', async () => {
f.mockResolvedValueOnce(okJson({ registrationResponse: 'RESP-R' })).mockResolvedValueOnce(
okJson({}, 204)
);
await opaqueRegister('correct horse battery staple', KSF, 1);
expect(f).toHaveBeenCalledTimes(2);
const [startUrl, startInit] = f.mock.calls[0];
expect(startUrl).toBe('/api/auth/opaque/register/start');
expect(JSON.parse(startInit.body as string)).toEqual({ registrationRequest: 'REQ-R' });
const [finishUrl, finishInit] = f.mock.calls[1];
expect(finishUrl).toBe('/api/auth/opaque/register/finish');
expect(JSON.parse(finishInit.body as string)).toEqual({
registrationRecord: 'RECORD-R',
ciphersuiteVersion: 1
});
});
it('throws ApiError with parsed error_type on start failure', async () => {
f.mockResolvedValueOnce(
errJson(409, { error_type: 'OpaqueAlreadyRegistered', message: 'already have envelope' })
);
await expect(opaqueRegister('pw', KSF, 1)).rejects.toMatchObject({
status: 409,
errorType: 'OpaqueAlreadyRegistered'
});
});
it('never puts the passphrase on the wire', async () => {
f.mockResolvedValueOnce(okJson({ registrationResponse: 'RESP-R' })).mockResolvedValueOnce(
okJson({}, 204)
);
const secret = 'hunter2';
await opaqueRegister(secret, KSF, 1);
for (const [, init] of f.mock.calls) {
expect(String(init.body ?? '')).not.toContain(secret);
}
});
});
describe('opaqueLogin', () => {
it('POSTs KE1 with the user identifier + KE3 with the exchange id', async () => {
f.mockResolvedValueOnce(
okJson({ exchangeId: 'XID-42', loginResponse: 'RESP-L' })
).mockResolvedValueOnce(
okJson({
user: { id: 'u1', email: 'a@x.test' },
access_token: 'at',
refresh_token: 'rt',
token_type: 'Bearer',
expires_in: 3600
})
);
const auth = await opaqueLogin('alice@example.com', 'pw', KSF);
expect(auth.access_token).toBe('at');
const [ke1Url, ke1Init] = f.mock.calls[0];
expect(ke1Url).toBe('/api/auth/opaque/login/ke1');
expect(JSON.parse(ke1Init.body as string)).toEqual({
userIdentifier: 'alice@example.com',
startLoginRequest: 'REQ-L'
});
const [ke3Url, ke3Init] = f.mock.calls[1];
expect(ke3Url).toBe('/api/auth/opaque/login/ke3');
expect(JSON.parse(ke3Init.body as string)).toEqual({
exchangeId: 'XID-42',
finishLoginRequest: 'FIN-L'
});
});
it('throws InvalidCredentials without touching the server when finishLogin returns undefined', async () => {
// Wrong-passphrase case in the WASM API: finishLogin returns
// undefined. Verify we short-circuit locally with the same
// error_type shape the server would emit — anti-enumeration
// requires both paths look identical to the caller.
fin.mockReturnValueOnce(undefined);
f.mockResolvedValueOnce(okJson({ exchangeId: 'XID', loginResponse: 'RESP-L' }));
await expect(opaqueLogin('a@x.test', 'wrong', KSF)).rejects.toMatchObject({
status: 401,
errorType: 'InvalidCredentials'
});
expect(f).toHaveBeenCalledTimes(1); // KE1 only — KE3 must NOT fire
});
it('bubbles up the server error_type on KE1 failure', async () => {
f.mockResolvedValueOnce(errJson(429, { error_type: 'RateLimited', message: 'slow down' }));
const err = await opaqueLogin('a@x.test', 'pw', KSF).then(
() => null,
(e) => e
);
expect(err).toBeInstanceOf(ApiError);
expect(err).toMatchObject({ status: 429, errorType: 'RateLimited' });
});
});
+236
View File
@@ -0,0 +1,236 @@
/**
* 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 { client, ready } from '@serenity-kit/opaque';
import { ApiError, apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { AuthResponse } from '$lib/api/types';
/**
* 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;
}
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> {
await ready;
// ── 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)
});
const finishRes = await apiFetch('/api/auth/opaque/register/finish', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ registrationRecord, ciphersuiteVersion })
});
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
): Promise<AuthResponse> {
await ready;
// ── 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 })
});
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;
}