From 48bb06c02944511874dbd4baf69e05a9ddc5b2ce Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:54:51 +0100 Subject: [PATCH] feat: rotation progress spinner + quick-unlock re-wrap on master-pw change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 12 +++++-- delphi-backend/assets/assets.res | Bin 667564 -> 669816 bytes js/app.js | 54 +++++++++++++++++++++++++++---- 3 files changed, 56 insertions(+), 10 deletions(-) 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 3735e69e54b0c68253a3c4f5101deb3ff72441c8..29dac9d2229da1a7c3959ed934890a43d7ae3d87 100644 GIT binary patch delta 1817 zcmZXVe`s4(6vy|HzW34}+iKUDWp3l(k2YPJv<1c0u5R5ru%aVNH?>;XZeDJZM{nQD z-n(fGPFqkM2+EjaXRtq%{UJIhtRm!}AtHzfGV34O3QlD(QKnM;XQCjU`_f;-z|D{Q z?m73I@A;gQOFzW_T8z(psJ#^pJ#qEqg{Vd=L@P|IftFso5H-GwP51Ob#+6Lkt^*!V za>w$jCFuj7;$9WFBk4WPoZQj|lag7*N!ss%vXkQ;$RY=aSDJuc}c-aNQDQg=u?x^-hseKB|tIz!6TfzWgJe|k0cVS2h<@u4{@?SOtJ}h82Wmlqa)Y}{M`o+ zz~CTxg%c-MFQE-G!M>YjM`~GQ+bIMB+uXlC^EPxsV&!}SI(wFW;|n*28V%R&Z8L^{G)`J@aCd5!svXtOX0b93)+VRcb z%@#a8V#FKipo%$>z}bD;ow!FgTl!ZOQ}M0Vdo{+o5kJAv9Qt~F2Q|J*Y77kV;Tc(M}0CScV zT*1WhlI-fVd&`YbA+9{;_$B5M>EgB+G6#}RW`@$rwqncTGXFsm3GkVaq>N2Yefngg z;HD@cCV6Tn{ql}+mb=ZJ$Mb@6s;5hRL`@;els%K=+4iC2D%j(cN(t@Bhn6!XAFiPMZ-}n4Qo1Xe918I*t|Y~k9=;n;rgU*;yd$t z4B?+}3!c5GZ^XHIy#*)Q^lkX-yuRh^FN7T|ni2eBGSrF>u}~8(UeufL#eGKPjXA9W z;|qEcPb?Bq2QHDho(*lpj~Db-Javm)&MoLWHmP8aP!_v}Jdai5RP`>LJ!Ndd#T()F zfLPp~*4oj1Uyn5?L~!;xJvetx-+_x6qXq46^$j@usks?H>DM;l+z{QH{W`pvRAE&b zRgNlVE*Lv`!3v~%pg0XEtH_)J&*4}98e1{_f!>JEz8h)YO^hpaAkXZ;@Rif_RS3v3 zf|Llc1ZSSYpGFa};3zz3{IDqw3}>%|+IQ0h#7|t6fpqSwlP!x>wTfG&S(PIYX^tvq z1?FX0f!8OKnjd)ToZg`_1Ke}OxC6J38Y%4EMfKTx(9kh`Iilm=S0d~2r{kdwc=?%7 z1J1lkZ$0;F%~;wJZrn?)FcyqNicdAt@wH7Bv=2CJ$}ZH$dH@Y5mOR(uzE665|GF8y zD?M_so+-^zK@(Vw?W{{k7hJqJsBNEK%a-~JQ1T?Dn+#-PzHb*C^-th~#T*))q=;`R zQ5@<}6_#K$Oa)o`UEOPaRf@J%To%fs+5Nv9s6{K@R;rM?c1d_uRMK1llER%on-A8W zA2rVFsF`LvKK)+9dbInDX1x4ZsQG07m_f^|^^ZmFc{#Lh-O2H5k=ppR#&qUCv3Oza delta 431 zcmezIL1WE*jfO3Z3&fdvj00Mz%+G6cJrWA-PF9~qRN7j%)E33bp>sO z#GD+*l9I&a485Gxy!4U`ppd#kB3L9hHLpaYSo2ZCvgr>4*+ixXd$TD_pDV%4J^hk5 zo91+dgKSchFEFxfpW?%IoSiXb`otn8=`fAVG=;>xN==30lFXbOg^c{1lwyUFj8p}G z9~T8j*If&2=RRY)t! z&s7LcEdhDDSZ{h@1iRF9tuQvX>1!g`w5HFy#mX~%LNOD|bnYF@g3}!yai~q_tmfdG xE)&MCGTpY5iEF!Q4BK?p=~1g$c-!A5vH>ys_P2=~mM!fX&p5VgJmU-q0sy!1lDGf> 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; } }