fix(2fa): per-account verify lockout and tighter TOTP skew (BUNYIP-201) #243

Merged
Claude-Run merged 1 commit from fix/bunyip-201-2fa-per-account-lockout into main 2026-06-26 06:22:09 +02:00
Member

POST /v1/auth/2fa/verify was throttled only per source IP (5/min via RateLimitConfig::LOGIN), so an attacker rotating cheap proxy IPs against one victim's challenge token faced no aggregate cap, and the 6-digit TOTP was checked with skew=1 (3 valid codes live at once), giving ~3/10^6 per guess. Combined that put a likely 2FA bypass within roughly half an hour with no lockout.

Add a per-account failed-attempt counter (RateLimitConfig::TWO_FACTOR_VERIFY_FAILURES, 5 failures / 15 min) keyed on 2fa_verify_user:{user_id}, independent of source IP. It is read read-only before code verification (compared with >= so the boundary blocks and a possibly-correct attempt does not spend the budget), incremented only on a failed code, and reset on success, so a legitimate user is never throttled but once the cap is hit the account's 2FA verification is locked for the rest of the window (even a correct code is refused). Counter writes are best-effort so a DB hiccup cannot change the auth outcome or turn an invalid-code response into a 500.

Reduce the TOTP skew from 1 to 0 in build_totp/check_code so only the current 30s step is accepted, removing the adjacent-window codes and tripling per-guess difficulty.

#BUNYIP-201

POST /v1/auth/2fa/verify was throttled only per source IP (5/min via RateLimitConfig::LOGIN), so an attacker rotating cheap proxy IPs against one victim's challenge token faced no aggregate cap, and the 6-digit TOTP was checked with skew=1 (3 valid codes live at once), giving ~3/10^6 per guess. Combined that put a likely 2FA bypass within roughly half an hour with no lockout. Add a per-account failed-attempt counter (RateLimitConfig::TWO_FACTOR_VERIFY_FAILURES, 5 failures / 15 min) keyed on 2fa_verify_user:{user_id}, independent of source IP. It is read read-only before code verification (compared with >= so the boundary blocks and a possibly-correct attempt does not spend the budget), incremented only on a failed code, and reset on success, so a legitimate user is never throttled but once the cap is hit the account's 2FA verification is locked for the rest of the window (even a correct code is refused). Counter writes are best-effort so a DB hiccup cannot change the auth outcome or turn an invalid-code response into a 500. Reduce the TOTP skew from 1 to 0 in build_totp/check_code so only the current 30s step is accepted, removing the adjacent-window codes and tripling per-guess difficulty. #BUNYIP-201
fix(2fa): per-account verify lockout and tighter TOTP skew (BUNYIP-201)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 1m10s
Check / fmt + clippy + build + tests (pull_request) Successful in 23m0s
Create release / Create release from merged PR (pull_request) Has been skipped
14c46f0c1b
POST /v1/auth/2fa/verify was throttled only per source IP (5/min via RateLimitConfig::LOGIN), so an attacker rotating cheap proxy IPs against one victim's challenge token faced no aggregate cap, and the 6-digit TOTP was checked with skew=1 (3 valid codes live at once), giving ~3/10^6 per guess. Combined that put a likely 2FA bypass within roughly half an hour with no lockout.

Add a per-account failed-attempt counter (RateLimitConfig::TWO_FACTOR_VERIFY_FAILURES, 5 failures / 15 min) keyed on 2fa_verify_user:{user_id}, independent of source IP. It is read read-only before code verification (compared with >= so the boundary blocks and a possibly-correct attempt does not spend the budget), incremented only on a failed code, and reset on success, so a legitimate user is never throttled but once the cap is hit the account's 2FA verification is locked for the rest of the window (even a correct code is refused). Counter writes are best-effort so a DB hiccup cannot change the auth outcome or turn an invalid-code response into a 500.

Reduce the TOTP skew from 1 to 0 in build_totp/check_code so only the current 30s step is accepted, removing the adjacent-window codes and tripling per-guess difficulty.

#BUNYIP-201
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!243
No description provided.