e0e452306e983892c36dc43fb7329de4aedd52ec
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|