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:
r-zakarya
2026-07-03 12:38:20 +01:00
parent 3076fec710
commit 3f8ecde571
6 changed files with 162 additions and 31 deletions
+31
View File
@@ -516,6 +516,37 @@ restore-then-sync actually stick. A live `vault_entries` row can never
coexist with its tombstone (hard-delete removes the row), so `PUT` coexist with its tombstone (hard-delete removes the row), so `PUT`
needs no purge. needs no purge.
## Auth-hash schemes (`users.hash_algo`)
Three markers, all zero-knowledge (server never sees the master pw) :
- `pbkdf2` (**LEGACY**) : stored hash = raw `PBKDF2(pw,salt,iters)` hex.
Those bytes ARE the AES vault key → a stolen `vault.db` = the key.
Auto-upgraded to `pbkdf2-sha256` at next login via `/migrate-kdf`.
- `pbkdf2-sha256` (**previous default**) : stored = `SHA256(verifier)`
where the client's transmitted `verifier` is still the key hex. Safe
at rest, but the `/login` body carries the key.
- `pbkdf2-sha256-v2` (**DECOUPLED, current default**) : the client sends
`verifier = SHA256(keyHex + "pmserver/auth-verifier/v2")` instead of
`keyHex`. The transmitted verifier is now a one-way function of the
key → intercepting `/login` no longer hands over the AES key. Stored =
`SHA256(verifier)` (same server wrap as `pbkdf2-sha256`; only the algo
LABEL differs, telling the client which verifier formula to use).
**The AES key (`cryptoKey`) is ALWAYS `hex(PBKDF2)` regardless of algo**
— only the verifier string changes, so entries stay decryptable and
switching schemes never re-encrypts data.
Adoption is **new-registration + master-pw-change only** — existing
accounts stay on their algo until they rotate (no forced login-path
migration; `not SameText(algo, LEGACY)` no longer signals migration, so
sha256/v2 accounts are left alone). Client picks the verifier formula
from the algo returned by `/login/challenge`, cached in `state.hashAlgo`
(also carried in the quick-unlock / PIN cold-start blobs and the
`/recovery-key/redeem` response). Unknown/empty `hashAlgo` → key hex →
correct for every pre-decoupling account, which is what makes the
rollout safe. Central client helper: `verifierFromKeyHex(keyHex, algo)`.
## PIN unlock ## PIN unlock
Optional shortcut unlock with a 412 digit PIN, complementary to Quick Optional shortcut unlock with a 412 digit PIN, complementary to Quick
+37 -10
View File
@@ -47,6 +47,15 @@ const
// during /login to compute the comparison. // during /login to compute the comparison.
HASH_ALGO_LEGACY = 'pbkdf2'; HASH_ALGO_LEGACY = 'pbkdf2';
HASH_ALGO_CURRENT = 'pbkdf2-sha256'; HASH_ALGO_CURRENT = 'pbkdf2-sha256';
// 'pbkdf2-sha256-v2' : DECOUPLED. Same stored form as CURRENT
// (SHA256 of the client verifier), but the client's transmitted
// verifier is now SHA256(keyHex + domain) instead of keyHex — so the
// /login body no longer carries the raw AES vault key. Used by new
// registrations and by every master-pw change. Existing accounts stay
// on their current algo until they rotate (no forced migration).
// Verification is identical to CURRENT (VerifierToStoredHash wraps any
// non-legacy verifier in SHA256), so no new verify branch is needed.
HASH_ALGO_DECOUPLED = 'pbkdf2-sha256-v2';
DEFAULT_FOLDERS: array[0..4] of string = ('All', 'Social', 'Banking', 'Work', 'Personal'); DEFAULT_FOLDERS: array[0..4] of string = ('All', 'Social', 'Banking', 'Work', 'Personal');
@@ -235,10 +244,16 @@ begin
LQ.Free; LQ.Free;
end; end;
// New ZK registrations land on the DECOUPLED scheme; the plaintext
// fallback (legacy clients) stays on CURRENT. VerifierToStoredHash
// wraps both the same way (SHA256), so only the stored algo LABEL
// differs — it's what tells the client which verifier formula to use.
var LRegAlgo := HASH_ALGO_CURRENT;
if LVerifier <> '' then if LVerifier <> '' then
begin begin
// ZK path: use the client-supplied salt + iters + verifier as-is. // ZK path: use the client-supplied salt + iters + verifier as-is.
LHash := VerifierToStoredHash(LVerifier, HASH_ALGO_CURRENT); LRegAlgo := HASH_ALGO_DECOUPLED;
LHash := VerifierToStoredHash(LVerifier, LRegAlgo);
end end
else else
begin begin
@@ -253,7 +268,8 @@ begin
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
LQ.SQL.Text := LQ.SQL.Text :=
'INSERT INTO users (username, password_hash, salt, hash_algo, kdf_iterations) ' + 'INSERT INTO users (username, password_hash, salt, hash_algo, kdf_iterations) ' +
'VALUES (:u, :h, :s, ''' + HASH_ALGO_CURRENT + ''', :it)'; 'VALUES (:u, :h, :s, :algo, :it)';
LQ.ParamByName('algo').AsString := LRegAlgo;
LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('u').AsString := LUser;
LQ.ParamByName('h').AsString := LHash; LQ.ParamByName('h').AsString := LHash;
LQ.ParamByName('s').AsString := LSalt; LQ.ParamByName('s').AsString := LSalt;
@@ -396,12 +412,15 @@ begin
LogAudit(LUserId, 'login', LIP); LogAudit(LUserId, 'login', LIP);
// Signal migration whenever EITHER: // Signal migration whenever EITHER:
// - the user's iteration count is below the target (KDF bump needed), OR // - the user's iteration count is below the target (KDF bump needed), OR
// - the user's hash_algo is not the current scheme (format upgrade needed // - the user is on the LEGACY 'pbkdf2' scheme (stored hash = raw key hex;
// to remove the AES-key-in-vault.db architectural flaw). // upgrade to SHA256-wrapped to remove the AES-key-in-vault.db flaw).
// The client then calls /migrate-kdf which fixes both in one atomic step. // NOTE: we deliberately do NOT signal for 'pbkdf2-sha256' or the newer
// 'pbkdf2-sha256-v2' (decoupled) — those are already SHA256-wrapped at
// rest, and forcing sha256 → v2 is out of scope (v2 is adopted only on
// register / master-pw change, never force-migrated at login).
SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF, LKdfIters, SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF, LKdfIters,
(LKdfIters < PBKDF2_ITERATIONS_TARGET) or (LKdfIters < PBKDF2_ITERATIONS_TARGET) or
not SameText(LAlgo, HASH_ALGO_CURRENT)); SameText(LAlgo, HASH_ALGO_LEGACY));
end; end;
// ===== /logout =============================================================== // ===== /logout ===============================================================
@@ -535,8 +554,11 @@ begin
var LObj := TJSONObject.Create; var LObj := TJSONObject.Create;
LObj.AddPair('message', 'OK'); LObj.AddPair('message', 'OK');
LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters));
// Same rule as HandleLogin: only KDF-bump or LEGACY format triggers
// migration. sha256 / v2 accounts are left as-is (v2 must not be
// force-downgraded to sha256 by migrate-kdf).
if (LKdfIters < PBKDF2_ITERATIONS_TARGET) or if (LKdfIters < PBKDF2_ITERATIONS_TARGET) or
not SameText(LAlgo, HASH_ALGO_CURRENT) then SameText(LAlgo, HASH_ALGO_LEGACY) then
begin begin
var LMig := TJSONObject.Create; var LMig := TJSONObject.Create;
LMig.AddPair('target', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET)); LMig.AddPair('target', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET));
@@ -883,9 +905,14 @@ begin
end; end;
// Step 3: compute the new auth hash. ZK path: just wrap the // Step 3: compute the new auth hash. ZK path: just wrap the
// client-supplied newVerifier. Plaintext: derive server-side. // client-supplied newVerifier (rotating onto the DECOUPLED scheme).
// Plaintext: derive server-side (stays CURRENT).
var LNewAlgo := HASH_ALGO_CURRENT;
if LNewVerifier <> '' then if LNewVerifier <> '' then
LNewHash := VerifierToStoredHash(LNewVerifier, HASH_ALGO_CURRENT) begin
LNewAlgo := HASH_ALGO_DECOUPLED;
LNewHash := VerifierToStoredHash(LNewVerifier, LNewAlgo);
end
else else
LNewHash := ComputeAuthHashCurrent(LNewPwd, LNewSalt, PBKDF2_ITERATIONS_TARGET); LNewHash := ComputeAuthHashCurrent(LNewPwd, LNewSalt, PBKDF2_ITERATIONS_TARGET);
@@ -905,7 +932,7 @@ begin
LQ.ParamByName('h').AsString := LNewHash; LQ.ParamByName('h').AsString := LNewHash;
LQ.ParamByName('s').AsString := LNewSalt; LQ.ParamByName('s').AsString := LNewSalt;
LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET; LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET;
LQ.ParamByName('algo').AsString := HASH_ALGO_CURRENT; LQ.ParamByName('algo').AsString := LNewAlgo;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL; LQ.ExecSQL;
finally finally
@@ -273,7 +273,7 @@ procedure HandleRedeem(ARequest: TIdHTTPRequestInfo;
var var
LBody, LObj: TJSONObject; LBody, LObj: TJSONObject;
LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv, LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv,
LSalt, LToken, LCSRF: string; LSalt, LToken, LCSRF, LAlgo: string;
LUserId, LKdfIters, LCurrentUses, LNewUses: Integer; LUserId, LKdfIters, LCurrentUses, LNewUses: Integer;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
@@ -309,7 +309,7 @@ begin
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
// Join to users to look up by username + verify the code in one shot. // Join to users to look up by username + verify the code in one shot.
LQ.SQL.Text := LQ.SQL.Text :=
'SELECT u.id, u.salt, u.kdf_iterations, ' + 'SELECT u.id, u.salt, u.kdf_iterations, u.hash_algo, ' +
' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv, rk.remaining_uses ' + ' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv, rk.remaining_uses ' +
'FROM users u ' + 'FROM users u ' +
'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' + 'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' +
@@ -326,6 +326,7 @@ begin
LUserId := LQ.FieldByName('id').AsInteger; LUserId := LQ.FieldByName('id').AsInteger;
LSalt := LQ.FieldByName('salt').AsString; LSalt := LQ.FieldByName('salt').AsString;
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
LAlgo := LQ.FieldByName('hash_algo').AsString;
LStoredHash := LQ.FieldByName('code_hash').AsString; LStoredHash := LQ.FieldByName('code_hash').AsString;
LKdfSalt := LQ.FieldByName('kdf_salt').AsString; LKdfSalt := LQ.FieldByName('kdf_salt').AsString;
LWrappedKey := LQ.FieldByName('wrapped_key').AsString; LWrappedKey := LQ.FieldByName('wrapped_key').AsString;
@@ -401,6 +402,7 @@ begin
LObj.AddPair('csrfToken', LCSRF); LObj.AddPair('csrfToken', LCSRF);
LObj.AddPair('salt', LSalt); LObj.AddPair('salt', LSalt);
LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters));
LObj.AddPair('hashAlgo', LAlgo);
LObj.AddPair('wrappedKey', LWrappedKey); LObj.AddPair('wrappedKey', LWrappedKey);
LObj.AddPair('wrappedIv', LWrappedIv); LObj.AddPair('wrappedIv', LWrappedIv);
LObj.AddPair('kdfSalt', LKdfSalt); LObj.AddPair('kdfSalt', LKdfSalt);
+4 -1
View File
@@ -91,7 +91,10 @@ if ($jsFiles) {
foreach ($jf in $jsFiles) { foreach ($jf in $jsFiles) {
Log "Syntax check: $($jf.Relative)" Log "Syntax check: $($jf.Relative)"
# --check prints errors to stderr and returns non-zero on failure. # --check prints errors to stderr and returns non-zero on failure.
$out = & $node.Source --check $jf.FullPath 2>&1 # Pipe $null into node so it can NEVER block waiting on stdin
# (some Windows node shims read stdin when launched from a
# non-interactive pre-build event, which would hang the build).
$out = $null | & $node.Source --check $jf.FullPath 2>&1
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Log "JS SYNTAX ERROR in $($jf.Relative):" Log "JS SYNTAX ERROR in $($jf.Relative):"
Log ($out | Out-String) Log ($out | Out-String)
Binary file not shown.
+86 -18
View File
@@ -493,6 +493,13 @@ const state = {
// and on-the-fly verifier computations don't need a /login/challenge // and on-the-fly verifier computations don't need a /login/challenge
// round trip every time. Refreshed from every auth response. // round trip every time. Refreshed from every auth response.
kdfIterations: parseInt(sessionStorage.getItem('kdfIterations') || '0') || 0, 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, cryptoKey: null,
entries: [], entries: [],
trashed: [], trashed: [],
@@ -619,7 +626,29 @@ function bytesToHex(arr) {
return hex; 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; iterations = iterations || 100000;
const enc = new TextEncoder(); const enc = new TextEncoder();
const km = await crypto.subtle.importKey( const km = await crypto.subtle.importKey(
@@ -631,11 +660,12 @@ async function deriveKeyAndVerifier(pwd, saltHex, iterations) {
const keyBytes = new Uint8Array(bits); const keyBytes = new Uint8Array(bits);
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']);
return { cryptoKey, verifier: bytesToHex(keyBytes) }; const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo);
return { cryptoKey, verifier };
} }
async function computeVerifier(pwd, saltHex, iterations) { async function computeVerifier(pwd, saltHex, iterations, algo) {
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations); const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo);
return r.verifier; return r.verifier;
} }
@@ -1591,8 +1621,12 @@ async function runKdfMigration(masterPwd, fromIters, toIters) {
// the user knows the master pw under the current (legacy) iters; // the user knows the master pw under the current (legacy) iters;
// newVerifier is what the server will SHA-256-wrap to be the new // newVerifier is what the server will SHA-256-wrap to be the new
// stored hash after migration. Master pw never leaves the browser. // stored hash after migration. Master pw never leaves the browser.
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters); // Only non-v2 accounts ever reach the KDF migration (v2 accounts are
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters); // 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', { await api('/migrate-kdf', {
method: 'POST', method: 'POST',
@@ -1767,7 +1801,10 @@ async function doLogin(e) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u }), 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', { const r = await api('/login', {
method: 'POST', method: 'POST',
@@ -1784,6 +1821,7 @@ async function doLogin(e) {
sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username); sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo);
// Persist via DPAPI when running inside the Delphi host (localStorage // Persist via DPAPI when running inside the Delphi host (localStorage
// is wiped on each restart because the HTTP port — and therefore the // is wiped on each restart because the HTTP port — and therefore the
// origin — changes every launch). Fall back to localStorage for the // origin — changes every launch). Fall back to localStorage for the
@@ -1840,7 +1878,9 @@ async function doRegister(e) {
// leaves the browser. // leaves the browser.
const newSalt = randomHexSalt(); const newSalt = randomHexSalt();
const newIters = 600000; 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', { const r = await api('/register', {
method: 'POST', method: 'POST',
@@ -1850,6 +1890,7 @@ async function doRegister(e) {
salt: newSalt, salt: newSalt,
kdfIterations: newIters, kdfIterations: newIters,
verifier: derived.verifier, verifier: derived.verifier,
hashAlgo: HASH_ALGO_V2,
}), }),
}); });
state.token = r.token; state.token = r.token;
@@ -1862,6 +1903,7 @@ async function doRegister(e) {
sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username); sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo);
state.cryptoKey = derived.cryptoKey; state.cryptoKey = derived.cryptoKey;
await persistCryptoKey(); await persistCryptoKey();
toast('Vault created'); toast('Vault created');
@@ -1974,7 +2016,7 @@ async function doUnlock(p) {
// Compute the verifier locally with the salt+iters cached at login. // Compute the verifier locally with the salt+iters cached at login.
// Server compares verifier → never sees the plaintext master pw. // Server compares verifier → never sees the plaintext master pw.
const iters = state.kdfIterations || 100000; 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', { const r = await api('/reauth', {
method: 'POST', method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
@@ -6996,6 +7038,11 @@ async function pinBuildBlob(pin) {
username: state.username, username: state.username,
loginSalt: state.salt, loginSalt: state.salt,
loginIters: state.kdfIterations || 600000, 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), salt: bytesToBase64(salt),
iters: PIN_KDF_ITERS, iters: PIN_KDF_ITERS,
iv: bytesToBase64(iv), iv: bytesToBase64(iv),
@@ -7103,7 +7150,7 @@ async function pinSetupFlow() {
if (!masterPwd) return; if (!masterPwd) return;
try { try {
const verifier = await computeVerifier( const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000); masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
await api('/reauth', { await api('/reauth', {
method: 'POST', method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
@@ -7190,6 +7237,7 @@ async function loginViaPin(pin) {
state.username = blob.username || state.username; state.username = blob.username || state.username;
state.salt = blob.loginSalt || state.salt; state.salt = blob.loginSalt || state.salt;
state.kdfIterations = blob.loginIters || state.kdfIterations || 600000; state.kdfIterations = blob.loginIters || state.kdfIterations || 600000;
state.hashAlgo = blob.hashAlgo || '';
try { try {
state.cryptoKey = await crypto.subtle.importKey( state.cryptoKey = await crypto.subtle.importKey(
@@ -7197,7 +7245,8 @@ async function loginViaPin(pin) {
} catch (_) { return false; } } catch (_) { return false; }
try { 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', { const r = await api('/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -7216,6 +7265,7 @@ async function loginViaPin(pin) {
sessionStorage.setItem('username', state.username); sessionStorage.setItem('username', state.username);
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('authToken', state.token); sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('csrfToken', state.csrf);
await persistCryptoKey(); await persistCryptoKey();
@@ -7242,7 +7292,7 @@ async function enableQuickUnlock() {
if (!masterPwd) return; if (!masterPwd) return;
try { try {
const verifier = await computeVerifier( const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000); masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
await api('/reauth', { await api('/reauth', {
method: 'POST', method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
@@ -7262,6 +7312,8 @@ async function enableQuickUnlock() {
username: state.username, username: state.username,
salt: state.salt, salt: state.salt,
kdfIterations: state.kdfIterations, kdfIterations: state.kdfIterations,
// Auth scheme for cold-start verifier selection (see pinBuildBlob).
hashAlgo: state.hashAlgo || '',
key: bytesToBase64(raw), key: bytesToBase64(raw),
}); });
const b64 = bytesToBase64(new TextEncoder().encode(blob)); const b64 = bytesToBase64(new TextEncoder().encode(blob));
@@ -7320,6 +7372,7 @@ async function tryQuickUnlock() {
state.username = parsed.username; state.username = parsed.username;
state.salt = parsed.salt; state.salt = parsed.salt;
state.kdfIterations = parsed.kdfIterations || 600000; state.kdfIterations = parsed.kdfIterations || 600000;
state.hashAlgo = parsed.hashAlgo || '';
const rawKey = base64ToBytes(parsed.key); const rawKey = base64ToBytes(parsed.key);
try { try {
@@ -7335,7 +7388,8 @@ async function tryQuickUnlock() {
// up by the server's session GC, which used to drop the user back to // up by the server's session GC, which used to drop the user back to
// the login screen on cold start. // the login screen on cold start.
try { 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', { const r = await api('/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -7355,6 +7409,7 @@ async function tryQuickUnlock() {
sessionStorage.setItem('username', state.username); sessionStorage.setItem('username', state.username);
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('authToken', state.token); sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('csrfToken', state.csrf);
await persistCryptoKey(); await persistCryptoKey();
@@ -7507,7 +7562,7 @@ async function doGenerateRecoveryKey() {
// Send a verifier instead of the master pw — server proves the // Send a verifier instead of the master pw — server proves the
// user still knows the master pw without ever seeing the plaintext. // user still knows the master pw without ever seeing the plaintext.
const verifier = await computeVerifier( const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000); masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
await api('/recovery-key/setup', { await api('/recovery-key/setup', {
method: 'POST', method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
@@ -7686,11 +7741,15 @@ async function doRecoveryRedeem() {
state.salt = r.salt; state.salt = r.salt;
state.username = u.trim(); state.username = u.trim();
state.kdfIterations = r.kdfIterations || 600000; 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('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username); sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo);
// Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable // 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). // 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 // Also compute the verifier for the CURRENT pw so the server can
// authenticate the change without ever seeing the plaintext. // authenticate the change without ever seeing the plaintext.
const newSalt = randomHexSalt(); 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; const newKey = newDerived.cryptoKey;
let currentVerifier; let currentVerifier;
if (recoveryMode) { 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)); const rawCurrentKey = new Uint8Array(await crypto.subtle.exportKey('raw', state.cryptoKey));
currentVerifier = bytesToHex(rawCurrentKey); currentVerifier = await verifierFromKeyHex(bytesToHex(rawCurrentKey), state.hashAlgo);
} else { } else {
currentVerifier = await computeVerifier( 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 // 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. // the cached ciphertexts, persist for F5 survival.
state.salt = r.salt || newSalt; state.salt = r.salt || newSalt;
state.kdfIterations = r.kdfIterations || 600000; state.kdfIterations = r.kdfIterations || 600000;
// The account is now on the decoupled-verifier scheme.
state.hashAlgo = HASH_ALGO_V2;
state.cryptoKey = newKey; state.cryptoKey = newKey;
await persistCryptoKey(); 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);
// Server invalidated every session for this user (including ours) // Server invalidated every session for this user (including ours)
// and minted a fresh pair — adopt them so subsequent API calls // and minted a fresh pair — adopt them so subsequent API calls
// don't bounce with "invalid session". // don't bounce with "invalid session".
@@ -8751,7 +8819,7 @@ async function doExport() {
if (!masterPwd) return; // user cancelled if (!masterPwd) return; // user cancelled
try { try {
const verifier = await computeVerifier( const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000); masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
await api('/reauth', { await api('/reauth', {
method: 'POST', method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),