Skip to content

ADR-0031: Two-Pass PBKDF2 for Free-Tier Password Security ​

Status: proposed
Date: 2026-07-28
Deciders: @barateza
Relates to: Security audit findings H-1, H-2

Context ​

The Workers free tier imposes a 10ms CPU budget per request. The current server-side password hashing uses PBKDF2 at 100,000 iterations (~8ms), while the client already uses 600,000 iterations for offline IndexedDB-stored passwords. Argon2id via WASM was evaluated but requires 150ms+ even with minimal parameters — it cannot complete within the free tier budget.

The goal is to strengthen the effective iteration count of passwords stored in D1 without paying for the Workers Bundled plan ($5/mo).

Decision ​

Two-pass PBKDF2: the client pre-hashes the plaintext password at 600k iterations before sending it to the server. The server re-hashes the received value at 100k iterations before storage or comparison.

Client:  PBKDF2(600k, password)           → clientHash  (8ms client-side)
Server:  PBKDF2(100k, clientHash)          → storedHash  (8ms server-side)

Attacker cracking storedHash: must run 100k + 600k = 700k iterations per guess

Stored format ​

FormatMeaning
pbkdf2:<salt>:<hash>Legacy — server did 100k on raw password
pbkdf2:client:<salt>:<hash>Two-pass — server did 100k on client-hashed (600k) input

Version signaling ​

The client sends both the raw password and a clientHash (128-char hex, the output of client-side PBKDF2 at 600k) in the login request body. The server auto-detects the stored hash format:

  • Stored pbkdf2:client: → verify using clientHash.
  • Stored pbkdf2: (legacy) → verify using raw password. If valid, re-hash to pbkdf2:client: using clientHash.

Rollout ​

  1. Deploy server-side support for both formats (new hashPassword variant + dual-path verifyPassword)
  2. Deploy client-side pre-hashing — sends both raw password and clientHash in login body
  3. On next successful login of a legacy-hash user, re-hash to pbkdf2:client: format
  4. Seed, user creation, and onboarding guardian creation initially produce v1 hashes — these auto-upgrade on first login via re-hash-on-login

Consequences ​

Positive ​

  • Effective ~700k-iteration security within the free tier's 10ms CPU budget
  • Backward compatible — no forced password reset
  • Gradual migration via re-hash-on-login
  • No new dependencies or WASM bundles

Negative ​

  • The client hash transmitted over the wire is the effective password from the server's perspective. If TLS is compromised, an attacker who intercepts the client hash can authenticate as that user. This is true of any password-authenticated protocol — TLS remains the defense.
  • Two code paths in verifyPassword during the migration window (legacy + two-pass)
  • hashPassword needs a variant that accepts a pre-hashed input rather than a raw password

Neutral ​

  • The existing hashPassword function signature accepts an optional version parameter (defaults to 1 for backward compat).
  • Offline auth (IndexedDB) is unaffected — it continues to store the 600k client hash directly.
  • Server-side account creation (seed, onboarding approve, admin user creation) produces v1 hashes initially. These are upgraded to v2 on the user's first successful login from a v2-capable client.

Correction (2026-08-16): client hash must be deterministic ​

The original implementation computed the client pre-hash with a random salt on every login (pbkdf2() in app/src/modules/auth/encryptionService.ts). Because the server verifies pbkdf2:client: hashes by re-deriving from the clientHash it receives, a fresh random-salted client hash could never match the stored verifier — every user was locked out on their second login.

Fix (shipped with the IJCP production instance):

  • New app/src/modules/auth/clientPrehash.ts — clientPrehash(password) derives the PBKDF2 salt deterministically from the password (first 16 bytes of SHA-256("neemias:client-prehash:v1:" + password)), so the same 128-hex client hash is sent on every login, on every device.
  • backendLogin() now uses clientPrehash() for the clientHash field. pbkdf2() (random salt) remains in use only for offline/IndexedDB storage.
  • Consequences: the transmitted client hash is a stable per-password verifier (the ADR already accepts this — "the client hash is the effective password"; TLS is the defense). Server-side code is unchanged.

Impact on existing deployments:

  • New/fresh databases (e.g. IJCP): v1 hashes upgrade on first login to stable v2 hashes — repeated logins work.
  • The public demo: users whose hashes were already upgraded to v2 under the buggy random-salt path cannot authenticate and need a password/hash reset (the demo's weekly reseed recreates them as v1, which self-heals).

Distribuído sob licença MIT.