Skip to content

fix(auth): bound password hashing and verify unknown users for real - #10

Merged
tee8z merged 2 commits into
masterfrom
fix/password-work-admission
Oct 9, 2026
Merged

tee8z merged 2 commits into
masterfrom
fix/password-work-admission

Conversation

@tee8z

@tee8z tee8z commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Follows #9. Applies the replay verifier's admission pattern from #9 to the password routes.

What changed and why

Bounded password work. /api/v1/users/username/register, /api/v1/users/username/login and /api/v1/users/reset-password called Argon2 (19 MiB, tens of milliseconds per job) directly on runtime worker threads with no bound. Login needs no authentication, so a flood using public leaderboard names could occupy every runtime worker and stall every route.

  • PasswordWork::admit() -> PasswordPermit in domain/users/password.rs mirrors ReplayVerifier::admit() -> ReplayPermit: one shared budget of two workers for all password hashing and verification, no waiting queue.
  • The permit moves into the spawn_blocking job, so a disconnected caller cannot release capacity while Argon2 still runs.
  • Each handler admits just before its Argon2 pass, after its database lookups, so a slow lookup cannot hold a worker. Login looks the name up on both paths before admission, so a refusal reveals nothing about the name. When both slots are busy it answers 503 with Retry-After: 1, rendered the same way as the replay busy response. The existing client already shows the response text.
  • hash_password and verify_password are now private, so password work can only run through a permit.
  • AppState carries the budget beside replay_verifier.

Real verification for unknown users. The login dummy hash used a seven-byte salt. argon2 0.5 refuses salts under eight bytes before hashing, so unknown usernames answered much faster than real accounts, which allowed username enumeration. Unknown users now verify against a hash built once from a random password, on first use and inside the blocking job.

Verification errors are no longer a mismatch. verify_password returned Ok(false) for any error. It now returns Ok(false) only for a wrong password. A malformed stored hash is logged as an internal error and still answered as the generic 401 login failure.

The README's authentication section documents the 503 behaviour.

Tests (in domain/users/password.rs)

  • unknown_user_verification_runs_a_real_argon2_pass: verifying a password against the dummy hash returns Ok(false), not an error. The old seven-byte-salt hash fails this test.
  • malformed_stored_hash_is_an_error_not_a_mismatch: the old short-salt hash and a non-PHC string return VerifyError instead of Ok(false).
  • stored_hash_accepts_only_its_password_and_unknown_users_never_pass: a hash made through a permit accepts its password and rejects another. Verifying with no stored hash returns false.
  • saturated_work_refuses_until_jobs_end_even_after_cancellation: with both slots held, a third admission returns Busy, which renders as 503 with retry-after: 1. Aborting a caller mid-job does not free its slot. The slot comes back once that job ends, observed by awaiting the semaphore, not by sleeping. A caller that waits for its result has its slot back by then. The test uses channels and no sleeps.
  • failed_worker_returns_its_slot: a panicking job maps to WorkerFailed and its slot comes back.

Skipped from the review

  • Router-level 503 test for login: the crate has no router or AppState test fixture to copy. Building one would mean constructing the Lightning, ledger and store services for a single assertion. The 503 rendering is asserted on PasswordError::Busy. Each route's wiring is a single admit() call right before its Argon2 pass.

Username registration, username login and password reset ran Argon2
(19 MiB, tens of milliseconds) directly on runtime worker threads with
no concurrency bound. Login needs no authentication, so a flood using
public leaderboard names could occupy every runtime worker and stall
all routes.

Password work now follows the replay verifier's admit/run shape: a
handler admits one of two shared slots before touching the store, and
the permit moves into the spawn_blocking job so a disconnected caller
cannot free capacity while Argon2 still runs. When both slots are busy
the handler answers 503 with Retry-After: 1.

The login dummy hash had a seven-byte salt, which argon2 0.5 refuses
before hashing, so unknown usernames answered much faster than real
ones. Unknown users now verify against a hash built once, on a blocking
worker, from a random password. Verification no longer folds errors
into a mismatch: a malformed stored hash is logged and still answered
as a generic login failure.
@tee8z
tee8z marked this pull request as ready for review October 9, 2026 15:58
@tee8z
tee8z marked this pull request as draft October 9, 2026 16:00
Admit just before hashing or verification, after the database lookups,
so a slow lookup cannot hold both workers and turn other password
requests away. Login looks the name up on both paths before admission,
so a refusal still reveals nothing about whether the name exists.
@tee8z
tee8z marked this pull request as ready for review October 9, 2026 16:04
@tee8z
tee8z merged commit 84456c9 into master Oct 9, 2026
8 checks passed
tee8z added a commit that referenced this pull request Oct 9, 2026
The password budget from #10 is a new AppState field.
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