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) {
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.