Commit Graph

4 Commits

Author SHA1 Message Date
Zaki e0e452306e feat(crypto): PBKDF2 iterations 100k → 600k with transparent re-encryption
Bumps the PBKDF2-SHA256 iteration count from 100,000 (OWASP 2017) to
600,000 (OWASP 2023). 6x slowdown on every brute-force attempt against
either the server-stored auth hash OR the AES-GCM ciphertext of the
entries — both currently use the same PBKDF2 output (see KNOWN ISSUE
below for why that's another problem to fix later).

Schema
======
users.kdf_iterations INTEGER DEFAULT 100000
  Per-user iteration count. Legacy rows predating the column default
  to 100k via the DEFAULT clause. New accounts insert 600k explicitly.

Migration flow
==============
Atomic from the user's perspective. No partial state ever persisted.

  1. /login (or /reauth):
     server reads users.kdf_iterations and verifies the master pw at
     that count. Login succeeds at the legacy strength. Response now
     includes kdfIterations (current) and optionally kdfMigration =
     { target: 600000 } when an upgrade is recommended.

  2. Client:
     derives the AES key at the OLD count to decrypt current entries
     (state.cryptoKey). enterApp() loads the vault normally.

  3. runKdfMigration() (background, after enterApp):
     - derives the NEW key at target iterations
     - decrypts every entry with the old key
     - re-encrypts every entry with the new key + fresh random IVs
     - POSTs { masterPassword, entries: [...] } to /migrate-kdf

  4. /migrate-kdf (new endpoint):
     - verifies the master pw against the OLD hash
     - in a single transaction:
        UPDATE users  SET password_hash = pbkdf2(pw, salt, 600k),
                          kdf_iterations = 600000
        UPDATE vault_entries SET encrypted_password, iv (per entry)
     - on any failure: ROLLBACK. User stays at legacy config, retries
       at next login. No half-migrated state possible.

  5. Client (post-commit):
     swaps state.cryptoKey to the new key, persists it, updates the
     cached ciphertext in state.entries, shows a "Vault security
     upgraded" toast.

Idempotency: server's /migrate-kdf short-circuits with "Already at
target" if users.kdf_iterations >= PBKDF2_ITERATIONS_TARGET.

Race conditions: two concurrent migrations from two tabs both
recompute the SAME new key (deterministic PBKDF2). The losing
transaction's entries get re-encrypted with the winning one's IVs,
but both clients can decrypt because the keys are identical.

KNOWN ISSUE (not fixed by this commit)
======================================
The server's password_hash IS the client's AES key, in hex form —
both sides compute PBKDF2(pw, salt, iters) and store/use the same
32 bytes. This means a stolen vault.db gives the attacker the
encryption key directly, without needing to brute-force anything.
The 100k → 600k bump still helps because the AES-GCM ciphertext
itself is also a brute-force target, but the architectural fix
(server stores SHA256(aes_key) instead of aes_key in hex) is a
separate concern that needs its own migration.

Other changes
=============
 - HandleRegister: new accounts insert kdf_iterations=600000.
 - HandleReauth: response upgraded to JSON with kdfIterations
   + optional kdfMigration. Unlock path now also triggers migration.
 - SendAuthSuccess: extended signature, all callers updated.
 - deriveKey(pwd, saltHex, iterations) in app.js: iterations param
   required, defaults to 100000 for back-compat with any legacy caller.
2026-05-23 04:54:05 +01:00
Zaki bff9bdf9f2 feat(bridge): auto-lock on sleep/hibernate + fix UPSERT syntax
Sleep/hibernate handling
========================
Adds WM_POWERBROADCAST / PBT_APMSUSPEND handling alongside the existing
WTS_SESSION_LOCK detection. Closing a laptop lid often suspends the
system without firing a session lock, leaving the decrypted vault in
memory until resume — this fixes that.

Implementation note: WM_POWERBROADCAST is normally only delivered to
top-level windows, and Windows can silently skip hidden utility windows.
PowerRegisterSuspendResumeNotification (user32, Win 8+) forces delivery
to our specific HWND regardless. Loaded dynamically via GetProcAddress
so older Windows degrades gracefully (WTS lock still works).

The suspend handler reuses OnSystemLock — semantically the same event
from the user's perspective ("I'm leaving the machine"). Calls
lockVault() in JS via ExecuteJavaScript.

RateLimit fix (related: lockout feature from previous commit)
=============================================================
The UPSERT (INSERT ... ON CONFLICT DO UPDATE) in RecordFailedAccountAttempt
errored with "near ON: syntax error" — either the bundled SQLite version
or FireDAC's parameter preprocessor doesn't handle UPSERT correctly.
Replaced with portable UPDATE-then-INSERT (safe under our DB.Lock).

Also:
 - datetime modifier ("+60 seconds") built in Delphi via Format() rather
   than SQL-side concatenation ('+' || :sec || ' seconds'), which FireDAC
   was mangling on some configs.
 - GetAccountLockoutRemaining rewritten with julianday() (the SQLite
   idiom for date arithmetic) instead of strftime('%s'). Cleaner, NULL-safe.
2026-05-23 00:29:21 +01:00
Zaki 9f6636defc feat(auth): per-account brute-force lockout with exponential backoff
Existing protection was per-IP only (login_attempts table). On a loopback
deployment everyone hits 127.0.0.1, so the per-IP counter is mostly
ornamental — the real attacker is on the same machine. Adds a second
defense layer that tracks failures per username with an exponential
backoff schedule.

Schema:
  account_lockouts (username TEXT PK, failed_count INT,
                    locked_until DATETIME, last_attempt_at, last_attempt_ip)

Backoff after threshold (4+ failures):
  1, 2, 3 failures → no lockout (grace window for typos)
  4th             → 60 s
  5th             → 5 min
  6th             → 15 min
  7th             → 1 h
  8th             → 6 h
  9th and beyond  → 24 h (capped)

Counter resets to 0 on successful login or reauth. Old non-locked rows
older than 30 days are pruned by CleanupExpired alongside the existing
sessions / audit_log / login_attempts cleanups.

Wiring:
 - HandleLogin / HandleReauth both check RejectIfAccountLocked() before
   touching the users table. Lockout responses are 429 with JSON body
   { error, retry_after } and a Retry-After header.
 - Failed attempts are recorded against the username even when the user
   doesn't exist, preventing account enumeration via differential
   "is this account locked?" probes.
 - PBKDF2 hash comparison was already constant-time (ConstantTimeEquals);
   no change there.

Client (js/app.js):
 - api() now preserves response status + body on Error so callers can
   distinguish 429-lockout from other errors.
 - New showLockoutCountdown(seconds) renders a live "Account locked —
   try again in Xm Ys" message in #authHint, disables #loginBtn until
   the countdown reaches 0, then re-enables it.
 - doLogin / doUnlock both branch on err.status === 429 + retry_after
   to call showLockoutCountdown instead of a generic error toast.

Known limitation: an attacker can DoS-lock arbitrary usernames by
spamming /login with that name. This is intentional — the alternative
(per-(username,IP) tracking) would let attackers enumerate accounts.
DoS-lock is acceptable; auth bypass is not.
2026-05-23 00:14:44 +01:00
Zaki 506aee7e6f feat: Delphi backend + JS↔Delphi bridge (clipboard, tray, auto-lock)
Introduces the Delphi 12 FMX backend (PMServer) that hosts the embedded
WebView2 vault on 127.0.0.1, and a native bridge between JS and Delphi
that wires three privacy-focused features:

1. Secure clipboard
   Copying a password registers the Win32 "ExcludeClipboardContentFromMonitorProcessing"
   format alongside CF_UNICODETEXT, so Win+V clipboard history never sees
   the value. Auto-clears after 30s via TTimer. Bridge.copySecure() in
   app.js routes all password/username/secret copy paths through the
   native layer when running inside the Delphi WebView2 (falls back to
   navigator.clipboard for the PHP standalone).

2. Tray icon (X-to-tray when server running)
   Closing the dev panel hides both the form HWND and the TFMAppClass
   per-process proxy window that owns the FMX taskbar entry — the form's
   HWND alone is not the taskbar-visible one in FMX (took some iteration
   to discover). Tray menu: Open, Lock vault, Quit. Clipboard is force-
   cleared on minimize as extra safety. First-time minimize fires a
   balloon notification so the user knows the app is still running.

3. Auto-lock on Windows session lock (Win+L)
   wtsapi32.dll!WTSRegisterSessionNotification on a dedicated message-only
   window. On WM_WTSSESSION_CHANGE / WTS_SESSION_LOCK, the bridge calls
   ExecuteJavaScript('lockVault()'). Same path used by the tray "Lock vault"
   menu item.

Bridge architecture:
 - JS → Delphi via cmd:// URLs intercepted in OnBeforeNavigate
   (pattern lifted from DeskInsight Monaco). Currently exposes
   cmd://clipboard/copy?text=...&clear=... and cmd://clipboard/clear.
 - Delphi → JS via TTMSFNCWebBrowser.ExecuteJavaScript with guarded
   calls (typeof check) so the bridge degrades cleanly if app.js isn't
   loaded yet.

Files:
 - Source/PM.Bridge.pas (new) — TSecureClipboard + TPMBridge
 - UMainForm.pas/.fmx — bridge wiring, FormCloseQuery intercept, tray
   callbacks (BridgeTrayRestore / BridgeLockRequest / BridgeQuit)
 - js/app.js — Bridge object, 5 navigator.clipboard sites migrated to
   Bridge.copySecure with PHP-compatible fallback, Bridge.onTrayRestore
   handler that resets the auto-lock timer

.gitignore extended with Delphi build artifacts (*.dcu, Win32/, Win64/,
__history/, __recovery/, *.identcache, *.dsk, *.local, etc.) so source
checkouts stay clean.
2026-05-22 23:47:57 +01:00