Coverage for app/pw_hash.py: 100%
24 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
1"""
2Password hashing — Argon2id (preferred) with transparent bcrypt upgrade.
4New passwords are hashed with Argon2id (RFC 9106 / OWASP-recommended
5memory-hard algorithm, resistant to GPU/ASIC brute force).
7Existing bcrypt hashes are still verified correctly. On successful login the
8caller should invoke `needs_rehash()` and, when True, re-hash with `hash()`.
9This completes the migration to Argon2id transparently at login time without
10requiring a forced password reset.
12Bcrypt hashes are identified by the "$2b$" or "$2a$" prefix.
13"""
15import argon2 # argon2-cffi
17_ph = argon2.PasswordHasher(
18 time_cost=2, # iterations (OWASP 2024 minimum: 2)
19 memory_cost=65536, # 64 MiB
20 parallelism=2,
21 hash_len=32,
22 salt_len=16,
23 type=argon2.Type.ID,
24)
27def hash(password: str) -> str:
28 """Return an Argon2id hash of *password*."""
29 return _ph.hash(password)
32def verify(password: str, stored_hash: str) -> bool:
33 """
34 Return True if *password* matches *stored_hash*.
36 Supports both Argon2id (new) and bcrypt (legacy) hashes.
37 Raises no exception on mismatch — returns False instead.
38 """
39 if _is_bcrypt(stored_hash):
40 import bcrypt as _bcrypt # pyright: ignore[reportMissingImports]
42 try:
43 return _bcrypt.checkpw(password.encode(), stored_hash.encode())
44 except Exception:
45 return False
46 try:
47 return _ph.verify(stored_hash, password)
48 except argon2.exceptions.VerifyMismatchError:
49 return False
50 except Exception:
51 return False
54def needs_rehash(stored_hash: str) -> bool:
55 """
56 Return True if *stored_hash* should be upgraded to a fresh Argon2id hash.
58 Always True for bcrypt hashes. Also True if the Argon2id parameters have
59 changed (argon2-cffi checks this automatically via check_needs_rehash).
60 """
61 if _is_bcrypt(stored_hash):
62 return True
63 return _ph.check_needs_rehash(stored_hash)
66def _is_bcrypt(h: str) -> bool:
67 return h.startswith(("$2b$", "$2a$", "$2y$"))
70# Pre-computed Argon2id dummy hash used to equalise timing when no user record
71# is found (prevents timing-based account enumeration — CWE-208).
72DUMMY_HASH: str = hash("dummy-timing-equalization-placeholder")