Coverage for src / taipanstack / security / password.py: 100%
58 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-12 21:18 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-12 21:18 +0000
1"""
2Password hashing and verification utilities.
4This module provides functions for secure password management using Argon2,
5with fallback verification support for PBKDF2-HMAC-SHA256.
6"""
8import hashlib
9import secrets
11import argon2
12from argon2.exceptions import VerifyMismatchError
13from pydantic import SecretStr
15# Global Argon2 PasswordHasher instance
16_ph = argon2.PasswordHasher()
18# Legacy PBKDF2 Constants
19LEGACY_HASH_ALGORITHM = "sha256"
20LEGACY_FORMAT = "pbkdf2_sha256"
21MAX_LEGACY_ITERATIONS = 1_000_000
24# Constants to prevent DoS via massive processing times
25MAX_PASSWORD_LENGTH = 1024
28def _get_password_str(password: str | SecretStr) -> str:
29 if not isinstance(password, (str, SecretStr)):
30 msg = "password must be a string or SecretStr"
31 raise TypeError(msg)
33 if isinstance(password, SecretStr):
34 return password.get_secret_value()
35 return password
38def hash_password(password: str | SecretStr) -> str:
39 """
40 Hash a password using Argon2id.
42 Args:
43 password: The plaintext password to hash.
45 Returns:
46 The hashed password in Argon2 format.
48 Raises:
49 TypeError: If `password` is not a str or SecretStr.
50 ValueError: If `password` length exceeds the maximum allowed or is empty.
52 """
53 password_str = _get_password_str(password)
55 if not password_str:
56 msg = "password cannot be empty"
57 raise ValueError(msg)
59 if len(password_str) > MAX_PASSWORD_LENGTH:
60 msg = f"password length exceeds {MAX_PASSWORD_LENGTH} characters"
61 raise ValueError(msg)
63 return _ph.hash(password_str)
66def _verify_legacy_pbkdf2(password_str: str, password_hash: str) -> bool:
67 try:
68 parts = password_hash.split("$")
69 if len(parts) != 4: # noqa: PLR2004
70 return False
72 _algorithm, iterations_str, salt_hex, hash_hex = parts
74 iterations = int(iterations_str)
75 if iterations > MAX_LEGACY_ITERATIONS:
76 return False
78 salt = bytes.fromhex(salt_hex)
79 stored_hash = bytes.fromhex(hash_hex)
81 new_hash = hashlib.pbkdf2_hmac(
82 LEGACY_HASH_ALGORITHM,
83 password_str.encode("utf-8"),
84 salt,
85 iterations,
86 )
88 return secrets.compare_digest(new_hash, stored_hash)
89 except (ValueError, TypeError, OverflowError):
90 return False
93def _verify_argon2(password_str: str, password_hash: str) -> bool:
94 try:
95 return _ph.verify(password_hash, password_str)
96 except (
97 VerifyMismatchError,
98 ValueError,
99 TypeError,
100 argon2.exceptions.InvalidHashError,
101 argon2.exceptions.VerificationError,
102 ):
103 return False
106def verify_password(password: str | SecretStr, password_hash: str) -> bool:
107 """
108 Verify a password against an Argon2 or legacy PBKDF2-HMAC-SHA256 hash.
110 Args:
111 password: The plaintext password to verify.
112 password_hash: The stored password hash.
114 Returns:
115 True if the password matches the hash, False otherwise.
117 Raises:
118 TypeError: If `password` or `password_hash` are not the correct types.
120 """
121 password_str = _get_password_str(password)
123 if not isinstance(password_hash, str):
124 msg = "password_hash must be a string"
125 raise TypeError(msg)
127 if not password_str:
128 return False
130 if len(password_str) > MAX_PASSWORD_LENGTH:
131 return False
133 if password_hash.startswith(LEGACY_FORMAT + "$"):
134 return _verify_legacy_pbkdf2(password_str, password_hash)
136 return _verify_argon2(password_str, password_hash)