1"""

2 PasswordHasher — bcrypt-like password hashing with pure stdlib.

3

4 Supports:

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

11 from __future__ import annotations

12

13 import hashlib

14 import hmac

15 import os

16 import secrets

17 from typing import Tuple

18

19

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

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

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

23

24 DEFAULT_ITERATIONS = 100_000

25 SALT_LENGTH = 16

26 HASH_LENGTH = 32

27

28

29 class 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

102 def _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

108 def _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)