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
+12 -18
View File
@@ -118,9 +118,14 @@ async function deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams) {
async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams) { async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams) {
const keyBytes = await deriveKeyBytes(pwd, saltHex, algo, iterations, 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( const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); 'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo); const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo);
keyBytes.fill(0);
return { cryptoKey, verifier }; return { cryptoKey, verifier };
} }
@@ -152,21 +157,10 @@ async function decryptPwd(encB64, ivB64) {
} }
} }
async function persistCryptoKey() { // NB: the vault key is deliberately NOT cached anywhere it outlives the live
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); // CryptoKey. An earlier persistCryptoKey() stored the raw key base64 in
sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); // 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
async function restoreCryptoKey() { // master key in renderer memory. A cold start re-derives via Quick Unlock /
const saved = sessionStorage.getItem('cryptoKey'); // PIN / master-password instead.
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;
}
}
+6 -25
View File
@@ -1133,7 +1133,6 @@ async function doLogin(e) {
// cryptoKey is already derived — no second PBKDF2 pass. // cryptoKey is already derived — no second PBKDF2 pass.
state.cryptoKey = derived.cryptoKey; state.cryptoKey = derived.cryptoKey;
state.justRecovered = false; state.justRecovered = false;
await persistCryptoKey();
toast('Welcome back, ' + u); toast('Welcome back, ' + u);
await enterApp(); await enterApp();
// KDF auto-migration (100k→600k) removed 2026-07-16: it changed the AES // 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('hashAlgo', state.hashAlgo);
sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params)); sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params));
state.cryptoKey = derived.cryptoKey; state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
toast('Vault created'); toast('Vault created');
await enterApp(); await enterApp();
} catch (err) { } catch (err) {
@@ -1329,7 +1327,6 @@ async function doUnlock(p) {
state.kdfIterations = r.kdfIterations || iters; state.kdfIterations = r.kdfIterations || iters;
sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
state.cryptoKey = derived.cryptoKey; state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
state.locked = false; state.locked = false;
$('#loginUsername').readOnly = false; $('#loginUsername').readOnly = false;
$('#authHint').textContent = ''; $('#authHint').textContent = '';
@@ -6260,7 +6257,6 @@ async function doChangeMasterPassword() {
state.hashAlgo = HASH_ALGO_ARGON2; state.hashAlgo = HASH_ALGO_ARGON2;
state.argon2Params = newArgon; state.argon2Params = newArgon;
state.cryptoKey = newKey; state.cryptoKey = newKey;
await persistCryptoKey();
sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('hashAlgo', state.hashAlgo);
@@ -8170,31 +8166,16 @@ async function init() {
else localStorage.removeItem('quickUnlockEnabled'); else localStorage.removeItem('quickUnlockEnabled');
} }
// Restore session if any // The vault key is never persisted (see app.crypto.js — no reload path,
if (state.token && state.salt) { // so a cached raw key was pure exposure). Every start therefore re-derives:
const ok = await restoreCryptoKey(); // DPAPI Quick Unlock restores token + salt + key + username silently if
if (ok) { // set, otherwise the master-password prompt. Pre-fill the username when we
await enterApp(); // still know it (a lock within this run keeps state.username).
} 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()) { if (await tryQuickUnlock()) {
await enterApp(); await enterApp();
} else { } else {
showAuth(); showAuth();
$('#loginUsername').value = state.username; if (state.username) $('#loginUsername').value = state.username;
}
}
} 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();
}
} }
} }
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
-3
View File
@@ -401,7 +401,6 @@ async function loginViaPin(pin) {
sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('hashAlgo', state.hashAlgo);
sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('csrfToken', state.csrf);
await persistCryptoKey();
state.locked = false; state.locked = false;
state.justRecovered = false; state.justRecovered = false;
@@ -547,7 +546,6 @@ async function tryQuickUnlock() {
sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('hashAlgo', state.hashAlgo);
sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('csrfToken', state.csrf);
await persistCryptoKey();
state.locked = false; state.locked = false;
return true; return true;
@@ -891,7 +889,6 @@ async function doRecoveryRedeem() {
// so master-pw change can later re-export and re-wrap as needed). // so master-pw change can later re-export and re-wrap as needed).
state.cryptoKey = await crypto.subtle.importKey( state.cryptoKey = await crypto.subtle.importKey(
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
await persistCryptoKey();
const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0; const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0;
if (remaining <= 0) { if (remaining <= 0) {