Skip to content

Commit

Permalink
feat: add argon2 hasher
Browse files Browse the repository at this point in the history
  • Loading branch information
ThirVondukr committed Oct 13, 2024
1 parent 9d4aae4 commit 435a22a
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 12 deletions.
45 changes: 45 additions & 0 deletions libpass/hashers/argon2.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 21 additions & 4 deletions libpass/inspect/phc/_phc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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"]
Expand All @@ -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,
**{
Expand Down
37 changes: 33 additions & 4 deletions libpass/inspect/phc/defs.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
84 changes: 84 additions & 0 deletions tests/libpass/hashers/test_argon2.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions tests/libpass/inspect/test_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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,
Expand All @@ -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

Expand Down

0 comments on commit 435a22a

Please sign in to comment.