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

1""" 

2Password hashing — Argon2id (preferred) with transparent bcrypt upgrade. 

3 

4New passwords are hashed with Argon2id (RFC 9106 / OWASP-recommended 

5memory-hard algorithm, resistant to GPU/ASIC brute force). 

6 

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. 

11 

12Bcrypt hashes are identified by the "$2b$" or "$2a$" prefix. 

13""" 

14 

15import argon2 # argon2-cffi 

16 

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) 

25 

26 

27def hash(password: str) -> str: 

28 """Return an Argon2id hash of *password*.""" 

29 return _ph.hash(password) 

30 

31 

32def verify(password: str, stored_hash: str) -> bool: 

33 """ 

34 Return True if *password* matches *stored_hash*. 

35 

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] 

41 

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 

52 

53 

54def needs_rehash(stored_hash: str) -> bool: 

55 """ 

56 Return True if *stored_hash* should be upgraded to a fresh Argon2id hash. 

57 

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) 

64 

65 

66def _is_bcrypt(h: str) -> bool: 

67 return h.startswith(("$2b$", "$2a$", "$2y$")) 

68 

69 

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")