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

57 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-03 08:24 +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 os 

16import secrets 

17from typing import Tuple 

18 

19 

20# ============================================================================ 

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

22# ============================================================================ 

23 

24DEFAULT_ITERATIONS = 100_000 

25SALT_LENGTH = 16 

26HASH_LENGTH = 32 

27 

28 

29class PasswordHasher: 

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

31 

32 Usage: 

33 ph = PasswordHasher() 

34 

35 # Hash a password 

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

37 

38 # Verify 

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

40 

41 # Check if rehash is needed 

42 if ph.needs_upgrade(hashed): 

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

44 """ 

45 

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

47 self._iterations = iterations 

48 

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

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

51 salt = secrets.token_bytes(SALT_LENGTH) 

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

53 salt_b64 = _b64_encode(salt) 

54 hash_b64 = _b64_encode(dk) 

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

56 

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

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

59 

60 needs_upgrade is True when hash uses weaker parameters. 

61 """ 

62 params = self._parse(hashed) 

63 if not params: 

64 return False, False 

65 

66 iterations, salt, stored_hash, algorithm = params 

67 

68 if algorithm != "pbkdf2-sha256": 

69 return False, False 

70 

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

72 valid = hmac.compare_digest(dk, stored_hash) 

73 needs_upgrade = valid and iterations < self._iterations 

74 return valid, needs_upgrade 

75 

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

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

78 params = self._parse(hashed) 

79 if not params: 

80 return True 

81 iterations, _, _, algorithm = params 

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

83 

84 # ---------- Internal ---------- 

85 

86 @staticmethod 

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

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

89 try: 

90 parts = hashed.split("$") 

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

92 return None 

93 algorithm = parts[1] 

94 iterations = int(parts[2]) 

95 salt = _b64_decode(parts[3]) 

96 h = _b64_decode(parts[4]) 

97 return iterations, salt, h, algorithm 

98 except (ValueError, IndexError): 

99 return None 

100 

101 

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

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

104 import base64 

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

106 

107 

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

109 import base64 

110 padding = 4 - len(s) % 4 

111 if padding != 4: 

112 s += "=" * padding 

113 return base64.b64decode(s)