feat: rotation progress spinner + quick-unlock re-wrap on master-pw change

- doChangeMasterPassword shows the busy overlay while it re-encrypts the
  vault: "Re-encrypting vault…" → "Re-encrypting entries… N/total" →
  "Re-encrypting attachments… N/total", cleared in finally. A rotation on
  a big vault took tens of seconds with no feedback before.
- Quick-unlock now SURVIVES a master-pw change instead of being wiped.
  The blob stores the raw key (DPAPI-wrapped, no user secret), so it's
  re-wrapped in place with the new key/salt/iters/algo (state already
  holds the new values at that point). Cold-start then re-logs in with a
  verifier derived from the new key. Falls back to clearing if the
  re-wrap throws, so a stale old-key blob is never left behind.
- PIN blob still cleared (wrapped by PBKDF2(pin) — can't re-wrap without
  the PIN). A setTimeout(0) separates the quickunlock/store and pin/clear
  navigations so the back-to-back window.location.href assignments don't
  coalesce and drop the re-wrap.
- Fixed a `failed` counter declaration accidentally dropped from the
  attachment re-encryption loop while adding progress (ReferenceError at
  runtime; node --check wouldn't catch it).
- CLAUDE.md updated for the re-wrap vs clear distinction.

Rebuild: BuildAssets + F9 (JS only this commit; F9 to re-embed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-03 17:54:51 +01:00
parent 22bec64348
commit 48bb06c029
3 changed files with 56 additions and 10 deletions
+47 -7
View File
@@ -7968,6 +7968,11 @@ async function doChangeMasterPassword() {
// re-encryption passes in parallel.
const btn = $('#cmConfirmBtn');
if (btn) btn.disabled = true;
// Rotation re-encrypts every entry (and every attachment) under the new
// key — seconds to tens of seconds on a big vault. Show a spinner so it
// doesn't look frozen; 0ms yield lets it paint before the loop blocks.
showBusy('Re-encrypting vault…');
await new Promise(r => setTimeout(r, 0));
try {
// Step 1: generate the new salt and derive the new AES key + verifier.
// Also compute the verifier for the CURRENT pw so the server can
@@ -7997,7 +8002,12 @@ async function doChangeMasterPassword() {
// round-trip can still reach the OLD key.
const oldKey = state.cryptoKey;
const encrypted = [];
let _cmDone = 0;
const _cmTotal = state.entries.length;
for (const e of state.entries) {
_cmDone++;
if (_cmTotal > 10 && (_cmDone % 5 === 0 || _cmDone === _cmTotal))
updateBusy('Re-encrypting entries… ' + _cmDone + '/' + _cmTotal);
const plain = await decryptPwd(e.encrypted_password, e.iv);
if (plain === '[ERROR]') {
throw new Error('Could not decrypt entry id=' + e.id);
@@ -8090,9 +8100,12 @@ async function doChangeMasterPassword() {
try {
const allAttach = await api('/attachments/all', { headers: authHeaders() });
if (allAttach && allAttach.length > 0) {
toast('Re-encrypting ' + allAttach.length + ' attachment(s)…');
let failed = 0;
let _atDone = 0;
const _atTotal = allAttach.length;
for (const meta of allAttach) {
_atDone++;
updateBusy('Re-encrypting attachments… ' + _atDone + '/' + _atTotal);
try {
// state.cryptoKey is already newKey at this point.
// Swap to oldKey for decryption, then back for upload.
@@ -8133,14 +8146,40 @@ async function doChangeMasterPassword() {
state.entries[i].totp_iv = nc.totp_iv || null;
}
// The DPAPI quick-unlock blob (if any) still holds the OLD AES key
// bundle, which would unlock to entries encrypted with the new key
// → unreadable. Clear it; user can re-enable from Settings.
// Quick-unlock blob holds the raw AES key bundle. It stores the key
// directly (DPAPI-wrapped, no user secret), so instead of forcing
// the user to re-enable it after every rotation we transparently
// RE-WRAP it with the new key + salt + iters + algo. state.* already
// reflects the new values at this point (step 4 above). Cold-start
// then re-logs in with a verifier derived from the new key.
if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') {
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
try {
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
const blob = JSON.stringify({
v: 2,
username: state.username,
salt: state.salt,
kdfIterations: state.kdfIterations,
hashAlgo: state.hashAlgo || '',
key: bytesToBase64(new Uint8Array(raw)),
});
const b64 = bytesToBase64(new TextEncoder().encode(blob));
window.location.href = 'cmd://quickunlock/store?data=' +
encodeURIComponent(b64);
// stays enabled — flag + state unchanged
} catch (_) {
// Re-wrap failed → fall back to clearing so we never leave a
// stale (old-key) blob that would decrypt to garbage.
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
}
// Yield so the quick-unlock store navigation above is processed
// before the PIN clear below — both go through window.location.href
// and back-to-back assignments can coalesce (only the last lands),
// which would drop the quick-unlock re-wrap and leave a stale blob.
await new Promise(r => setTimeout(r, 0));
// Same problem for the PIN blob — wrapped key is from the old
// master, server verifier won't match anymore. Wipe so the user
// gets a clean fallback to master pw next time.
@@ -8170,6 +8209,7 @@ async function doChangeMasterPassword() {
showCmError('Failed: ' + (err.message || 'unknown error'));
}
} finally {
hideBusy();
if (btn) btn.disabled = false;
}
}