From 8272197cea2a95dbc5572a655209594c9119b5f8 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 27 Jun 2026 16:28:31 +0200 Subject: [PATCH] fix(auth): escalating recovery-PIN lockout backoff (M4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failed-attempt counter is no longer reset when the lockout window expires, so each further wrong PIN past the 3-strike threshold doubles the lockout — 15min, 30, 60, … capped at 24h. Combined with the now 6-digit PIN (1M space), this makes sustained guessing via /recover infeasible. A successful recovery or a host PIN reset clears the counter; legitimate users (who don't fail) are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/auth/handlers.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/src/auth/handlers.rs b/backend/src/auth/handlers.rs index b3679f5..2c3c378 100644 --- a/backend/src/auth/handlers.rs +++ b/backend/src/auth/handlers.rs @@ -175,20 +175,18 @@ pub async fn recover( } for user in &users { - // Check PIN lockout. If the lockout has expired, also reset the failed-attempt - // counter so the user gets a fresh 3-strike window — otherwise the counter - // stays at 3+ and every subsequent wrong PIN immediately re-locks them, even - // after waiting out the cooldown. Without this reset, a once-locked account - // is effectively permanently fragile. + // Check PIN lockout. The failed-attempt counter is deliberately NOT reset + // when the window expires — it drives the *escalating* backoff below, so a + // determined guesser faces an exponentially growing wait. A successful + // recovery (and a host PIN reset) is what clears it. The counter only ever + // grows on a *wrong* PIN, so a legitimate user is unaffected. if let Some(locked_until) = user.pin_locked_until { if Utc::now() < locked_until { return Err(AppError::TooManyRequests( - "Zu viele Versuche. Bitte warte 15 Minuten.".into(), + "Zu viele Versuche. Bitte warte und versuche es später erneut.".into(), None, )); } - // Lockout window expired — wipe the counter and the timestamp. - User::reset_pin_attempts(&state.pool, user.id).await?; } let pin_matches = @@ -227,13 +225,19 @@ pub async fn recover( "recover: wrong PIN" ); if attempts >= 3 { - let lockout = Utc::now() + chrono::Duration::minutes(15); + // Escalating backoff: 15min, 30, 60, … doubling per failure past the + // threshold, capped at 24h. Makes sustained guessing against the + // 6-digit space (1M combinations) astronomically slow. + let exp = (attempts as u32).saturating_sub(3).min(10); + let minutes = 15i64.saturating_mul(1i64 << exp).min(24 * 60); + let lockout = Utc::now() + chrono::Duration::minutes(minutes); User::lock_pin(&state.pool, user.id, lockout).await?; tracing::warn!( user_id = %user.id, event_id = %event.id, ip = %ip, - "recover: account locked for 15 minutes" + minutes, + "recover: account locked (escalating backoff)" ); } }