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