Coverage for agentos/tools/password_hasher.py: 30%

56 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 11:37 +0800

1""" 

2PasswordHasher — bcrypt-like password hashing with pure stdlib. 

3 

4Supports: 

5 - Hash password (pbkdf2_hmac with SHA256, 100k iterations) 

6 - Verify password against hash 

7 - Needs-upgrade detection (for rehashing with stronger params) 

8 - Self-contained format: $pbkdf2-sha256$iterations$salt$hash 

9""" 

10 

11from __future__ import annotations 

12 

13import hashlib 

14import hmac 

15import secrets 

16from typing import Tuple 

17 

18 

19# ============================================================================ 

20# Hash format: $pbkdf2-sha256$iterations$salt$hash 

21# ============================================================================ 

22 

23DEFAULT_ITERATIONS = 100_000 

24SALT_LENGTH = 16 

25HASH_LENGTH = 32 

26 

27 

28class PasswordHasher: 

29 """Secure password hashing using PBKDF2-HMAC-SHA256. 

30 

31 Usage: 

32 ph = PasswordHasher() 

33 

34 # Hash a password 

35 hashed = ph.hash("my-password") 

36 

37 # Verify 

38 ok = ph.verify("my-password", hashed) # True 

39 

40 # Check if rehash is needed 

41 if ph.needs_upgrade(hashed): 

42 new_hashed = ph.hash("my-password") 

43 """ 

44 

45 def __init__(self, iterations: int = DEFAULT_ITERATIONS): 

46 self._iterations = iterations 

47 

48 def hash(self, password: str) -> str: 

49 """Hash a password and return the formatted hash string.""" 

50 salt = secrets.token_bytes(SALT_LENGTH) 

51 dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, self._iterations, dklen=HASH_LENGTH) 

52 salt_b64 = _b64_encode(salt) 

53 hash_b64 = _b64_encode(dk) 

54 return f"$pbkdf2-sha256${self._iterations}${salt_b64}${hash_b64}" 

55 

56 def verify(self, password: str, hashed: str) -> Tuple[bool, bool]: 

57 """Verify password against hash. Returns (valid, needs_upgrade). 

58 

59 needs_upgrade is True when hash uses weaker parameters. 

60 """ 

61 params = self._parse(hashed) 

62 if not params: 

63 return False, False 

64 

65 iterations, salt, stored_hash, algorithm = params 

66 

67 if algorithm != "pbkdf2-sha256": 

68 return False, False 

69 

70 dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations, dklen=HASH_LENGTH) 

71 valid = hmac.compare_digest(dk, stored_hash) 

72 needs_upgrade = valid and iterations < self._iterations 

73 return valid, needs_upgrade 

74 

75 def needs_upgrade(self, hashed: str) -> bool: 

76 """Check if a hash needs to be upgraded to current params.""" 

77 params = self._parse(hashed) 

78 if not params: 

79 return True 

80 iterations, _, _, algorithm = params 

81 return algorithm != "pbkdf2-sha256" or iterations < self._iterations 

82 

83 # ---------- Internal ---------- 

84 

85 @staticmethod 

86 def _parse(hashed: str) -> Tuple[int, bytes, bytes, str] | None: 

87 """Parse hash string into (iterations, salt_bytes, hash_bytes, algorithm).""" 

88 try: 

89 parts = hashed.split("$") 

90 if len(parts) != 5 or parts[0] != "": 

91 return None 

92 algorithm = parts[1] 

93 iterations = int(parts[2]) 

94 salt = _b64_decode(parts[3]) 

95 h = _b64_decode(parts[4]) 

96 return iterations, salt, h, algorithm 

97 except (ValueError, IndexError): 

98 return None 

99 

100 

101def _b64_encode(data: bytes) -> str: 

102 """Base64 encode without padding (URL-safe style for hash storage).""" 

103 import base64 

104 return base64.b64encode(data).rstrip(b"=").decode("ascii") 

105 

106 

107def _b64_decode(s: str) -> bytes: 

108 import base64 

109 padding = 4 - len(s) % 4 

110 if padding != 4: 

111 s += "=" * padding 

112 return base64.b64decode(s)