M-02: Timing-safe login prevents username enumeration

When a login targets a non-existent username, run Argon2id against a
pre-computed dummy hash so response time (~60ms) matches wrong-password
attempts. Also reorder the login flow to run verify_password_with_upgrade
BEFORE the lockout check, preventing timing side-channels that could
distinguish locked accounts from wrong passwords.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-27 15:44:27 +08:00
parent 2f58282c31
commit 8e27f2920b

View File

@ -36,6 +36,7 @@ from app.schemas.auth import (
)
from app.services.auth import (
hash_password,
verify_password,
verify_password_with_upgrade,
create_session_token,
verify_session_token,
@ -47,6 +48,12 @@ from app.config import settings as app_settings
router = APIRouter()
# Pre-computed dummy hash for timing equalization (M-02).
# When a login attempt targets a non-existent username, we still run
# Argon2id verification against this dummy hash so the response time
# is indistinguishable from a wrong-password attempt.
_DUMMY_HASH = hash_password("timing-equalization-dummy")
# ---------------------------------------------------------------------------
# Cookie helper
# ---------------------------------------------------------------------------
@ -287,12 +294,17 @@ async def login(
user = result.scalar_one_or_none()
if not user:
# M-02: Run Argon2id against a dummy hash so the response time is
# indistinguishable from a wrong-password attempt (prevents username enumeration).
verify_password("x", _DUMMY_HASH)
raise HTTPException(status_code=401, detail="Invalid username or password")
await _check_account_lockout(user)
# M-02: Run password verification BEFORE lockout check so Argon2id always
# executes — prevents distinguishing "locked" from "wrong password" via timing.
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
await _check_account_lockout(user)
if not valid:
await _record_failed_login(db, user)
await log_audit_event(