e0e452306e983892c36dc43fb7329de4aedd52ec
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.
Description
full local password manager
Languages
JavaScript
51.8%
Pascal
30.5%
CSS
7.4%
HTML
6.3%
PHP
3.2%
Other
0.7%