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
« 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.
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
16from typing import Tuple
19# ============================================================================
20# Hash format: $pbkdf2-sha256$iterations$salt$hash
21# ============================================================================
23DEFAULT_ITERATIONS = 100_000
24SALT_LENGTH = 16
25HASH_LENGTH = 32
28class PasswordHasher:
29 """Secure password hashing using PBKDF2-HMAC-SHA256.
31 Usage:
32 ph = PasswordHasher()
34 # Hash a password
35 hashed = ph.hash("my-password")
37 # Verify
38 ok = ph.verify("my-password", hashed) # True
40 # Check if rehash is needed
41 if ph.needs_upgrade(hashed):
42 new_hashed = ph.hash("my-password")
43 """
45 def __init__(self, iterations: int = DEFAULT_ITERATIONS):
46 self._iterations = iterations
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}"
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("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
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
83 # ---------- Internal ----------
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
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")
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)