diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index f74d837..37242c6 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -60,6 +60,48 @@ begin Result := SHA256Hex(PBKDF2_SHA256_Hex(APwd, ASalt, AIters)); end; +// ===== Zero-knowledge verifier path ========================================== +// In the verifier flow the CLIENT computes PBKDF2(pw, salt, iters) and sends +// the resulting hex (the "verifier") instead of the plaintext master pw. The +// server then either: +// - hashes the verifier with SHA-256 and compares to stored (CURRENT algo) +// - compares the verifier directly to stored (LEGACY algo, where the +// stored value IS the PBKDF2 hex) +// Either way the server never sees the master pw plaintext. +// +// IsValidVerifier guards against malformed input — accept only lowercase or +// uppercase hex of 64 chars (32 bytes of PBKDF2-SHA-256 output). +function IsValidVerifier(const AVerifier: string): Boolean; +var + I: Integer; +begin + Result := False; + if Length(AVerifier) <> 64 then Exit; + for I := 1 to 64 do + if not CharInSet(AVerifier[I], ['0'..'9', 'a'..'f', 'A'..'F']) then Exit; + Result := True; +end; + +// Returns the stored-hash representation of a verifier under a given algo. +// Useful for both verification (compare to stored) and persistence (write +// after a successful pw change). +function VerifierToStoredHash(const AVerifier, AAlgo: string): string; +begin + if SameText(AAlgo, HASH_ALGO_LEGACY) then + Result := AVerifier // legacy stores PBKDF2 hex directly + else + Result := SHA256Hex(AVerifier); +end; + +// Constant-time verifier check. Returns False if the verifier is malformed +// or the algo string is unsupported, otherwise compares per-algo. +function CheckVerifier(const AVerifier, AStoredHash, AAlgo: string): Boolean; +begin + Result := False; + if not IsValidVerifier(AVerifier) then Exit; + Result := ConstantTimeEquals(VerifierToStoredHash(AVerifier, AAlgo), AStoredHash); +end; + procedure EnsureDefaultFolders(AUserId: Integer); var LQ: TFDQuery; @@ -118,7 +160,8 @@ procedure HandleRegister(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var LBody: TJSONObject; - LUser, LPwd, LSalt, LHash, LToken, LCSRF, LIP: string; + LUser, LPwd, LVerifier, LSalt, LHash, LToken, LCSRF, LIP: string; + LKdfIters: Integer; LQ: TFDQuery; LUserId: Integer; begin @@ -131,15 +174,47 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try - LUser := Trim(LBody.GetValue('username', '')); - LPwd := LBody.GetValue('masterPassword', ''); + LUser := Trim(LBody.GetValue('username', '')); + LPwd := LBody.GetValue('masterPassword', ''); + // Zero-knowledge register: client generates the salt + verifier locally + // so the master pw never leaves the client. Optional — clients that + // still send masterPassword get the legacy server-side derivation. + LVerifier := LBody.GetValue('verifier', ''); + LSalt := LBody.GetValue('salt', ''); + LKdfIters := LBody.GetValue('kdfIterations', PBKDF2_ITERATIONS_TARGET); finally LBody.Free; end; - if (Length(LUser) < 3) or (Length(LPwd) < 8) then + // Username length always required. Master pw length only matters when the + // client is sending plaintext — under the verifier flow the server has no + // way to check pw length (it never sees it), so we trust the client to + // enforce client-side. + if Length(LUser) < 3 then begin - TJSONHelper.SendError(AResponse, 400, 'Min 3/8 chars'); + TJSONHelper.SendError(AResponse, 400, 'Username min 3 chars'); + Exit; + end; + if (LVerifier = '') and (Length(LPwd) < 8) then + begin + TJSONHelper.SendError(AResponse, 400, 'Master password min 8 chars'); + Exit; + end; + if (LVerifier <> '') and (not IsValidVerifier(LVerifier)) then + begin + TJSONHelper.SendError(AResponse, 400, 'Malformed verifier'); + Exit; + end; + if (LVerifier <> '') and (Length(LSalt) <> 64) then + begin + TJSONHelper.SendError(AResponse, 400, + 'Client-supplied salt must be 64 hex chars'); + Exit; + end; + if (LVerifier <> '') and ((LKdfIters < 100000) or (LKdfIters > 5000000)) then + begin + TJSONHelper.SendError(AResponse, 400, + 'kdfIterations out of allowed range'); Exit; end; @@ -160,10 +235,18 @@ begin LQ.Free; end; - LSalt := RandomHex(32); - // New accounts use the current target iteration count + the SHA-256 - // wrapped auth-hash scheme. password_hash is no longer the AES key. - LHash := ComputeAuthHashCurrent(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET); + if LVerifier <> '' then + begin + // ZK path: use the client-supplied salt + iters + verifier as-is. + LHash := VerifierToStoredHash(LVerifier, HASH_ALGO_CURRENT); + end + else + begin + // Legacy plaintext path: server generates salt + derives. + LSalt := RandomHex(32); + LKdfIters := PBKDF2_ITERATIONS_TARGET; + LHash := ComputeAuthHashCurrent(LPwd, LSalt, LKdfIters); + end; LQ := TFDQuery.Create(nil); try @@ -174,7 +257,7 @@ begin LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('h').AsString := LHash; LQ.ParamByName('s').AsString := LSalt; - LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET; + LQ.ParamByName('it').AsInteger := LKdfIters; LQ.ExecSQL; LUserId := DB.Connection.GetLastAutoGenValue('users'); finally @@ -188,8 +271,7 @@ begin CreateSession(LUserId, LToken, LCSRF); LogAudit(LUserId, 'register', LIP); // No migration ever needed for fresh accounts. - SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF, - PBKDF2_ITERATIONS_TARGET, False); + SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF, LKdfIters, False); end; // ===== /login ================================================================ @@ -198,7 +280,7 @@ procedure HandleLogin(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var LBody: TJSONObject; - LUser, LPwd, LSalt, LStoredHash, LAlgo, LToken, LCSRF, LIP: string; + LUser, LPwd, LVerifier, LSalt, LStoredHash, LAlgo, LToken, LCSRF, LIP: string; LUserId, LKdfIters: Integer; LQ: TFDQuery; LComputed: string; @@ -213,8 +295,9 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try - LUser := Trim(LBody.GetValue('username', '')); - LPwd := LBody.GetValue('masterPassword', ''); + LUser := Trim(LBody.GetValue('username', '')); + LPwd := LBody.GetValue('masterPassword', ''); + LVerifier := LBody.GetValue('verifier', ''); finally LBody.Free; end; @@ -263,18 +346,24 @@ begin end; LValid := False; - if SameText(LAlgo, HASH_ALGO_LEGACY) then + if LVerifier <> '' then begin - // Legacy scheme: stored hash is raw PBKDF2 hex (= AES key bytes). Verify - // by direct comparison. On success, login proceeds normally — the - // migration to HASH_ALGO_CURRENT is signaled via kdfMigration in the - // auth response and handled by the client through /migrate-kdf. + // Zero-knowledge path: client already computed PBKDF2(pw, salt, iters) + // and sent us the hex. Server only does the SHA-256 wrap (CURRENT) or + // direct compare (LEGACY). Master pw never leaves the client. + LValid := CheckVerifier(LVerifier, LStoredHash, LAlgo); + end + else if SameText(LAlgo, HASH_ALGO_LEGACY) then + begin + // Legacy plaintext path: stored hash is raw PBKDF2 hex (= AES key bytes). + // Verify by direct comparison. Kept for compatibility with any client + // that hasn't been upgraded to send a verifier yet. LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LKdfIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); end else if SameText(LAlgo, HASH_ALGO_CURRENT) then begin - // Current scheme: stored hash is SHA-256 of the PBKDF2 output. + // Current plaintext path: stored hash is SHA-256 of the PBKDF2 output. LComputed := ComputeAuthHashCurrent(LPwd, LSalt, LKdfIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); end @@ -348,7 +437,7 @@ procedure HandleReauth(ARequest: TIdHTTPRequestInfo; var LUserId, LKdfIters: Integer; LBody: TJSONObject; - LUser, LPwd, LStoredHash, LSalt, LAlgo, LIP, LComputed: string; + LUser, LPwd, LVerifier, LStoredHash, LSalt, LAlgo, LIP, LComputed: string; LQ: TFDQuery; LValid: Boolean; begin @@ -368,7 +457,8 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try - LPwd := LBody.GetValue('masterPassword', ''); + LPwd := LBody.GetValue('masterPassword', ''); + LVerifier := LBody.GetValue('verifier', ''); finally LBody.Free; end; @@ -411,7 +501,9 @@ begin if RejectIfAccountLocked(AResponse, LUser) then Exit; LValid := False; - if SameText(LAlgo, HASH_ALGO_LEGACY) then + if LVerifier <> '' then + LValid := CheckVerifier(LVerifier, LStoredHash, LAlgo) + else if SameText(LAlgo, HASH_ALGO_LEGACY) then begin LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LKdfIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); @@ -469,7 +561,8 @@ var LUserId, LOldIters, I: Integer; LBody, LEntry: TJSONObject; LEntries: TJSONArray; - LUser, LPwd, LSalt, LStoredHash, LAlgo, LIP, LComputed, LNewHash: string; + LUser, LPwd, LOldVerifier, LNewVerifier, LSalt, LStoredHash, LAlgo, LIP, + LComputed, LNewHash: string; LQ: TFDQuery; LValid: Boolean; LEntryId: Integer; @@ -486,7 +579,12 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try - LPwd := LBody.GetValue('masterPassword', ''); + LPwd := LBody.GetValue('masterPassword', ''); + // ZK path: client provides PBKDF2 hex at the OLD iter count (oldVerifier, + // for current-hash verification) AND at the new TARGET iter count + // (newVerifier, for the post-migration stored hash). + LOldVerifier := LBody.GetValue('oldVerifier', ''); + LNewVerifier := LBody.GetValue('newVerifier', ''); LEntries := LBody.GetValue('entries'); if LEntries = nil then begin @@ -532,10 +630,12 @@ begin Exit; end; - // Step 2: verify the master pw against the CURRENT (old) hash, - // using whichever scheme the user is currently on. + // Step 2: verify the master pw against the CURRENT (old) hash. Prefer + // the ZK verifier path; fall back to plaintext for legacy clients. LValid := False; - if SameText(LAlgo, HASH_ALGO_LEGACY) then + if LOldVerifier <> '' then + LValid := CheckVerifier(LOldVerifier, LStoredHash, LAlgo) + else if SameText(LAlgo, HASH_ALGO_LEGACY) then begin LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LOldIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); @@ -553,11 +653,21 @@ begin Exit; end; - // Step 3: compute the new password hash. ALWAYS uses the current - // scheme (SHA-256 wrap) and the target iteration count, regardless - // of where the user was before — migration converges everyone to - // the same modern config. - LNewHash := ComputeAuthHashCurrent(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET); + // Step 3: compute the new password hash under the current scheme + // (SHA-256 wrap) at the target iter count. ZK path takes the + // newVerifier (PBKDF2 at the target iters, computed client-side) and + // just wraps it; plaintext path runs PBKDF2 server-side. + if LNewVerifier <> '' then + begin + if not IsValidVerifier(LNewVerifier) then + begin + TJSONHelper.SendError(AResponse, 400, 'Malformed newVerifier'); + Exit; + end; + LNewHash := VerifierToStoredHash(LNewVerifier, HASH_ALGO_CURRENT); + end + else + LNewHash := ComputeAuthHashCurrent(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET); // Step 4: atomic transaction — update user hash AND every entry's // ciphertext together. Any failure rolls back, leaving the user on @@ -655,8 +765,8 @@ var LUserId, I: Integer; LBody, LEntry, LObj: TJSONObject; LEntries: TJSONArray; - LUser, LCurPwd, LNewPwd, LNewSalt, LStoredHash, LOldSalt, LAlgo, LIP, - LComputed, LNewHash: string; + LUser, LCurPwd, LNewPwd, LCurVerifier, LNewVerifier, LNewSalt, + LStoredHash, LOldSalt, LAlgo, LIP, LComputed, LNewHash: string; LOldIters: Integer; LQ: TFDQuery; LValid: Boolean; @@ -674,14 +784,28 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try - LCurPwd := LBody.GetValue('currentMasterPassword', ''); - LNewPwd := LBody.GetValue('newMasterPassword', ''); - LNewSalt := LBody.GetValue('newSalt', ''); + LCurPwd := LBody.GetValue('currentMasterPassword', ''); + LNewPwd := LBody.GetValue('newMasterPassword', ''); + LNewSalt := LBody.GetValue('newSalt', ''); + // ZK path: verifier for the OLD pw (PBKDF2 over OLD salt + iters) and + // for the NEW pw (PBKDF2 over the new salt at target iters). + LCurVerifier := LBody.GetValue('currentVerifier', ''); + LNewVerifier := LBody.GetValue('newVerifier', ''); LEntries := LBody.GetValue('entries'); - // Input validation. Length 64 = 32 raw bytes in hex, matches the salt - // format produced by RandomHex(32) and client-side randomHexSalt(). - if (Length(LCurPwd) < 1) or (Length(LNewPwd) < 8) then + // Input validation. Either plaintext OR verifier must be present; we + // can't enforce min-length on the new pw in the ZK path (we don't see it). + if (LCurPwd = '') and (LCurVerifier = '') then + begin + TJSONHelper.SendError(AResponse, 400, 'Missing current credentials'); + Exit; + end; + if (LNewPwd = '') and (LNewVerifier = '') then + begin + TJSONHelper.SendError(AResponse, 400, 'Missing new credentials'); + Exit; + end; + if (LNewPwd <> '') and (Length(LNewPwd) < 8) then begin TJSONHelper.SendError(AResponse, 400, 'New master password must be at least 8 characters'); @@ -692,6 +816,11 @@ begin TJSONHelper.SendError(AResponse, 400, 'Invalid newSalt length'); Exit; end; + if (LNewVerifier <> '') and (not IsValidVerifier(LNewVerifier)) then + begin + TJSONHelper.SendError(AResponse, 400, 'Malformed newVerifier'); + Exit; + end; if LEntries = nil then begin TJSONHelper.SendError(AResponse, 400, 'Missing entries array'); @@ -730,9 +859,11 @@ begin // brute-force the current pw to swap it for one they know). if RejectIfAccountLocked(AResponse, LUser) then Exit; - // Step 2: verify the CURRENT master pw. + // Step 2: verify the CURRENT master pw — prefer verifier path. LValid := False; - if SameText(LAlgo, HASH_ALGO_LEGACY) then + if LCurVerifier <> '' then + LValid := CheckVerifier(LCurVerifier, LStoredHash, LAlgo) + else if SameText(LAlgo, HASH_ALGO_LEGACY) then begin LComputed := PBKDF2_SHA256_Hex(LCurPwd, LOldSalt, LOldIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); @@ -750,8 +881,12 @@ begin Exit; end; - // Step 3: compute the new auth hash with the new salt + target iters. - LNewHash := ComputeAuthHashCurrent(LNewPwd, LNewSalt, PBKDF2_ITERATIONS_TARGET); + // Step 3: compute the new auth hash. ZK path: just wrap the + // client-supplied newVerifier. Plaintext: derive server-side. + if LNewVerifier <> '' then + LNewHash := VerifierToStoredHash(LNewVerifier, HASH_ALGO_CURRENT) + else + LNewHash := ComputeAuthHashCurrent(LNewPwd, LNewSalt, PBKDF2_ITERATIONS_TARGET); // Step 4: atomic transaction — user row + every entry's ciphertext. DB.Connection.StartTransaction; @@ -839,7 +974,86 @@ begin TJSONHelper.SendJSON(AResponse, LObj); end; +// ===== POST /login/challenge ================================================= +// First leg of the zero-knowledge login: client posts the username, server +// returns the salt + KDF iteration count needed to compute the verifier on +// the client side. The actual login then sends the verifier (not the master +// pw) to POST /login. +// +// User existence: this endpoint DOES leak user existence (404 vs 200) — same +// as the existing /login through timing (PBKDF2 cost runs only on valid +// users). Closing that hole properly requires returning a deterministic fake +// salt for unknown users, which itself leaks via stability under retry. v1 +// accepts the timing leak in /login and the explicit leak here as equivalent. +// +// Rate-limited per IP via the existing login_attempts table. No per-account +// lockout fires here — that lives in /login proper, on actual verifier +// mismatches. +procedure HandleLoginChallenge(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LBody, LObj: TJSONObject; + LUser, LSalt, LIP, LAlgo: string; + LKdfIters: Integer; + LQ: TFDQuery; +begin + LIP := GetClientIP(ARequest); + if CheckRateLimit(LIP) >= 20 then + begin + TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LUser := Trim(LBody.GetValue('username', '')); + finally + LBody.Free; + end; + if LUser = '' then + begin + TJSONHelper.SendError(AResponse, 400, 'Username required'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT salt, kdf_iterations, hash_algo ' + + 'FROM users WHERE username = :u'; + LQ.ParamByName('u').AsString := LUser; + LQ.Open; + if LQ.IsEmpty then + begin + TJSONHelper.SendError(AResponse, 404, 'Unknown user'); + Exit; + end; + LSalt := LQ.FieldByName('salt').AsString; + LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; + LAlgo := LQ.FieldByName('hash_algo').AsString; + if LAlgo = '' then LAlgo := HASH_ALGO_LEGACY; + if LKdfIters <= 0 then LKdfIters := PBKDF2_ITERATIONS; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LObj := TJSONObject.Create; + LObj.AddPair('salt', LSalt); + LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); + // Echo back the hash_algo so the client can choose the right wrap path + // when needed (legacy vs current). Most clients ignore it. + LObj.AddPair('hashAlgo', LAlgo); + TJSONHelper.SendJSON(AResponse, LObj); +end; + initialization + Router.Register('POST', '/login/challenge', HandleLoginChallenge); Router.Register('POST', '/register', HandleRegister); Router.Register('POST', '/login', HandleLogin); Router.Register('POST', '/logout', HandleLogout); diff --git a/delphi-backend/Handlers/PM.Handler.Recovery.pas b/delphi-backend/Handlers/PM.Handler.Recovery.pas index 40aff50..572e3e5 100644 --- a/delphi-backend/Handlers/PM.Handler.Recovery.pas +++ b/delphi-backend/Handlers/PM.Handler.Recovery.pas @@ -38,9 +38,11 @@ uses PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Session, PM.Audit, PM.RateLimit; // Verifies the user's master password against their current stored hash. -// Used by /recovery-key/setup so a stolen session token alone can't set up -// a recovery backdoor. -function VerifyMasterPassword(AUserId: Integer; const APwd: string; +// Accepts EITHER plaintext (legacy clients) OR a precomputed verifier +// (ZK clients). Used by /recovery-key/setup so a stolen session token +// alone can't set up a recovery backdoor. +function VerifyMasterPassword(AUserId: Integer; + const APwd, AVerifier: string; out AUsername: string): Boolean; const HASH_ALGO_LEGACY = 'pbkdf2'; @@ -78,6 +80,18 @@ begin DB.Unlock; end; + if AVerifier <> '' then + begin + // ZK: client computed PBKDF2 hex locally. Server only does the wrap. + if Length(AVerifier) <> 64 then Exit; + if SameText(LAlgo, HASH_ALGO_LEGACY) then + Result := ConstantTimeEquals(AVerifier, LStoredHash) + else if SameText(LAlgo, HASH_ALGO_CURRENT) then + Result := ConstantTimeEquals(SHA256Hex(AVerifier), LStoredHash); + Exit; + end; + + // Plaintext fallback (legacy client). if SameText(LAlgo, HASH_ALGO_LEGACY) then begin LComputed := PBKDF2_SHA256_Hex(APwd, LSalt, LIters); @@ -141,7 +155,7 @@ procedure HandleSetup(ARequest: TIdHTTPRequestInfo; var LUserId: Integer; LBody: TJSONObject; - LPwd, LCodeHash, LKdfSalt, LWrappedKey, LWrappedIv, LIP, LUser: string; + LPwd, LVerifier, LCodeHash, LKdfSalt, LWrappedKey, LWrappedIv, LIP, LUser: string; LQ: TFDQuery; begin try @@ -155,6 +169,7 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try LPwd := LBody.GetValue('masterPassword', ''); + LVerifier := LBody.GetValue('verifier', ''); LCodeHash := LBody.GetValue('codeHash', ''); LKdfSalt := LBody.GetValue('kdfSalt', ''); LWrappedKey := LBody.GetValue('wrappedKey', ''); @@ -172,7 +187,7 @@ begin Exit; end; - if not VerifyMasterPassword(LUserId, LPwd, LUser) then + if not VerifyMasterPassword(LUserId, LPwd, LVerifier, LUser) then begin RecordFailedAccountAttempt(LUser, LIP); LogAudit(LUserId, 'failed_recovery_setup', LIP); diff --git a/js/app.js b/js/app.js index 57cabb9..a77f394 100644 --- a/js/app.js +++ b/js/app.js @@ -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