test(phase-02): add Nyquist validation tests — fill SEC-05, AUTH-08, SEC-03 and frontend gaps
8 test files, 60 new tests (14 backend + 46 frontend). All green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
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."
|
||||
)
|
||||
Reference in New Issue
Block a user