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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What this does NOT achieve
==========================
This is not SRP / OPAQUE. The stored value on the server IS the
final hash, and a stolen vault.db gives the attacker something they
can directly verify candidate guesses against (offline brute force).
Closing that requires asymmetric proofs (client and server holding
different things), which is a much larger refactor. The realistic
win here is "master pw never transits the network or sits in server
memory" — that's a meaningful reduction in attack surface, not a
cryptographic miracle.
This commit is contained in:
2026-05-23 12:03:41 +01:00
parent 749dc87058
commit d13e5bc89f
3 changed files with 458 additions and 122 deletions
+260 -46
View File
@@ -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<string>);
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<string>('username', ''));
LPwd := LBody.GetValue<string>('masterPassword', '');
LUser := Trim(LBody.GetValue<string>('username', ''));
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
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<string>);
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<string>('username', ''));
LPwd := LBody.GetValue<string>('masterPassword', '');
LUser := Trim(LBody.GetValue<string>('username', ''));
LPwd := LBody.GetValue<string>('masterPassword', '');
LVerifier := LBody.GetValue<string>('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<string>('masterPassword', '');
LPwd := LBody.GetValue<string>('masterPassword', '');
LVerifier := LBody.GetValue<string>('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<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');
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<string>('currentMasterPassword', '');
LNewPwd := LBody.GetValue<string>('newMasterPassword', '');
LNewSalt := LBody.GetValue<string>('newSalt', '');
LCurPwd := LBody.GetValue<string>('currentMasterPassword', '');
LNewPwd := LBody.GetValue<string>('newMasterPassword', '');
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');
// 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<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
Router.Register('POST', '/login/challenge', HandleLoginChallenge);
Router.Register('POST', '/register', HandleRegister);
Router.Register('POST', '/login', HandleLogin);
Router.Register('POST', '/logout', HandleLogout);