diff --git a/algorithms.py b/algorithms.py index c549f16..e5451af 100644 --- a/algorithms.py +++ b/algorithms.py @@ -38,3 +38,7 @@ def get_algorithm(name: str) -> Algorithm: def list_algorithms() -> list[str]: """Return list of registered algorithm identifiers.""" return sorted(_ALGORITHMS.keys()) + + +# Import to auto-register algorithms +import pbkdf2_algorithm # noqa: E402, F401 diff --git a/pbkdf2_algorithm.py b/pbkdf2_algorithm.py new file mode 100644 index 0000000..4c1d3ee --- /dev/null +++ b/pbkdf2_algorithm.py @@ -0,0 +1,69 @@ +"""PBKDF2 algorithm implementation.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import os + +DEFAULT_SALT_BYTES = 16 +DEFAULT_ITERATIONS = int(os.environ.get("PBKDF2_ITERATIONS", "200000")) + + +class PBKDF2Algorithm: + """PBKDF2-HMAC-SHA256 password hashing algorithm.""" + + identifier = "pbkdf2" + + def hash( + self, + password: str, + *, + iterations: int | None = None, + salt_bytes: int = DEFAULT_SALT_BYTES, + ) -> tuple[str, str]: + """Hash a password using PBKDF2-HMAC-SHA256.""" + iterations = iterations or DEFAULT_ITERATIONS + salt = os.urandom(salt_bytes) + derived = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + ) + return ( + base64.b64encode(salt).decode("utf-8"), + base64.b64encode(derived).decode("utf-8"), + ) + + def verify( + self, + password: str, + salt_b64: str, + hash_b64: str, + *, + iterations: int | None = None, + ) -> bool: + """Verify a password against PBKDF2 salt and hash.""" + iterations = iterations or DEFAULT_ITERATIONS + try: + salt = base64.b64decode(salt_b64, validate=True) + stored_hash = base64.b64decode(hash_b64, validate=True) + except (binascii.Error, ValueError): + return False + + derived = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + ) + return hmac.compare_digest(derived, stored_hash) + + +from algorithms import register_algorithm + +# Auto-register when module is imported +register_algorithm(PBKDF2Algorithm()) diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index ac4a15e..59057c8 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -21,3 +21,20 @@ def test_get_algorithm_unknown_raises_error(): """Verify unknown algorithm raises ValueError.""" with pytest.raises(ValueError, match="Unknown algorithm"): get_algorithm("unknown") + + +def test_pbkdf2_algorithm_hash_round_trip(): + """Verify PBKDF2 algorithm can hash and verify passwords.""" + algo = get_algorithm("pbkdf2") + salt, hashed = algo.hash("test password") + assert algo.verify("test password", salt, hashed) + assert not algo.verify("wrong password", salt, hashed) + + +def test_pbkdf2_algorithm_respects_iterations(): + """Verify PBKDF2 algorithm respects custom iterations.""" + algo = get_algorithm("pbkdf2") + salt1, hash1 = algo.hash("test", iterations=100000) + salt2, hash2 = algo.hash("test", iterations=200000) + # Different iterations should produce different hashes even with same password + assert hash1 != hash2