diff --git a/CLAUDE.md b/CLAUDE.md index 39a6e72..8ce8bd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 3735e69..29dac9d 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/js/app.js b/js/app.js index 8a7948f..2af0897 100644 --- a/js/app.js +++ b/js/app.js @@ -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; } }