feat(zero-knowledge): client computes verifier, master pw never leaves the browser

THE GAP THIS CLOSES
===================
Before this commit, every auth endpoint accepted the master password
in plaintext. The server ran PBKDF2 + SHA-256 server-side to verify.
That means:
 - master pw traveled over HTTP (loopback, but still observable by
   any process that can intercept localhost)
 - master pw sat in the server's process memory (a local string
   variable) for the ~500 ms PBKDF2 took to run
 - a memory dump of PMServer.exe during /login would expose it

This commit moves the PBKDF2 step to the CLIENT and sends only the
hex result (the "verifier") to the server. The master pw never
leaves the browser — server is now zero-knowledge in the everyday
sense (the stored hash and the hash-format remain the same; SRP-
style proper zero-knowledge would be another refactor).

NEW ENDPOINT
============
POST /login/challenge  body { username }
                       -> { salt, kdfIterations, hashAlgo }

First leg of login: client posts username, server returns the params
needed for the client to compute PBKDF2 locally. Per-IP rate-limited.
Returns 404 for unknown user — client masks this as a generic
"Invalid credentials" toast to preserve user-existence opacity
(consistent with the existing /login timing leak).

UPDATED ENDPOINTS
=================
All auth endpoints now accept EITHER plaintext masterPassword OR a
precomputed verifier. New helpers in PM.Handler.Auth:

  function IsValidVerifier(s): 64 hex chars sanity check
  function VerifierToStoredHash(v, algo): SHA-256 wrap (CURRENT) or
                                          identity (LEGACY)
  function CheckVerifier(v, stored, algo): constant-time compare

Endpoint matrix:
  /register        :: salt, kdfIterations, verifier (all client-gen)
                      OR masterPassword (legacy)
  /login           :: verifier OR masterPassword
  /reauth          :: verifier OR masterPassword
  /migrate-kdf     :: oldVerifier + newVerifier OR masterPassword
                      (oldVerifier = under current iters, newVerifier
                      = under target iters)
  /change-master-pw:: currentVerifier + newVerifier + newSalt
                      OR currentMasterPassword + newMasterPassword
  /recovery-key/setup :: verifier OR masterPassword
                         (via VerifyMasterPassword helper updated to
                         accept either input)

When a verifier is present, the server simply applies the SHA-256
wrap (for HASH_ALGO_CURRENT) or compares directly (LEGACY) — no
PBKDF2 work, no plaintext pw in memory.

CLIENT
======
New helpers in app.js:

  bytesToHex(arr) : matches the server's PBKDF2_SHA256_Hex output
                    format (lowercase hex, no separators)
  deriveKeyAndVerifier(pwd, saltHex, iters)
                  : single PBKDF2 → returns BOTH the AES-GCM CryptoKey
                    AND the hex verifier. No double-PBKDF2 cost.
  computeVerifier(pwd, salt, iters)
                  : verifier-only variant for places that don't need
                    the CryptoKey (reauth, recovery setup, ...).

state.kdfIterations is now tracked + persisted to sessionStorage so
verifier computation works without a fresh /login/challenge round
trip on every reauth / change-pw / recovery setup.

Flows updated:
  doLogin           : POST /login/challenge → derive locally → POST
                      /login with verifier. CryptoKey reused from
                      the same PBKDF2 run.
  doRegister        : client-side randomHexSalt + derive → POST with
                      {salt, kdfIterations, verifier}.
  doUnlock          : verifier from cached salt+iters → POST /reauth.
  runKdfMigration   : compute oldVerifier + newVerifier from same
                      salt at different iters → POST.
  doChangeMasterPassword:
                    : currentVerifier (old salt+iters) + newVerifier
                      (fresh salt, target iters) + newSalt. New key
                      ready in memory by the time we POST.
  doGenerateRecoveryKey:
                    : verifier → /recovery-key/setup.
  enableQuickUnlock,
  doExport          : both /reauth callers switched to verifier.

Persistence
===========
The DPAPI quick-unlock blob (when enabled) now includes kdfIterations
so cold-start restores can correctly re-derive verifiers if reauth
is needed later. Recovery redeem similarly stashes kdfIterations
from the server response.

Backward compat
===============
Server endpoints still accept the legacy masterPassword path so
older client builds keep working through the next deploy. Future
cleanup: drop the plaintext branches once everyone has rolled
forward.

What this does NOT achieve
==========================
This is not SRP / OPAQUE. The stored value on the server IS the
final hash, and a stolen vault.db gives the attacker something they
can directly verify candidate guesses against (offline brute force).
Closing that requires asymmetric proofs (client and server holding
different things), which is a much larger refactor. The realistic
win here is "master pw never transits the network or sits in server
memory" — that's a meaningful reduction in attack surface, not a
cryptographic miracle.
This commit is contained in:
2026-05-23 12:03:41 +01:00
parent 749dc87058
commit d13e5bc89f
3 changed files with 458 additions and 122 deletions
+178 -71
View File
@@ -67,6 +67,10 @@ const state = {
csrf: sessionStorage.getItem('csrfToken') || '',
salt: sessionStorage.getItem('salt') || '',
username: sessionStorage.getItem('username') || '',
// KDF iteration count of the currently-logged-in user. Cached so reauth
// 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,
cryptoKey: null,
entries: [],
trashed: [],
@@ -111,6 +115,47 @@ async function deriveKey(pwd, saltHex, iterations) {
);
}
// ---- Zero-knowledge auth helpers --------------------------------
//
// Single PBKDF2 → both outputs at once:
// - cryptoKey: the AES-GCM key used to encrypt entries (= raw PBKDF2 bytes)
// - verifier: the same 32 bytes in hex form, sent to the server in place
// of the plaintext master password. Server then SHA-256-wraps
// it (HASH_ALGO_CURRENT) or compares directly (LEGACY) without
// ever seeing the plaintext.
//
// Doing it together avoids running PBKDF2 twice. computeVerifier() is for
// places that only need the hex (re-auth, current-pw verification on change,
// etc.) and skips the AES-GCM importKey work.
function bytesToHex(arr) {
if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr);
let hex = '';
for (let i = 0; i < arr.length; i++)
hex += arr[i].toString(16).padStart(2, '0');
return hex;
}
async function deriveKeyAndVerifier(pwd, saltHex, iterations) {
iterations = iterations || 100000;
const enc = new TextEncoder();
const km = await crypto.subtle.importKey(
'raw', enc.encode(pwd), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: enc.encode(saltHex),
iterations: iterations, hash: 'SHA-256' },
km, 256); // 256 bits = 32 bytes — matches PBKDF2_SHA256_Hex output
const keyBytes = new Uint8Array(bits);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
return { cryptoKey, verifier: bytesToHex(keyBytes) };
}
async function computeVerifier(pwd, saltHex, iterations) {
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations);
return r.verifier;
}
async function encryptPwd(plain) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
@@ -424,22 +469,28 @@ async function runKdfMigration(masterPwd, fromIters, toIters) {
newCiphertexts = [];
}
// Send the atomic migrate request. Server verifies the master pw
// against the OLD hash, then updates the user row (hash, iter
// count, hash_algo) AND every entry's ciphertext in a single
// transaction.
// Zero-knowledge: compute BOTH verifiers locally. oldVerifier proves
// 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);
await api('/migrate-kdf', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
masterPassword: masterPwd,
entries: newCiphertexts,
oldVerifier: oldVerifier,
newVerifier: newVerifier,
entries: newCiphertexts,
}),
});
if (kdfChange) {
// Swap to the new AES key + update cached ciphertexts.
state.cryptoKey = newKey;
state.kdfIterations = toIters;
sessionStorage.setItem('kdfIterations', String(toIters));
await persistCryptoKey();
for (let i = 0; i < state.entries.length; i++) {
const nc = newCiphertexts[i];
@@ -557,23 +608,33 @@ async function doLogin(e) {
}
$('#loginBtn').disabled = true;
try {
// Zero-knowledge: ask the server for the user's salt + iter count,
// compute the verifier locally, send only the verifier. Master pw
// never leaves the browser.
const ch = await api('/login/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u }),
});
const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations);
const r = await api('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, masterPassword: p }),
body: JSON.stringify({ username: u, verifier: derived.verifier }),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
// Derive with the server-specified iteration count — legacy users
// receive 100k, modern users 600k. The cryptoKey is what currently
// decrypts the entries on this server.
state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations);
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u;
state.kdfIterations = r.kdfIterations || ch.kdfIterations;
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));
// cryptoKey is already derived — no second PBKDF2 pass.
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
toast('Welcome back, ' + u);
await enterApp();
@@ -589,7 +650,14 @@ async function doLogin(e) {
showLockoutCountdown(err.body.retry_after);
return; // do NOT re-enable the button in finally
}
toast(err.message, 'error');
// Mask "Unknown user" from /login/challenge as a generic credentials
// failure — keeps user-existence enumeration consistent with the
// existing /login behavior.
if (err.status === 404) {
toast('Invalid credentials', 'error');
} else {
toast(err.message, 'error');
}
} finally {
// Only re-enable when not in lockout (showLockoutCountdown manages
// the button itself for the lockout case).
@@ -604,22 +672,34 @@ async function doRegister(e) {
if (u.length < 3 || p.length < 8) return toast('Min 3 / 8 chars', 'error');
$('#registerBtn').disabled = true;
try {
// Zero-knowledge register: client generates salt + iters, computes
// the verifier locally, sends only the verifier. Master pw never
// leaves the browser.
const newSalt = randomHexSalt();
const newIters = 600000;
const derived = await deriveKeyAndVerifier(p, newSalt, newIters);
const r = await api('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, masterPassword: p }),
body: JSON.stringify({
username: u,
salt: newSalt,
kdfIterations: newIters,
verifier: derived.verifier,
}),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
// Fresh account → server returns kdfIterations = current target.
// No migration ever needed for a brand-new vault.
state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations);
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt || newSalt;
state.username = u;
state.kdfIterations = r.kdfIterations || newIters;
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));
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
toast('Vault created');
await enterApp();
@@ -641,6 +721,7 @@ async function doLogout() {
}
sessionStorage.clear();
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
state.kdfIterations = 0;
state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All'];
state.locked = false;
showAuth();
@@ -684,13 +765,19 @@ function lockVault() {
// then re-derive the crypto key locally without rotating session/csrf.
async function doUnlock(p) {
try {
// 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 r = await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ masterPassword: p }),
body: JSON.stringify({ verifier: derived.verifier }),
});
// r now carries kdfIterations + optional kdfMigration, same as /login.
state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations);
// Refresh cached iter count in case the server has migrated us.
state.kdfIterations = r.kdfIterations || iters;
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
state.locked = false;
$('#loginUsername').readOnly = false;
@@ -2499,10 +2586,12 @@ async function enableQuickUnlock() {
'Confirm your master password to enable Quick unlock on this device.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ masterPassword: masterPwd }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
return toast('Wrong master password', 'error');
@@ -2512,12 +2601,13 @@ async function enableQuickUnlock() {
// restore (no master pw available). Send as base64-encoded UTF-8 JSON.
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
const blob = JSON.stringify({
v: 1,
username: state.username,
salt: state.salt,
token: state.token,
csrf: state.csrf,
key: bytesToBase64(raw),
v: 1,
username: state.username,
salt: state.salt,
kdfIterations: state.kdfIterations,
token: state.token,
csrf: state.csrf,
key: bytesToBase64(raw),
});
const b64 = bytesToBase64(new TextEncoder().encode(blob));
window.location.href = 'cmd://quickunlock/store?data=' + encodeURIComponent(b64);
@@ -2570,12 +2660,14 @@ async function tryQuickUnlock() {
if (!parsed || !parsed.key || !parsed.salt || !parsed.username) return false;
// Restore session state from the blob.
state.username = parsed.username;
state.salt = parsed.salt;
state.token = parsed.token || sessionStorage.getItem('authToken') || '';
state.csrf = parsed.csrf || sessionStorage.getItem('csrfToken') || '';
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('salt', state.salt);
state.username = parsed.username;
state.salt = parsed.salt;
state.kdfIterations = parsed.kdfIterations || 600000;
state.token = parsed.token || sessionStorage.getItem('authToken') || '';
state.csrf = parsed.csrf || sessionStorage.getItem('csrfToken') || '';
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
if (state.token) sessionStorage.setItem('authToken', state.token);
if (state.csrf) sessionStorage.setItem('csrfToken', state.csrf);
@@ -2689,15 +2781,19 @@ async function doGenerateRecoveryKey() {
rawKey, code, kdfSalt);
try {
// 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);
await api('/recovery-key/setup', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
masterPassword: masterPwd,
codeHash: codeHash,
kdfSalt: kdfSalt,
wrappedKey: wrappedKey,
wrappedIv: wrappedIv,
verifier: verifier,
codeHash: codeHash,
kdfSalt: kdfSalt,
wrappedKey: wrappedKey,
wrappedIv: wrappedIv,
}),
});
} catch (err) {
@@ -2816,14 +2912,16 @@ async function doRecoveryRedeem() {
}
// Reconstitute state from the new session.
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u.trim();
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u.trim();
state.kdfIterations = r.kdfIterations || 600000;
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));
// 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).
@@ -2902,9 +3000,14 @@ async function doChangeMasterPassword() {
const btn = $('#cmConfirmBtn');
if (btn) btn.disabled = true;
try {
// Step 1: generate the new salt and derive the new AES key.
// Step 1: generate the new salt and derive the new AES key + verifier.
// Also compute the verifier for the CURRENT pw so the server can
// authenticate the change without ever seeing the plaintext.
const newSalt = randomHexSalt();
const newKey = await deriveKey(newPwd, newSalt, 600000);
const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000);
const newKey = newDerived.cryptoKey;
const currentVerifier = await computeVerifier(
curPwd, state.salt, state.kdfIterations || 100000);
// Step 2: re-encrypt every entry's password AND every entry's TOTP
// secret (if present) under the new key. The current state.cryptoKey
@@ -2950,19 +3053,21 @@ async function doChangeMasterPassword() {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
currentMasterPassword: curPwd,
newMasterPassword: newPwd,
newSalt: newSalt,
entries: encrypted,
currentVerifier: currentVerifier,
newVerifier: newDerived.verifier,
newSalt: newSalt,
entries: encrypted,
}),
});
// Step 4: server committed → switch the in-memory key & salt, refresh
// the cached ciphertexts, persist for F5 survival.
state.salt = r.salt || newSalt;
state.cryptoKey = newKey;
state.salt = r.salt || newSalt;
state.kdfIterations = r.kdfIterations || 600000;
state.cryptoKey = newKey;
await persistCryptoKey();
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
for (let i = 0; i < state.entries.length; i++) {
const nc = encrypted[i];
state.entries[i].encrypted_password = nc.encrypted_password;
@@ -3377,10 +3482,12 @@ async function doExport() {
'Enter your master password to start an encrypted export.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ masterPassword: masterPwd }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
// 429 (account lockout) is possible here too — propagate as a clear