Skip to content

fix(auth): run the password KDF outside the shared DB lock - #213

Merged
argszero merged 1 commit into
mainfrom
fix/kdf-outside-db-lock
Sep 13, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/kdf-outside-db-lock

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

AppState.db is a single process-wide Arc<Mutex<Connection>>, and four handlers ran argon2 while holding it: login, register, reset_password and change_password. Argon2 is deliberately expensive (~0.24 s with the default parameters), so one unauthenticated request blocked every DB-backed path in the process (including order settlement) for that long.

Measured through the real router with a thread timing st.db.lock():

request max_lock_wait
login (known e-mail, wrong password — no auth needed) 235.5 ms
register (fresh e-mail — no preconditions at all) 228.2 ms
8 concurrent logins 1.907 s = 8 × ~235 ms, fully serialized
control (unknown e-mail, returns before the KDF) 0.00 ms

A sweep of every non-test db.lock() site found argon2 to be the only slow work running inside a lock, so the fix applies the repo's own rule ("send mail outside the lock", "keep the lock scope to the synchronous section") to the password path.

Related Issue

No linked issue — found while auditing lock hold times. Happy to open one if you'd prefer this tracked.

Changes

  • login: read the stored hash under the lock, verify the password outside it.
  • register: hash the password (and build the verification code) outside the lock, then re-check the e-mail under the lock before inserting. The e-mail check is repeated because the KDF window now lets a concurrent registration win the race between the first check and the insert; the second check returns the same 409 the first one would have, instead of letting the users.email UNIQUE constraint turn into a 500.
  • reset_password: verify the code under one lock scope, hash outside it, and re-check that the code is still valid under the final lock scope before writing the new password.
  • change_password: read the old hash under the lock; both argon2 calls (verify the old password, hash the new one) run outside it.
  • Removed dao::verify_user_password — find_user_by_email already returns (id, password_hash), so no helper is needed to verify under the lock.
  • No configuration or data-structure changes.

Tests

  • cargo test — 215 passed, 0 failed
  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • New unit tests added (4)

The new tests measure lock availability with a counter thread that try_locks the real AppState.db once per millisecond while a request runs. The fixture hash carries heavy argon2 parameters (~0.8 s to verify), so the KDF runtime is guaranteed by the PHC string rather than by a timing threshold:

test observer hits before after
login_verifies_the_password_outside_the_db_lock 0 158
register_hashes_the_password_outside_the_db_lock 0 159
reset_password_hashes_the_password_outside_the_db_lock 1 525
change_password_hashes_the_password_outside_the_db_lock 0 670

The assertion is a count, not a duration: before the fix each request held the lock from its first query to its last write; after the fix the lock is free during the KDF. A positive control asserts the observer really can acquire the lock while it is idle, so a silently broken observer cannot pass by reporting zero hits. Reverting each handler individually turns exactly its own test red (4 mutations → 4 distinct single-test failures).

Checklist

  • Branch naming follows the convention (fix/)
  • Commit message uses Conventional Commits (fix(auth): ...)
  • Single responsibility, minimal diff (2 files: src/dao.rs, src/routes/mod.rs)

`AppState.db` is a single process-wide `Arc<Mutex<Connection>>`, and four
handlers ran argon2 while holding it: `login`, `register`, `reset_password`
and `change_password`. Argon2 is deliberately expensive (~0.24 s with default
parameters), so a single unauthenticated request stalled every DB-backed path
in the process for that long. Measured through the real router:

  login    (known e-mail, wrong password) -> max_lock_wait 235.5 ms
  register (fresh e-mail)                  -> max_lock_wait 228.2 ms
  8 concurrent                             -> 1.907 s (8 x ~235 ms, serialized)
  control  (unknown e-mail, returns early) -> 0.00 ms

No other slow work runs inside a lock: a sweep of all non-test `db.lock()`
sites left argon2 as the only case. The fix is the repo's own rule ("send mail
outside the lock", "keep the lock scope to the synchronous section") applied
to the password path: query under the lock, hash/verify outside it.

- `login`: read the stored hash under the lock, verify outside it.
- `register`: hash and generate the verification code outside the lock, then
  re-check the e-mail under the lock before inserting. The e-mail check is
  repeated because a concurrent registration may now win the race between the
  first check and the insert; the second check returns the same 409 the first
  one would have, instead of turning the `users.email` UNIQUE constraint into
  a 500.
- `reset_password`: verify the code under one lock scope, hash outside it, and
  re-check that the code is still valid under the final lock scope before
  writing the new password.
- `change_password`: read the old hash under the lock; both argon2 calls
  (verify old, hash new) run outside it.
- `dao::verify_user_password` is removed — `find_user_by_email` already returns
  `(id, password_hash)`, so no helper is needed to verify under the lock.

Tests: four new tests measure lock availability with a counter thread that
`try_lock`s the real `AppState.db` once per millisecond while a request runs.
The fixture hash carries heavy argon2 parameters (~0.8 s to verify), so the
KDF runtime is guaranteed by the PHC string rather than by a timing threshold.
The assertion is a count, not a duration: before the fix each request held the
lock from its first query to its last write (0 hits); after the fix the lock is
free during the KDF (hundreds of hits). A positive control asserts the observer
can actually acquire the lock while it is idle, so a silent observer cannot
pass the test by reporting zero hits.

  before -> after (observer hits): login 0 -> 158, register 0 -> 159,
  reset_password 1 -> 525, change_password 0 -> 670

`cargo test`: 215 passed. `cargo fmt --check` and
`cargo clippy --all-targets -- -D warnings` are clean.
@argszero
argszero merged commit fa6a794 into main Sep 13, 2026
1 check passed
@argszero
argszero deleted the fix/kdf-outside-db-lock branch September 13, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant