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:
+12
-18
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user