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

55 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 20:49 +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 

16 

17# ============================================================================ 

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

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

20 

21DEFAULT_ITERATIONS = 100_000 

22SALT_LENGTH = 16 

23HASH_LENGTH = 32 

24 

25 

26class PasswordHasher: 

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

28 

29 Usage: 

30 ph = PasswordHasher() 

31 

32 # Hash a password 

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

34 

35 # Verify 

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

37 

38 # Check if rehash is needed 

39 if ph.needs_upgrade(hashed): 

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

41 """ 

42 

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

44 self._iterations = iterations 

45 

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

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

48 salt = secrets.token_bytes(SALT_LENGTH) 

49 dk = hashlib.pbkdf2_hmac( 

50 "sha256", password.encode("utf-8"), salt, self._iterations, dklen=HASH_LENGTH 

51 ) 

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( 

71 "sha256", password.encode("utf-8"), salt, iterations, dklen=HASH_LENGTH 

72 ) 

73 valid = hmac.compare_digest(dk, stored_hash) 

74 needs_upgrade = valid and iterations < self._iterations 

75 return valid, needs_upgrade 

76 

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

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

79 params = self._parse(hashed) 

80 if not params: 

81 return True 

82 iterations, _, _, algorithm = params 

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

84 

85 # ---------- Internal ---------- 

86 

87 @staticmethod 

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

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

90 try: 

91 parts = hashed.split("$") 

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

93 return None 

94 algorithm = parts[1] 

95 iterations = int(parts[2]) 

96 salt = _b64_decode(parts[3]) 

97 h = _b64_decode(parts[4]) 

98 return iterations, salt, h, algorithm 

99 except (ValueError, IndexError): 

100 return None 

101 

102 

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

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

105 import base64 

106 

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

108 

109 

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

111 import base64 

112 

113 padding = 4 - len(s) % 4 

114 if padding != 4: 

115 s += "=" * padding 

116 return base64.b64decode(s)