From 41309888b26c2196fb51905ac68e1f7a84399cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Mon, 12 Feb 2024 09:13:19 +0100 Subject: [PATCH] Implement basics with Argon2 --- pwdlib/__init__.py | 19 ++----------- pwdlib/_hash.py | 41 +++++++++++++++++++++++++++ pwdlib/exceptions.py | 55 ++++++++++++++++++++++++++++++++++++ pwdlib/hashers/__init__.py | 3 ++ pwdlib/hashers/argon2.py | 42 +++++++++++++++++++++++++++ pwdlib/hashers/base.py | 30 ++++++++++++++++++++ pyproject.toml | 9 ++++++ tests/hashers/__init__.py | 0 tests/hashers/test_argon2.py | 52 ++++++++++++++++++++++++++++++++++ tests/test_add.py | 15 ---------- tests/test_hash.py | 43 ++++++++++++++++++++++++++++ 11 files changed, 277 insertions(+), 32 deletions(-) create mode 100644 pwdlib/_hash.py create mode 100644 pwdlib/exceptions.py create mode 100644 pwdlib/hashers/__init__.py create mode 100644 pwdlib/hashers/argon2.py create mode 100644 pwdlib/hashers/base.py create mode 100644 tests/hashers/__init__.py create mode 100644 tests/hashers/test_argon2.py delete mode 100644 tests/test_add.py create mode 100644 tests/test_hash.py diff --git a/pwdlib/__init__.py b/pwdlib/__init__.py index e5787d5..8d316d2 100644 --- a/pwdlib/__init__.py +++ b/pwdlib/__init__.py @@ -2,21 +2,6 @@ __version__ = "0.0.0" +from ._hash import PasswordHash -def add(a: int, b: int) -> int: - """ - Add two integers. - - Args: - a: - The first operand. - b: - The second operand. - - Examples: - Add two integers - - r = add(2, 3) - print(r) # 5 - """ - return a + b +__all__ = ["PasswordHash"] diff --git a/pwdlib/_hash.py b/pwdlib/_hash.py new file mode 100644 index 0000000..93c2776 --- /dev/null +++ b/pwdlib/_hash.py @@ -0,0 +1,41 @@ +import typing + +from . import exceptions +from .hashers import HasherProtocol + + +class PasswordHash: + def __init__(self, hashers: typing.Sequence[HasherProtocol]) -> None: + assert len(hashers) > 0, "You must specify at least one hasher." + self.hashers = hashers + self.current_hasher = hashers[0] + + def hash( + self, + password: typing.Union[str, bytes], + *, + salt: typing.Union[bytes, None] = None, + ) -> str: + return self.current_hasher.hash(password, salt=salt) + + def verify( + self, hash: typing.Union[str, bytes], password: typing.Union[str, bytes] + ) -> bool: + for hasher in self.hashers: + if hasher.identify(hash): + return hasher.verify(hash, password) + raise exceptions.UnknownHashError(hash) + + def verify_and_update( + self, hash: typing.Union[str, bytes], password: typing.Union[str, bytes] + ) -> typing.Tuple[bool, typing.Union[str, None]]: + for hasher in self.hashers: + if hasher.identify(hash): + if not hasher.verify(hash, password): + return False, None + else: + updated_hash: typing.Union[str, None] = None + if hasher != self.current_hasher or hasher.check_needs_rehash(hash): + updated_hash = hasher.hash(password) + return True, updated_hash + raise exceptions.UnknownHashError(hash) diff --git a/pwdlib/exceptions.py b/pwdlib/exceptions.py new file mode 100644 index 0000000..5afe31b --- /dev/null +++ b/pwdlib/exceptions.py @@ -0,0 +1,55 @@ +import typing + + +class PwdlibError(Exception): + """ + Base pwdlib error. + """ + + def __init__(self, message: str) -> None: + """ + Args: + message: + The error message. + """ + self.message = message + super().__init__(message) + + +class HasherNotAvailable(PwdlibError): + """ + Error raised when an unavailable hash algorithm was installed. + """ + + def __init__(self, hasher: str) -> None: + """ + Args: + hasher: + The unavailable hash algorithm. + """ + self.hasher = hasher + message = ( + f"The {hasher} hash algorithm is not available. " + "Are you sure it's installed? " + f"Try to run `pip install pdwlib[{hasher}]`." + ) + super().__init__(message) + + +class UnknownHashError(PwdlibError): + """ + Error raised when the hash can't be identified from the list of provided hashers. + """ + + def __init__(self, hash: typing.Union[str, bytes]) -> None: + """ + Args: + hash: + The hash we failed to identify. + """ + self.hash = hash + message = ( + "This hash can't be identified. " + "Make sure it's valid and that its corresponding hasher is enabled." + ) + super().__init__(message) diff --git a/pwdlib/hashers/__init__.py b/pwdlib/hashers/__init__.py new file mode 100644 index 0000000..2521edd --- /dev/null +++ b/pwdlib/hashers/__init__.py @@ -0,0 +1,3 @@ +from .base import HasherProtocol + +__all__ = ["HasherProtocol"] diff --git a/pwdlib/hashers/argon2.py b/pwdlib/hashers/argon2.py new file mode 100644 index 0000000..acdf1d5 --- /dev/null +++ b/pwdlib/hashers/argon2.py @@ -0,0 +1,42 @@ +import typing + +try: + import argon2.exceptions + from argon2 import PasswordHasher +except ImportError as e: + from ..exceptions import HasherNotAvailable + + raise HasherNotAvailable("argon2") from e + +from .base import HasherProtocol, ensure_str_hash + + +class Argon2Hasher(HasherProtocol): + def __init__(self) -> None: + self._hasher = PasswordHasher() # TODO: handle parameters + + @classmethod + def identify(cls, hash: typing.Union[str, bytes]) -> bool: + return ensure_str_hash(hash).startswith("$argon2id$") + + def hash( + self, + password: typing.Union[str, bytes], + *, + salt: typing.Union[bytes, None] = None, + ) -> str: + return self._hasher.hash(password, salt=salt) + + def verify( + self, hash: typing.Union[str, bytes], password: typing.Union[str, bytes] + ) -> bool: + try: + return self._hasher.verify(hash, password) + except ( + argon2.exceptions.VerificationError, + argon2.exceptions.InvalidHashError, + ): + return False + + def check_needs_rehash(self, hash: typing.Union[str, bytes]) -> bool: + return self._hasher.check_needs_rehash(ensure_str_hash(hash)) diff --git a/pwdlib/hashers/base.py b/pwdlib/hashers/base.py new file mode 100644 index 0000000..8d6b151 --- /dev/null +++ b/pwdlib/hashers/base.py @@ -0,0 +1,30 @@ +import typing + + +def ensure_str_hash(hash: typing.Union[str, bytes]) -> str: + return hash.decode("ascii") if isinstance(hash, bytes) else typing.cast(str, hash) + + +class HasherProtocol(typing.Protocol): + @classmethod + def identify(cls, hash: typing.Union[str, bytes]) -> bool: + ... + + def hash( + self, + password: typing.Union[str, bytes], + *, + salt: typing.Union[bytes, None] = None, + ) -> str: + ... + + def verify( + self, hash: typing.Union[str, bytes], password: typing.Union[str, bytes] + ) -> bool: + ... + + def check_needs_rehash(self, hash: typing.Union[str, bytes]) -> bool: + ... + + +__all__ = ["HasherProtocol", "ensure_str_hash"] diff --git a/pyproject.toml b/pyproject.toml index af4410e..9f93f30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ path = "pwdlib/__init__.py" [tool.hatch.envs.default] python = "3.8" +features = [ + "argon2", +] dependencies = [ "mypy", "ruff", @@ -72,6 +75,12 @@ classifiers = [ ] requires-python = ">=3.8" dependencies = [ + +] + +[project.optional-dependencies] +argon2 = [ + "argon2-cffi ==23.1.0", ] [project.urls] diff --git a/tests/hashers/__init__.py b/tests/hashers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hashers/test_argon2.py b/tests/hashers/test_argon2.py new file mode 100644 index 0000000..c500815 --- /dev/null +++ b/tests/hashers/test_argon2.py @@ -0,0 +1,52 @@ +import typing + +import pytest + +from pwdlib.hashers.argon2 import Argon2Hasher + +_PASSWORD = "herminetincture" + +_HASHER = Argon2Hasher() +_HASH_STR = _HASHER.hash(_PASSWORD) +_HASH_BYTES = _HASH_STR.encode("ascii") + + +@pytest.fixture +def argon2_hasher() -> Argon2Hasher: + return Argon2Hasher() + + +@pytest.mark.parametrize( + "hash,result", + [ + (_HASH_STR, True), + (_HASH_BYTES, True), + ("INVALID_HASH", False), + (b"INVALID_HASH", False), + ], +) +def test_identify(hash: typing.Union[str, bytes], result: bool) -> None: + assert Argon2Hasher.identify(hash) == result + + +def test_hash(argon2_hasher: Argon2Hasher) -> None: + hash = argon2_hasher.hash("herminetincture") + assert isinstance(hash, str) + + +@pytest.mark.parametrize( + "hash,password,result", + [ + (_HASH_STR, _PASSWORD, True), + (_HASH_BYTES, _PASSWORD, True), + (_HASH_STR, "INVALID_PASSWORD", False), + (_HASH_BYTES, "INVALID_PASSWORD", False), + ], +) +def test_verify( + hash: typing.Union[str, bytes], + password: str, + result: bool, + argon2_hasher: Argon2Hasher, +) -> None: + assert argon2_hasher.verify(hash, password) == result diff --git a/tests/test_add.py b/tests/test_add.py deleted file mode 100644 index a498c40..0000000 --- a/tests/test_add.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - -from pwdlib import add - - -@pytest.mark.parametrize( - "a,b,result", - [ - (0, 0, 0), - (1, 1, 2), - (3, 2, 5), - ], -) -def test_add(a: int, b: int, result: int): - assert add(a, b) == result diff --git a/tests/test_hash.py b/tests/test_hash.py new file mode 100644 index 0000000..44d5fa6 --- /dev/null +++ b/tests/test_hash.py @@ -0,0 +1,43 @@ +import typing + +import pytest + +from pwdlib import PasswordHash, exceptions +from pwdlib.hashers.argon2 import Argon2Hasher + +_PASSWORD = "herminetincture" + +_ARGON2_HASHER = Argon2Hasher() +_ARGON2_HASH_STR = _ARGON2_HASHER.hash(_PASSWORD) + + +@pytest.fixture +def password_hash() -> PasswordHash: + return PasswordHash((Argon2Hasher(),)) + + +def test_hash(password_hash: PasswordHash) -> None: + hash = password_hash.hash("herminetincture") + assert isinstance(hash, str) + assert password_hash.current_hasher.identify(hash) + + +@pytest.mark.parametrize( + "hash,password,result", + [ + (_ARGON2_HASH_STR, _PASSWORD, True), + (_ARGON2_HASH_STR, "INVALID_PASSWORD", False), + ], +) +def test_verify( + hash: typing.Union[str, bytes], + password: str, + result: bool, + password_hash: PasswordHash, +) -> None: + assert password_hash.verify(hash, password) == result + + +def test_verify_unknown_hash(password_hash: PasswordHash) -> None: + with pytest.raises(exceptions.UnknownHashError): + assert password_hash.verify("INVALID_HASH", _PASSWORD)