feat(auth): change master password with full vault re-encryption
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.
This commit is contained in:
+41
@@ -370,6 +370,9 @@
|
||||
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px">
|
||||
Signed in as <b id="settingUser"></b>
|
||||
</p>
|
||||
<button class="btn btn-ghost btn-sm" id="changeMasterBtn">
|
||||
<svg><use href="#i-lock"/></svg> Change master password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -402,6 +405,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MODAL: Change Master Password -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="changeMasterModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel modal-panel-sm">
|
||||
<header class="modal-header">
|
||||
<h3>Change master password</h3>
|
||||
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<form id="changeMasterForm" class="modal-body" autocomplete="off">
|
||||
<p style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5">
|
||||
Your vault will be re-encrypted with the new key.
|
||||
All other sessions will be signed out.
|
||||
</p>
|
||||
<label class="field">
|
||||
<span>Current master password</span>
|
||||
<input id="cmCurrentPwd" type="password" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>New master password (min 8 chars)</span>
|
||||
<input id="cmNewPwd" type="password" required minlength="8">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Confirm new master password</span>
|
||||
<input id="cmConfirmPwd" type="password" required>
|
||||
</label>
|
||||
<p id="cmError" style="margin:8px 0 0;color:#dc2626;font-size:12px;display:none"></p>
|
||||
</form>
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-close>Cancel</button>
|
||||
<button type="submit" form="changeMasterForm" class="btn btn-primary" id="cmConfirmBtn">
|
||||
<svg><use href="#i-check"/></svg> Change password
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
|
||||
Reference in New Issue
Block a user