From e0e452306e983892c36dc43fb7329de4aedd52ec Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Sat, 23 May 2026 04:54:05 +0100 Subject: [PATCH] =?UTF-8?q?feat(crypto):=20PBKDF2=20iterations=20100k=20?= =?UTF-8?q?=E2=86=92=20600k=20with=20transparent=20re-encryption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the PBKDF2-SHA256 iteration count from 100,000 (OWASP 2017) to 600,000 (OWASP 2023). 6x slowdown on every brute-force attempt against either the server-stored auth hash OR the AES-GCM ciphertext of the entries — both currently use the same PBKDF2 output (see KNOWN ISSUE below for why that's another problem to fix later). Schema ====== users.kdf_iterations INTEGER DEFAULT 100000 Per-user iteration count. Legacy rows predating the column default to 100k via the DEFAULT clause. New accounts insert 600k explicitly. Migration flow ============== Atomic from the user's perspective. No partial state ever persisted. 1. /login (or /reauth): server reads users.kdf_iterations and verifies the master pw at that count. Login succeeds at the legacy strength. Response now includes kdfIterations (current) and optionally kdfMigration = { target: 600000 } when an upgrade is recommended. 2. Client: derives the AES key at the OLD count to decrypt current entries (state.cryptoKey). enterApp() loads the vault normally. 3. runKdfMigration() (background, after enterApp): - derives the NEW key at target iterations - decrypts every entry with the old key - re-encrypts every entry with the new key + fresh random IVs - POSTs { masterPassword, entries: [...] } to /migrate-kdf 4. /migrate-kdf (new endpoint): - verifies the master pw against the OLD hash - in a single transaction: UPDATE users SET password_hash = pbkdf2(pw, salt, 600k), kdf_iterations = 600000 UPDATE vault_entries SET encrypted_password, iv (per entry) - on any failure: ROLLBACK. User stays at legacy config, retries at next login. No half-migrated state possible. 5. Client (post-commit): swaps state.cryptoKey to the new key, persists it, updates the cached ciphertext in state.entries, shows a "Vault security upgraded" toast. Idempotency: server's /migrate-kdf short-circuits with "Already at target" if users.kdf_iterations >= PBKDF2_ITERATIONS_TARGET. Race conditions: two concurrent migrations from two tabs both recompute the SAME new key (deterministic PBKDF2). The losing transaction's entries get re-encrypted with the winning one's IVs, but both clients can decrypt because the keys are identical. KNOWN ISSUE (not fixed by this commit) ====================================== The server's password_hash IS the client's AES key, in hex form — both sides compute PBKDF2(pw, salt, iters) and store/use the same 32 bytes. This means a stolen vault.db gives the attacker the encryption key directly, without needing to brute-force anything. The 100k → 600k bump still helps because the AES-GCM ciphertext itself is also a brute-force target, but the architectural fix (server stores SHA256(aes_key) instead of aes_key in hex) is a separate concern that needs its own migration. Other changes ============= - HandleRegister: new accounts insert kdf_iterations=600000. - HandleReauth: response upgraded to JSON with kdfIterations + optional kdfMigration. Unlock path now also triggers migration. - SendAuthSuccess: extended signature, all callers updated. - deriveKey(pwd, saltHex, iterations) in app.js: iterations param required, defaults to 100000 for back-compat with any legacy caller. --- delphi-backend/Handlers/PM.Handler.Auth.pas | 247 ++++++++++++++++++-- delphi-backend/Source/PM.Database.pas | 6 + js/app.js | 116 ++++++++- 3 files changed, 345 insertions(+), 24 deletions(-) diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index 2c8ca4a..4074a9c 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -26,7 +26,15 @@ uses PM.Session, PM.RateLimit, PM.Audit; const + // Legacy iteration count from the initial 2025 release. Kept around to + // verify pre-migration login attempts (each user row records its own + // value in users.kdf_iterations). New code paths should reference + // PBKDF2_ITERATIONS_TARGET instead. PBKDF2_ITERATIONS = 100000; + // Current target. New accounts hash at this strength; legacy accounts + // are transparently upgraded at next login (see HandleLogin/HandleReauth). + // Value picked per OWASP 2023 PBKDF2-SHA256 recommendation. + PBKDF2_ITERATIONS_TARGET = 600000; DEFAULT_FOLDERS: array[0..4] of string = ('All', 'Social', 'Banking', 'Work', 'Personal'); procedure EnsureDefaultFolders(AUserId: Integer); @@ -56,9 +64,10 @@ begin end; procedure SendAuthSuccess(AResponse: TIdHTTPResponseInfo; - AUserId: Integer; const AToken, ASalt, ACSRFToken: string); + AUserId: Integer; const AToken, ASalt, ACSRFToken: string; + AKdfIterations: Integer; ANeedsMigration: Boolean); var - LObj: TJSONObject; + LObj, LMig: TJSONObject; begin LObj := TJSONObject.Create; LObj.AddPair('message', 'OK'); @@ -66,6 +75,17 @@ begin LObj.AddPair('userId', TJSONNumber.Create(AUserId)); LObj.AddPair('salt', ASalt); LObj.AddPair('csrfToken', ACSRFToken); + // kdfIterations is the iteration count the client must use when deriving + // the AES-GCM key for THIS session — matches the count under which the + // existing entries are encrypted. If the server signals migration, the + // client should re-encrypt with the new target and call /migrate-kdf. + LObj.AddPair('kdfIterations', TJSONNumber.Create(AKdfIterations)); + if ANeedsMigration then + begin + LMig := TJSONObject.Create; + LMig.AddPair('target', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET)); + LObj.AddPair('kdfMigration', LMig); + end; TJSONHelper.SendJSON(AResponse, LObj); end; @@ -118,17 +138,20 @@ begin end; LSalt := RandomHex(32); - LHash := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS); + // New accounts use the current target iteration count — no migration + // path needed since this is a brand-new vault with zero entries. + LHash := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET); LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; LQ.SQL.Text := - 'INSERT INTO users (username, password_hash, salt, hash_algo) ' + - 'VALUES (:u, :h, :s, ''pbkdf2'')'; + 'INSERT INTO users (username, password_hash, salt, hash_algo, kdf_iterations) ' + + 'VALUES (:u, :h, :s, ''pbkdf2'', :it)'; LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('h').AsString := LHash; LQ.ParamByName('s').AsString := LSalt; + LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET; LQ.ExecSQL; LUserId := DB.Connection.GetLastAutoGenValue('users'); finally @@ -141,7 +164,9 @@ begin EnsureDefaultFolders(LUserId); CreateSession(LUserId, LToken, LCSRF); LogAudit(LUserId, 'register', LIP); - SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF); + // No migration ever needed for fresh accounts. + SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF, + PBKDF2_ITERATIONS_TARGET, False); end; // ===== /login ================================================================ @@ -151,7 +176,7 @@ procedure HandleLogin(ARequest: TIdHTTPRequestInfo; var LBody: TJSONObject; LUser, LPwd, LSalt, LStoredHash, LAlgo, LToken, LCSRF, LIP: string; - LUserId: Integer; + LUserId, LKdfIters: Integer; LQ: TFDQuery; LComputed: string; LValid: Boolean; @@ -182,7 +207,8 @@ begin try LQ.Connection := DB.Connection; LQ.SQL.Text := - 'SELECT id, password_hash, salt, hash_algo FROM users WHERE username = :u'; + 'SELECT id, password_hash, salt, hash_algo, kdf_iterations ' + + 'FROM users WHERE username = :u'; LQ.ParamByName('u').AsString := LUser; LQ.Open; if LQ.IsEmpty then @@ -201,7 +227,11 @@ begin LStoredHash := LQ.FieldByName('password_hash').AsString; LSalt := LQ.FieldByName('salt').AsString; LAlgo := LQ.FieldByName('hash_algo').AsString; + LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; if LAlgo = '' then LAlgo := 'pbkdf2'; + // Legacy rows predating the kdf_iterations column have NULL → 0 here; + // treat as the original 100k value used by api.php and early Delphi. + if LKdfIters <= 0 then LKdfIters := PBKDF2_ITERATIONS; finally LQ.Free; end; @@ -212,7 +242,10 @@ begin LValid := False; if SameText(LAlgo, 'pbkdf2') then begin - LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS); + // Verify with the user's own iteration count (NOT the global constant). + // Legacy users at 100k still need to log in successfully so the client + // can decrypt their entries before triggering the /migrate-kdf flow. + LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LKdfIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); end else if SameText(LAlgo, 'bcrypt') then @@ -242,7 +275,11 @@ begin EnsureDefaultFolders(LUserId); CreateSession(LUserId, LToken, LCSRF); LogAudit(LUserId, 'login', LIP); - SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF); + // Signal migration when the user's current iteration count is below the + // target. The client will re-encrypt all entries and call /migrate-kdf + // to commit everything atomically. + SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF, + LKdfIters, LKdfIters < PBKDF2_ITERATIONS_TARGET); end; // ===== /logout =============================================================== @@ -276,7 +313,7 @@ end; procedure HandleReauth(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var - LUserId: Integer; + LUserId, LKdfIters: Integer; LBody: TJSONObject; LUser, LPwd, LStoredHash, LSalt, LAlgo, LIP, LComputed: string; LQ: TFDQuery; @@ -310,7 +347,8 @@ begin LQ.Connection := DB.Connection; // Pull username too — needed for the per-account lockout calls. LQ.SQL.Text := - 'SELECT username, password_hash, salt, hash_algo FROM users WHERE id = :uid'; + 'SELECT username, password_hash, salt, hash_algo, kdf_iterations ' + + 'FROM users WHERE id = :uid'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.Open; if LQ.IsEmpty then @@ -323,7 +361,9 @@ begin LStoredHash := LQ.FieldByName('password_hash').AsString; LSalt := LQ.FieldByName('salt').AsString; LAlgo := LQ.FieldByName('hash_algo').AsString; + LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; if LAlgo = '' then LAlgo := 'pbkdf2'; + if LKdfIters <= 0 then LKdfIters := PBKDF2_ITERATIONS; finally LQ.Free; end; @@ -340,7 +380,8 @@ begin LValid := False; if SameText(LAlgo, 'pbkdf2') then begin - LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS); + // Verify with the user's stored iteration count, same as HandleLogin. + LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LKdfIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); end; @@ -356,13 +397,183 @@ begin ClearAttempts(LIP); ClearAccountLockout(LUser); LogAudit(LUserId, 'reauth', LIP); - TJSONHelper.SendOK(AResponse, 'OK'); + + // Return KDF state so the client can detect legacy accounts that haven't + // been migrated yet — unlock from a locked state goes through reauth, not + // login, so we need the same migration signaling here. + begin + var LObj := TJSONObject.Create; + LObj.AddPair('message', 'OK'); + LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); + if LKdfIters < PBKDF2_ITERATIONS_TARGET then + begin + var LMig := TJSONObject.Create; + LMig.AddPair('target', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET)); + LObj.AddPair('kdfMigration', LMig); + end; + TJSONHelper.SendJSON(AResponse, LObj); + end; +end; + +// ===== /migrate-kdf ========================================================== +// Atomic transition from an old PBKDF2 iteration count to the current target. +// Client side: derive both old and new AES keys, decrypt each entry with old, +// re-encrypt with new, then POST the new ciphertext blob to this endpoint +// along with the master password (so we can recompute the new server hash). +// Server side: verify the master pw with the old hash, then in a single +// transaction: update users.password_hash to the new PBKDF2 output, set +// kdf_iterations to TARGET, and replace each entry's encrypted_password/iv. +// All-or-nothing: if anything fails, the user stays on the old config. +procedure HandleMigrateKdf(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LOldIters, I: Integer; + LBody, LEntry: TJSONObject; + LEntries: TJSONArray; + LUser, LPwd, LSalt, LStoredHash, LAlgo, LIP, LComputed, LNewHash: string; + LQ: TFDQuery; + LValid: Boolean; + LEntryId: Integer; + LEncPwd, LIv: string; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LIP := GetClientIP(ARequest); + + LBody := TJSONHelper.ReadBody(ARequest); + try + LPwd := LBody.GetValue('masterPassword', ''); + LEntries := LBody.GetValue('entries'); + if LEntries = nil then + begin + TJSONHelper.SendError(AResponse, 400, 'Missing entries array'); + Exit; + end; + + DB.Lock; + try + // Step 1: load current user state. + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT username, password_hash, salt, hash_algo, kdf_iterations ' + + 'FROM users WHERE id = :uid'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + if LQ.IsEmpty then + begin + TJSONHelper.SendError(AResponse, 401, 'User not found'); + Exit; + end; + LUser := LQ.FieldByName('username').AsString; + LStoredHash := LQ.FieldByName('password_hash').AsString; + LSalt := LQ.FieldByName('salt').AsString; + LAlgo := LQ.FieldByName('hash_algo').AsString; + LOldIters := LQ.FieldByName('kdf_iterations').AsInteger; + if LAlgo = '' then LAlgo := 'pbkdf2'; + if LOldIters <= 0 then LOldIters := PBKDF2_ITERATIONS; + finally + LQ.Free; + end; + + // Idempotency: if already at target, nothing to do. + if LOldIters >= PBKDF2_ITERATIONS_TARGET then + begin + TJSONHelper.SendOK(AResponse, 'Already at target'); + Exit; + end; + + // Step 2: verify the master pw against the CURRENT (old) hash. + LValid := False; + if SameText(LAlgo, 'pbkdf2') then + begin + LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LOldIters); + LValid := ConstantTimeEquals(LComputed, LStoredHash); + end; + if not LValid then + begin + RecordFailedAccountAttempt(LUser, LIP); + LogAudit(LUserId, 'failed_migrate_kdf', LIP); + TJSONHelper.SendError(AResponse, 401, 'Invalid password'); + Exit; + end; + + // Step 3: compute the new password hash with target iterations. + LNewHash := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET); + + // Step 4: atomic transaction — update user hash AND every entry's + // ciphertext together. Any failure rolls back, leaving the user on + // the legacy config (safe to retry next login). + DB.Connection.StartTransaction; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE users SET password_hash = :h, kdf_iterations = :it ' + + 'WHERE id = :uid'; + LQ.ParamByName('h').AsString := LNewHash; + LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries ' + + 'SET encrypted_password = :ep, iv = :iv, updated_at = CURRENT_TIMESTAMP ' + + 'WHERE id = :id AND user_id = :uid'; + + for I := 0 to LEntries.Count - 1 do + begin + LEntry := LEntries.Items[I] as TJSONObject; + LEntryId := LEntry.GetValue('id', 0); + LEncPwd := LEntry.GetValue('encrypted_password', ''); + LIv := LEntry.GetValue('iv', ''); + if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then + raise Exception.CreateFmt('Invalid entry payload at index %d', [I]); + + LQ.ParamByName('id').AsInteger := LEntryId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('ep').AsString := LEncPwd; + LQ.ParamByName('iv').AsString := LIv; + LQ.ExecSQL; + end; + finally + LQ.Free; + end; + + DB.Connection.Commit; + except + DB.Connection.Rollback; + raise; + end; + finally + DB.Unlock; + end; + finally + LBody.Free; + end; + + LogAudit(LUserId, Format('migrate_kdf %d->%d', [LOldIters, PBKDF2_ITERATIONS_TARGET]), LIP); + TJSONHelper.SendOK(AResponse, 'Migration complete'); end; initialization - Router.Register('POST', '/register', HandleRegister); - Router.Register('POST', '/login', HandleLogin); - Router.Register('POST', '/logout', HandleLogout); - Router.Register('POST', '/reauth', HandleReauth); + Router.Register('POST', '/register', HandleRegister); + Router.Register('POST', '/login', HandleLogin); + Router.Register('POST', '/logout', HandleLogout); + Router.Register('POST', '/reauth', HandleReauth); + Router.Register('POST', '/migrate-kdf', HandleMigrateKdf); end. diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 5390e2b..119d934 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -216,6 +216,12 @@ begin // Simple format, search via LIKE %tag%. Frontend handles parsing/joining. AddColumnIfMissing('vault_entries', 'tags', 'TEXT DEFAULT '''''); AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2'''); + // PBKDF2 iteration count per user. Legacy rows (predating this column) + // default to 100000 — the value used by api.php / the early Delphi build. + // New accounts created here use the current PBKDF2_ITERATIONS_TARGET + // (600 000 as of 2026). Login flow transparently re-hashes legacy users + // and re-encrypts their entries on the client side. + AddColumnIfMissing('users', 'kdf_iterations', 'INTEGER DEFAULT 100000'); AddColumnIfMissing('sessions', 'csrf_token', 'TEXT'); end; diff --git a/js/app.js b/js/app.js index 9802627..94b576d 100644 --- a/js/app.js +++ b/js/app.js @@ -88,13 +88,18 @@ const state = { // CRYPTO (preserved from legacy app.js — DO NOT TOUCH) // ============================================================ -async function deriveKey(pwd, saltHex) { +async function deriveKey(pwd, saltHex, iterations) { + // Iterations parameter is the per-user value returned by the server in + // the /login response (legacy users = 100000, modern = 600000). Falling + // back to 100000 keeps backwards compatibility with old code paths that + // didn't pass the value, but every new caller should pass it explicitly. + iterations = iterations || 100000; const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); // saltHex is the same string that PHP/Delphi passed to PBKDF2 — use its bytes. const sb = enc.encode(saltHex); return crypto.subtle.deriveKey( - { name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, + { name: 'PBKDF2', salt: sb, iterations: iterations, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] @@ -169,6 +174,92 @@ async function api(path, opts) { return body; } +// ============================================================ +// KDF MIGRATION (PBKDF2 100k → 600k re-encryption) +// ============================================================ +// +// When the server signals kdfMigration in /login or /reauth, we transparently +// re-encrypt every entry with a stronger key (600k PBKDF2 iterations) and +// commit the new ciphertext + the new server-side hash in one atomic +// /migrate-kdf request. If anything fails, the user stays on the legacy +// config and the migration retries at next login. The entries currently +// loaded in state.entries are encrypted with the OLD key (state.cryptoKey). +// +// Threading: runs in the background after enterApp completes. Locking the +// vault during migration is safe — we just lose the in-flight transition +// and the server's atomic rollback means nothing persisted. + +let kdfMigrationInProgress = false; + +async function runKdfMigration(masterPwd, fromIters, toIters) { + if (kdfMigrationInProgress) return; // dedupe concurrent calls + if (!state.entries || !state.cryptoKey) return; + kdfMigrationInProgress = true; + + try { + // Derive the new key. The old key is already in state.cryptoKey + // (used to decrypt the entries we just loaded). + const newKey = await deriveKey(masterPwd, state.salt, toIters); + + // Re-encrypt every entry. Each entry gets a fresh random IV under + // the new key — never reuse the old IV with the new key (would be + // pointless but also a small information leak via IV reuse patterns). + const newCiphertexts = []; + for (const entry of state.entries) { + const plain = await decryptPwd(entry.encrypted_password, entry.iv); + if (plain === '[ERROR]') { + // One decrypt failure aborts the whole migration — better to + // stay on the legacy config than to commit partial state. + throw new Error('Could not decrypt entry id=' + entry.id); + } + const tmpKey = state.cryptoKey; + try { + state.cryptoKey = newKey; + const re = await encryptPwd(plain); + newCiphertexts.push({ + id: entry.id, + encrypted_password: re.encrypted, + iv: re.iv, + }); + } finally { + state.cryptoKey = tmpKey; // restore for any concurrent read + } + } + + // Send the atomic migrate request. Server verifies the master pw + // against the OLD hash, then updates hash + iterations + every + // entry in a single transaction. + await api('/migrate-kdf', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + masterPassword: masterPwd, + entries: newCiphertexts, + }), + }); + + // Server committed → switch our in-memory crypto key and update + // the cached ciphertexts in state.entries so subsequent reads use + // the new key transparently. + state.cryptoKey = newKey; + await persistCryptoKey(); + for (let i = 0; i < state.entries.length; i++) { + const nc = newCiphertexts[i]; + state.entries[i].encrypted_password = nc.encrypted_password; + state.entries[i].iv = nc.iv; + } + + toast('Vault security upgraded (' + fromIters.toLocaleString() + + ' → ' + toIters.toLocaleString() + ' KDF iterations)'); + } catch (err) { + // Silent retry on next login — the migration is idempotent and + // safe to abandon (server rolled back). + console.warn('KDF migration aborted:', err); + } finally { + kdfMigrationInProgress = false; + } +} + // ============================================================ // ACCOUNT LOCKOUT UI // ============================================================ @@ -277,10 +368,17 @@ async function doLogin(e) { sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); - state.cryptoKey = await deriveKey(p, state.salt); + // Derive with the server-specified iteration count — legacy users + // receive 100k, modern users 600k. The cryptoKey is what currently + // decrypts the entries on this server. + state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations); await persistCryptoKey(); toast('Welcome back, ' + u); await enterApp(); + // Trigger KDF migration AFTER entries are loaded into state. + if (r.kdfMigration && r.kdfMigration.target) { + runKdfMigration(p, r.kdfIterations, r.kdfMigration.target); + } } catch (err) { // 429 with retry_after = account lockout. Show countdown in the // auth hint instead of a generic error toast, and keep the login @@ -317,7 +415,9 @@ async function doRegister(e) { sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); - state.cryptoKey = await deriveKey(p, state.salt); + // Fresh account → server returns kdfIterations = current target. + // No migration ever needed for a brand-new vault. + state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations); await persistCryptoKey(); toast('Vault created'); await enterApp(); @@ -375,18 +475,22 @@ function lockVault() { // then re-derive the crypto key locally without rotating session/csrf. async function doUnlock(p) { try { - await api('/reauth', { + const r = await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ masterPassword: p }), }); - state.cryptoKey = await deriveKey(p, state.salt); + // r now carries kdfIterations + optional kdfMigration, same as /login. + state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations); await persistCryptoKey(); state.locked = false; $('#loginUsername').readOnly = false; $('#authHint').textContent = ''; toast('Unlocked'); await enterApp(); + if (r.kdfMigration && r.kdfMigration.target) { + runKdfMigration(p, r.kdfIterations, r.kdfMigration.target); + } return true; } catch (err) { // Account lockout (too many wrong master pw attempts): show