fix(kdf): disable the data-loss KDF auto-migration path (audit 6c-1)
/migrate-kdf changed the AES key (100k->600k) but only re-encrypted encrypted_password, leaving totp_secret/custom_fields/*_enc + attachments under the old key = permanent data loss. Both live accounts are already at 600k so it never fired, but the path was armed and auto-triggered by the server's login signal. Removed the two triggers (doLogin + unlock) and the now-dead runKdfMigration. Legacy <600k accounts upgrade safely via Change Master Password instead (re-encrypts everything + moves to Argon2id). The Delphi endpoint is left inert: without the client orchestrating the key swap it can't corrupt anything, so no untested server change. Closes 6c-4 too (unreachable now). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -941,122 +941,6 @@ async function hibpCheckAllEntries() {
|
||||
render();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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 {
|
||||
// Two distinct migration scenarios:
|
||||
// A. fromIters !== toIters: KDF iteration count is changing, so
|
||||
// the AES key is changing. We re-encrypt every entry with the
|
||||
// new key + fresh IVs, swap state.cryptoKey at the end.
|
||||
// B. fromIters === toIters: same KDF, only the server-side hash
|
||||
// format is being upgraded (legacy "pbkdf2" raw → "pbkdf2-sha256"
|
||||
// wrapped). No entry re-encryption needed — just trigger the
|
||||
// endpoint so the server rewrites the user row.
|
||||
const kdfChange = fromIters !== toIters;
|
||||
let newKey, newCiphertexts;
|
||||
|
||||
if (kdfChange) {
|
||||
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).
|
||||
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 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
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Hash-format-only upgrade — server still wants an entries
|
||||
// array (it's an idempotent transactional update), just empty.
|
||||
newCiphertexts = [];
|
||||
}
|
||||
|
||||
// Zero-knowledge: compute BOTH verifiers locally. oldVerifier proves
|
||||
// the user knows the master pw under the current (legacy) iters;
|
||||
// newVerifier is what the server will SHA-256-wrap to be the new
|
||||
// stored hash after migration. Master pw never leaves the browser.
|
||||
// Only non-v2 accounts ever reach the KDF migration (v2 accounts are
|
||||
// 600k + decoupled → never signalled). Under a non-v2 algo the
|
||||
// verifier is the key hex, so both derivations round-trip exactly as
|
||||
// before; passing state.hashAlgo keeps it explicit.
|
||||
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters, state.hashAlgo, state.argon2Params);
|
||||
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters, state.hashAlgo, state.argon2Params);
|
||||
|
||||
await api('/migrate-kdf', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
oldVerifier: oldVerifier,
|
||||
newVerifier: newVerifier,
|
||||
entries: newCiphertexts,
|
||||
}),
|
||||
});
|
||||
|
||||
if (kdfChange) {
|
||||
// Swap to the new AES key + update cached ciphertexts.
|
||||
state.cryptoKey = newKey;
|
||||
state.kdfIterations = toIters;
|
||||
sessionStorage.setItem('kdfIterations', String(toIters));
|
||||
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)');
|
||||
} else {
|
||||
// Format-only upgrade is silent — the user didn't perceive a
|
||||
// weakness change, and nothing visible in the UI changed.
|
||||
// (A subtle "Auth format upgraded" toast felt noisy.)
|
||||
}
|
||||
} 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
|
||||
// ============================================================
|
||||
@@ -1242,10 +1126,11 @@ async function doLogin(e) {
|
||||
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);
|
||||
}
|
||||
// KDF auto-migration (100k→600k) removed 2026-07-16: it changed the AES
|
||||
// key but only re-encrypted encrypted_password, corrupting the 7 other
|
||||
// per-entry blobs + attachments (§6c-1, a data-loss footgun). Legacy
|
||||
// <600k accounts upgrade safely via Change Master Password instead
|
||||
// (that path re-encrypts everything and moves to Argon2id).
|
||||
} catch (err) {
|
||||
// 429 with retry_after = account lockout. Show countdown in the
|
||||
// auth hint instead of a generic error toast, and keep the login
|
||||
@@ -1440,9 +1325,7 @@ async function doUnlock(p) {
|
||||
$('#authHint').textContent = '';
|
||||
toast('Unlocked');
|
||||
await enterApp();
|
||||
if (r.kdfMigration && r.kdfMigration.target) {
|
||||
runKdfMigration(p, r.kdfIterations, r.kdfMigration.target);
|
||||
}
|
||||
// KDF auto-migration removed — see the note in the login path (§6c-1).
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Account lockout (too many wrong master pw attempts): show
|
||||
|
||||
Reference in New Issue
Block a user