cca8184b81
Adds the canonical PM feature: let the user pick a new master password
and have every entry transparently re-encrypted under the new key,
without ever exposing plaintext to the server.
Backend endpoint: POST /change-master-password
==============================================
Body:
{
currentMasterPassword, verified against current stored hash
newMasterPassword, basis for the new hash + new client key
newSalt, 64-char hex, client-generated
entries: [{ id, encrypted_password, iv,
totp_secret?, totp_iv? }, ...]
}
Flow:
1. Authenticate + RequireCSRF (caller already logged in).
2. RejectIfAccountLocked — pw change is brute-forceable through a
hijacked session, so it respects the same per-account lockout as
/login.
3. Verify currentMasterPassword against the stored hash. Branches on
hash_algo to handle both legacy 'pbkdf2' and current 'pbkdf2-sha256'.
Wrong pw → RecordFailedAccountAttempt + audit + 401.
4. Compute new auth hash = SHA256(PBKDF2(new_pw, new_salt, 600k)),
always using the current scheme (migration baked in).
5. ATOMIC transaction:
UPDATE users SET password_hash, salt, kdf_iterations, hash_algo
UPDATE vault_entries SET encrypted_password, iv, totp_secret, totp_iv
(per entry)
Any failure → rollback, user stays on the old config.
6. DeleteAllUserSessions — every OTHER session is invalidated so a
leaked old token can't keep working past the rotation. The current
caller's session stays valid.
7. ClearAccountLockout + audit_log entry.
8. Returns { message, salt, kdfIterations }.
Client
======
New modal in index.html (#changeMasterModal) with three password
fields (current / new / confirm) + inline error display. Added a
"Change master password" button in the Settings panel → Account
section. Escape-key handler routes through it like the other modals.
doChangeMasterPassword():
1. Local validation: all fields filled, new ≥ 8 chars, new == confirm,
new ≠ current. Fast failure beats a round trip.
2. randomHexSalt() → 32 secure random bytes, hex-encoded.
3. Derive newKey = PBKDF2(new_pw, new_salt, 600k).
4. Walk state.entries: decrypt password + (optional) TOTP under the
current key, re-encrypt under newKey with fresh random IVs.
One decrypt failure aborts the whole change — better than partial
commit.
5. POST to /change-master-password.
6. On success: swap state.salt + state.cryptoKey, persistCryptoKey,
update sessionStorage, refresh cached ciphertexts in state.entries,
close modal, toast.
7. On 401 / 429 / generic error: show inline error in the modal so
the user can fix and retry without re-typing everything.
Threat model notes
==================
- The current session token stays valid because the new server hash
only invalidates OTHER sessions. Self-logout would be needlessly
disruptive (user already proved knowledge of both pws).
- Server still sees the old + new master pws transiently in /change-
master-password. Same trade-off as /login — eliminating it requires
redesigning to send pre-computed verifiers (SRP-style), tracked
separately.
- The salt rotates with the password — best-practice against any
precomputed dictionary attack tied to the previous salt.