security: stop caching the raw vault key in sessionStorage

A memory dump of the WebView2 renderer (vault unlocked) showed the AES vault
key sitting in sessionStorage as a clean base64 string — a plaintext copy of
the master secret, trivially grep-able, present the whole session.

persistCryptoKey/restoreCryptoKey existed only to survive a page reload, but
the shell has no reload path (F5/Ctrl+R blocked, every navigation is a cmd://
bridge call), so the branch that consumed it was unreachable. Removed both
functions, their 7 call sites, and collapsed the init restore branch into the
plain cold-start path (Quick Unlock → PIN → master-pw, all already there).

Also scrub the derived key bytes from the heap (keyBytes.fill(0)) once the
CryptoKey and verifier are built — the CryptoKey keeps its own copy.

Not fixable here, documented: the key stays extractable:true (Quick Unlock,
PIN, recovery and master-pw change all exportKey it), and decrypted password
strings still linger in the V8/Blink heap after lock — immutable strings the
GC never zeroes, inherent to a JS/WebView2 password manager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-24 23:40:56 +01:00
parent de50a5f71f
commit b542106c76
3 changed files with 21 additions and 49 deletions
+9 -28
View File
@@ -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);