Check passwords a few at a time off the async workers, and check unknown names too (#137, #138)

The load tests (#133) sent forty clients' wrong passwords to /api/login, 54 attempts a second,
which takes no account. Everyone else's requests took 4s (median 3.96s for /api/feeds, about 20ms
otherwise) and `ipx status`, the healthcheck, 1.3s (#137): each attempt was an Argon2id check,
tens of milliseconds of CPU, run inside the handler on one of the runtime's workers, so a handful
at once held every worker the rest of the server answers on. And a wrong password for an
account's name was refused a median 31ms later than one for a made-up name (#138), since only a
name with a hash was checked: the answer read the same, the time said which names are accounts.

auth::check_password runs the check on the blocking pool, at most half the cores at once, so a
flood waits on itself, and checks a name with no account, or no password, against a fixed decoy
hash, false in the same time. Under the same flood the rest of the site answers at p95 57ms,
`ipx status` at most 90ms, the gap is 0.2ms, and twice as many attempts are answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 17:37:35 +00:00
parent 697e907c86
commit f1d360420c
3 changed files with 46 additions and 5 deletions

View File

@@ -315,11 +315,8 @@ async fn login(
) -> Result<Response, ApiError> {
let name = body.name.trim().to_ascii_lowercase();
let user = state.ctx.db.user_by_name(&name).await?;
// The same answer either way: whether a name exists is not something to leak.
let ok = user
.as_ref()
.and_then(|u| u.pass_hash.as_deref())
.is_some_and(|h| crate::auth::verify_password(&body.password, h));
// The same answer either way, in the same time: whether a name exists is not something to leak.
let ok = crate::auth::check_password(body.password, user.as_ref().and_then(|u| u.pass_hash.clone())).await;
if !ok {
tracing::warn!(user = %name, "failed sign-in");
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());