Muhammad Usman commited on
Commit
ee44eb5
·
2 Parent(s): 118d22d7c97ae1

Merge branch 'feature/timing-floor-mitigation' into main

Browse files
Files changed (3) hide show
  1. src/auth.py +12 -3
  2. src/config.py +1 -0
  3. 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
- # Mitigate timing side-channels with simulated dummy cryptographic operations
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 guaranteed uniform message
 
 
 
 
 
 
 
 
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)."""