diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index 937ac24..f74d837 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -626,11 +626,225 @@ begin TJSONHelper.SendOK(AResponse, 'Migration complete'); end; +// ===== POST /change-master-password ========================================== +// Body: { +// currentMasterPassword, // verified against current stored hash +// newMasterPassword, // basis for new hash + new client AES key +// newSalt, // 64-char hex, client-generated +// entries: [{ id, encrypted_password, iv, totp_secret?, totp_iv? }, ...] +// // entries re-encrypted client-side with the +// // new key (derived from new pw + new salt) +// } +// +// All-or-nothing transaction: verifies current, then in one tx updates the +// user row (hash + salt + iter count + algo) AND every entry's ciphertext. +// On any failure the user stays on the old config — they can retry without +// data loss. +// +// Side effects: +// - Invalidates ALL other sessions so a leaked old token can't keep +// working past the pw change. +// - Writes an audit_log entry. +// +// The /migrate-kdf endpoint exists for the same "re-encrypt all entries" +// pattern when the master pw stays the same; this endpoint differs by +// rotating the salt + pw too. +procedure HandleChangeMasterPassword(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, I: Integer; + LBody, LEntry, LObj: TJSONObject; + LEntries: TJSONArray; + LUser, LCurPwd, LNewPwd, LNewSalt, LStoredHash, LOldSalt, LAlgo, LIP, + LComputed, LNewHash: string; + LOldIters: Integer; + LQ: TFDQuery; + LValid: Boolean; + LEntryId: Integer; + LEncPwd, LIv, LTotpSec, LTotpIv: 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 + LCurPwd := LBody.GetValue('currentMasterPassword', ''); + LNewPwd := LBody.GetValue('newMasterPassword', ''); + LNewSalt := LBody.GetValue('newSalt', ''); + LEntries := LBody.GetValue('entries'); + + // Input validation. Length 64 = 32 raw bytes in hex, matches the salt + // format produced by RandomHex(32) and client-side randomHexSalt(). + if (Length(LCurPwd) < 1) or (Length(LNewPwd) < 8) then + begin + TJSONHelper.SendError(AResponse, 400, + 'New master password must be at least 8 characters'); + Exit; + end; + if Length(LNewSalt) <> 64 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid newSalt length'); + Exit; + end; + if LEntries = nil then + begin + TJSONHelper.SendError(AResponse, 400, 'Missing entries array'); + Exit; + end; + + DB.Lock; + try + // Step 1: load current 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; + LOldSalt := LQ.FieldByName('salt').AsString; + LAlgo := LQ.FieldByName('hash_algo').AsString; + LOldIters := LQ.FieldByName('kdf_iterations').AsInteger; + if LAlgo = '' then LAlgo := HASH_ALGO_LEGACY; + if LOldIters <= 0 then LOldIters := PBKDF2_ITERATIONS; + finally + LQ.Free; + end; + + // Lockout protection on the pw change itself (same threat model as + // /login — attacker with a hijacked session shouldn't be able to + // brute-force the current pw to swap it for one they know). + if RejectIfAccountLocked(AResponse, LUser) then Exit; + + // Step 2: verify the CURRENT master pw. + LValid := False; + if SameText(LAlgo, HASH_ALGO_LEGACY) then + begin + LComputed := PBKDF2_SHA256_Hex(LCurPwd, LOldSalt, LOldIters); + LValid := ConstantTimeEquals(LComputed, LStoredHash); + end + else if SameText(LAlgo, HASH_ALGO_CURRENT) then + begin + LComputed := ComputeAuthHashCurrent(LCurPwd, LOldSalt, LOldIters); + LValid := ConstantTimeEquals(LComputed, LStoredHash); + end; + if not LValid then + begin + RecordFailedAccountAttempt(LUser, LIP); + LogAudit(LUserId, 'failed_change_password', LIP); + TJSONHelper.SendError(AResponse, 401, 'Current password is incorrect'); + Exit; + end; + + // Step 3: compute the new auth hash with the new salt + target iters. + LNewHash := ComputeAuthHashCurrent(LNewPwd, LNewSalt, PBKDF2_ITERATIONS_TARGET); + + // Step 4: atomic transaction — user row + every entry's ciphertext. + DB.Connection.StartTransaction; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE users SET ' + + ' password_hash = :h, ' + + ' salt = :s, ' + + ' kdf_iterations = :it, ' + + ' hash_algo = :algo ' + + 'WHERE id = :uid'; + LQ.ParamByName('h').AsString := LNewHash; + LQ.ParamByName('s').AsString := LNewSalt; + LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET; + LQ.ParamByName('algo').AsString := HASH_ALGO_CURRENT; + 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, ' + + ' totp_secret = :ts, totp_iv = :tiv, ' + + ' 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', ''); + LTotpSec := LEntry.GetValue('totp_secret', ''); + LTotpIv := LEntry.GetValue('totp_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; + // TOTP fields are optional per entry — clear when empty so + // existing-NULL rows don't get stomped with empty strings. + if LTotpSec = '' then LQ.ParamByName('ts').Clear + else LQ.ParamByName('ts').AsString := LTotpSec; + if LTotpIv = '' then LQ.ParamByName('tiv').Clear + else LQ.ParamByName('tiv').AsString := LTotpIv; + LQ.ExecSQL; + end; + finally + LQ.Free; + end; + + DB.Connection.Commit; + except + DB.Connection.Rollback; + raise; + end; + finally + DB.Unlock; + end; + + // Step 5: invalidate every other session for this user. The CURRENT + // session token is still valid — caller stays logged in. + DeleteAllUserSessions(LUserId); + finally + LBody.Free; + end; + + ClearAccountLockout(LUser); + LogAudit(LUserId, 'change_master_password', LIP); + + LObj := TJSONObject.Create; + LObj.AddPair('message', 'Master password changed'); + LObj.AddPair('salt', LNewSalt); + LObj.AddPair('kdfIterations', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET)); + TJSONHelper.SendJSON(AResponse, LObj); +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', '/migrate-kdf', HandleMigrateKdf); + 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); + Router.Register('POST', '/change-master-password', HandleChangeMasterPassword); end. diff --git a/index.html b/index.html index 944fdce..06b6a99 100644 --- a/index.html +++ b/index.html @@ -370,6 +370,9 @@

Signed in as

+ @@ -402,6 +405,44 @@ + + + + + diff --git a/js/app.js b/js/app.js index b02618d..fbf9771 100644 --- a/js/app.js +++ b/js/app.js @@ -2409,6 +2409,151 @@ function closeReauth(ok) { } } +// ============================================================ +// CHANGE MASTER PASSWORD +// ============================================================ +// +// Two-step flow: +// 1. Modal collects current pw + new pw (×2) + validates locally. +// 2. Client re-encrypts every entry under the new key (derived from +// new pw + freshly-generated salt) and sends the whole payload to +// /change-master-password. Server verifies current pw, then commits +// user row + all entry ciphertexts in one transaction. +// On success: state.salt + state.cryptoKey are swapped, the user stays +// logged in (current session preserved), all OTHER sessions invalidated. + +// 64-char hex salt — matches the format the server expects and the shape +// produced by Delphi RandomHex(32). Crypto-secure RNG. +function randomHexSalt() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + let hex = ''; + for (const b of bytes) hex += b.toString(16).padStart(2, '0'); + return hex; +} + +function openChangeMasterModal() { + $('#cmCurrentPwd').value = ''; + $('#cmNewPwd').value = ''; + $('#cmConfirmPwd').value = ''; + const errEl = $('#cmError'); + if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; } + $('#changeMasterModal').classList.remove('is-hidden'); + setTimeout(() => $('#cmCurrentPwd').focus(), 50); +} + +function closeChangeMasterModal() { + $('#changeMasterModal').classList.add('is-hidden'); +} + +function showCmError(msg) { + const el = $('#cmError'); + if (!el) return; + el.textContent = msg; + el.style.display = ''; +} + +async function doChangeMasterPassword() { + const curPwd = $('#cmCurrentPwd').value; + const newPwd = $('#cmNewPwd').value; + const confPwd = $('#cmConfirmPwd').value; + + // Local validation. Server enforces these too, but failing fast saves + // a round trip + leaves the modal open so the user can fix and retry. + if (!curPwd || !newPwd || !confPwd) return showCmError('All fields are required'); + if (newPwd.length < 8) return showCmError('New password must be at least 8 characters'); + if (newPwd !== confPwd) return showCmError('New password and confirmation do not match'); + if (newPwd === curPwd) return showCmError('New password must differ from the current one'); + + // Disable the confirm button so a double-click doesn't fire two + // re-encryption passes in parallel. + const btn = $('#cmConfirmBtn'); + if (btn) btn.disabled = true; + try { + // Step 1: generate the new salt and derive the new AES key. + const newSalt = randomHexSalt(); + const newKey = await deriveKey(newPwd, newSalt, 600000); + + // Step 2: re-encrypt every entry's password AND every entry's TOTP + // secret (if present) under the new key. The current state.cryptoKey + // still decrypts the existing ciphertext. + const encrypted = []; + for (const e of state.entries) { + const plain = await decryptPwd(e.encrypted_password, e.iv); + if (plain === '[ERROR]') { + throw new Error('Could not decrypt entry id=' + e.id); + } + // Swap the key around encryptPwd so it picks up the new one. + const oldKey = state.cryptoKey; + state.cryptoKey = newKey; + try { + const re = await encryptPwd(plain); + let totpEnc = '', totpIv = ''; + if (e.totp_secret && e.totp_iv) { + state.cryptoKey = oldKey; + const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); + state.cryptoKey = newKey; + if (plainTotp !== '[ERROR]') { + const t = await encryptPwd(plainTotp); + totpEnc = t.encrypted; + totpIv = t.iv; + } + } + encrypted.push({ + id: e.id, + encrypted_password: re.encrypted, + iv: re.iv, + totp_secret: totpEnc, + totp_iv: totpIv, + }); + } finally { + state.cryptoKey = oldKey; // restore until server confirms + } + } + + // Step 3: send the atomic request. Server verifies the current pw, + // updates the user row, swaps every entry's ciphertext, returns + // the new salt + iter count. + const r = await api('/change-master-password', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + currentMasterPassword: curPwd, + newMasterPassword: newPwd, + newSalt: newSalt, + entries: encrypted, + }), + }); + + // Step 4: server committed → switch the in-memory key & salt, refresh + // the cached ciphertexts, persist for F5 survival. + state.salt = r.salt || newSalt; + state.cryptoKey = newKey; + await persistCryptoKey(); + sessionStorage.setItem('salt', state.salt); + for (let i = 0; i < state.entries.length; i++) { + const nc = encrypted[i]; + state.entries[i].encrypted_password = nc.encrypted_password; + state.entries[i].iv = nc.iv; + state.entries[i].totp_secret = nc.totp_secret || null; + state.entries[i].totp_iv = nc.totp_iv || null; + } + + closeChangeMasterModal(); + toast('Master password changed · other sessions signed out'); + } catch (err) { + if (err.status === 401) { + showCmError('Current password is incorrect'); + } else if (err.status === 429 && err.body && err.body.retry_after) { + showCmError('Account locked, try again in ' + + Math.ceil(err.body.retry_after / 60) + ' min'); + } else { + showCmError('Failed: ' + (err.message || 'unknown error')); + } + } finally { + if (btn) btn.disabled = false; + } +} + // ============================================================ // ENCRYPTED EXPORT CONTAINER // ============================================================ @@ -3184,6 +3329,13 @@ async function init() { }); $('#exportBtn').addEventListener('click', doExport); $('#importBtn').addEventListener('click', doImport); + $('#changeMasterBtn').addEventListener('click', openChangeMasterModal); + $('#changeMasterForm').addEventListener('submit', e => { + e.preventDefault(); + doChangeMasterPassword(); + }); + $$('#changeMasterModal [data-close]').forEach(b => + b.addEventListener('click', closeChangeMasterModal)); // Re-auth modal $('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); }); @@ -3211,6 +3363,10 @@ async function init() { closeConfirm(false); return; } + if (!$('#changeMasterModal').classList.contains('is-hidden')) { + closeChangeMasterModal(); + return; + } closePalette(); closeSlideOver(); closeEntryModal();