Repository navigation
fix(auth): bound password hashing and verify unknown users for real - #10
Merged
Merged
Conversation
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
marked this pull request as ready for review
October 9, 2026 15:58
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
marked this pull request as ready for review
October 9, 2026 16:04
tee8z
added a commit
that referenced
this pull request
Oct 9, 2026
The password budget from #10 is a new AppState field.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/loginand/api/v1/users/reset-passwordcalled 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() -> PasswordPermitindomain/users/password.rsmirrorsReplayVerifier::admit() -> ReplayPermit: one shared budget of two workers for all password hashing and verification, no waiting queue.spawn_blockingjob, so a disconnected caller cannot release capacity while Argon2 still runs.Retry-After: 1, rendered the same way as the replay busy response. The existing client already shows the response text.hash_passwordandverify_passwordare now private, so password work can only run through a permit.AppStatecarries the budget besidereplay_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_passwordreturnedOk(false)for any error. It now returnsOk(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 returnsOk(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 returnVerifyErrorinstead ofOk(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 returnsfalse.saturated_work_refuses_until_jobs_end_even_after_cancellation: with both slots held, a third admission returnsBusy, which renders as 503 withretry-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 toWorkerFailedand its slot comes back.Skipped from the review
AppStatetest fixture to copy. Building one would mean constructing the Lightning, ledger and store services for a single assertion. The 503 rendering is asserted onPasswordError::Busy. Each route's wiring is a singleadmit()call right before its Argon2 pass.