Files
Password-Manager/delphi-backend
Zaki 60aa106a30 fix(crypto): SHA-256 wrap auth hash so vault.db at rest no longer = AES key
THE PROBLEM
===========
Before this commit, users.password_hash stored on the server contained
PBKDF2(pw, salt, iters) in hex — the exact same 32 bytes the client
uses as the AES-GCM key to encrypt every entry. Anyone who got hold of
vault.db (filesystem access, backup leak, etc.) had the encryption key
in their hand, no brute force needed. The increased PBKDF2 iteration
count from the previous commit helped against the cipher-text path,
but the easier path was right there in the user row.

THE FIX
=======
Wrap the PBKDF2 output in SHA-256 before storing:

  password_hash = SHA256(PBKDF2(pw, salt, iters))

SHA-256 is one-way. The stored hash can still be verified at login
(server recomputes PBKDF2 from the posted master pw, then SHA-256s it,
compares to stored), but the AES key can no longer be recovered from
it. At rest, vault.db only contains an irreversible derivative.

The server still sees pw transiently during /login while computing
the comparison — eliminating that requires a redesigned auth
protocol where the client sends a pre-computed verifier (SRP, OPAQUE,
or simply SHA-256(PBKDF2(pw, salt, iters)) sent from the client).
That's a separate, larger refactor. This commit closes the at-rest
hole, which is the realistic attack surface for vault file leaks.

SCHEMA / MARKER
===============
users.hash_algo distinguishes the two schemes:
  'pbkdf2'        — LEGACY (raw hex, = AES key)
  'pbkdf2-sha256' — CURRENT (SHA-256-wrapped, one-way)

A constant HASH_ALGO_CURRENT replaces the string literal everywhere
to avoid silent drift between the writer and the reader sides.

MIGRATION
=========
Folded into the existing /migrate-kdf endpoint introduced for the
100k→600k iteration bump. Login response now signals migration on
EITHER:
  - kdf_iterations < PBKDF2_ITERATIONS_TARGET, OR
  - hash_algo != 'pbkdf2-sha256'

The endpoint handles both transitions in one atomic transaction:
  UPDATE users SET password_hash = SHA256(PBKDF2(pw, salt, 600k)),
                   kdf_iterations = 600000,
                   hash_algo = 'pbkdf2-sha256'
  UPDATE vault_entries SET encrypted_password, iv (per entry, if KDF changed)

Idempotency tightened: the "already at target" short-circuit now
requires BOTH conditions, not just the iteration count. Without this,
users who migrated KDF before this commit landed would have been
stuck on the legacy hash format.

CLIENT
======
runKdfMigration() branches on whether the KDF actually changed:
  - kdfChange (fromIters !== toIters): re-encrypt all entries with the
    new key, send them in the entries array, swap state.cryptoKey on
    success. Shows "Vault security upgraded" toast.
  - !kdfChange (hash format only): skip the entry re-encryption loop
    entirely, send entries: []. Silent — the user didn't perceive a
    weakness change worth toasting about.

LOGIN / REAUTH
==============
Both now branch on hash_algo to pick the right verifier:
  HASH_ALGO_LEGACY  → ConstantTimeEquals(stored, PBKDF2(pw, salt, iters))
  HASH_ALGO_CURRENT → ConstantTimeEquals(stored, SHA256(PBKDF2(pw, salt, iters)))

Same constant-time comparison helper as before. Same legacy bcrypt
fallback (still 501-not-implemented).

ALL THREE SCENARIOS AFTER THIS COMMIT
=====================================
1. New register: starts at HASH_ALGO_CURRENT + 600k. No migration ever.
2. Legacy 100k + 'pbkdf2': full migration on next login (hash format
   + iter count + entry re-encryption) in one transaction.
3. Mid-state (already-migrated KDF + still-'pbkdf2'): hash format
   upgrade only on next login, no entry re-encryption.
2026-05-23 05:19:39 +01:00
..