Merge branch 'feature/timing-floor-mitigation' into main
Browse files- src/auth.py +12 -3
- src/config.py +1 -0
- tests/test_password_reset.py +22 -0
src/auth.py
CHANGED
|
@@ -13,6 +13,7 @@ from datetime import datetime, timedelta, timezone
|
|
| 13 |
import hashlib
|
| 14 |
import hmac
|
| 15 |
import secrets
|
|
|
|
| 16 |
from typing import Any, Dict, Optional, Tuple
|
| 17 |
import jwt
|
| 18 |
from pydantic import BaseModel, Field, field_validator
|
|
@@ -389,6 +390,7 @@ def request_password_reset(
|
|
| 389 |
if not user_id_or_email or not user_id_or_email.strip():
|
| 390 |
raise ValueError("Username or email address cannot be empty.")
|
| 391 |
|
|
|
|
| 392 |
clean_id = user_id_or_email.strip()
|
| 393 |
|
| 394 |
# 1. Look up user account first (supports username or email)
|
|
@@ -418,14 +420,21 @@ def request_password_reset(
|
|
| 418 |
send_password_reset_email(to_email=user.email, reset_link=reset_link)
|
| 419 |
logger.info(f"Initiated password reset for user '{user.user_id}'.")
|
| 420 |
else:
|
| 421 |
-
#
|
| 422 |
_ = secrets.token_urlsafe(32)
|
| 423 |
-
_ = hashlib.pbkdf2_hmac(HASH_NAME, b"dummy_constant_timing_padding", b"dummy_salt_bytes", 10_000)
|
| 424 |
logger.info(
|
| 425 |
f"Password reset requested for non-existent or email-less identifier. Skipped delivery."
|
| 426 |
)
|
| 427 |
|
| 428 |
-
# 4. Anti-enumeration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
return "If an account with that identifier exists, a password reset email has been sent."
|
| 430 |
|
| 431 |
|
|
|
|
| 13 |
import hashlib
|
| 14 |
import hmac
|
| 15 |
import secrets
|
| 16 |
+
import time
|
| 17 |
from typing import Any, Dict, Optional, Tuple
|
| 18 |
import jwt
|
| 19 |
from pydantic import BaseModel, Field, field_validator
|
|
|
|
| 390 |
if not user_id_or_email or not user_id_or_email.strip():
|
| 391 |
raise ValueError("Username or email address cannot be empty.")
|
| 392 |
|
| 393 |
+
start_time = time.monotonic()
|
| 394 |
clean_id = user_id_or_email.strip()
|
| 395 |
|
| 396 |
# 1. Look up user account first (supports username or email)
|
|
|
|
| 420 |
send_password_reset_email(to_email=user.email, reset_link=reset_link)
|
| 421 |
logger.info(f"Initiated password reset for user '{user.user_id}'.")
|
| 422 |
else:
|
| 423 |
+
# Dummy token creation for state consistency
|
| 424 |
_ = secrets.token_urlsafe(32)
|
|
|
|
| 425 |
logger.info(
|
| 426 |
f"Password reset requested for non-existent or email-less identifier. Skipped delivery."
|
| 427 |
)
|
| 428 |
|
| 429 |
+
# 4. Anti-enumeration timing equalization floor (closes network vs local timing side-channel)
|
| 430 |
+
if settings.password_reset_timing_floor_seconds > 0:
|
| 431 |
+
elapsed = time.monotonic() - start_time
|
| 432 |
+
# Add random 0-40ms jitter to prevent micro-architectural timing analysis
|
| 433 |
+
target_floor = settings.password_reset_timing_floor_seconds + (secrets.randbelow(40) / 1000.0)
|
| 434 |
+
if elapsed < target_floor:
|
| 435 |
+
time.sleep(target_floor - elapsed)
|
| 436 |
+
|
| 437 |
+
# 5. Anti-enumeration guaranteed uniform message
|
| 438 |
return "If an account with that identifier exists, a password reset email has been sent."
|
| 439 |
|
| 440 |
|
src/config.py
CHANGED
|
@@ -87,6 +87,7 @@ class Settings(BaseModel):
|
|
| 87 |
resend_from_email: str = Field(default_factory=lambda: os.getenv("RESEND_FROM_EMAIL", "onboarding@resend.dev"))
|
| 88 |
password_reset_token_expire_minutes: int = Field(default_factory=lambda: int(os.getenv("PASSWORD_RESET_TOKEN_EXPIRE_MINUTES", "15")))
|
| 89 |
password_reset_limit_per_hour: int = Field(default_factory=lambda: int(os.getenv("PASSWORD_RESET_LIMIT_PER_HOUR", "3")))
|
|
|
|
| 90 |
|
| 91 |
def validate_keys(self) -> None:
|
| 92 |
"""Ensure required API keys and security credentials are valid."""
|
|
|
|
| 87 |
resend_from_email: str = Field(default_factory=lambda: os.getenv("RESEND_FROM_EMAIL", "onboarding@resend.dev"))
|
| 88 |
password_reset_token_expire_minutes: int = Field(default_factory=lambda: int(os.getenv("PASSWORD_RESET_TOKEN_EXPIRE_MINUTES", "15")))
|
| 89 |
password_reset_limit_per_hour: int = Field(default_factory=lambda: int(os.getenv("PASSWORD_RESET_LIMIT_PER_HOUR", "3")))
|
| 90 |
+
password_reset_timing_floor_seconds: float = Field(default_factory=lambda: float(os.getenv("PASSWORD_RESET_TIMING_FLOOR_SECONDS", "0.35")))
|
| 91 |
|
| 92 |
def validate_keys(self) -> None:
|
| 93 |
"""Ensure required API keys and security credentials are valid."""
|
tests/test_password_reset.py
CHANGED
|
@@ -183,6 +183,28 @@ class TestUserEnumerationPrevention(unittest.TestCase):
|
|
| 183 |
self.assertEqual(res_real, res_fake)
|
| 184 |
self.assertEqual(res_real, "If an account with that identifier exists, a password reset email has been sent.")
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
class TestPasswordResetRateLimiting(unittest.TestCase):
|
| 188 |
"""Test rate limiting on password reset requests (e.g. 3 requests/hour)."""
|
|
|
|
| 183 |
self.assertEqual(res_real, res_fake)
|
| 184 |
self.assertEqual(res_real, "If an account with that identifier exists, a password reset email has been sent.")
|
| 185 |
|
| 186 |
+
@patch("src.auth.send_password_reset_email")
|
| 187 |
+
def test_timing_side_channel_mitigation(self, mock_send_email):
|
| 188 |
+
def simulated_network_send(*args, **kwargs):
|
| 189 |
+
import time
|
| 190 |
+
time.sleep(0.04)
|
| 191 |
+
return True
|
| 192 |
+
mock_send_email.side_effect = simulated_network_send
|
| 193 |
+
|
| 194 |
+
with patch.object(settings, "password_reset_timing_floor_seconds", 0.08):
|
| 195 |
+
import time
|
| 196 |
+
t0 = time.monotonic()
|
| 197 |
+
request_password_reset(self.storage, "real_user", rate_limiter=self.limiter)
|
| 198 |
+
duration_real = time.monotonic() - t0
|
| 199 |
+
|
| 200 |
+
t0 = time.monotonic()
|
| 201 |
+
request_password_reset(self.storage, "ghost_user", rate_limiter=self.limiter)
|
| 202 |
+
duration_ghost = time.monotonic() - t0
|
| 203 |
+
|
| 204 |
+
self.assertGreaterEqual(duration_real, 0.075)
|
| 205 |
+
self.assertGreaterEqual(duration_ghost, 0.075)
|
| 206 |
+
self.assertLess(abs(duration_real - duration_ghost), 0.06)
|
| 207 |
+
|
| 208 |
|
| 209 |
class TestPasswordResetRateLimiting(unittest.TestCase):
|
| 210 |
"""Test rate limiting on password reset requests (e.g. 3 requests/hour)."""
|