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>
This commit is contained in:
@@ -516,6 +516,37 @@ restore-then-sync actually stick. A live `vault_entries` row can never
|
||||
coexist with its tombstone (hard-delete removes the row), so `PUT`
|
||||
needs no purge.
|
||||
|
||||
## Auth-hash schemes (`users.hash_algo`)
|
||||
|
||||
Three markers, all zero-knowledge (server never sees the master pw) :
|
||||
|
||||
- `pbkdf2` (**LEGACY**) : stored hash = raw `PBKDF2(pw,salt,iters)` hex.
|
||||
Those bytes ARE the AES vault key → a stolen `vault.db` = the key.
|
||||
Auto-upgraded to `pbkdf2-sha256` at next login via `/migrate-kdf`.
|
||||
- `pbkdf2-sha256` (**previous default**) : stored = `SHA256(verifier)`
|
||||
where the client's transmitted `verifier` is still the key hex. Safe
|
||||
at rest, but the `/login` body carries the key.
|
||||
- `pbkdf2-sha256-v2` (**DECOUPLED, current default**) : the client sends
|
||||
`verifier = SHA256(keyHex + "pmserver/auth-verifier/v2")` instead of
|
||||
`keyHex`. The transmitted verifier is now a one-way function of the
|
||||
key → intercepting `/login` no longer hands over the AES key. Stored =
|
||||
`SHA256(verifier)` (same server wrap as `pbkdf2-sha256`; only the algo
|
||||
LABEL differs, telling the client which verifier formula to use).
|
||||
|
||||
**The AES key (`cryptoKey`) is ALWAYS `hex(PBKDF2)` regardless of algo**
|
||||
— only the verifier string changes, so entries stay decryptable and
|
||||
switching schemes never re-encrypts data.
|
||||
|
||||
Adoption is **new-registration + master-pw-change only** — existing
|
||||
accounts stay on their algo until they rotate (no forced login-path
|
||||
migration; `not SameText(algo, LEGACY)` no longer signals migration, so
|
||||
sha256/v2 accounts are left alone). Client picks the verifier formula
|
||||
from the algo returned by `/login/challenge`, cached in `state.hashAlgo`
|
||||
(also carried in the quick-unlock / PIN cold-start blobs and the
|
||||
`/recovery-key/redeem` response). Unknown/empty `hashAlgo` → key hex →
|
||||
correct for every pre-decoupling account, which is what makes the
|
||||
rollout safe. Central client helper: `verifierFromKeyHex(keyHex, algo)`.
|
||||
|
||||
## PIN unlock
|
||||
|
||||
Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick
|
||||
|
||||
Reference in New Issue
Block a user