diff --git a/js/app.crypto.js b/js/app.crypto.js index b64112d..86b2216 100644 --- a/js/app.crypto.js +++ b/js/app.crypto.js @@ -118,9 +118,14 @@ async function deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams) { async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams) { const keyBytes = await deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams); + // extractable:true is REQUIRED — Quick Unlock, PIN, recovery-code and + // master-password change all exportKey('raw') the vault key to (re)wrap or + // re-derive it. The CryptoKey keeps its own copy of the bytes; scrub ours + // from the heap so the raw key doesn't linger as a stray Uint8Array. const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo); + keyBytes.fill(0); return { cryptoKey, verifier }; } @@ -152,21 +157,10 @@ async function decryptPwd(encB64, ivB64) { } } -async function persistCryptoKey() { - const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); - sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); -} - -async function restoreCryptoKey() { - const saved = sessionStorage.getItem('cryptoKey'); - if (!saved) return false; - try { - const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); - state.cryptoKey = await crypto.subtle.importKey( - 'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] - ); - return true; - } catch (e) { - return false; - } -} +// NB: the vault key is deliberately NOT cached anywhere it outlives the live +// CryptoKey. An earlier persistCryptoKey() stored the raw key base64 in +// sessionStorage to survive a page reload — but the WebView2 shell has no +// reload path (F5/Ctrl+R blocked, all navigations are cmd:// bridge calls), +// so that copy only ever served as a plaintext, grep-able duplicate of the +// master key in renderer memory. A cold start re-derives via Quick Unlock / +// PIN / master-password instead. diff --git a/js/app.js b/js/app.js index ccd66f8..23cbe5c 100644 --- a/js/app.js +++ b/js/app.js @@ -1133,7 +1133,6 @@ async function doLogin(e) { // cryptoKey is already derived — no second PBKDF2 pass. state.cryptoKey = derived.cryptoKey; state.justRecovered = false; - await persistCryptoKey(); toast('Welcome back, ' + u); await enterApp(); // KDF auto-migration (100k→600k) removed 2026-07-16: it changed the AES @@ -1207,7 +1206,6 @@ async function doRegister(e) { sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params)); state.cryptoKey = derived.cryptoKey; - await persistCryptoKey(); toast('Vault created'); await enterApp(); } catch (err) { @@ -1329,7 +1327,6 @@ async function doUnlock(p) { state.kdfIterations = r.kdfIterations || iters; sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); state.cryptoKey = derived.cryptoKey; - await persistCryptoKey(); state.locked = false; $('#loginUsername').readOnly = false; $('#authHint').textContent = ''; @@ -6260,7 +6257,6 @@ async function doChangeMasterPassword() { state.hashAlgo = HASH_ALGO_ARGON2; state.argon2Params = newArgon; state.cryptoKey = newKey; - await persistCryptoKey(); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('hashAlgo', state.hashAlgo); @@ -8170,31 +8166,16 @@ async function init() { else localStorage.removeItem('quickUnlockEnabled'); } - // Restore session if any - if (state.token && state.salt) { - const ok = await restoreCryptoKey(); - if (ok) { - await enterApp(); - } else { - // sessionStorage still has token+salt but the crypto key was - // cleared (locked / tab closed). Try the DPAPI quick-unlock - // path before falling back to the master-pw prompt. - if (await tryQuickUnlock()) { - await enterApp(); - } else { - showAuth(); - $('#loginUsername').value = state.username; - } - } + // The vault key is never persisted (see app.crypto.js — no reload path, + // so a cached raw key was pure exposure). Every start therefore re-derives: + // DPAPI Quick Unlock restores token + salt + key + username silently if + // set, otherwise the master-password prompt. Pre-fill the username when we + // still know it (a lock within this run keeps state.username). + if (await tryQuickUnlock()) { + await enterApp(); } else { - // Cold start: no sessionStorage at all. Quick unlock can still - // restore everything (token + salt + key + username) from the - // DPAPI blob. - if (await tryQuickUnlock()) { - await enterApp(); - } else { - showAuth(); - } + showAuth(); + if (state.username) $('#loginUsername').value = state.username; } } document.addEventListener('DOMContentLoaded', init); diff --git a/js/app.unlock.js b/js/app.unlock.js index 2c44766..e6477bb 100644 --- a/js/app.unlock.js +++ b/js/app.unlock.js @@ -401,7 +401,6 @@ async function loginViaPin(pin) { sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); - await persistCryptoKey(); state.locked = false; state.justRecovered = false; @@ -547,7 +546,6 @@ async function tryQuickUnlock() { sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); - await persistCryptoKey(); state.locked = false; return true; @@ -891,7 +889,6 @@ async function doRecoveryRedeem() { // so master-pw change can later re-export and re-wrap as needed). state.cryptoKey = await crypto.subtle.importKey( 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); - await persistCryptoKey(); const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0; if (remaining <= 0) {