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

1""" 

2Password hashing and verification utilities. 

3 

4This module provides functions for secure password management using Argon2, 

5with fallback verification support for PBKDF2-HMAC-SHA256. 

6""" 

7 

8import hashlib 

9import secrets 

10 

11import argon2 

12from argon2.exceptions import VerifyMismatchError 

13from pydantic import SecretStr 

14 

15# Global Argon2 PasswordHasher instance 

16_ph = argon2.PasswordHasher() 

17 

18# Legacy PBKDF2 Constants 

19LEGACY_HASH_ALGORITHM = "sha256" 

20LEGACY_FORMAT = "pbkdf2_sha256" 

21MAX_LEGACY_ITERATIONS = 1_000_000 

22 

23 

24# Constants to prevent DoS via massive processing times 

25MAX_PASSWORD_LENGTH = 1024 

26 

27 

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) 

32 

33 if isinstance(password, SecretStr): 

34 return password.get_secret_value() 

35 return password 

36 

37 

38def hash_password(password: str | SecretStr) -> str: 

39 """ 

40 Hash a password using Argon2id. 

41 

42 Args: 

43 password: The plaintext password to hash. 

44 

45 Returns: 

46 The hashed password in Argon2 format. 

47 

48 Raises: 

49 TypeError: If `password` is not a str or SecretStr. 

50 ValueError: If `password` length exceeds the maximum allowed or is empty. 

51 

52 """ 

53 password_str = _get_password_str(password) 

54 

55 if not password_str: 

56 msg = "password cannot be empty" 

57 raise ValueError(msg) 

58 

59 if len(password_str) > MAX_PASSWORD_LENGTH: 

60 msg = f"password length exceeds {MAX_PASSWORD_LENGTH} characters" 

61 raise ValueError(msg) 

62 

63 return _ph.hash(password_str) 

64 

65 

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 

71 

72 _algorithm, iterations_str, salt_hex, hash_hex = parts 

73 

74 iterations = int(iterations_str) 

75 if iterations > MAX_LEGACY_ITERATIONS: 

76 return False 

77 

78 salt = bytes.fromhex(salt_hex) 

79 stored_hash = bytes.fromhex(hash_hex) 

80 

81 new_hash = hashlib.pbkdf2_hmac( 

82 LEGACY_HASH_ALGORITHM, 

83 password_str.encode("utf-8"), 

84 salt, 

85 iterations, 

86 ) 

87 

88 return secrets.compare_digest(new_hash, stored_hash) 

89 except (ValueError, TypeError, OverflowError): 

90 return False 

91 

92 

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 

104 

105 

106def verify_password(password: str | SecretStr, password_hash: str) -> bool: 

107 """ 

108 Verify a password against an Argon2 or legacy PBKDF2-HMAC-SHA256 hash. 

109 

110 Args: 

111 password: The plaintext password to verify. 

112 password_hash: The stored password hash. 

113 

114 Returns: 

115 True if the password matches the hash, False otherwise. 

116 

117 Raises: 

118 TypeError: If `password` or `password_hash` are not the correct types. 

119 

120 """ 

121 password_str = _get_password_str(password) 

122 

123 if not isinstance(password_hash, str): 

124 msg = "password_hash must be a string" 

125 raise TypeError(msg) 

126 

127 if not password_str: 

128 return False 

129 

130 if len(password_str) > MAX_PASSWORD_LENGTH: 

131 return False 

132 

133 if password_hash.startswith(LEGACY_FORMAT + "$"): 

134 return _verify_legacy_pbkdf2(password_str, password_hash) 

135 

136 return _verify_argon2(password_str, password_hash)