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:
@@ -60,6 +60,48 @@ begin
|
|||||||
Result := SHA256Hex(PBKDF2_SHA256_Hex(APwd, ASalt, AIters));
|
Result := SHA256Hex(PBKDF2_SHA256_Hex(APwd, ASalt, AIters));
|
||||||
end;
|
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);
|
procedure EnsureDefaultFolders(AUserId: Integer);
|
||||||
var
|
var
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
@@ -118,7 +160,8 @@ procedure HandleRegister(ARequest: TIdHTTPRequestInfo;
|
|||||||
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
var
|
var
|
||||||
LBody: TJSONObject;
|
LBody: TJSONObject;
|
||||||
LUser, LPwd, LSalt, LHash, LToken, LCSRF, LIP: string;
|
LUser, LPwd, LVerifier, LSalt, LHash, LToken, LCSRF, LIP: string;
|
||||||
|
LKdfIters: Integer;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
LUserId: Integer;
|
LUserId: Integer;
|
||||||
begin
|
begin
|
||||||
@@ -133,13 +176,45 @@ begin
|
|||||||
try
|
try
|
||||||
LUser := Trim(LBody.GetValue<string>('username', ''));
|
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||||
LPwd := LBody.GetValue<string>('masterPassword', '');
|
LPwd := LBody.GetValue<string>('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<string>('verifier', '');
|
||||||
|
LSalt := LBody.GetValue<string>('salt', '');
|
||||||
|
LKdfIters := LBody.GetValue<Integer>('kdfIterations', PBKDF2_ITERATIONS_TARGET);
|
||||||
finally
|
finally
|
||||||
LBody.Free;
|
LBody.Free;
|
||||||
end;
|
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
|
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;
|
Exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -160,10 +235,18 @@ begin
|
|||||||
LQ.Free;
|
LQ.Free;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
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);
|
LSalt := RandomHex(32);
|
||||||
// New accounts use the current target iteration count + the SHA-256
|
LKdfIters := PBKDF2_ITERATIONS_TARGET;
|
||||||
// wrapped auth-hash scheme. password_hash is no longer the AES key.
|
LHash := ComputeAuthHashCurrent(LPwd, LSalt, LKdfIters);
|
||||||
LHash := ComputeAuthHashCurrent(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET);
|
end;
|
||||||
|
|
||||||
LQ := TFDQuery.Create(nil);
|
LQ := TFDQuery.Create(nil);
|
||||||
try
|
try
|
||||||
@@ -174,7 +257,7 @@ begin
|
|||||||
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;
|
||||||
LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET;
|
LQ.ParamByName('it').AsInteger := LKdfIters;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
LUserId := DB.Connection.GetLastAutoGenValue('users');
|
LUserId := DB.Connection.GetLastAutoGenValue('users');
|
||||||
finally
|
finally
|
||||||
@@ -188,8 +271,7 @@ begin
|
|||||||
CreateSession(LUserId, LToken, LCSRF);
|
CreateSession(LUserId, LToken, LCSRF);
|
||||||
LogAudit(LUserId, 'register', LIP);
|
LogAudit(LUserId, 'register', LIP);
|
||||||
// No migration ever needed for fresh accounts.
|
// No migration ever needed for fresh accounts.
|
||||||
SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF,
|
SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF, LKdfIters, False);
|
||||||
PBKDF2_ITERATIONS_TARGET, False);
|
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// ===== /login ================================================================
|
// ===== /login ================================================================
|
||||||
@@ -198,7 +280,7 @@ procedure HandleLogin(ARequest: TIdHTTPRequestInfo;
|
|||||||
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
var
|
var
|
||||||
LBody: TJSONObject;
|
LBody: TJSONObject;
|
||||||
LUser, LPwd, LSalt, LStoredHash, LAlgo, LToken, LCSRF, LIP: string;
|
LUser, LPwd, LVerifier, LSalt, LStoredHash, LAlgo, LToken, LCSRF, LIP: string;
|
||||||
LUserId, LKdfIters: Integer;
|
LUserId, LKdfIters: Integer;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
LComputed: string;
|
LComputed: string;
|
||||||
@@ -215,6 +297,7 @@ begin
|
|||||||
try
|
try
|
||||||
LUser := Trim(LBody.GetValue<string>('username', ''));
|
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||||
LPwd := LBody.GetValue<string>('masterPassword', '');
|
LPwd := LBody.GetValue<string>('masterPassword', '');
|
||||||
|
LVerifier := LBody.GetValue<string>('verifier', '');
|
||||||
finally
|
finally
|
||||||
LBody.Free;
|
LBody.Free;
|
||||||
end;
|
end;
|
||||||
@@ -263,18 +346,24 @@ begin
|
|||||||
end;
|
end;
|
||||||
|
|
||||||
LValid := False;
|
LValid := False;
|
||||||
if SameText(LAlgo, HASH_ALGO_LEGACY) then
|
if LVerifier <> '' then
|
||||||
begin
|
begin
|
||||||
// Legacy scheme: stored hash is raw PBKDF2 hex (= AES key bytes). Verify
|
// Zero-knowledge path: client already computed PBKDF2(pw, salt, iters)
|
||||||
// by direct comparison. On success, login proceeds normally — the
|
// and sent us the hex. Server only does the SHA-256 wrap (CURRENT) or
|
||||||
// migration to HASH_ALGO_CURRENT is signaled via kdfMigration in the
|
// direct compare (LEGACY). Master pw never leaves the client.
|
||||||
// auth response and handled by the client through /migrate-kdf.
|
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);
|
LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LKdfIters);
|
||||||
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
||||||
end
|
end
|
||||||
else if SameText(LAlgo, HASH_ALGO_CURRENT) then
|
else if SameText(LAlgo, HASH_ALGO_CURRENT) then
|
||||||
begin
|
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);
|
LComputed := ComputeAuthHashCurrent(LPwd, LSalt, LKdfIters);
|
||||||
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
||||||
end
|
end
|
||||||
@@ -348,7 +437,7 @@ procedure HandleReauth(ARequest: TIdHTTPRequestInfo;
|
|||||||
var
|
var
|
||||||
LUserId, LKdfIters: Integer;
|
LUserId, LKdfIters: Integer;
|
||||||
LBody: TJSONObject;
|
LBody: TJSONObject;
|
||||||
LUser, LPwd, LStoredHash, LSalt, LAlgo, LIP, LComputed: string;
|
LUser, LPwd, LVerifier, LStoredHash, LSalt, LAlgo, LIP, LComputed: string;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
LValid: Boolean;
|
LValid: Boolean;
|
||||||
begin
|
begin
|
||||||
@@ -369,6 +458,7 @@ begin
|
|||||||
LBody := TJSONHelper.ReadBody(ARequest);
|
LBody := TJSONHelper.ReadBody(ARequest);
|
||||||
try
|
try
|
||||||
LPwd := LBody.GetValue<string>('masterPassword', '');
|
LPwd := LBody.GetValue<string>('masterPassword', '');
|
||||||
|
LVerifier := LBody.GetValue<string>('verifier', '');
|
||||||
finally
|
finally
|
||||||
LBody.Free;
|
LBody.Free;
|
||||||
end;
|
end;
|
||||||
@@ -411,7 +501,9 @@ begin
|
|||||||
if RejectIfAccountLocked(AResponse, LUser) then Exit;
|
if RejectIfAccountLocked(AResponse, LUser) then Exit;
|
||||||
|
|
||||||
LValid := False;
|
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
|
begin
|
||||||
LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LKdfIters);
|
LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LKdfIters);
|
||||||
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
||||||
@@ -469,7 +561,8 @@ var
|
|||||||
LUserId, LOldIters, I: Integer;
|
LUserId, LOldIters, I: Integer;
|
||||||
LBody, LEntry: TJSONObject;
|
LBody, LEntry: TJSONObject;
|
||||||
LEntries: TJSONArray;
|
LEntries: TJSONArray;
|
||||||
LUser, LPwd, LSalt, LStoredHash, LAlgo, LIP, LComputed, LNewHash: string;
|
LUser, LPwd, LOldVerifier, LNewVerifier, LSalt, LStoredHash, LAlgo, LIP,
|
||||||
|
LComputed, LNewHash: string;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
LValid: Boolean;
|
LValid: Boolean;
|
||||||
LEntryId: Integer;
|
LEntryId: Integer;
|
||||||
@@ -487,6 +580,11 @@ begin
|
|||||||
LBody := TJSONHelper.ReadBody(ARequest);
|
LBody := TJSONHelper.ReadBody(ARequest);
|
||||||
try
|
try
|
||||||
LPwd := LBody.GetValue<string>('masterPassword', '');
|
LPwd := LBody.GetValue<string>('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<string>('oldVerifier', '');
|
||||||
|
LNewVerifier := LBody.GetValue<string>('newVerifier', '');
|
||||||
LEntries := LBody.GetValue<TJSONArray>('entries');
|
LEntries := LBody.GetValue<TJSONArray>('entries');
|
||||||
if LEntries = nil then
|
if LEntries = nil then
|
||||||
begin
|
begin
|
||||||
@@ -532,10 +630,12 @@ begin
|
|||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Step 2: verify the master pw against the CURRENT (old) hash,
|
// Step 2: verify the master pw against the CURRENT (old) hash. Prefer
|
||||||
// using whichever scheme the user is currently on.
|
// the ZK verifier path; fall back to plaintext for legacy clients.
|
||||||
LValid := False;
|
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
|
begin
|
||||||
LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LOldIters);
|
LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LOldIters);
|
||||||
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
||||||
@@ -553,10 +653,20 @@ begin
|
|||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Step 3: compute the new password hash. ALWAYS uses the current
|
// Step 3: compute the new password hash under the current scheme
|
||||||
// scheme (SHA-256 wrap) and the target iteration count, regardless
|
// (SHA-256 wrap) at the target iter count. ZK path takes the
|
||||||
// of where the user was before — migration converges everyone to
|
// newVerifier (PBKDF2 at the target iters, computed client-side) and
|
||||||
// the same modern config.
|
// 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);
|
LNewHash := ComputeAuthHashCurrent(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET);
|
||||||
|
|
||||||
// Step 4: atomic transaction — update user hash AND every entry's
|
// Step 4: atomic transaction — update user hash AND every entry's
|
||||||
@@ -655,8 +765,8 @@ var
|
|||||||
LUserId, I: Integer;
|
LUserId, I: Integer;
|
||||||
LBody, LEntry, LObj: TJSONObject;
|
LBody, LEntry, LObj: TJSONObject;
|
||||||
LEntries: TJSONArray;
|
LEntries: TJSONArray;
|
||||||
LUser, LCurPwd, LNewPwd, LNewSalt, LStoredHash, LOldSalt, LAlgo, LIP,
|
LUser, LCurPwd, LNewPwd, LCurVerifier, LNewVerifier, LNewSalt,
|
||||||
LComputed, LNewHash: string;
|
LStoredHash, LOldSalt, LAlgo, LIP, LComputed, LNewHash: string;
|
||||||
LOldIters: Integer;
|
LOldIters: Integer;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
LValid: Boolean;
|
LValid: Boolean;
|
||||||
@@ -677,11 +787,25 @@ begin
|
|||||||
LCurPwd := LBody.GetValue<string>('currentMasterPassword', '');
|
LCurPwd := LBody.GetValue<string>('currentMasterPassword', '');
|
||||||
LNewPwd := LBody.GetValue<string>('newMasterPassword', '');
|
LNewPwd := LBody.GetValue<string>('newMasterPassword', '');
|
||||||
LNewSalt := LBody.GetValue<string>('newSalt', '');
|
LNewSalt := LBody.GetValue<string>('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<string>('currentVerifier', '');
|
||||||
|
LNewVerifier := LBody.GetValue<string>('newVerifier', '');
|
||||||
LEntries := LBody.GetValue<TJSONArray>('entries');
|
LEntries := LBody.GetValue<TJSONArray>('entries');
|
||||||
|
|
||||||
// Input validation. Length 64 = 32 raw bytes in hex, matches the salt
|
// Input validation. Either plaintext OR verifier must be present; we
|
||||||
// format produced by RandomHex(32) and client-side randomHexSalt().
|
// can't enforce min-length on the new pw in the ZK path (we don't see it).
|
||||||
if (Length(LCurPwd) < 1) or (Length(LNewPwd) < 8) then
|
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
|
begin
|
||||||
TJSONHelper.SendError(AResponse, 400,
|
TJSONHelper.SendError(AResponse, 400,
|
||||||
'New master password must be at least 8 characters');
|
'New master password must be at least 8 characters');
|
||||||
@@ -692,6 +816,11 @@ begin
|
|||||||
TJSONHelper.SendError(AResponse, 400, 'Invalid newSalt length');
|
TJSONHelper.SendError(AResponse, 400, 'Invalid newSalt length');
|
||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
|
if (LNewVerifier <> '') and (not IsValidVerifier(LNewVerifier)) then
|
||||||
|
begin
|
||||||
|
TJSONHelper.SendError(AResponse, 400, 'Malformed newVerifier');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
if LEntries = nil then
|
if LEntries = nil then
|
||||||
begin
|
begin
|
||||||
TJSONHelper.SendError(AResponse, 400, 'Missing entries array');
|
TJSONHelper.SendError(AResponse, 400, 'Missing entries array');
|
||||||
@@ -730,9 +859,11 @@ begin
|
|||||||
// brute-force the current pw to swap it for one they know).
|
// brute-force the current pw to swap it for one they know).
|
||||||
if RejectIfAccountLocked(AResponse, LUser) then Exit;
|
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;
|
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
|
begin
|
||||||
LComputed := PBKDF2_SHA256_Hex(LCurPwd, LOldSalt, LOldIters);
|
LComputed := PBKDF2_SHA256_Hex(LCurPwd, LOldSalt, LOldIters);
|
||||||
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
||||||
@@ -750,7 +881,11 @@ begin
|
|||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Step 3: compute the new auth hash with the new salt + target iters.
|
// 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);
|
LNewHash := ComputeAuthHashCurrent(LNewPwd, LNewSalt, PBKDF2_ITERATIONS_TARGET);
|
||||||
|
|
||||||
// Step 4: atomic transaction — user row + every entry's ciphertext.
|
// Step 4: atomic transaction — user row + every entry's ciphertext.
|
||||||
@@ -839,7 +974,86 @@ begin
|
|||||||
TJSONHelper.SendJSON(AResponse, LObj);
|
TJSONHelper.SendJSON(AResponse, LObj);
|
||||||
end;
|
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<string>);
|
||||||
|
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<string>('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
|
initialization
|
||||||
|
Router.Register('POST', '/login/challenge', HandleLoginChallenge);
|
||||||
Router.Register('POST', '/register', HandleRegister);
|
Router.Register('POST', '/register', HandleRegister);
|
||||||
Router.Register('POST', '/login', HandleLogin);
|
Router.Register('POST', '/login', HandleLogin);
|
||||||
Router.Register('POST', '/logout', HandleLogout);
|
Router.Register('POST', '/logout', HandleLogout);
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ uses
|
|||||||
PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Session, PM.Audit, PM.RateLimit;
|
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.
|
// 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
|
// Accepts EITHER plaintext (legacy clients) OR a precomputed verifier
|
||||||
// a recovery backdoor.
|
// (ZK clients). Used by /recovery-key/setup so a stolen session token
|
||||||
function VerifyMasterPassword(AUserId: Integer; const APwd: string;
|
// alone can't set up a recovery backdoor.
|
||||||
|
function VerifyMasterPassword(AUserId: Integer;
|
||||||
|
const APwd, AVerifier: string;
|
||||||
out AUsername: string): Boolean;
|
out AUsername: string): Boolean;
|
||||||
const
|
const
|
||||||
HASH_ALGO_LEGACY = 'pbkdf2';
|
HASH_ALGO_LEGACY = 'pbkdf2';
|
||||||
@@ -78,6 +80,18 @@ begin
|
|||||||
DB.Unlock;
|
DB.Unlock;
|
||||||
end;
|
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
|
if SameText(LAlgo, HASH_ALGO_LEGACY) then
|
||||||
begin
|
begin
|
||||||
LComputed := PBKDF2_SHA256_Hex(APwd, LSalt, LIters);
|
LComputed := PBKDF2_SHA256_Hex(APwd, LSalt, LIters);
|
||||||
@@ -141,7 +155,7 @@ procedure HandleSetup(ARequest: TIdHTTPRequestInfo;
|
|||||||
var
|
var
|
||||||
LUserId: Integer;
|
LUserId: Integer;
|
||||||
LBody: TJSONObject;
|
LBody: TJSONObject;
|
||||||
LPwd, LCodeHash, LKdfSalt, LWrappedKey, LWrappedIv, LIP, LUser: string;
|
LPwd, LVerifier, LCodeHash, LKdfSalt, LWrappedKey, LWrappedIv, LIP, LUser: string;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -155,6 +169,7 @@ begin
|
|||||||
LBody := TJSONHelper.ReadBody(ARequest);
|
LBody := TJSONHelper.ReadBody(ARequest);
|
||||||
try
|
try
|
||||||
LPwd := LBody.GetValue<string>('masterPassword', '');
|
LPwd := LBody.GetValue<string>('masterPassword', '');
|
||||||
|
LVerifier := LBody.GetValue<string>('verifier', '');
|
||||||
LCodeHash := LBody.GetValue<string>('codeHash', '');
|
LCodeHash := LBody.GetValue<string>('codeHash', '');
|
||||||
LKdfSalt := LBody.GetValue<string>('kdfSalt', '');
|
LKdfSalt := LBody.GetValue<string>('kdfSalt', '');
|
||||||
LWrappedKey := LBody.GetValue<string>('wrappedKey', '');
|
LWrappedKey := LBody.GetValue<string>('wrappedKey', '');
|
||||||
@@ -172,7 +187,7 @@ begin
|
|||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
if not VerifyMasterPassword(LUserId, LPwd, LUser) then
|
if not VerifyMasterPassword(LUserId, LPwd, LVerifier, LUser) then
|
||||||
begin
|
begin
|
||||||
RecordFailedAccountAttempt(LUser, LIP);
|
RecordFailedAccountAttempt(LUser, LIP);
|
||||||
LogAudit(LUserId, 'failed_recovery_setup', LIP);
|
LogAudit(LUserId, 'failed_recovery_setup', LIP);
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ const state = {
|
|||||||
csrf: sessionStorage.getItem('csrfToken') || '',
|
csrf: sessionStorage.getItem('csrfToken') || '',
|
||||||
salt: sessionStorage.getItem('salt') || '',
|
salt: sessionStorage.getItem('salt') || '',
|
||||||
username: sessionStorage.getItem('username') || '',
|
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,
|
cryptoKey: null,
|
||||||
entries: [],
|
entries: [],
|
||||||
trashed: [],
|
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) {
|
async function encryptPwd(plain) {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
const enc = await crypto.subtle.encrypt(
|
const enc = await crypto.subtle.encrypt(
|
||||||
@@ -424,15 +469,19 @@ async function runKdfMigration(masterPwd, fromIters, toIters) {
|
|||||||
newCiphertexts = [];
|
newCiphertexts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the atomic migrate request. Server verifies the master pw
|
// Zero-knowledge: compute BOTH verifiers locally. oldVerifier proves
|
||||||
// against the OLD hash, then updates the user row (hash, iter
|
// the user knows the master pw under the current (legacy) iters;
|
||||||
// count, hash_algo) AND every entry's ciphertext in a single
|
// newVerifier is what the server will SHA-256-wrap to be the new
|
||||||
// transaction.
|
// 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', {
|
await api('/migrate-kdf', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
masterPassword: masterPwd,
|
oldVerifier: oldVerifier,
|
||||||
|
newVerifier: newVerifier,
|
||||||
entries: newCiphertexts,
|
entries: newCiphertexts,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -440,6 +489,8 @@ async function runKdfMigration(masterPwd, fromIters, toIters) {
|
|||||||
if (kdfChange) {
|
if (kdfChange) {
|
||||||
// Swap to the new AES key + update cached ciphertexts.
|
// Swap to the new AES key + update cached ciphertexts.
|
||||||
state.cryptoKey = newKey;
|
state.cryptoKey = newKey;
|
||||||
|
state.kdfIterations = toIters;
|
||||||
|
sessionStorage.setItem('kdfIterations', String(toIters));
|
||||||
await persistCryptoKey();
|
await persistCryptoKey();
|
||||||
for (let i = 0; i < state.entries.length; i++) {
|
for (let i = 0; i < state.entries.length; i++) {
|
||||||
const nc = newCiphertexts[i];
|
const nc = newCiphertexts[i];
|
||||||
@@ -557,23 +608,33 @@ async function doLogin(e) {
|
|||||||
}
|
}
|
||||||
$('#loginBtn').disabled = true;
|
$('#loginBtn').disabled = true;
|
||||||
try {
|
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', {
|
const r = await api('/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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.token = r.token;
|
||||||
state.csrf = r.csrfToken;
|
state.csrf = r.csrfToken;
|
||||||
state.salt = r.salt;
|
state.salt = r.salt;
|
||||||
state.username = u;
|
state.username = u;
|
||||||
|
state.kdfIterations = r.kdfIterations || ch.kdfIterations;
|
||||||
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);
|
||||||
// Derive with the server-specified iteration count — legacy users
|
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||||
// receive 100k, modern users 600k. The cryptoKey is what currently
|
// cryptoKey is already derived — no second PBKDF2 pass.
|
||||||
// decrypts the entries on this server.
|
state.cryptoKey = derived.cryptoKey;
|
||||||
state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations);
|
|
||||||
await persistCryptoKey();
|
await persistCryptoKey();
|
||||||
toast('Welcome back, ' + u);
|
toast('Welcome back, ' + u);
|
||||||
await enterApp();
|
await enterApp();
|
||||||
@@ -589,7 +650,14 @@ async function doLogin(e) {
|
|||||||
showLockoutCountdown(err.body.retry_after);
|
showLockoutCountdown(err.body.retry_after);
|
||||||
return; // do NOT re-enable the button in finally
|
return; // do NOT re-enable the button in finally
|
||||||
}
|
}
|
||||||
|
// 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');
|
toast(err.message, 'error');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Only re-enable when not in lockout (showLockoutCountdown manages
|
// Only re-enable when not in lockout (showLockoutCountdown manages
|
||||||
// the button itself for the lockout case).
|
// 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');
|
if (u.length < 3 || p.length < 8) return toast('Min 3 / 8 chars', 'error');
|
||||||
$('#registerBtn').disabled = true;
|
$('#registerBtn').disabled = true;
|
||||||
try {
|
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', {
|
const r = await api('/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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.token = r.token;
|
||||||
state.csrf = r.csrfToken;
|
state.csrf = r.csrfToken;
|
||||||
state.salt = r.salt;
|
state.salt = r.salt || newSalt;
|
||||||
state.username = u;
|
state.username = u;
|
||||||
|
state.kdfIterations = r.kdfIterations || newIters;
|
||||||
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);
|
||||||
// Fresh account → server returns kdfIterations = current target.
|
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||||
// No migration ever needed for a brand-new vault.
|
state.cryptoKey = derived.cryptoKey;
|
||||||
state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations);
|
|
||||||
await persistCryptoKey();
|
await persistCryptoKey();
|
||||||
toast('Vault created');
|
toast('Vault created');
|
||||||
await enterApp();
|
await enterApp();
|
||||||
@@ -641,6 +721,7 @@ async function doLogout() {
|
|||||||
}
|
}
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
|
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
|
||||||
|
state.kdfIterations = 0;
|
||||||
state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All'];
|
state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All'];
|
||||||
state.locked = false;
|
state.locked = false;
|
||||||
showAuth();
|
showAuth();
|
||||||
@@ -684,13 +765,19 @@ function lockVault() {
|
|||||||
// then re-derive the crypto key locally without rotating session/csrf.
|
// then re-derive the crypto key locally without rotating session/csrf.
|
||||||
async function doUnlock(p) {
|
async function doUnlock(p) {
|
||||||
try {
|
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', {
|
const r = await api('/reauth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
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.
|
// Refresh cached iter count in case the server has migrated us.
|
||||||
state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations);
|
state.kdfIterations = r.kdfIterations || iters;
|
||||||
|
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||||
|
state.cryptoKey = derived.cryptoKey;
|
||||||
await persistCryptoKey();
|
await persistCryptoKey();
|
||||||
state.locked = false;
|
state.locked = false;
|
||||||
$('#loginUsername').readOnly = false;
|
$('#loginUsername').readOnly = false;
|
||||||
@@ -2499,10 +2586,12 @@ async function enableQuickUnlock() {
|
|||||||
'Confirm your master password to enable Quick unlock on this device.');
|
'Confirm your master password to enable Quick unlock on this device.');
|
||||||
if (!masterPwd) return;
|
if (!masterPwd) return;
|
||||||
try {
|
try {
|
||||||
|
const verifier = await computeVerifier(
|
||||||
|
masterPwd, state.salt, state.kdfIterations || 100000);
|
||||||
await api('/reauth', {
|
await api('/reauth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ masterPassword: masterPwd }),
|
body: JSON.stringify({ verifier: verifier }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return toast('Wrong master password', 'error');
|
return toast('Wrong master password', 'error');
|
||||||
@@ -2515,6 +2604,7 @@ async function enableQuickUnlock() {
|
|||||||
v: 1,
|
v: 1,
|
||||||
username: state.username,
|
username: state.username,
|
||||||
salt: state.salt,
|
salt: state.salt,
|
||||||
|
kdfIterations: state.kdfIterations,
|
||||||
token: state.token,
|
token: state.token,
|
||||||
csrf: state.csrf,
|
csrf: state.csrf,
|
||||||
key: bytesToBase64(raw),
|
key: bytesToBase64(raw),
|
||||||
@@ -2572,10 +2662,12 @@ async function tryQuickUnlock() {
|
|||||||
// Restore session state from the blob.
|
// Restore session state from the blob.
|
||||||
state.username = parsed.username;
|
state.username = parsed.username;
|
||||||
state.salt = parsed.salt;
|
state.salt = parsed.salt;
|
||||||
|
state.kdfIterations = parsed.kdfIterations || 600000;
|
||||||
state.token = parsed.token || sessionStorage.getItem('authToken') || '';
|
state.token = parsed.token || sessionStorage.getItem('authToken') || '';
|
||||||
state.csrf = parsed.csrf || sessionStorage.getItem('csrfToken') || '';
|
state.csrf = parsed.csrf || sessionStorage.getItem('csrfToken') || '';
|
||||||
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));
|
||||||
if (state.token) sessionStorage.setItem('authToken', state.token);
|
if (state.token) sessionStorage.setItem('authToken', state.token);
|
||||||
if (state.csrf) sessionStorage.setItem('csrfToken', state.csrf);
|
if (state.csrf) sessionStorage.setItem('csrfToken', state.csrf);
|
||||||
|
|
||||||
@@ -2689,11 +2781,15 @@ async function doGenerateRecoveryKey() {
|
|||||||
rawKey, code, kdfSalt);
|
rawKey, code, kdfSalt);
|
||||||
|
|
||||||
try {
|
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', {
|
await api('/recovery-key/setup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
masterPassword: masterPwd,
|
verifier: verifier,
|
||||||
codeHash: codeHash,
|
codeHash: codeHash,
|
||||||
kdfSalt: kdfSalt,
|
kdfSalt: kdfSalt,
|
||||||
wrappedKey: wrappedKey,
|
wrappedKey: wrappedKey,
|
||||||
@@ -2820,10 +2916,12 @@ async function doRecoveryRedeem() {
|
|||||||
state.csrf = r.csrfToken;
|
state.csrf = r.csrfToken;
|
||||||
state.salt = r.salt;
|
state.salt = r.salt;
|
||||||
state.username = u.trim();
|
state.username = u.trim();
|
||||||
|
state.kdfIterations = r.kdfIterations || 600000;
|
||||||
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));
|
||||||
|
|
||||||
// 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).
|
||||||
@@ -2902,9 +3000,14 @@ async function doChangeMasterPassword() {
|
|||||||
const btn = $('#cmConfirmBtn');
|
const btn = $('#cmConfirmBtn');
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
try {
|
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 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
|
// Step 2: re-encrypt every entry's password AND every entry's TOTP
|
||||||
// secret (if present) under the new key. The current state.cryptoKey
|
// secret (if present) under the new key. The current state.cryptoKey
|
||||||
@@ -2950,8 +3053,8 @@ async function doChangeMasterPassword() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
currentMasterPassword: curPwd,
|
currentVerifier: currentVerifier,
|
||||||
newMasterPassword: newPwd,
|
newVerifier: newDerived.verifier,
|
||||||
newSalt: newSalt,
|
newSalt: newSalt,
|
||||||
entries: encrypted,
|
entries: encrypted,
|
||||||
}),
|
}),
|
||||||
@@ -2960,9 +3063,11 @@ async function doChangeMasterPassword() {
|
|||||||
// Step 4: server committed → switch the in-memory key & salt, refresh
|
// Step 4: server committed → switch the in-memory key & salt, refresh
|
||||||
// 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.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));
|
||||||
for (let i = 0; i < state.entries.length; i++) {
|
for (let i = 0; i < state.entries.length; i++) {
|
||||||
const nc = encrypted[i];
|
const nc = encrypted[i];
|
||||||
state.entries[i].encrypted_password = nc.encrypted_password;
|
state.entries[i].encrypted_password = nc.encrypted_password;
|
||||||
@@ -3377,10 +3482,12 @@ async function doExport() {
|
|||||||
'Enter your master password to start an encrypted export.');
|
'Enter your master password to start an encrypted export.');
|
||||||
if (!masterPwd) return;
|
if (!masterPwd) return;
|
||||||
try {
|
try {
|
||||||
|
const verifier = await computeVerifier(
|
||||||
|
masterPwd, state.salt, state.kdfIterations || 100000);
|
||||||
await api('/reauth', {
|
await api('/reauth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ masterPassword: masterPwd }),
|
body: JSON.stringify({ verifier: verifier }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 429 (account lockout) is possible here too — propagate as a clear
|
// 429 (account lockout) is possible here too — propagate as a clear
|
||||||
|
|||||||
Reference in New Issue
Block a user