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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user