feat(opaque): permits ksf values change
KSF values are stored per user, if admin change value, client will detect it and regenerate the envelop This pervent users being stuck
This commit is contained in:
@@ -96,13 +96,16 @@ it('tryRefresh returns false when the refresh fails', async () => {
|
||||
// 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 () => {
|
||||
it('login flips to OPAQUE when the lookup reports hasOpaque: true (no rotation when KSF matches)', 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 }
|
||||
// 2. POST /api/auth/opaque/login/lookup → { hasOpaque: true, ksf: <matches /params> }
|
||||
// 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.
|
||||
// The legacy /api/auth/login POST must NOT fire on this branch, AND
|
||||
// Phase C's rotation MUST NOT fire because the envelope's KSF matches
|
||||
// what /params publishes — nothing to rotate to.
|
||||
const paramsKsf = { memoryKib: 8, iterations: 1, parallelism: 1 };
|
||||
f.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -110,14 +113,14 @@ it('login flips to OPAQUE when the lookup reports hasOpaque: true', async () =>
|
||||
json: async () => ({
|
||||
enabled: true,
|
||||
ciphersuiteVersion: 1,
|
||||
ksf: { memoryKib: 8, iterations: 1, parallelism: 1 }
|
||||
ksf: paramsKsf
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ hasOpaque: true })
|
||||
json: async () => ({ hasOpaque: true, ksf: paramsKsf })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -150,6 +153,138 @@ it('login flips to OPAQUE when the lookup reports hasOpaque: true', async () =>
|
||||
]);
|
||||
// Legacy MUST NOT run when we took the OPAQUE branch.
|
||||
expect(urls).not.toContain('/api/auth/login');
|
||||
// Phase C: no rotation → no register/* calls.
|
||||
expect(urls).not.toContain('/api/auth/opaque/register/start');
|
||||
expect(urls).not.toContain('/api/auth/opaque/register/finish');
|
||||
});
|
||||
|
||||
// ── Phase C: silent KSF rotation on OPAQUE login ─────────────────────
|
||||
//
|
||||
// `login()` MUST fire `syncOpaqueEnvelope(password)` after a successful
|
||||
// OPAQUE login when the envelope's stored KSF differs from the server's
|
||||
// currently-published KSF (or when the envelope predates per-envelope
|
||||
// KSF storage, signalled by `lookup.ksf === null / absent`). Regression
|
||||
// this guards: silently ignoring the drift would freeze users on
|
||||
// whatever KSF they registered under years ago, making the operator's
|
||||
// tuning-defaults knob effectively write-only for existing accounts.
|
||||
|
||||
it('OPAQUE login triggers silent KSF rotation when envelope KSF drifted from /params', async () => {
|
||||
// Wire order:
|
||||
// 1. GET /api/auth/opaque/params (via checkOpaqueAvailable — server publishes NEW KSF)
|
||||
// 2. POST /api/auth/opaque/login/lookup → { hasOpaque: true, ksf: <OLD, drifted from /params> }
|
||||
// 3. POST /api/auth/opaque/login/ke1 → { exchangeId, loginResponse } (uses OLD envelope KSF)
|
||||
// 4. POST /api/auth/opaque/login/ke3 → AuthResponse
|
||||
// 5. POST /api/auth/opaque/register/start ← rotation fires
|
||||
// 6. POST /api/auth/opaque/register/finish
|
||||
// After (6), the envelope is re-minted under the CURRENT /params
|
||||
// KSF, so the user's next login uses the new values.
|
||||
const newParamsKsf = { memoryKib: 8, iterations: 1, parallelism: 1 };
|
||||
const oldEnvelopeKsf = { memoryKib: 32, iterations: 3, parallelism: 4 };
|
||||
f.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ enabled: true, ciphersuiteVersion: 1, ksf: newParamsKsf })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ hasOpaque: true, ksf: oldEnvelopeKsf })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ exchangeId: 'ex-1', loginResponse: 'LR' })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({
|
||||
user: { id: 'u1', email: 'a@x.test' },
|
||||
access_token: 'at-opaque',
|
||||
refresh_token: 'rt-opaque',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ registrationResponse: 'RESP-R' })
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) });
|
||||
|
||||
const authResponse = await auth.login('alice@example.com', 'pw');
|
||||
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',
|
||||
'/api/auth/opaque/register/start',
|
||||
'/api/auth/opaque/register/finish'
|
||||
]);
|
||||
});
|
||||
|
||||
it('OPAQUE login triggers silent rotation when envelope predates per-envelope KSF (ksf null)', async () => {
|
||||
// `lookup.ksf` is null/absent → envelope predates migration
|
||||
// 20261005000000 → rotation fires so the envelope gets stored under
|
||||
// the new per-envelope schema on the next go-round. Same wire
|
||||
// sequence as the drift case above.
|
||||
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',
|
||||
// No `ksf` field at all — pre-migration envelope.
|
||||
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 () => ({
|
||||
user: { id: 'u1', email: 'a@x.test' },
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ registrationResponse: 'RESP-R' })
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) });
|
||||
|
||||
await auth.login('alice@example.com', 'pw');
|
||||
|
||||
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
|
||||
expect(urls).toContain('/api/auth/opaque/register/start');
|
||||
expect(urls).toContain('/api/auth/opaque/register/finish');
|
||||
});
|
||||
|
||||
it('login falls back to legacy + silent-migration when hasOpaque: false', async () => {
|
||||
|
||||
@@ -87,8 +87,43 @@ export async function login(emailOrUsername: string, password: string): Promise<
|
||||
// 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());
|
||||
const lookup = await checkOpaqueAvailable(emailOrUsername);
|
||||
if (lookup.has) {
|
||||
// Prefer the envelope's OWN KSF (returned by lookup) over the
|
||||
// server's current /params values: after a KSF config change,
|
||||
// existing envelopes need their historical KSF for the OPRF
|
||||
// to derive the right value; using current /params would fail
|
||||
// the AKE integrity check and return `InvalidCredentials`.
|
||||
// Fallback to /params only when the envelope predates
|
||||
// per-envelope KSF storage (`ksf === null`), which preserves
|
||||
// the pre-migration behaviour.
|
||||
const ksf = lookup.ksf ?? (await opaqueKsfForClient());
|
||||
const auth = await opaqueLogin(emailOrUsername, password, ksf);
|
||||
|
||||
// Phase C: silent KSF rotation. If this envelope's KSF drifted
|
||||
// from what the server currently publishes (operator retuned
|
||||
// OXICLOUD_AUTH_OPAQUE_KSF_*), re-register the envelope under
|
||||
// the current params so the NEXT login benefits from the new
|
||||
// values (faster / stronger / whatever the tuning direction).
|
||||
// Envelopes that predate per-envelope storage (`lookup.ksf ===
|
||||
// null`) always trigger rotation — that's how they migrate
|
||||
// into the new storage schema organically.
|
||||
//
|
||||
// `syncOpaqueEnvelope` is the same helper the Phase 2 hook
|
||||
// uses after a legacy login; it swallows errors, so a
|
||||
// rotation failure is non-fatal (this login already succeeded)
|
||||
// and the NEXT login retries the same check. Fires only when
|
||||
// there's a real difference — no wasted crypto on the common
|
||||
// same-params case.
|
||||
const current = await opaqueKsfForClient();
|
||||
const needsRotation =
|
||||
!lookup.ksf ||
|
||||
lookup.ksf.memoryKib !== current.memoryKib ||
|
||||
lookup.ksf.iterations !== current.iterations ||
|
||||
lookup.ksf.parallelism !== current.parallelism;
|
||||
if (needsRotation) await syncOpaqueEnvelope(password);
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ── Legacy login (fallback for users without an envelope yet) ─────
|
||||
|
||||
@@ -120,9 +120,15 @@ describe('opaqueRegister', () => {
|
||||
|
||||
const [finishUrl, finishInit] = f.mock.calls[1];
|
||||
expect(finishUrl).toBe('/api/auth/opaque/register/finish');
|
||||
// Body carries the KSF the client declared. Server persists these
|
||||
// per-envelope so future KSF config changes don't invalidate this
|
||||
// envelope on login — see migration 20261005000000.
|
||||
expect(JSON.parse(finishInit.body as string)).toEqual({
|
||||
registrationRecord: 'RECORD-R',
|
||||
ciphersuiteVersion: 1
|
||||
ciphersuiteVersion: 1,
|
||||
ksfMemoryKib: KSF.memoryKib,
|
||||
ksfIterations: KSF.iterations,
|
||||
ksfParallelism: KSF.parallelism
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -154,13 +154,27 @@ export function fetchOpaqueParams(): Promise<OpaqueServerParams> {
|
||||
* 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> {
|
||||
/**
|
||||
* 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 false;
|
||||
if (!params.enabled) return { has: false, ksf: null };
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/opaque/login/lookup', {
|
||||
method: 'POST',
|
||||
@@ -168,11 +182,15 @@ export async function checkOpaqueAvailable(userIdentifier: string): Promise<bool
|
||||
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;
|
||||
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 false;
|
||||
return { has: false, ksf: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,11 +304,22 @@ export async function opaqueRegister(
|
||||
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 })
|
||||
body: JSON.stringify({
|
||||
registrationRecord,
|
||||
ciphersuiteVersion,
|
||||
ksfMemoryKib: ksf.memoryKib,
|
||||
ksfIterations: ksf.iterations,
|
||||
ksfParallelism: ksf.parallelism
|
||||
})
|
||||
});
|
||||
if (!finishRes.ok) {
|
||||
const { errorType, message } = await parseErrorBody(finishRes);
|
||||
|
||||
Reference in New Issue
Block a user