feat(security): decouple the login verifier from the AES vault key
The zero-knowledge verifier sent to /login used to be the raw PBKDF2 output in hex — i.e. the exact bytes of the AES key that encrypts every entry. Intercepting a /login body (loopback, but still) handed over the vault key. This introduces a decoupled scheme where the transmitted verifier is a one-way function of the key. New auth-hash scheme - users.hash_algo 'pbkdf2-sha256-v2': the client sends verifier = SHA256(keyHex + "pmserver/auth-verifier/v2") instead of keyHex. Stored form is still SHA256(verifier) (identical server wrap to 'pbkdf2-sha256'), so only the algo LABEL differs — it tells the client which verifier formula to use. Verification needs no new server branch (VerifierToStoredHash already SHA256-wraps any non-legacy verifier). - The AES key (cryptoKey) stays hex(PBKDF2) for EVERY algo, so entries remain decryptable and switching schemes never re-encrypts data. Adoption: new-registration + master-pw-change only - Register and change-master-password write v2. Existing accounts keep their algo until they rotate — the login/reauth migration signal now fires only for LEGACY 'pbkdf2' (was: anything != CURRENT), so sha256/v2 accounts are never force-migrated (which would have downgraded v2 → sha256 via migrate-kdf). Client (js/app.js): algo-aware everywhere - verifierFromKeyHex(keyHex, algo) central helper; deriveKeyAndVerifier / computeVerifier take an algo arg. state.hashAlgo caches the account scheme, set from /login/challenge, register, change-master, the quick-unlock / PIN cold-start blobs, and the /recovery-key/redeem response. All ~12 verifier sites updated (login, register, reauth ×4, change-master current+new, migrate-kdf, quick-unlock + PIN cold-start, recovery-mode current verifier). Safety invariant: unknown/empty hashAlgo → key hex → byte-identical to the old behaviour, so every pre-decoupling account (and every existing quick-unlock / PIN blob without the new field) keeps working unchanged. Verified: existing account + pre-change quick-unlock still unlocks; a master-pw change now writes 'pbkdf2-sha256-v2' in vault.db. Server: recovery redeem returns hashAlgo; register + change-master store the decoupled algo; login + reauth migration signal narrowed to legacy. Also: BuildAssets.ps1 pipes $null into node --check so the JS syntax gate can't block on stdin in the Delphi pre-build environment. Addresses CODE_AUDIT.md section 1.1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -493,6 +493,13 @@ const state = {
|
||||
// and on-the-fly verifier computations don't need a /login/challenge
|
||||
// round trip every time. Refreshed from every auth response.
|
||||
kdfIterations: parseInt(sessionStorage.getItem('kdfIterations') || '0') || 0,
|
||||
// Auth-hash scheme of the current account. Drives which verifier formula
|
||||
// the client sends: 'pbkdf2-sha256-v2' → SHA256(keyHex + domain) so the
|
||||
// transmitted verifier is NOT the raw AES key; anything else → keyHex
|
||||
// (legacy / pre-decoupling accounts, byte-identical to before). Set from
|
||||
// the /login/challenge response, from the cold-start blob, or hardcoded
|
||||
// to v2 on register / master-pw change.
|
||||
hashAlgo: sessionStorage.getItem('hashAlgo') || '',
|
||||
cryptoKey: null,
|
||||
entries: [],
|
||||
trashed: [],
|
||||
@@ -619,7 +626,29 @@ function bytesToHex(arr) {
|
||||
return hex;
|
||||
}
|
||||
|
||||
async function deriveKeyAndVerifier(pwd, saltHex, iterations) {
|
||||
// Decoupled-verifier scheme marker + domain separator. When the account's
|
||||
// hash_algo is HASH_ALGO_V2, the verifier sent to the server is a one-way
|
||||
// SHA-256 of the key hex (domain-separated), NOT the key hex itself — so
|
||||
// intercepting the /login body no longer hands over the AES vault key.
|
||||
// The AES key (cryptoKey) is ALWAYS the raw PBKDF2 output regardless, so
|
||||
// entries stay decryptable and legacy accounts are unaffected.
|
||||
const HASH_ALGO_V2 = 'pbkdf2-sha256-v2';
|
||||
const AUTH_VERIFIER_DOMAIN = 'pmserver/auth-verifier/v2';
|
||||
|
||||
async function sha256Hex(str) {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
||||
return bytesToHex(new Uint8Array(buf));
|
||||
}
|
||||
|
||||
// Map the raw PBKDF2 key hex → the verifier to transmit, per account algo.
|
||||
// v2 → domain-separated SHA-256 (decoupled from the key). Anything else →
|
||||
// the key hex verbatim (legacy behaviour, unchanged for existing accounts).
|
||||
async function verifierFromKeyHex(keyHex, algo) {
|
||||
if (algo === HASH_ALGO_V2) return await sha256Hex(keyHex + AUTH_VERIFIER_DOMAIN);
|
||||
return keyHex;
|
||||
}
|
||||
|
||||
async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo) {
|
||||
iterations = iterations || 100000;
|
||||
const enc = new TextEncoder();
|
||||
const km = await crypto.subtle.importKey(
|
||||
@@ -631,11 +660,12 @@ async function deriveKeyAndVerifier(pwd, saltHex, iterations) {
|
||||
const keyBytes = new Uint8Array(bits);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
||||
return { cryptoKey, verifier: bytesToHex(keyBytes) };
|
||||
const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo);
|
||||
return { cryptoKey, verifier };
|
||||
}
|
||||
|
||||
async function computeVerifier(pwd, saltHex, iterations) {
|
||||
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations);
|
||||
async function computeVerifier(pwd, saltHex, iterations, algo) {
|
||||
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo);
|
||||
return r.verifier;
|
||||
}
|
||||
|
||||
@@ -1591,8 +1621,12 @@ async function runKdfMigration(masterPwd, fromIters, toIters) {
|
||||
// the user knows the master pw under the current (legacy) iters;
|
||||
// newVerifier is what the server will SHA-256-wrap to be the new
|
||||
// stored hash after migration. Master pw never leaves the browser.
|
||||
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters);
|
||||
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters);
|
||||
// Only non-v2 accounts ever reach the KDF migration (v2 accounts are
|
||||
// 600k + decoupled → never signalled). Under a non-v2 algo the
|
||||
// verifier is the key hex, so both derivations round-trip exactly as
|
||||
// before; passing state.hashAlgo keeps it explicit.
|
||||
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters, state.hashAlgo);
|
||||
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters, state.hashAlgo);
|
||||
|
||||
await api('/migrate-kdf', {
|
||||
method: 'POST',
|
||||
@@ -1767,7 +1801,10 @@ async function doLogin(e) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: u }),
|
||||
});
|
||||
const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations);
|
||||
// The challenge tells us the account's auth scheme; compute the
|
||||
// verifier accordingly (v2 → decoupled, else → key hex).
|
||||
state.hashAlgo = ch.hashAlgo || '';
|
||||
const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations, state.hashAlgo);
|
||||
|
||||
const r = await api('/login', {
|
||||
method: 'POST',
|
||||
@@ -1784,6 +1821,7 @@ async function doLogin(e) {
|
||||
sessionStorage.setItem('salt', state.salt);
|
||||
sessionStorage.setItem('username', state.username);
|
||||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
// Persist via DPAPI when running inside the Delphi host (localStorage
|
||||
// is wiped on each restart because the HTTP port — and therefore the
|
||||
// origin — changes every launch). Fall back to localStorage for the
|
||||
@@ -1840,7 +1878,9 @@ async function doRegister(e) {
|
||||
// leaves the browser.
|
||||
const newSalt = randomHexSalt();
|
||||
const newIters = 600000;
|
||||
const derived = await deriveKeyAndVerifier(p, newSalt, newIters);
|
||||
// New accounts use the decoupled-verifier scheme (v2).
|
||||
state.hashAlgo = HASH_ALGO_V2;
|
||||
const derived = await deriveKeyAndVerifier(p, newSalt, newIters, HASH_ALGO_V2);
|
||||
|
||||
const r = await api('/register', {
|
||||
method: 'POST',
|
||||
@@ -1850,6 +1890,7 @@ async function doRegister(e) {
|
||||
salt: newSalt,
|
||||
kdfIterations: newIters,
|
||||
verifier: derived.verifier,
|
||||
hashAlgo: HASH_ALGO_V2,
|
||||
}),
|
||||
});
|
||||
state.token = r.token;
|
||||
@@ -1862,6 +1903,7 @@ async function doRegister(e) {
|
||||
sessionStorage.setItem('salt', state.salt);
|
||||
sessionStorage.setItem('username', state.username);
|
||||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
state.cryptoKey = derived.cryptoKey;
|
||||
await persistCryptoKey();
|
||||
toast('Vault created');
|
||||
@@ -1974,7 +2016,7 @@ async function doUnlock(p) {
|
||||
// Compute the verifier locally with the salt+iters cached at login.
|
||||
// Server compares verifier → never sees the plaintext master pw.
|
||||
const iters = state.kdfIterations || 100000;
|
||||
const derived = await deriveKeyAndVerifier(p, state.salt, iters);
|
||||
const derived = await deriveKeyAndVerifier(p, state.salt, iters, state.hashAlgo);
|
||||
const r = await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
@@ -6996,6 +7038,11 @@ async function pinBuildBlob(pin) {
|
||||
username: state.username,
|
||||
loginSalt: state.salt,
|
||||
loginIters: state.kdfIterations || 600000,
|
||||
// Auth scheme so cold-start sends the right verifier (v2 accounts
|
||||
// need the decoupled transform, not the raw key hex). Absent on
|
||||
// pre-decoupling blobs → cold-start defaults to the key hex, which
|
||||
// is correct for those (legacy) accounts.
|
||||
hashAlgo: state.hashAlgo || '',
|
||||
salt: bytesToBase64(salt),
|
||||
iters: PIN_KDF_ITERS,
|
||||
iv: bytesToBase64(iv),
|
||||
@@ -7103,7 +7150,7 @@ async function pinSetupFlow() {
|
||||
if (!masterPwd) return;
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000);
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
@@ -7190,6 +7237,7 @@ async function loginViaPin(pin) {
|
||||
state.username = blob.username || state.username;
|
||||
state.salt = blob.loginSalt || state.salt;
|
||||
state.kdfIterations = blob.loginIters || state.kdfIterations || 600000;
|
||||
state.hashAlgo = blob.hashAlgo || '';
|
||||
|
||||
try {
|
||||
state.cryptoKey = await crypto.subtle.importKey(
|
||||
@@ -7197,7 +7245,8 @@ async function loginViaPin(pin) {
|
||||
} catch (_) { return false; }
|
||||
|
||||
try {
|
||||
const verifier = bytesToHex(rawKey);
|
||||
// v2 accounts need the decoupled verifier; legacy → key hex.
|
||||
const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo);
|
||||
const r = await api('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -7216,6 +7265,7 @@ async function loginViaPin(pin) {
|
||||
sessionStorage.setItem('username', state.username);
|
||||
sessionStorage.setItem('salt', state.salt);
|
||||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
sessionStorage.setItem('authToken', state.token);
|
||||
sessionStorage.setItem('csrfToken', state.csrf);
|
||||
await persistCryptoKey();
|
||||
@@ -7242,7 +7292,7 @@ async function enableQuickUnlock() {
|
||||
if (!masterPwd) return;
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000);
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
@@ -7262,6 +7312,8 @@ async function enableQuickUnlock() {
|
||||
username: state.username,
|
||||
salt: state.salt,
|
||||
kdfIterations: state.kdfIterations,
|
||||
// Auth scheme for cold-start verifier selection (see pinBuildBlob).
|
||||
hashAlgo: state.hashAlgo || '',
|
||||
key: bytesToBase64(raw),
|
||||
});
|
||||
const b64 = bytesToBase64(new TextEncoder().encode(blob));
|
||||
@@ -7320,6 +7372,7 @@ async function tryQuickUnlock() {
|
||||
state.username = parsed.username;
|
||||
state.salt = parsed.salt;
|
||||
state.kdfIterations = parsed.kdfIterations || 600000;
|
||||
state.hashAlgo = parsed.hashAlgo || '';
|
||||
const rawKey = base64ToBytes(parsed.key);
|
||||
|
||||
try {
|
||||
@@ -7335,7 +7388,8 @@ async function tryQuickUnlock() {
|
||||
// up by the server's session GC, which used to drop the user back to
|
||||
// the login screen on cold start.
|
||||
try {
|
||||
const verifier = bytesToHex(rawKey);
|
||||
// v2 accounts need the decoupled verifier; legacy → key hex.
|
||||
const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo);
|
||||
const r = await api('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -7355,6 +7409,7 @@ async function tryQuickUnlock() {
|
||||
sessionStorage.setItem('username', state.username);
|
||||
sessionStorage.setItem('salt', state.salt);
|
||||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
sessionStorage.setItem('authToken', state.token);
|
||||
sessionStorage.setItem('csrfToken', state.csrf);
|
||||
await persistCryptoKey();
|
||||
@@ -7507,7 +7562,7 @@ async function doGenerateRecoveryKey() {
|
||||
// Send a verifier instead of the master pw — server proves the
|
||||
// user still knows the master pw without ever seeing the plaintext.
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000);
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
await api('/recovery-key/setup', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
@@ -7686,11 +7741,15 @@ async function doRecoveryRedeem() {
|
||||
state.salt = r.salt;
|
||||
state.username = u.trim();
|
||||
state.kdfIterations = r.kdfIterations || 600000;
|
||||
// Account's auth scheme — needed so the recovery-mode master-pw change
|
||||
// proves the current key under the right verifier transform.
|
||||
state.hashAlgo = r.hashAlgo || '';
|
||||
sessionStorage.setItem('authToken', state.token);
|
||||
sessionStorage.setItem('csrfToken', state.csrf);
|
||||
sessionStorage.setItem('salt', state.salt);
|
||||
sessionStorage.setItem('username', state.username);
|
||||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
|
||||
// Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable
|
||||
// so master-pw change can later re-export and re-wrap as needed).
|
||||
@@ -7791,15 +7850,21 @@ async function doChangeMasterPassword() {
|
||||
// Also compute the verifier for the CURRENT pw so the server can
|
||||
// authenticate the change without ever seeing the plaintext.
|
||||
const newSalt = randomHexSalt();
|
||||
const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000);
|
||||
// Rotate onto the decoupled-verifier scheme (v2) — a master-pw
|
||||
// change re-derives + re-encrypts everything anyway, so it's the
|
||||
// natural migration point for existing accounts.
|
||||
const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000, HASH_ALGO_V2);
|
||||
const newKey = newDerived.cryptoKey;
|
||||
let currentVerifier;
|
||||
if (recoveryMode) {
|
||||
// Current pw is proven via the in-memory recovered key. The
|
||||
// server compares under the account's CURRENT algo, so apply the
|
||||
// same verifier transform (v2 → decoupled, else → key hex).
|
||||
const rawCurrentKey = new Uint8Array(await crypto.subtle.exportKey('raw', state.cryptoKey));
|
||||
currentVerifier = bytesToHex(rawCurrentKey);
|
||||
currentVerifier = await verifierFromKeyHex(bytesToHex(rawCurrentKey), state.hashAlgo);
|
||||
} else {
|
||||
currentVerifier = await computeVerifier(
|
||||
curPwd, state.salt, state.kdfIterations || 100000);
|
||||
curPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
}
|
||||
|
||||
// Step 2: re-encrypt every entry's password AND every entry's TOTP
|
||||
@@ -7875,10 +7940,13 @@ async function doChangeMasterPassword() {
|
||||
// the cached ciphertexts, persist for F5 survival.
|
||||
state.salt = r.salt || newSalt;
|
||||
state.kdfIterations = r.kdfIterations || 600000;
|
||||
// The account is now on the decoupled-verifier scheme.
|
||||
state.hashAlgo = HASH_ALGO_V2;
|
||||
state.cryptoKey = newKey;
|
||||
await persistCryptoKey();
|
||||
sessionStorage.setItem('salt', state.salt);
|
||||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
// Server invalidated every session for this user (including ours)
|
||||
// and minted a fresh pair — adopt them so subsequent API calls
|
||||
// don't bounce with "invalid session".
|
||||
@@ -8751,7 +8819,7 @@ async function doExport() {
|
||||
if (!masterPwd) return; // user cancelled
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000);
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
|
||||
Reference in New Issue
Block a user