Coverage for agentos/tools/password_hasher.py: 0%
55 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 10:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 10:59 +0800
1"""
2PasswordHasher — bcrypt-like password hashing with pure stdlib.
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"""
11from __future__ import annotations
13import hashlib
14import hmac
15import secrets
17# ============================================================================
18# Hash format: $pbkdf2-sha256$iterations$salt$hash
19# ============================================================================
21DEFAULT_ITERATIONS = 100_000
22SALT_LENGTH = 16
23HASH_LENGTH = 32
26class PasswordHasher:
27 """Secure password hashing using PBKDF2-HMAC-SHA256.
29 Usage:
30 ph = PasswordHasher()
32 # Hash a password
33 hashed = ph.hash("my-password")
35 # Verify
36 ok = ph.verify("my-password", hashed) # True
38 # Check if rehash is needed
39 if ph.needs_upgrade(hashed):
40 new_hashed = ph.hash("my-password")
41 """
43 def __init__(self, iterations: int = DEFAULT_ITERATIONS):
44 self._iterations = iterations
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}"
56 def verify(self, password: str, hashed: str) -> tuple[bool, bool]:
57 """Verify password against hash. Returns (valid, needs_upgrade).
59 needs_upgrade is True when hash uses weaker parameters.
60 """
61 params = self._parse(hashed)
62 if not params:
63 return False, False
65 iterations, salt, stored_hash, algorithm = params
67 if algorithm != "pbkdf2-sha256":
68 return False, False
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
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
85 # ---------- Internal ----------
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
103def _b64_encode(data: bytes) -> str:
104 """Base64 encode without padding (URL-safe style for hash storage)."""
105 import base64
107 return base64.b64encode(data).rstrip(b"=").decode("ascii")
110def _b64_decode(s: str) -> bytes:
111 import base64
113 padding = 4 - len(s) % 4
114 if padding != 4:
115 s += "=" * padding
116 return base64.b64decode(s)