diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 69038f2..a8b0266 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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(