829056f1facb4f3fb31a84296e8653beb25e90a6
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3f8ecde571 |
feat(security): decouple the login verifier from the AES vault key
The zero-knowledge verifier sent to /login used to be the raw PBKDF2 output in hex — i.e. the exact bytes of the AES key that encrypts every entry. Intercepting a /login body (loopback, but still) handed over the vault key. This introduces a decoupled scheme where the transmitted verifier is a one-way function of the key. New auth-hash scheme - users.hash_algo 'pbkdf2-sha256-v2': the client sends verifier = SHA256(keyHex + "pmserver/auth-verifier/v2") instead of keyHex. Stored form is still SHA256(verifier) (identical server wrap to 'pbkdf2-sha256'), so only the algo LABEL differs — it tells the client which verifier formula to use. Verification needs no new server branch (VerifierToStoredHash already SHA256-wraps any non-legacy verifier). - The AES key (cryptoKey) stays hex(PBKDF2) for EVERY algo, so entries remain decryptable and switching schemes never re-encrypts data. Adoption: new-registration + master-pw-change only - Register and change-master-password write v2. Existing accounts keep their algo until they rotate — the login/reauth migration signal now fires only for LEGACY 'pbkdf2' (was: anything != CURRENT), so sha256/v2 accounts are never force-migrated (which would have downgraded v2 → sha256 via migrate-kdf). Client (js/app.js): algo-aware everywhere - verifierFromKeyHex(keyHex, algo) central helper; deriveKeyAndVerifier / computeVerifier take an algo arg. state.hashAlgo caches the account scheme, set from /login/challenge, register, change-master, the quick-unlock / PIN cold-start blobs, and the /recovery-key/redeem response. All ~12 verifier sites updated (login, register, reauth ×4, change-master current+new, migrate-kdf, quick-unlock + PIN cold-start, recovery-mode current verifier). Safety invariant: unknown/empty hashAlgo → key hex → byte-identical to the old behaviour, so every pre-decoupling account (and every existing quick-unlock / PIN blob without the new field) keeps working unchanged. Verified: existing account + pre-change quick-unlock still unlocks; a master-pw change now writes 'pbkdf2-sha256-v2' in vault.db. Server: recovery redeem returns hashAlgo; register + change-master store the decoupled algo; login + reauth migration signal narrowed to legacy. Also: BuildAssets.ps1 pipes $null into node --check so the JS syntax gate can't block on stdin in the Delphi pre-build environment. Addresses CODE_AUDIT.md section 1.1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
40b3154a34 |
feat: MFA tools, single-instance, tray polish, prefs persistence
Session highlights:
- feat(prefs): DPAPI-backed key/value store (PM.UserPrefs) — fixes
rememberedUsername being lost across reboots due to the random
ephemeral HTTP port changing the localStorage origin every launch.
Bridge cmd://prefs/{get,set} round-trips through Delphi.
- feat(tray): icon visible from startup (NIM_ADD at constructor, not
at first minimize). Tray context menu themed via uxtheme!135
SetPreferredAppMode so it follows the app's dark/light setting.
- feat(single-instance): named mutex + RegisterWindowMessage broadcast.
Second launch posts WM_PMSHOW to HWND_BROADCAST and exits; the
running bridge restores the window from tray. Mutex lives in Local\
namespace so distinct Windows users can still each run one.
- feat(mfa): Authenticator sidebar view (live TOTP codes for every
entry with a secret) + standalone TOTP generator modal (paste
base32 / otpauth:// URI, or generate a random 20-byte secret).
- feat(sidebar): Folders / Tags / Tools sections collapsible with
chevron toggle. Badge counts stay visible when collapsed. State
persisted in settings_json (synced across devices).
- feat(autofill): hotkey when vault is locked now restores the app
and focuses the master password input instead of no-op'ing
silently. Cleaner UX for the common "I hit Ctrl+Shift+L but the
vault was locked" path.
- feat(quick-unlock): when enabled, skip lockVault on Windows lock /
sleep. Rationale: the DPAPI blob already gates access via the
Windows account, so re-locking on top of the OS lock is redundant.
Idle auto-lock still fires (separate opt-in).
- fix(quick-unlock): re-sync state.quickUnlockEnabled from DPAPI
source-of-truth at boot, instead of trusting (now-volatile)
localStorage.
- docs: CLAUDE.md updated with all new modules, bridge commands,
and the port-ephemeral pitfall.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
d13e5bc89f |
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.
|
||
|
|
01c56edf25 |
feat(recovery): single-use recovery code for forgotten master password
In a zero-knowledge vault, forgetting the master password normally
means losing the data — the AES key is derived from the master pw
and the server can't help. This commit adds the standard escape
hatch: a one-time recovery code that key-wraps the AES key so the
user can get back in.
Threat model
============
The plaintext recovery code is shown to the user exactly once, at
generation time. Server only ever stores SHA-256(code) + an AES-GCM
wrap of the vault key under a KEK = PBKDF2(code, kdf_salt, 600k).
Without the plaintext code the server cannot unwrap. The code is
high-entropy (96 bits from a 32-char ambiguity-free alphabet, in 4
groups of 4) — printed form is misreading-resistant.
Single use: redeeming deletes the row inside the same DB.Lock the
lookup happened in, so concurrent redeem attempts are race-free.
Failed redemptions feed both the per-IP rate limit AND the per-
username lockout, so brute-forcing the code is infeasible.
Schema
======
recovery_keys (
user_id INTEGER PRIMARY KEY (1:1 with users, FK cascade),
code_hash TEXT NOT NULL (SHA-256 hex of plaintext code),
kdf_salt TEXT NOT NULL (PBKDF2 salt for KEK derivation),
wrapped_key TEXT NOT NULL (base64 AES-GCM ciphertext of vault key),
wrapped_iv TEXT NOT NULL (base64 12B IV for the wrap),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
Backend: new unit PM.Handler.Recovery
=====================================
GET /recovery-key/status (auth) -> { configured, created_at? }
POST /recovery-key/setup (auth + CSRF) body {masterPassword, codeHash,
kdfSalt, wrappedKey, wrappedIv}
DELETE /recovery-key (auth + CSRF) -> remove config
POST /recovery-key/redeem (NO auth) body {username, code}
-> session + wrappedKey + wrappedIv + kdfSalt
+ user's current salt + kdfIterations
VerifyMasterPassword() helper handles both legacy 'pbkdf2' and
current 'pbkdf2-sha256' schemes consistently with PM.Handler.Auth.
Setup flow
==========
1. Settings → "Generate recovery code" button (asks master pw via reauth).
2. Client generates: 16-char code + fresh kdf_salt + exports the current
AES key via crypto.subtle.exportKey('raw').
3. Client wraps the raw key under KEK=PBKDF2(code, kdf_salt, 600k)
with a random 12B IV → base64.
4. POSTs to /recovery-key/setup. Server verifies master pw, INSERT-or-
replaces the row (DELETE+INSERT, no UPSERT — same pattern as the
lockout table since FireDAC's UPSERT support is patchy).
5. Confirm modal shows the plaintext code in a monospace, user-select-all
panel. The modal is forcing: "I saved it" button is the only way out.
Modal is the only place the code ever appears — server never sees it.
Redeem flow (forgot master pw)
==============================
1. Auth screen → "Forgot master password? Use a recovery code" link.
2. promptDialog: username, then code (masked input).
3. POST /recovery-key/redeem. Server hashes the typed code, joins with
users by username, ConstantTimeEquals against stored hash. On match:
- deletes the recovery_keys row (single-use)
- issues a fresh session token + CSRF
- returns: { token, csrfToken, salt, kdfIterations, kdfSalt,
wrappedKey, wrappedIv, userId }
4. Client unwraps the AES key with PBKDF2(code, kdfSalt, 600k) → raw bytes
→ importKey('raw') back into a CryptoKey.
5. State is reconstituted from the new session, persistCryptoKey, enterApp.
6. Client immediately opens the Change-master-password modal — the
recovery code is consumed and the account needs a fresh master pw
AND a fresh recovery code (the user generates a new one from Settings).
Backward compat
===============
Recovery is opt-in. Existing users see "No recovery key set" in Settings
until they generate one. No migration needed — the table is created via
CREATE TABLE IF NOT EXISTS at server startup, FK cascade on user delete.
Minor UI additions
==================
- .btn-link CSS class for the auth-screen "Forgot master password?" link.
- Recovery-status label in Settings refreshed on every openSettings()
via GET /recovery-key/status.
|