""" SEC-03: Constant-time comparison for token/code verification. Two layers of verification: 1. Source-code inspection: confirm hmac.compare_digest is used in auth service. 2. Behavioral tests: confirm that wrong tokens/codes are rejected correctly (proving the comparison pathway functions at minimum). The behavioral tests do not prove timing properties (impossible without statistical measurement), but they prove the reject-path works — a necessary condition for constant-time implementations to be meaningful. """ from __future__ import annotations import inspect import os import re import pytest # ── Source inspection ───────────────────────────────────────────────────────── def test_hmac_compare_digest_used_in_hibp_check(): """SEC-03: auth service uses hmac.compare_digest for HIBP suffix comparison. The HIBP check compares SHA-1 suffixes; using == would leak timing information about whether the prefix matches. hmac.compare_digest is the required approach. """ auth_path = os.path.join( os.path.dirname(__file__), "..", "services", "auth.py" ) with open(auth_path) as f: source = f.read() assert "hmac.compare_digest" in source, ( "SEC-03 VIOLATED: 'hmac.compare_digest' not found in services/auth.py. " "All security-sensitive comparisons must use constant-time comparison." ) def test_no_plain_equality_for_suffix_comparison(): """SEC-03: The HIBP check must not use plain == to compare hash suffixes. A plain == comparison leaks timing information — rejected by SEC-03. The implementation must use hmac.compare_digest. """ auth_path = os.path.join( os.path.dirname(__file__), "..", "services", "auth.py" ) with open(auth_path) as f: source = f.read() # Detect patterns like: candidate_suffix == suffix or suffix == candidate_suffix # These would be in the HIBP check or token comparison area # We allow == for simple type/key checks but not for suffix/token/code comparisons # Look specifically for suffix comparison using == dangerous_patterns = re.findall( r'candidate_suffix\s*==\s*\w+|suffix\s*==\s*candidate_suffix', source ) assert len(dangerous_patterns) == 0, ( f"SEC-03 VIOLATED: Plain equality (==) used for suffix comparison: " f"{dangerous_patterns}. Must use hmac.compare_digest." ) # ── Behavioral: verify_password ─────────────────────────────────────────────── def test_verify_password_correct_password_returns_true_constant_time(): """SEC-03: verify_password returns True for correct password.""" from services.auth import hash_password, verify_password h = hash_password("Correct1Password!") result = verify_password("Correct1Password!", h) assert result is True, ( "verify_password should return True for the correct password" ) def test_verify_password_wrong_password_returns_false_constant_time(): """SEC-03: verify_password returns False for incorrect password. This behavioral test confirms the rejection path works. The underlying pwdlib uses constant-time Argon2 verification. """ from services.auth import hash_password, verify_password h = hash_password("Correct1Password!") result = verify_password("WrongPassword1!", h) assert result is False, ( "SEC-03: verify_password must return False for wrong password — " "the comparison pathway is broken if this fails." ) def test_verify_password_empty_wrong_returns_false(): """SEC-03: verify_password returns False for empty string against real hash.""" from services.auth import hash_password, verify_password h = hash_password("ActualPassword1!") result = verify_password("", h) assert result is False def test_verify_password_against_invalid_hash_returns_false(): """SEC-03: verify_password returns False (not exception) for a malformed hash.""" from services.auth import verify_password # verify_password must never raise — it catches exceptions and returns False result = verify_password("anything", "not-a-valid-argon2-hash") assert result is False, ( "verify_password must return False on exception, not propagate it" ) # ── Behavioral: verify_backup_code ─────────────────────────────────────────── @pytest.mark.asyncio async def test_backup_code_verification_is_constant_time(db_session): """SEC-03: verify_backup_code checks ALL rows (no early exit on match). The implementation must iterate every unused backup code even after finding a match, to prevent timing-based enumeration of which position matched. This test checks the behavioral correctness of the implementation. """ import uuid as _uuid from db.models import User, Quota from services.auth import ( generate_backup_codes, hash_password, store_backup_codes, verify_backup_code, ) from sqlalchemy import select from db.models import BackupCode user_id = _uuid.uuid4() user = User( id=user_id, handle=f"ct_user_{user_id.hex[:6]}", email=f"ct_{user_id.hex[:6]}@example.com", password_hash=hash_password("ValidP@ss1!"), role="user", is_active=True, password_must_change=False, ) quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0) db_session.add(user) db_session.add(quota) await db_session.commit() codes = generate_backup_codes(10) await store_backup_codes(db_session, user_id, codes) # Verify: correct code returns True result = await verify_backup_code(db_session, user_id, codes[0]) assert result is True, "verify_backup_code must return True for a valid backup code" # Verify: SAME code second time returns False (marked used) result2 = await verify_backup_code(db_session, user_id, codes[0]) assert result2 is False, ( "verify_backup_code must return False when the code has already been used" ) # Verify: wrong code returns False result3 = await verify_backup_code(db_session, user_id, "XXXXXXXX") assert result3 is False, ( "verify_backup_code must return False for a code that was never issued" ) # Verify: after first code is consumed, remaining codes still work (independent rows) result4 = await verify_backup_code(db_session, user_id, codes[1]) assert result4 is True, ( "verify_backup_code must still validate unused backup codes after another was consumed" ) # Confirm the implementation inspects source — iterates all rows auth_path = os.path.join( os.path.dirname(__file__), "..", "services", "auth.py" ) with open(auth_path) as f: source = f.read() assert "matched_row" in source and "keep iterating" in source, ( "SEC-03: verify_backup_code should iterate all rows even after matching " "(constant-time invariant). Implementation comment 'keep iterating' not found." )