diff --git a/libpass/hashers/argon2.py b/libpass/hashers/argon2.py new file mode 100644 index 00000000..3f78ad17 --- /dev/null +++ b/libpass/hashers/argon2.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import contextlib +from typing import Literal + +import argon2 +from argon2.exceptions import InvalidHashError, VerifyMismatchError + +from libpass._utils.bytes import StrOrBytes, as_bytes, as_str +from libpass.hashers.abc import PasswordHasher +from libpass.inspect.phc import inspect_phc +from libpass.inspect.phc.defs import any_argon_phc + + +class Argon2Hasher(PasswordHasher): + def __init__( + self, + time_cost: int = argon2.DEFAULT_TIME_COST, + memory_cost: int = argon2.DEFAULT_MEMORY_COST, + parallelism: int = argon2.DEFAULT_PARALLELISM, + hash_len: int = argon2.DEFAULT_HASH_LENGTH, + salt_len: int = argon2.DEFAULT_RANDOM_SALT_LENGTH, + type: Literal["d", "i", "id"] = "id", + ): + self._hasher = argon2.PasswordHasher( + time_cost=time_cost, + memory_cost=memory_cost, + parallelism=parallelism, + hash_len=hash_len, + salt_len=salt_len, + type=argon2.Type[type.upper()], + ) + + def hash(self, secret: StrOrBytes, salt: str | None = None) -> str: + return self._hasher.hash( + password=secret, salt=as_bytes(salt) if salt is not None else None + ) + + def verify(self, hash: StrOrBytes, secret: StrOrBytes) -> bool: + with contextlib.suppress(InvalidHashError, VerifyMismatchError): + return self._hasher.verify(hash=hash, password=secret) + return False + + def identify(self, hash: StrOrBytes) -> bool: + return inspect_phc(hash=as_str(hash), definition=any_argon_phc) is not None diff --git a/libpass/inspect/phc/_phc.py b/libpass/inspect/phc/_phc.py index 5c36730d..5cbccc77 100644 --- a/libpass/inspect/phc/_phc.py +++ b/libpass/inspect/phc/_phc.py @@ -5,6 +5,7 @@ import functools import re import typing +from collections.abc import Sequence from typing import TYPE_CHECKING, ClassVar, Optional, TypeVar if TYPE_CHECKING: @@ -72,7 +73,22 @@ def _parse_phc_def(definition: type[TPHC]) -> _PHCDefinitionInfo: _parse_phc_def = functools.cache(_parse_phc_def) -def inspect_phc(hash: str, definition: type[TPHC]) -> TPHC | None: +def _choose_definition( + definitions: Sequence[type[TPHC]] | type[TPHC], id: str | None, version: int | None +) -> type[TPHC] | None: + if not isinstance(definitions, Sequence): + definitions = (definitions,) + + for definition in definitions: + if definition.id == id and definition.version == version: + return definition + return None + + +def inspect_phc( + hash: str, + definition: Sequence[type[TPHC]] | type[TPHC], +) -> TPHC | None: """ Parses PHC-style formatted string @@ -87,7 +103,8 @@ def inspect_phc(hash: str, definition: type[TPHC]) -> TPHC | None: id_ = groups["id"] version = int(groups["version"]) if groups["version"] is not None else None - if id_ != definition.id or version != definition.version: + chosen_definition = _choose_definition(definition, id=id_, version=version) + if chosen_definition is None: return None salt = groups["salt"] @@ -96,8 +113,8 @@ def inspect_phc(hash: str, definition: type[TPHC]) -> TPHC | None: key: value for key, value in (p.split("=") for p in groups["params"].split(",")) } - definition_info = _parse_phc_def(definition) - return definition( + definition_info = _parse_phc_def(chosen_definition) + return chosen_definition( salt=salt, hash=hash, **{ diff --git a/libpass/inspect/phc/defs.py b/libpass/inspect/phc/defs.py index 66230742..b7681e3a 100644 --- a/libpass/inspect/phc/defs.py +++ b/libpass/inspect/phc/defs.py @@ -1,20 +1,49 @@ import dataclasses -from typing import Annotated +from typing import Annotated, Literal from libpass.inspect.phc import PHC, Param -__all__ = ["Argon2PHC", "BcryptSHA256PHCV2"] +__all__ = [ + "Argon2IdPHC", + "Argon2DPHC", + "Argon2IPHC", + "any_argon_phc", + "BcryptSHA256PHCV2", + "BaseArgon2PHC", +] @dataclasses.dataclass -class Argon2PHC(PHC): - id = "argon2id" +class BaseArgon2PHC(PHC): + id = "" version = 19 memory_cost: Annotated[int, Param("m")] time_cost: Annotated[int, Param("t")] parallelism_cost: Annotated[int, Param("p")] + @property + def type(self) -> Literal["i", "d", "id"]: + return self.id.split("argon2")[1] # type: ignore[return-value] + + +@dataclasses.dataclass +class Argon2IdPHC(BaseArgon2PHC): + id = "argon2id" + + +@dataclasses.dataclass +class Argon2IPHC(BaseArgon2PHC): + id = "argon2i" + + +@dataclasses.dataclass +class Argon2DPHC(BaseArgon2PHC): + id = "argon2d" + + +any_argon_phc = (Argon2IdPHC, Argon2IPHC, Argon2DPHC) + @dataclasses.dataclass class BcryptSHA256PHCV2(PHC): diff --git a/tests/libpass/hashers/test_argon2.py b/tests/libpass/hashers/test_argon2.py new file mode 100644 index 00000000..3993f97f --- /dev/null +++ b/tests/libpass/hashers/test_argon2.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import pytest + +from libpass.errors import Panic +from libpass.hashers.argon2 import Argon2Hasher +from libpass.inspect.phc import inspect_phc +from libpass.inspect.phc.defs import ( + BaseArgon2PHC, + any_argon_phc, +) + + +@pytest.fixture +def hasher() -> Argon2Hasher: + return Argon2Hasher() + + +@pytest.mark.parametrize( + ("secret", "salt", "hash"), + [ + ( + "password", + "somesalt", + "$argon2i$v=19$m=65536,t=2,p=1$c29tZXNhbHQ$wWKIMhR9lyDFvRz9YTZweHKfbftvj+qf+YFY4NeBbtA", + ), + ( + "password", + "somesalt", + "$argon2i$v=19$m=1048576,t=2,p=1$c29tZXNhbHQ$0Vh6ygkiw7XWqD7asxvuPE667zQu1hJ6VdGbI1GtH0E", + ), + ( + "password", + "somesalt", + "$argon2i$v=19$m=262144,t=2,p=1$c29tZXNhbHQ$KW266AuAfNzqrUSudBtQbxTbCVkmexg7EY+bJCKbx8s", + ), + ( + "password", + "somesalt", + "$argon2i$v=19$m=256,t=2,p=1$c29tZXNhbHQ$iekCn0Y3spW+sCcFanM2xBT63UP2sghkUoHLIUpWRS8", + ), + ( + "password", + "somesalt", + "$argon2i$v=19$m=256,t=2,p=2$c29tZXNhbHQ$T/XOJ2mh1/TIpJHfCdQan76Q5esCFVoT5MAeIM1Oq2E", + ), + ( + "password", + "somesalt", + "$argon2i$v=19$m=65536,t=1,p=1$c29tZXNhbHQ$0WgHXE2YXhPr6uVgz4uUw7XYoWxRkWtvSsLaOsEbvs8", + ), + ( + "password", + "somesalt", + "$argon2i$v=19$m=65536,t=4,p=1$c29tZXNhbHQ$qqlT1YrzcGzj3xrv1KZKhOMdf1QXUjHxKFJZ+IF0zls", + ), + ( + "differentpassword", + "somesalt", + "$argon2i$v=19$m=65536,t=2,p=1$c29tZXNhbHQ$FK6NoBr+qHAMI1jc73xTWNkCEoK9iGY6RWL1n7dNIu4", + ), + ( + "password", + "diffsalt", + "$argon2i$v=19$m=65536,t=2,p=1$ZGlmZnNhbHQ$sDV8zPvvkfOGCw26RHsjSMvv7K2vmQq/6cxAcmxSEnE", + ), + ], +) +def test_hash_version_19( + secret: str, + salt: str, + hash: str, +) -> None: + info: BaseArgon2PHC | None = inspect_phc(hash=hash, definition=any_argon_phc) + if not info: + raise Panic + hasher = Argon2Hasher( + parallelism=info.parallelism_cost, + time_cost=info.time_cost, + memory_cost=info.memory_cost, + type=info.type, + ) + hashed = hasher.hash(secret=secret, salt=salt) + assert hashed == hash diff --git a/tests/libpass/inspect/test_inspect.py b/tests/libpass/inspect/test_inspect.py index 22541207..be9a71ea 100644 --- a/tests/libpass/inspect/test_inspect.py +++ b/tests/libpass/inspect/test_inspect.py @@ -2,7 +2,7 @@ from libpass.inspect.bcrypt import BcryptHashInfo, inspect_bcrypt_hash from libpass.inspect.phc import inspect_phc -from libpass.inspect.phc.defs import Argon2PHC +from libpass.inspect.phc.defs import Argon2IdPHC from libpass.inspect.sha_crypt import SHA256CryptInfo, inspect_sha_crypt @@ -29,7 +29,7 @@ def test_bcrypt_inspect(hash: str, expected: BcryptHashInfo) -> None: [ ( "$argon2id$v=19$m=65536,t=2,p=1$gZiV/M1gPc22ElAH/Jh1Hw$CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno", - Argon2PHC( + Argon2IdPHC( memory_cost=65536, time_cost=2, parallelism_cost=1, @@ -39,8 +39,8 @@ def test_bcrypt_inspect(hash: str, expected: BcryptHashInfo) -> None: ) ], ) -def test_argon_inspect(hash: str, expected: Argon2PHC) -> None: - parsed = inspect_phc(hash, Argon2PHC) +def test_argon_inspect(hash: str, expected: Argon2IdPHC) -> None: + parsed = inspect_phc(hash, Argon2IdPHC) assert parsed == expected assert parsed.as_str() == hash