unit PM.Handler.Auth; (* /register POST body {username, masterPassword} -> {message,token,userId,salt,csrfToken} /login POST body {username, masterPassword} -> {message,token,userId,salt,csrfToken} /logout POST auth + csrf -> {message} /reauth POST auth + csrf + body{masterPassword} -> {message} Hashing strategy: - Delphi creates new accounts with PBKDF2-SHA256 100k iterations (hash_algo='pbkdf2'), same format as PHP hash_pbkdf2. PHP can verify these too. - For login, we read hash_algo: pbkdf2 -> verify natively bcrypt -> reject with clear message (bcrypt verify not implemented yet) *) interface implementation uses System.SysUtils, System.JSON, System.Classes, FireDAC.Comp.Client, IdCustomHTTPServer, PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Session, PM.RateLimit, PM.Audit; const // Legacy iteration count from the initial 2025 release. Kept around to // verify pre-migration login attempts (each user row records its own // value in users.kdf_iterations). New code paths should reference // PBKDF2_ITERATIONS_TARGET instead. PBKDF2_ITERATIONS = 100000; // Current target. New accounts hash at this strength; legacy accounts // are transparently upgraded at next login (see HandleLogin/HandleReauth). // Value picked per OWASP 2023 PBKDF2-SHA256 recommendation. PBKDF2_ITERATIONS_TARGET = 600000; DEFAULT_FOLDERS: array[0..4] of string = ('All', 'Social', 'Banking', 'Work', 'Personal'); procedure EnsureDefaultFolders(AUserId: Integer); var LQ: TFDQuery; I: Integer; begin DB.Lock; try LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; LQ.SQL.Text := 'INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)'; for I := Low(DEFAULT_FOLDERS) to High(DEFAULT_FOLDERS) do begin LQ.ParamByName('uid').AsInteger := AUserId; LQ.ParamByName('name').AsString := DEFAULT_FOLDERS[I]; LQ.ExecSQL; end; finally LQ.Free; end; finally DB.Unlock; end; end; procedure SendAuthSuccess(AResponse: TIdHTTPResponseInfo; AUserId: Integer; const AToken, ASalt, ACSRFToken: string; AKdfIterations: Integer; ANeedsMigration: Boolean); var LObj, LMig: TJSONObject; begin LObj := TJSONObject.Create; LObj.AddPair('message', 'OK'); LObj.AddPair('token', AToken); LObj.AddPair('userId', TJSONNumber.Create(AUserId)); LObj.AddPair('salt', ASalt); LObj.AddPair('csrfToken', ACSRFToken); // kdfIterations is the iteration count the client must use when deriving // the AES-GCM key for THIS session — matches the count under which the // existing entries are encrypted. If the server signals migration, the // client should re-encrypt with the new target and call /migrate-kdf. LObj.AddPair('kdfIterations', TJSONNumber.Create(AKdfIterations)); if ANeedsMigration then begin LMig := TJSONObject.Create; LMig.AddPair('target', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET)); LObj.AddPair('kdfMigration', LMig); end; TJSONHelper.SendJSON(AResponse, LObj); end; // ===== /register ============================================================= procedure HandleRegister(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var LBody: TJSONObject; LUser, LPwd, LSalt, LHash, LToken, LCSRF, LIP: string; LQ: TFDQuery; LUserId: Integer; begin LIP := GetClientIP(ARequest); if CheckRateLimit(LIP) >= 5 then begin TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); Exit; end; LBody := TJSONHelper.ReadBody(ARequest); try LUser := Trim(LBody.GetValue('username', '')); LPwd := LBody.GetValue('masterPassword', ''); finally LBody.Free; end; if (Length(LUser) < 3) or (Length(LPwd) < 8) then begin TJSONHelper.SendError(AResponse, 400, 'Min 3/8 chars'); Exit; end; DB.Lock; try LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; LQ.SQL.Text := 'SELECT id FROM users WHERE username = :u'; LQ.ParamByName('u').AsString := LUser; LQ.Open; if not LQ.IsEmpty then begin TJSONHelper.SendError(AResponse, 409, 'Username exists'); Exit; end; finally LQ.Free; end; LSalt := RandomHex(32); // New accounts use the current target iteration count — no migration // path needed since this is a brand-new vault with zero entries. LHash := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET); LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; LQ.SQL.Text := 'INSERT INTO users (username, password_hash, salt, hash_algo, kdf_iterations) ' + 'VALUES (:u, :h, :s, ''pbkdf2'', :it)'; LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('h').AsString := LHash; LQ.ParamByName('s').AsString := LSalt; LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET; LQ.ExecSQL; LUserId := DB.Connection.GetLastAutoGenValue('users'); finally LQ.Free; end; finally DB.Unlock; end; EnsureDefaultFolders(LUserId); 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); end; // ===== /login ================================================================ procedure HandleLogin(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var LBody: TJSONObject; LUser, LPwd, LSalt, LStoredHash, LAlgo, LToken, LCSRF, LIP: string; LUserId, LKdfIters: Integer; LQ: TFDQuery; LComputed: string; LValid: Boolean; begin LIP := GetClientIP(ARequest); if CheckRateLimit(LIP) >= 10 then begin TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); Exit; end; LBody := TJSONHelper.ReadBody(ARequest); try LUser := Trim(LBody.GetValue('username', '')); LPwd := LBody.GetValue('masterPassword', ''); finally LBody.Free; end; // Per-username lockout check — runs BEFORE touching the users table, so // attackers can't probe account existence via timing differences between // "locked" and "not found" responses. if RejectIfAccountLocked(AResponse, LUser) then Exit; DB.Lock; try LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; LQ.SQL.Text := 'SELECT id, password_hash, salt, hash_algo, kdf_iterations ' + 'FROM users WHERE username = :u'; LQ.ParamByName('u').AsString := LUser; LQ.Open; if LQ.IsEmpty then begin // Unknown username — still record the failure against this username // so attackers can't enumerate accounts by observing which usernames // can be locked vs not. TCriticalSection is reentrant for the same // thread, so calling RecordAttempt/RecordFailedAccountAttempt from // inside our DB.Lock block is safe (they re-acquire the same lock). RecordAttempt(LIP); RecordFailedAccountAttempt(LUser, LIP); TJSONHelper.SendError(AResponse, 401, 'Invalid credentials'); Exit; end; LUserId := LQ.FieldByName('id').AsInteger; LStoredHash := LQ.FieldByName('password_hash').AsString; LSalt := LQ.FieldByName('salt').AsString; LAlgo := LQ.FieldByName('hash_algo').AsString; LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; if LAlgo = '' then LAlgo := 'pbkdf2'; // Legacy rows predating the kdf_iterations column have NULL → 0 here; // treat as the original 100k value used by api.php and early Delphi. if LKdfIters <= 0 then LKdfIters := PBKDF2_ITERATIONS; finally LQ.Free; end; finally DB.Unlock; end; LValid := False; if SameText(LAlgo, 'pbkdf2') then begin // Verify with the user's own iteration count (NOT the global constant). // Legacy users at 100k still need to log in successfully so the client // can decrypt their entries before triggering the /migrate-kdf flow. LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LKdfIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); end else if SameText(LAlgo, 'bcrypt') then begin // Not implemented in Delphi backend yet RecordAttempt(LIP); RecordFailedAccountAttempt(LUser, LIP); LogAudit(LUserId, 'failed_login_bcrypt', LIP); TJSONHelper.SendError(AResponse, 501, 'This account was created with bcrypt (PHP). The Delphi backend does ' + 'not verify bcrypt yet. Register a new account here, or login via PHP.'); Exit; end; if not LValid then begin RecordAttempt(LIP); RecordFailedAccountAttempt(LUser, LIP); LogAudit(LUserId, 'failed_login', LIP); TJSONHelper.SendError(AResponse, 401, 'Invalid credentials'); Exit; end; ClearAttempts(LIP); ClearAccountLockout(LUser); DeleteAllUserSessions(LUserId); EnsureDefaultFolders(LUserId); CreateSession(LUserId, LToken, LCSRF); LogAudit(LUserId, 'login', LIP); // Signal migration when the user's current iteration count is below the // target. The client will re-encrypt all entries and call /migrate-kdf // to commit everything atomically. SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF, LKdfIters, LKdfIters < PBKDF2_ITERATIONS_TARGET); end; // ===== /logout =============================================================== procedure HandleLogout(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var LUserId: Integer; LToken, LAuth: string; begin try LUserId := Authenticate(ARequest, AResponse); RequireCSRF(ARequest, AResponse, LUserId); except on ESessionRejected do Exit; end; LAuth := ARequest.RawHeaders.Values['Authorization']; if LAuth.StartsWith('Bearer ', True) then begin LToken := Copy(LAuth, 8, MaxInt); DeleteSessionByTokenHash(SHA256Hex(LToken)); end; LogAudit(LUserId, 'logout', GetClientIP(ARequest)); TJSONHelper.SendOK(AResponse, 'Logged out'); end; // ===== /reauth =============================================================== procedure HandleReauth(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var LUserId, LKdfIters: Integer; LBody: TJSONObject; LUser, LPwd, LStoredHash, LSalt, LAlgo, LIP, LComputed: string; LQ: TFDQuery; LValid: Boolean; begin try LUserId := Authenticate(ARequest, AResponse); RequireCSRF(ARequest, AResponse, LUserId); except on ESessionRejected do Exit; end; LIP := GetClientIP(ARequest); if CheckRateLimit(LIP) >= 5 then begin TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); Exit; end; LBody := TJSONHelper.ReadBody(ARequest); try LPwd := LBody.GetValue('masterPassword', ''); finally LBody.Free; end; DB.Lock; try LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; // Pull username too — needed for the per-account lockout calls. LQ.SQL.Text := 'SELECT username, password_hash, salt, hash_algo, kdf_iterations ' + 'FROM users WHERE id = :uid'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.Open; if LQ.IsEmpty then begin RecordAttempt(LIP); TJSONHelper.SendError(AResponse, 401, 'User not found'); Exit; end; LUser := LQ.FieldByName('username').AsString; LStoredHash := LQ.FieldByName('password_hash').AsString; LSalt := LQ.FieldByName('salt').AsString; LAlgo := LQ.FieldByName('hash_algo').AsString; LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; if LAlgo = '' then LAlgo := 'pbkdf2'; if LKdfIters <= 0 then LKdfIters := PBKDF2_ITERATIONS; finally LQ.Free; end; finally DB.Unlock; end; // Check account lockout AFTER we have the username. Even though the user // is already authenticated by their session token, the master-pw re-prompt // is itself brute-forceable (e.g. attacker hijacked a session and now tries // to escalate by guessing the master pw to unlock the JS crypto key). if RejectIfAccountLocked(AResponse, LUser) then Exit; LValid := False; if SameText(LAlgo, 'pbkdf2') then begin // Verify with the user's stored iteration count, same as HandleLogin. LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LKdfIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); end; if not LValid then begin RecordAttempt(LIP); RecordFailedAccountAttempt(LUser, LIP); LogAudit(LUserId, 'failed_reauth', LIP); TJSONHelper.SendError(AResponse, 401, 'Invalid password'); Exit; end; ClearAttempts(LIP); ClearAccountLockout(LUser); LogAudit(LUserId, 'reauth', LIP); // Return KDF state so the client can detect legacy accounts that haven't // been migrated yet — unlock from a locked state goes through reauth, not // login, so we need the same migration signaling here. begin var LObj := TJSONObject.Create; LObj.AddPair('message', 'OK'); LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); if LKdfIters < PBKDF2_ITERATIONS_TARGET then begin var LMig := TJSONObject.Create; LMig.AddPair('target', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET)); LObj.AddPair('kdfMigration', LMig); end; TJSONHelper.SendJSON(AResponse, LObj); end; end; // ===== /migrate-kdf ========================================================== // Atomic transition from an old PBKDF2 iteration count to the current target. // Client side: derive both old and new AES keys, decrypt each entry with old, // re-encrypt with new, then POST the new ciphertext blob to this endpoint // along with the master password (so we can recompute the new server hash). // Server side: verify the master pw with the old hash, then in a single // transaction: update users.password_hash to the new PBKDF2 output, set // kdf_iterations to TARGET, and replace each entry's encrypted_password/iv. // All-or-nothing: if anything fails, the user stays on the old config. procedure HandleMigrateKdf(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var LUserId, LOldIters, I: Integer; LBody, LEntry: TJSONObject; LEntries: TJSONArray; LUser, LPwd, LSalt, LStoredHash, LAlgo, LIP, LComputed, LNewHash: string; LQ: TFDQuery; LValid: Boolean; LEntryId: Integer; LEncPwd, LIv: string; begin try LUserId := Authenticate(ARequest, AResponse); RequireCSRF(ARequest, AResponse, LUserId); except on ESessionRejected do Exit; end; LIP := GetClientIP(ARequest); LBody := TJSONHelper.ReadBody(ARequest); try LPwd := LBody.GetValue('masterPassword', ''); LEntries := LBody.GetValue('entries'); if LEntries = nil then begin TJSONHelper.SendError(AResponse, 400, 'Missing entries array'); Exit; end; DB.Lock; try // Step 1: load current user state. LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; LQ.SQL.Text := 'SELECT username, password_hash, salt, hash_algo, kdf_iterations ' + 'FROM users WHERE id = :uid'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.Open; if LQ.IsEmpty then begin TJSONHelper.SendError(AResponse, 401, 'User not found'); Exit; end; LUser := LQ.FieldByName('username').AsString; LStoredHash := LQ.FieldByName('password_hash').AsString; LSalt := LQ.FieldByName('salt').AsString; LAlgo := LQ.FieldByName('hash_algo').AsString; LOldIters := LQ.FieldByName('kdf_iterations').AsInteger; if LAlgo = '' then LAlgo := 'pbkdf2'; if LOldIters <= 0 then LOldIters := PBKDF2_ITERATIONS; finally LQ.Free; end; // Idempotency: if already at target, nothing to do. if LOldIters >= PBKDF2_ITERATIONS_TARGET then begin TJSONHelper.SendOK(AResponse, 'Already at target'); Exit; end; // Step 2: verify the master pw against the CURRENT (old) hash. LValid := False; if SameText(LAlgo, 'pbkdf2') then begin LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LOldIters); LValid := ConstantTimeEquals(LComputed, LStoredHash); end; if not LValid then begin RecordFailedAccountAttempt(LUser, LIP); LogAudit(LUserId, 'failed_migrate_kdf', LIP); TJSONHelper.SendError(AResponse, 401, 'Invalid password'); Exit; end; // Step 3: compute the new password hash with target iterations. LNewHash := PBKDF2_SHA256_Hex(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 // the legacy config (safe to retry next login). DB.Connection.StartTransaction; try LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; LQ.SQL.Text := 'UPDATE users SET password_hash = :h, kdf_iterations = :it ' + 'WHERE id = :uid'; LQ.ParamByName('h').AsString := LNewHash; LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ExecSQL; finally LQ.Free; end; LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; LQ.SQL.Text := 'UPDATE vault_entries ' + 'SET encrypted_password = :ep, iv = :iv, updated_at = CURRENT_TIMESTAMP ' + 'WHERE id = :id AND user_id = :uid'; for I := 0 to LEntries.Count - 1 do begin LEntry := LEntries.Items[I] as TJSONObject; LEntryId := LEntry.GetValue('id', 0); LEncPwd := LEntry.GetValue('encrypted_password', ''); LIv := LEntry.GetValue('iv', ''); if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then raise Exception.CreateFmt('Invalid entry payload at index %d', [I]); LQ.ParamByName('id').AsInteger := LEntryId; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('ep').AsString := LEncPwd; LQ.ParamByName('iv').AsString := LIv; LQ.ExecSQL; end; finally LQ.Free; end; DB.Connection.Commit; except DB.Connection.Rollback; raise; end; finally DB.Unlock; end; finally LBody.Free; end; LogAudit(LUserId, Format('migrate_kdf %d->%d', [LOldIters, PBKDF2_ITERATIONS_TARGET]), LIP); TJSONHelper.SendOK(AResponse, 'Migration complete'); end; initialization Router.Register('POST', '/register', HandleRegister); Router.Register('POST', '/login', HandleLogin); Router.Register('POST', '/logout', HandleLogout); Router.Register('POST', '/reauth', HandleReauth); Router.Register('POST', '/migrate-kdf', HandleMigrateKdf); end.