From 94e5b9e3552c454a2be78e05a7b5dd419e19a44d Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 5 Aug 2026 22:16:03 +0200 Subject: [PATCH] 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 --- frontend/src/lib/api/endpoints/auth.test.ts | 145 ++++++++++++++- frontend/src/lib/api/endpoints/auth.ts | 39 +++- frontend/src/lib/api/endpoints/opaque.test.ts | 8 +- frontend/src/lib/api/endpoints/opaque.ts | 43 ++++- ...005000000_auth_opaque_per_envelope_ksf.sql | 80 +++++++++ src/application/ports/opaque_ports.rs | 31 +++- src/bin/oxicloud-cli.rs | 6 +- .../repositories/pg/opaque_pg_repository.rs | 167 +++++++++++++++--- .../api/handlers/opaque_auth_handler.rs | 90 ++++++++-- 9 files changed, 547 insertions(+), 62 deletions(-) create mode 100644 migrations/20261005000000_auth_opaque_per_envelope_ksf.sql diff --git a/frontend/src/lib/api/endpoints/auth.test.ts b/frontend/src/lib/api/endpoints/auth.test.ts index 937113f1..2fae11a7 100644 --- a/frontend/src/lib/api/endpoints/auth.test.ts +++ b/frontend/src/lib/api/endpoints/auth.test.ts @@ -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: } // 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: } + // 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 () => { diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 48d6b81a..8b570294 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -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) ───── diff --git a/frontend/src/lib/api/endpoints/opaque.test.ts b/frontend/src/lib/api/endpoints/opaque.test.ts index 45517822..ea31ecad 100644 --- a/frontend/src/lib/api/endpoints/opaque.test.ts +++ b/frontend/src/lib/api/endpoints/opaque.test.ts @@ -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 }); }); diff --git a/frontend/src/lib/api/endpoints/opaque.ts b/frontend/src/lib/api/endpoints/opaque.ts index 5687bebe..e687c1e2 100644 --- a/frontend/src/lib/api/endpoints/opaque.ts +++ b/frontend/src/lib/api/endpoints/opaque.ts @@ -154,13 +154,27 @@ export function fetchOpaqueParams(): Promise { * both "unknown user" and "user without envelope." Callers must * never assume `hasOpaque: false` implies the user exists. */ -export async function checkOpaqueAvailable(userIdentifier: string): Promise { +/** + * 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 { // 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, + /// 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, +} + +/// 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 diff --git a/src/bin/oxicloud-cli.rs b/src/bin/oxicloud-cli.rs index 66bb71ae..d4b8e135 100644 --- a/src/bin/oxicloud-cli.rs +++ b/src/bin/oxicloud-cli.rs @@ -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, } } diff --git a/src/infrastructure/repositories/pg/opaque_pg_repository.rs b/src/infrastructure/repositories/pg/opaque_pg_repository.rs index 724de89c..0f5efd3f 100644 --- a/src/infrastructure/repositories/pg/opaque_pg_repository.rs +++ b/src/infrastructure/repositories/pg/opaque_pg_repository.rs @@ -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, #[sqlx(rename = "opaque_registered_at")] registered_at: Option>, + // 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, + #[sqlx(rename = "opaque_ksf_iterations")] + ksf_iterations: Option, + #[sqlx(rename = "opaque_ksf_parallelism")] + ksf_parallelism: Option, } #[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` at the boundary so // downstream comparisons stay simple. let envelope_bytes: Vec = 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 diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index 37ccdac3..9d7a2439 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -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, + #[serde(rename = "ksfIterations", default)] + pub ksf_iterations: Option, + #[serde(rename = "ksfParallelism", default)] + pub ksf_parallelism: Option, } /// KE1 (register/start): parse client `RegistrationRequest`, run @@ -329,7 +342,25 @@ pub async fn register_finish( let stored = ServerRegistration::::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, } /// 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 ─────────────────────────────────────────────────