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
« 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.
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 os
16import secrets
17from typing import Tuple
20# ============================================================================
21# Hash format: $pbkdf2-sha256$iterations$salt$hash
22# ============================================================================
24DEFAULT_ITERATIONS = 100_000
25SALT_LENGTH = 16
26HASH_LENGTH = 32
29class PasswordHasher:
30 """Secure password hashing using PBKDF2-HMAC-SHA256.
32 Usage:
33 ph = PasswordHasher()
35 # Hash a password
36 hashed = ph.hash("my-password")
38 # Verify
39 ok = ph.verify("my-password", hashed) # True
41 # Check if rehash is needed
42 if ph.needs_upgrade(hashed):
43 new_hashed = ph.hash("my-password")
44 """
46 def __init__(self, iterations: int = DEFAULT_ITERATIONS):
47 self._iterations = iterations
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}"
57 def verify(self, password: str, hashed: str) -> Tuple[bool, bool]:
58 """Verify password against hash. Returns (valid, needs_upgrade).
60 needs_upgrade is True when hash uses weaker parameters.
61 """
62 params = self._parse(hashed)
63 if not params:
64 return False, False
66 iterations, salt, stored_hash, algorithm = params
68 if algorithm != "pbkdf2-sha256":
69 return False, False
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
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
84 # ---------- Internal ----------
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
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")
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)