Skip to content

Commit

Permalink
Implement basics with Argon2
Browse files Browse the repository at this point in the history
  • Loading branch information
frankie567 committed Feb 12, 2024
1 parent cb36916 commit 4130988
Show file tree
Hide file tree
Showing 11 changed files with 277 additions and 32 deletions.
19 changes: 2 additions & 17 deletions pwdlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
41 changes: 41 additions & 0 deletions pwdlib/_hash.py
Original file line number Diff line number Diff line change
@@ -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)
55 changes: 55 additions & 0 deletions pwdlib/exceptions.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions pwdlib/hashers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .base import HasherProtocol

__all__ = ["HasherProtocol"]
42 changes: 42 additions & 0 deletions pwdlib/hashers/argon2.py
Original file line number Diff line number Diff line change
@@ -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))
30 changes: 30 additions & 0 deletions pwdlib/hashers/base.py
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ path = "pwdlib/__init__.py"

[tool.hatch.envs.default]
python = "3.8"
features = [
"argon2",
]
dependencies = [
"mypy",
"ruff",
Expand Down Expand Up @@ -72,6 +75,12 @@ classifiers = [
]
requires-python = ">=3.8"
dependencies = [

]

[project.optional-dependencies]
argon2 = [
"argon2-cffi ==23.1.0",
]

[project.urls]
Expand Down
Empty file added tests/hashers/__init__.py
Empty file.
52 changes: 52 additions & 0 deletions tests/hashers/test_argon2.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 0 additions & 15 deletions tests/test_add.py

This file was deleted.

43 changes: 43 additions & 0 deletions tests/test_hash.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 4130988

Please sign in to comment.