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);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Per-envelope OPAQUE KSF parameters
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Client-side Argon2id key-stretching parameters (memory / iterations / lanes)
|
||||
-- used at OPAQUE register time, stored per-envelope. Complements the three
|
||||
-- existing OPAQUE columns (envelope, ciphersuite_version, registered_at)
|
||||
-- introduced by 20261001000002_auth_opaque.sql.
|
||||
--
|
||||
-- ## Why per-envelope
|
||||
--
|
||||
-- OPAQUE's KSF runs entirely client-side. The server publishes the CURRENT
|
||||
-- default via GET /api/auth/opaque/params, and the client feeds those values
|
||||
-- into `argon2::Argon2` before the OPRF blinding step. The resulting
|
||||
-- stretched password becomes part of the input the AKE integrity-checks
|
||||
-- against the envelope. **If the client ever uses a different KSF than the
|
||||
-- one that was used at register time, the AKE fails and the server returns
|
||||
-- InvalidCredentials.**
|
||||
--
|
||||
-- Consequence: without per-envelope storage, changing
|
||||
-- OXICLOUD_AUTH_OPAQUE_KSF_* invalidates every existing envelope. Users hit
|
||||
-- a mass "invalid credentials" lockout and the only recovery is
|
||||
-- `oxicloud-cli opaque reset` to force re-registration.
|
||||
--
|
||||
-- With per-envelope storage: `/api/auth/opaque/login/lookup` returns the
|
||||
-- envelope's own KSF params alongside hasOpaque=true. The client uses those
|
||||
-- for the login handshake (matching what was used at register), and NEW
|
||||
-- registrations pick up the current server defaults. Config changes stop
|
||||
-- being a lockout vector.
|
||||
--
|
||||
-- ## Migration handling for existing envelopes
|
||||
--
|
||||
-- Columns are nullable — existing envelopes minted before this migration
|
||||
-- carry NULL and the client falls back to the server's published /params
|
||||
-- values for the login handshake. That preserves the pre-migration
|
||||
-- behaviour (works as long as the server's current config matches what
|
||||
-- was in effect at register time). After a user re-registers (silent
|
||||
-- migration on next password change, or explicit CLI reset + re-login),
|
||||
-- the columns get populated with the client's declared values and future
|
||||
-- config changes stop affecting that envelope.
|
||||
--
|
||||
-- We DO NOT backfill with a hardcoded historical default here — different
|
||||
-- OxiCloud deployments have carried different defaults over the branch's
|
||||
-- life, and picking one would be wrong for the others. NULL + fallback to
|
||||
-- current /params is the safest posture.
|
||||
--
|
||||
-- ## Columns
|
||||
--
|
||||
-- opaque_ksf_memory_kib INTEGER — client-side Argon2id memory cost
|
||||
-- in KiB, as CLIENT-DECLARED at
|
||||
-- register/finish time.
|
||||
-- opaque_ksf_iterations INTEGER — client-side iteration count.
|
||||
-- opaque_ksf_parallelism INTEGER — client-side parallelism lanes.
|
||||
--
|
||||
-- All three move as one atomic set (populated together at register_finish,
|
||||
-- nulled together at clear_registration). Sibling to `opaque_ciphersuite_version`
|
||||
-- but distinct: ciphersuite version is a schema/algorithm identifier
|
||||
-- (Ristretto255-SHA512-3DH-Argon2id v1); KSF params tune the Argon2id
|
||||
-- cost within that ciphersuite.
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN opaque_ksf_memory_kib INTEGER,
|
||||
ADD COLUMN opaque_ksf_iterations INTEGER,
|
||||
ADD COLUMN opaque_ksf_parallelism INTEGER;
|
||||
|
||||
COMMENT ON COLUMN auth.users.opaque_ksf_memory_kib IS
|
||||
'Argon2id memory cost (KiB) the CLIENT used at OPAQUE register time.
|
||||
Baked in per-envelope so future changes to OXICLOUD_AUTH_OPAQUE_KSF_*
|
||||
do not invalidate existing envelopes — the /login/lookup endpoint
|
||||
returns this value alongside hasOpaque=true, and the client uses it
|
||||
on the login handshake. NULL for envelopes minted before per-envelope
|
||||
storage landed; those envelopes still work as long as the server''s
|
||||
current /params matches what they were registered under.';
|
||||
|
||||
COMMENT ON COLUMN auth.users.opaque_ksf_iterations IS
|
||||
'Argon2id iteration count the CLIENT used at OPAQUE register time.
|
||||
See opaque_ksf_memory_kib for the per-envelope-storage rationale.';
|
||||
|
||||
COMMENT ON COLUMN auth.users.opaque_ksf_parallelism IS
|
||||
'Argon2id parallelism (lanes) the CLIENT used at OPAQUE register time.
|
||||
See opaque_ksf_memory_kib for the per-envelope-storage rationale.';
|
||||
@@ -54,6 +54,26 @@ pub struct StoredEnvelope {
|
||||
/// across re-registrations (password changes) via a NULL check in
|
||||
/// [`OpaqueRepositoryPort::write_registration`].
|
||||
pub registered_at: DateTime<Utc>,
|
||||
/// Client-side Argon2id KSF parameters the CLIENT declared at
|
||||
/// register time. `None` when the envelope predates the
|
||||
/// per-envelope-KSF migration (`20261005000000`) — callers fall
|
||||
/// back to the server's current `OpaqueConfig::ksf_*` in that
|
||||
/// case. See the migration file for the "why per-envelope"
|
||||
/// rationale.
|
||||
pub ksf: Option<StoredKsf>,
|
||||
}
|
||||
|
||||
/// Client-declared Argon2id parameters carried alongside an OPAQUE
|
||||
/// envelope. All three move as an atomic set (populated together at
|
||||
/// `register_finish`, nulled together at `clear_registration`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StoredKsf {
|
||||
/// Argon2id memory cost in KiB.
|
||||
pub memory_kib: u32,
|
||||
/// Argon2id iteration count.
|
||||
pub iterations: u32,
|
||||
/// Argon2id parallelism (lanes).
|
||||
pub parallelism: u32,
|
||||
}
|
||||
|
||||
/// Secondary (outbound) port for OPAQUE envelope persistence.
|
||||
@@ -67,7 +87,15 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static {
|
||||
///
|
||||
/// Idempotent w.r.t. `opaque_registered_at`: the first-registration
|
||||
/// timestamp is preserved across re-registrations. Only the
|
||||
/// envelope + ciphersuite_version rotate on password change.
|
||||
/// envelope + ciphersuite_version + KSF params rotate on password
|
||||
/// change.
|
||||
///
|
||||
/// `ksf` carries the Argon2id parameters the CLIENT used at
|
||||
/// register time (declared in the register/finish request). Stored
|
||||
/// per-envelope so future changes to the server's
|
||||
/// `OpaqueConfig::ksf_*` do not invalidate this envelope — the
|
||||
/// lookup endpoint returns these values and the client uses them
|
||||
/// on the login handshake.
|
||||
///
|
||||
/// Does NOT touch `opaque_migrated_at` — that's flipped by the
|
||||
/// login endpoint after the first successful OPAQUE handshake.
|
||||
@@ -76,6 +104,7 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static {
|
||||
user_id: Uuid,
|
||||
envelope: &[u8],
|
||||
ciphersuite_version: i16,
|
||||
ksf: StoredKsf,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Read the current envelope for `user_id`. Returns `None` when
|
||||
|
||||
@@ -119,11 +119,7 @@ mod opaque {
|
||||
pub async fn run(action: Action) -> ExitCode {
|
||||
match action {
|
||||
Action::Setup => run_setup(),
|
||||
Action::Reset {
|
||||
user,
|
||||
all,
|
||||
dry_run,
|
||||
} => run_reset(user, all, dry_run).await,
|
||||
Action::Reset { user, all, dry_run } => run_reset(user, all, dry_run).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::opaque_ports::{OpaqueRepositoryPort, StoredEnvelope};
|
||||
use crate::application::ports::opaque_ports::{OpaqueRepositoryPort, StoredEnvelope, StoredKsf};
|
||||
use crate::common::errors::{DomainError, Result};
|
||||
|
||||
pub struct OpaquePgRepository {
|
||||
@@ -46,24 +46,38 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
|
||||
user_id: Uuid,
|
||||
envelope: &[u8],
|
||||
ciphersuite_version: i16,
|
||||
ksf: StoredKsf,
|
||||
) -> Result<()> {
|
||||
// COALESCE preserves the first-registration timestamp across
|
||||
// re-registrations (password change → new envelope, same
|
||||
// registered_at). The alternative — always stamping `NOW()`
|
||||
// — would erase the operational signal "when did this user
|
||||
// first join OPAQUE," which the migration dashboard reads.
|
||||
//
|
||||
// KSF params ARE rewritten on every re-registration (unlike
|
||||
// registered_at) — the point of storing them is to reflect
|
||||
// what the CURRENT envelope was minted under, not the
|
||||
// historical first. INTEGER column type in PG is i32; cast
|
||||
// from u32 is lossless for realistic Argon2 values (max
|
||||
// memory_kib would need ~2 TiB to overflow i32).
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET opaque_envelope = $2,
|
||||
opaque_ciphersuite_version = $3,
|
||||
opaque_registered_at = COALESCE(opaque_registered_at, NOW())
|
||||
opaque_registered_at = COALESCE(opaque_registered_at, NOW()),
|
||||
opaque_ksf_memory_kib = $4,
|
||||
opaque_ksf_iterations = $5,
|
||||
opaque_ksf_parallelism = $6
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(envelope)
|
||||
.bind(ciphersuite_version)
|
||||
.bind(ksf.memory_kib as i32)
|
||||
.bind(ksf.iterations as i32)
|
||||
.bind(ksf.parallelism as i32)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("OpaquePg", format!("write_registration: {e}")))?;
|
||||
@@ -86,7 +100,10 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
|
||||
r#"
|
||||
SELECT opaque_envelope,
|
||||
opaque_ciphersuite_version,
|
||||
opaque_registered_at
|
||||
opaque_registered_at,
|
||||
opaque_ksf_memory_kib,
|
||||
opaque_ksf_iterations,
|
||||
opaque_ksf_parallelism
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -100,15 +117,46 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
|
||||
return Err(DomainError::not_found("User", user_id.to_string()));
|
||||
};
|
||||
|
||||
// All three columns are NULL together (they're set atomically by
|
||||
// `write_registration`). Any partial-NULL is a schema-drift
|
||||
// symptom — return None with a warn so ops can catch it.
|
||||
// Envelope + ciphersuite_version + registered_at move as one
|
||||
// atomic set (all three set together by write_registration).
|
||||
// Partial-NULL there is schema drift → warn + treat as
|
||||
// unregistered.
|
||||
//
|
||||
// KSF columns are independently nullable: existing envelopes
|
||||
// minted before migration 20261005000000 predate per-envelope
|
||||
// storage and carry NULL. The service layer falls back to the
|
||||
// server's current `OpaqueConfig` KSF in that case. Partial-
|
||||
// NULL of the KSF triple IS caught below because they are also
|
||||
// set atomically at register time.
|
||||
match (row.envelope, row.ciphersuite_version, row.registered_at) {
|
||||
(Some(env), Some(ver), Some(at)) => Ok(Some(StoredEnvelope {
|
||||
envelope: env,
|
||||
ciphersuite_version: ver,
|
||||
registered_at: at,
|
||||
})),
|
||||
(Some(env), Some(ver), Some(at)) => {
|
||||
let ksf = match (row.ksf_memory_kib, row.ksf_iterations, row.ksf_parallelism) {
|
||||
(Some(m), Some(i), Some(p)) => Some(StoredKsf {
|
||||
memory_kib: m as u32,
|
||||
iterations: i as u32,
|
||||
parallelism: p as u32,
|
||||
}),
|
||||
(None, None, None) => None,
|
||||
(m, i, p) => {
|
||||
tracing::warn!(
|
||||
target: "oxicloud::opaque",
|
||||
user_id = %user_id,
|
||||
memory_kib_set = m.is_some(),
|
||||
iterations_set = i.is_some(),
|
||||
parallelism_set = p.is_some(),
|
||||
"OPAQUE KSF columns partial-NULL — treating as absent \
|
||||
(falls back to server current defaults). Check for a broken migration."
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
Ok(Some(StoredEnvelope {
|
||||
envelope: env,
|
||||
ciphersuite_version: ver,
|
||||
registered_at: at,
|
||||
ksf,
|
||||
}))
|
||||
}
|
||||
(None, None, None) => Ok(None),
|
||||
(env, ver, at) => {
|
||||
tracing::warn!(
|
||||
@@ -171,9 +219,13 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
|
||||
}
|
||||
|
||||
async fn clear_registration(&self, user_id: Uuid) -> Result<()> {
|
||||
// One UPDATE writes both the envelope invalidation AND the
|
||||
// force-change flag — matches the atomicity we promise in the
|
||||
// port doc, avoids drift between two separate writes.
|
||||
// One UPDATE nulls the whole OPAQUE column set AND flips the
|
||||
// force-change flag — matches the atomicity we promise in
|
||||
// the port doc, avoids drift between separate writes. KSF
|
||||
// columns move with the envelope (they're bound to it) so
|
||||
// they're nulled here too; a subsequent silent-migration
|
||||
// re-registration will re-populate them with the client's
|
||||
// current declared values.
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
@@ -181,6 +233,9 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
|
||||
opaque_ciphersuite_version = NULL,
|
||||
opaque_registered_at = NULL,
|
||||
opaque_migrated_at = NULL,
|
||||
opaque_ksf_memory_kib = NULL,
|
||||
opaque_ksf_iterations = NULL,
|
||||
opaque_ksf_parallelism = NULL,
|
||||
force_password_change_at_next_login = TRUE
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -205,6 +260,15 @@ struct EnvelopeRow {
|
||||
ciphersuite_version: Option<i16>,
|
||||
#[sqlx(rename = "opaque_registered_at")]
|
||||
registered_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
// KSF columns are per-envelope (see migration 20261005000000). PG
|
||||
// stores them as INTEGER (i32); the domain layer widens to u32.
|
||||
// NULL for envelopes minted before per-envelope storage landed.
|
||||
#[sqlx(rename = "opaque_ksf_memory_kib")]
|
||||
ksf_memory_kib: Option<i32>,
|
||||
#[sqlx(rename = "opaque_ksf_iterations")]
|
||||
ksf_iterations: Option<i32>,
|
||||
#[sqlx(rename = "opaque_ksf_parallelism")]
|
||||
ksf_parallelism: Option<i32>,
|
||||
}
|
||||
|
||||
#[cfg(integration_tests)]
|
||||
@@ -268,9 +332,18 @@ mod integration_tests {
|
||||
);
|
||||
|
||||
let payload = b"envelope-v1-bytes".to_vec();
|
||||
repo.write_registration(user, &payload, 1)
|
||||
.await
|
||||
.expect("write");
|
||||
repo.write_registration(
|
||||
user,
|
||||
&payload,
|
||||
1,
|
||||
StoredKsf {
|
||||
memory_kib: 47_104,
|
||||
iterations: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write");
|
||||
|
||||
let stored = repo
|
||||
.read_registration(user)
|
||||
@@ -294,18 +367,36 @@ mod integration_tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
repo.write_registration(user, b"first-envelope", 1)
|
||||
.await
|
||||
.expect("first write");
|
||||
repo.write_registration(
|
||||
user,
|
||||
b"first-envelope",
|
||||
1,
|
||||
StoredKsf {
|
||||
memory_kib: 47_104,
|
||||
iterations: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("first write");
|
||||
let first = repo.read_registration(user).await.unwrap().unwrap();
|
||||
|
||||
// Tiny sleep so a bug that overwrites registered_at with NOW()
|
||||
// would produce a measurably different timestamp.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
repo.write_registration(user, b"second-envelope", 1)
|
||||
.await
|
||||
.expect("second write");
|
||||
repo.write_registration(
|
||||
user,
|
||||
b"second-envelope",
|
||||
1,
|
||||
StoredKsf {
|
||||
memory_kib: 47_104,
|
||||
iterations: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("second write");
|
||||
let second = repo.read_registration(user).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(second.envelope, b"second-envelope");
|
||||
@@ -324,9 +415,18 @@ mod integration_tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
repo.write_registration(user, b"envelope", 1)
|
||||
.await
|
||||
.expect("prime with envelope");
|
||||
repo.write_registration(
|
||||
user,
|
||||
b"envelope",
|
||||
1,
|
||||
StoredKsf {
|
||||
memory_kib: 47_104,
|
||||
iterations: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("prime with envelope");
|
||||
repo.clear_registration(user)
|
||||
.await
|
||||
.expect("clear registration");
|
||||
@@ -429,9 +529,18 @@ mod integration_tests {
|
||||
// `GenericArray`; convert to `Vec<u8>` at the boundary so
|
||||
// downstream comparisons stay simple.
|
||||
let envelope_bytes: Vec<u8> = password_file.serialize().to_vec();
|
||||
repo.write_registration(user, &envelope_bytes, 1)
|
||||
.await
|
||||
.expect("persist envelope");
|
||||
repo.write_registration(
|
||||
user,
|
||||
&envelope_bytes,
|
||||
1,
|
||||
StoredKsf {
|
||||
memory_kib: 47_104,
|
||||
iterations: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("persist envelope");
|
||||
|
||||
// ── LOGIN — reads the envelope back the way `login/ke1` will ─────
|
||||
let stored = repo
|
||||
|
||||
@@ -181,12 +181,25 @@ pub struct OpaqueRegisterStartResponse {
|
||||
/// base64-encoded output of `ClientRegistration::finish(...).message`;
|
||||
/// `ciphersuiteVersion` is what the client believed it was minting
|
||||
/// under (compared to the current server value — mismatch → 400).
|
||||
///
|
||||
/// `ksf*` fields carry the Argon2id parameters the client actually
|
||||
/// used at `startRegistration`/`finishRegistration` time. Server
|
||||
/// persists them per-envelope so future changes to the server's
|
||||
/// `OpaqueConfig::ksf_*` don't invalidate this envelope. Optional
|
||||
/// on the wire (older clients that predate per-envelope storage
|
||||
/// omit them; server falls back to its current config in that case).
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct OpaqueRegisterFinishDto {
|
||||
#[serde(rename = "registrationRecord")]
|
||||
pub registration_record: String,
|
||||
#[serde(rename = "ciphersuiteVersion")]
|
||||
pub ciphersuite_version: i16,
|
||||
#[serde(rename = "ksfMemoryKib", default)]
|
||||
pub ksf_memory_kib: Option<u32>,
|
||||
#[serde(rename = "ksfIterations", default)]
|
||||
pub ksf_iterations: Option<u32>,
|
||||
#[serde(rename = "ksfParallelism", default)]
|
||||
pub ksf_parallelism: Option<u32>,
|
||||
}
|
||||
|
||||
/// KE1 (register/start): parse client `RegistrationRequest`, run
|
||||
@@ -329,7 +342,25 @@ pub async fn register_finish(
|
||||
let stored = ServerRegistration::<OxiCloudSuite>::finish(record);
|
||||
let envelope_bytes = stored.serialize();
|
||||
|
||||
repo.write_registration(user_id, &envelope_bytes, svc.ciphersuite_version())
|
||||
// KSF params the client used. Client-declared per migration
|
||||
// 20261005000000; older clients omit and we fall back to the
|
||||
// server's CURRENT config values (best guess: the client fetched
|
||||
// /params right before registering, so current-config is what it
|
||||
// saw). Persisting the exact per-envelope values means future
|
||||
// config changes don't break this envelope on login.
|
||||
let ksf = crate::application::ports::opaque_ports::StoredKsf {
|
||||
memory_kib: dto
|
||||
.ksf_memory_kib
|
||||
.unwrap_or_else(|| svc.config_ksf_memory_kib()),
|
||||
iterations: dto
|
||||
.ksf_iterations
|
||||
.unwrap_or_else(|| svc.config_ksf_iterations()),
|
||||
parallelism: dto
|
||||
.ksf_parallelism
|
||||
.unwrap_or_else(|| svc.config_ksf_parallelism()),
|
||||
};
|
||||
|
||||
repo.write_registration(user_id, &envelope_bytes, svc.ciphersuite_version(), ksf)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
@@ -387,6 +418,25 @@ pub struct OpaqueLookupDto {
|
||||
pub struct OpaqueLookupResponse {
|
||||
#[serde(rename = "hasOpaque")]
|
||||
pub has_opaque: bool,
|
||||
/// KSF parameters this user's envelope was minted under. Present
|
||||
/// only when `has_opaque = true`. The client MUST use these values
|
||||
/// (not the ones from `GET /params`) on the login handshake — the
|
||||
/// envelope's OPRF derivation was bound to them at register time
|
||||
/// and a mismatch will fail the AKE with `InvalidCredentials`.
|
||||
///
|
||||
/// `None` when: (a) `has_opaque = false` (nothing to publish),
|
||||
/// or (b) the envelope predates per-envelope-KSF storage
|
||||
/// (migration `20261005000000`) — in which case the client falls
|
||||
/// back to `/params` values, which is the same behaviour as
|
||||
/// before per-envelope storage existed.
|
||||
///
|
||||
/// Anti-enum note: the presence of this field ONLY signals what
|
||||
/// `has_opaque` already signals (positive existence). Value
|
||||
/// differences across users could reveal timing of registration
|
||||
/// but not identity — same low-severity leak as the existing
|
||||
/// per-identifier probe, no additional exposure.
|
||||
#[serde(rename = "ksf", skip_serializing_if = "Option::is_none")]
|
||||
pub ksf: Option<OpaqueKsfParams>,
|
||||
}
|
||||
|
||||
/// Resolve `userIdentifier` → envelope-existence check. Used by the
|
||||
@@ -418,19 +468,35 @@ pub async fn login_lookup(
|
||||
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,
|
||||
// Resolve the identifier → user_id → envelope presence + KSF.
|
||||
// Any miss (unknown user, user without envelope, DB blip) collapses
|
||||
// to `hasOpaque: false, ksf: None` — 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).
|
||||
//
|
||||
// KSF fallback: if the envelope has NULL KSF (predates per-envelope
|
||||
// storage migration 20261005000000), we return `ksf: None` — the
|
||||
// client then uses the server's current `/params` values, which is
|
||||
// the pre-per-envelope-storage behaviour.
|
||||
let (has_opaque, ksf) = match auth.lookup_user_for_login(identifier).await {
|
||||
Ok(user) => match repo.read_registration(user.id()).await {
|
||||
Ok(Some(stored)) => {
|
||||
let ksf = stored.ksf.map(|k| OpaqueKsfParams {
|
||||
memory_kib: k.memory_kib,
|
||||
iterations: k.iterations,
|
||||
parallelism: k.parallelism,
|
||||
});
|
||||
(true, ksf)
|
||||
}
|
||||
_ => (false, None),
|
||||
},
|
||||
Err(_) => (false, None),
|
||||
};
|
||||
|
||||
Ok(Json(OpaqueLookupResponse { has_opaque }))
|
||||
Ok(Json(OpaqueLookupResponse { has_opaque, ksf }))
|
||||
}
|
||||
|
||||
// ── Login: KE1 + KE3 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user