fix(auth): close username-enumeration timing side-channel in login#6
Merged
Conversation
`verify_password_user` only ran the (deliberately expensive) Argon2 verify when the email matched an existing account. For a nonexistent email it returned `Invalid` immediately after the DB lookup, and for an unparseable stored hash it bailed the same way — so those paths returned ~25x faster than a wrong-password attempt against a real account (measured 0.57s vs 0.02s on the dioxus example). The error *message* was already identical for both cases, but the response time was not: an attacker could enumerate which emails have accounts purely by timing, defeating the indistinguishability `VerifyOutcome::Invalid` promises. Burn an equivalent Argon2 verify against a fixed dummy hash on both early-return branches so every path costs roughly the same regardless of whether the account exists. The dummy hash is built once via `LazyLock` with the default Argon2 params, matching a real verify's cost. Adds a regression test (`unknown_email_is_not_faster_than_wrong_password`) that asserts the not-found path spends at least half the time the wrong-password path does — pre-fix it was ~4%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The THC-Hydra brute-force script, the hardening-verification harness, and their generated wordlist are ad-hoc local tooling used to exercise an example's auth against credential attacks — not part of the shipped example. Ignore them so a stray `git add .` can't sweep them into the repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's `--lib` clippy pass denies `clippy::expect_used`, which the LazyLock initializer's `.expect()` tripped (the lib otherwise keeps panic paths out of request code). Replace the runtime hash generation with a precomputed default-param Argon2 hash as a `const` — no fallible hashing in this path, matching the lib's no-unwrap/expect convention. The embedded params (m=19456,t=2,p=1) are `Argon2::default()`, so the dummy verify still costs the same as a genuine one. Timing regression test still passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
The vulnerability
verify_password_useronly ran the (deliberately expensive) Argon2 verify when the email matched an existing account. For a nonexistent email it returnedInvalidimmediately after the DB lookup; for an unparseable stored hash it bailed the same way. The error message was already identical for both — but the response time was not.Measured against the dioxus example, a wrong-password attempt on a real account took ~0.57s (Argon2 runs) while a login for a nonexistent email returned in ~0.02s — a ~27× gap. An attacker can enumerate which emails have accounts purely by timing, defeating the indistinguishability
VerifyOutcome::Invalidis documented to promise.The fix
Burn an equivalent Argon2 verify against a fixed dummy hash on both early-return branches (no-such-account, unparseable-hash), so every path costs roughly the same regardless of whether the account exists. The dummy hash is built once via
LazyLockwith the default Argon2 params, matching a real verify's cost.Test
Adds
unknown_email_is_not_faster_than_wrong_password(next to the existing message-equality test, whose comment had warned about exactly this oracle). It asserts the not-found path spends at least half the time the wrong-password path does — pre-fix it was ~4%, so the test fails decisively without the fix.Verification
End-to-end against a rebuilt server, the enumeration-timing ratio dropped from 27.28× → 1.01×. Unit tests pass stably (3× runs),
cargo fmt --checkandclippyclean.🤖 Generated with Claude Code