Skip to content

fix(auth): close username-enumeration timing side-channel in login#6

Merged
tonybierman merged 3 commits into
mainfrom
fix/login-timing-enumeration
May 25, 2026
Merged

fix(auth): close username-enumeration timing side-channel in login#6
tonybierman merged 3 commits into
mainfrom
fix/login-timing-enumeration

Conversation

@tonybierman
Copy link
Copy Markdown
Collaborator

The vulnerability

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; 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::Invalid is 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 LazyLock with 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 --check and clippy clean.

🤖 Generated with Claude Code

tonybierman and others added 3 commits May 24, 2026 22:16
`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>
@tonybierman tonybierman merged commit b43d8ad into main May 25, 2026
28 checks passed
@tonybierman tonybierman deleted the fix/login-timing-enumeration branch May 25, 2026 08: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