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:
@@ -581,9 +581,15 @@ bypasses the PIN check (the device is already trusted).
|
||||
Sensitive actions (`askReauth` paths: export, change master pw,
|
||||
recovery code, etc.) ALWAYS require master pw — PIN never substitutes.
|
||||
|
||||
Master pw rotation clears the PIN blob (same reason as Quick Unlock :
|
||||
stored wrapped key + server verifier drift). User re-sets PIN from
|
||||
Settings after rotation.
|
||||
Master pw rotation **clears the PIN blob** (stored wrapped key + server
|
||||
verifier drift). Unlike Quick Unlock — which stores the raw key directly
|
||||
(DPAPI-wrapped, no user secret) and is therefore **re-wrapped in place**
|
||||
with the new key/salt/iters/algo during rotation so it survives — the PIN
|
||||
blob is wrapped by `PBKDF2(pin)` and can't be re-wrapped without the PIN,
|
||||
so it's wiped and the user re-sets it from Settings. `doChangeMasterPassword`
|
||||
does the two via `window.location.href` (quickunlock/store then pin/clear)
|
||||
with a `setTimeout(0)` between them so the back-to-back navigations don't
|
||||
coalesce and drop the re-wrap.
|
||||
|
||||
## Quick Unlock
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user