diff --git a/pyproject.toml b/pyproject.toml index 916a8ca..639e1f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -204,6 +204,7 @@ version_files = [ [tool.pytest.ini_options] addopts = "-W error::Warning" +python_files = ["test_*.py"] [tool.mypy] diff --git a/tests/handlers/__init__.py b/tests/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/handlers/test_bcrypt.py b/tests/handlers/test_bcrypt.py new file mode 100644 index 0000000..f7ca9df --- /dev/null +++ b/tests/handlers/test_bcrypt.py @@ -0,0 +1,122 @@ +from passlib.handlers.bcrypt import bcrypt +from tests.lib.base_test import BaseHandlerTestCase +from tests.test_handlers import UPASS_TABLE + +CONFIG_2 = "$2$05$" + "." * 22 +CONFIG_A = "$2a$05$" + "." * 22 + + +class TestBcrypt(BaseHandlerTestCase): + handler = bcrypt + known_correct_hashes = [ + # + # from JTR 1.7.9 + # + ("U*U*U*U*", "$2a$05$c92SVSfjeiCD6F2nAD6y0uBpJDjdRkt0EgeC4/31Rf2LUZbDRDE.O"), + ("U*U***U", "$2a$05$WY62Xk2TXZ7EvVDQ5fmjNu7b0GEzSzUXUh2cllxJwhtOeMtWV3Ujq"), + ("U*U***U*", "$2a$05$Fa0iKV3E2SYVUlMknirWU.CFYGvJ67UwVKI1E2FP6XeLiZGcH3MJi"), + ("*U*U*U*U", "$2a$05$.WRrXibc1zPgIdRXYfv.4uu6TD1KWf0VnHzq/0imhUhuxSxCyeBs2"), + ("", "$2a$05$Otz9agnajgrAe0.kFVF9V.tzaStZ2s1s4ZWi/LY4sw2k/MTVFj/IO"), + # + # test vectors from http://www.openwall.com/crypt v1.2 + # note that this omits any hashes that depend on crypt_blowfish's + # various CVE-2011-2483 workarounds (hash 2a and \xff\xff in password, + # and any 2x hashes); and only contain hashes which are correct + # under both crypt_blowfish 1.2 AND OpenBSD. + # + ("U*U", "$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW"), + ("U*U*", "$2a$05$CCCCCCCCCCCCCCCCCCCCC.VGOzA784oUp/Z0DY336zx7pLYAy0lwK"), + ("U*U*U", "$2a$05$XXXXXXXXXXXXXXXXXXXXXOAcXxm9kjPGEMsLznoKqmqw7tc8WCx4a"), + ("", "$2a$05$CCCCCCCCCCCCCCCCCCCCC.7uG0VCzI2bS7j6ymqJi9CdcdxiRTWNy"), + ( + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789chars after 72 are ignored", + "$2a$05$abcdefghijklmnopqrstuu5s2v8.iXieOjg/.AySBTTZIIVFJeBui", + ), + (b"\xa3", "$2a$05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq"), + ( + b"\xff\xa3345", + "$2a$05$/OK.fbVrR/bpIqNJ5ianF.nRht2l/HRhr6zmCp9vYUvvsqynflf9e", + ), + (b"\xa3ab", "$2a$05$/OK.fbVrR/bpIqNJ5ianF.6IflQkJytoRVc1yuaNtHfiuq.FRlSIS"), + ( + b"\xaa" * 72 + b"chars after 72 are ignored as usual", + "$2a$05$/OK.fbVrR/bpIqNJ5ianF.swQOIzjOiJ9GHEPuhEkvqrUyvWhEMx6", + ), + ( + b"\xaa\x55" * 36, + "$2a$05$/OK.fbVrR/bpIqNJ5ianF.R9xrDjiycxMbQE2bp.vgqlYpW5wx2yy", + ), + ( + b"\x55\xaa\xff" * 24, + "$2a$05$/OK.fbVrR/bpIqNJ5ianF.9tQZzcJfm3uj2NvJ/n5xkhpqLrMpWCe", + ), + # keeping one of their 2y tests, because we are supporting that. + (b"\xa3", "$2y$05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq"), + # + # 8bit bug (fixed in 2y/2b) + # + # NOTE: see assert_lacks_8bit_bug() for origins of this test vector. + (b"\xd1\x91", "$2y$05$6bNw2HLQYeqHYyBfLMsv/OUcZd0LKP39b87nBw3.S2tVZSqiQX6eu"), + # + # bsd wraparound bug (fixed in 2b) + # + # NOTE: if backend is vulnerable, password will hash the same as '0'*72 + # ("$2a$04$R1lJ2gkNaoPGdafE.H.16.nVyh2niHsGJhayOHLMiXlI45o8/DU.6"), + # rather than same as ("0123456789"*8)[:72] + # 255 should be sufficient, but checking + ( + ("0123456789" * 26)[:254], + "$2a$04$R1lJ2gkNaoPGdafE.H.16.1MKHPvmKwryeulRe225LKProWYwt9Oi", + ), + ( + ("0123456789" * 26)[:255], + "$2a$04$R1lJ2gkNaoPGdafE.H.16.1MKHPvmKwryeulRe225LKProWYwt9Oi", + ), + ( + ("0123456789" * 26)[:256], + "$2a$04$R1lJ2gkNaoPGdafE.H.16.1MKHPvmKwryeulRe225LKProWYwt9Oi", + ), + ( + ("0123456789" * 26)[:257], + "$2a$04$R1lJ2gkNaoPGdafE.H.16.1MKHPvmKwryeulRe225LKProWYwt9Oi", + ), + # + # from py-bcrypt tests + # + ("", "$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s."), + ("a", "$2a$10$k87L/MF28Q673VKh8/cPi.SUl7MU/rWuSiIDDFayrKk/1tBsSQu4u"), + ("abc", "$2a$10$WvvTPHKwdBJ3uk0Z37EMR.hLA2W6N9AEBhEgrAOljy2Ae5MtaSIUi"), + ( + "abcdefghijklmnopqrstuvwxyz", + "$2a$10$fVH8e28OQRj9tqiDXs1e1uxpsjN0c7II7YPKXua2NAKYvM6iQk7dq", + ), + ( + "~!@#$%^&*() ~!@#$%^&*()PNBFRD", + "$2a$10$LgfYWkbzEvQ4JakH7rOvHe0y8pHKF9OaFgwUZ2q7W2FFZmZzJYlfS", + ), + # + # custom test vectors + # + # ensures utf-8 used for unicode + (UPASS_TABLE, "$2a$05$Z17AXnnlpzddNUvnC6cZNOSwMA/8oNiKnHTHTwLlBijfucQQlHjaG"), + # ensure 2b support + (UPASS_TABLE, "$2b$05$Z17AXnnlpzddNUvnC6cZNOSwMA/8oNiKnHTHTwLlBijfucQQlHjaG"), + ("", CONFIG_2 + "J2ihDv8vVf7QZ9BsaRrKyqs2tkn55Yq"), + ("", CONFIG_A + "J2ihDv8vVf7QZ9BsaRrKyqs2tkn55Yq"), + ("abc", CONFIG_2 + "XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte"), + ("abc", CONFIG_A + "ev6gDwpVye3oMCUpLY85aTpfBNHD0Ga"), + ("abc" * 23, CONFIG_2 + "XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte"), + ("abc" * 23, CONFIG_A + "2kIdfSj/4/R/Q6n847VTvc68BXiRYZC"), + ("abc" * 24, CONFIG_2 + "XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte"), + ("abc" * 24, CONFIG_A + "XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte"), + ("abc" * 24 + "x", CONFIG_2 + "XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte"), + ("abc" * 24 + "x", CONFIG_A + "XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte"), + ] + known_correct_configs = [ + ( + "$2a$04$uM6csdM8R9SXTex/gbTaye", + UPASS_TABLE, + "$2a$04$uM6csdM8R9SXTex/gbTayezuvzFEufYGd2uB6of7qScLjQ4GwcD4G", + ), + ] diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lib/base_test.py b/tests/lib/base_test.py new file mode 100644 index 0000000..192027e --- /dev/null +++ b/tests/lib/base_test.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import abc +import contextlib +import math +import warnings +from typing import TYPE_CHECKING, Callable + +import pytest + +from passlib.exc import MissingBackendError, PasslibHashWarning +from passlib.utils import has_salt_info +from passlib.utils.handlers import BackendMixin, HasSalt +from tests.utils import RESERVED_BACKEND_NAMES, has_relaxed_setting + +if TYPE_CHECKING: + from collections.abc import Iterator + + from passlib.ifc import PasswordHash + + +@contextlib.contextmanager +def ignore_deprecation_warnings() -> Iterator[None]: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + yield + + +def as_bytes(string: str | bytes) -> bytes: + return string.encode() if isinstance(string, str) else string + + +def as_str(string: str | bytes) -> str: + return string.decode() if isinstance(string, bytes) else string + + +@contextlib.contextmanager +def restore_backend(handler: type[BackendMixin]) -> Iterator[None]: + backend = handler.get_backend() + try: + yield + finally: + handler.set_backend(backend) + + +class BaseHandlerTestCase: + known_correct_hashes: list[tuple[str | bytes, str]] = [] + # list of (config, secret, hash) tuples are known to be correct + known_correct_configs: list[tuple[str, str, str]] = [] + + # passwords used to test basic hash behavior - generally + # don't need to be overidden. + stock_passwords: list[str | bytes] = [ + "test", + "\u20ac\u00a5$", + b"\xe2\x82\xac\xc2\xa5$", + ] + + @property + @abc.abstractmethod + def handler(self) -> type[PasswordHash]: + raise NotImplementedError + + def _genconfig(self, **kwargs: object) -> str: + return self.handler.genconfig(**kwargs) + + @property + def _known_hashes(self) -> Iterator[tuple[str | bytes, str]]: + yield from self.known_correct_hashes + for config, secret, hash in self.known_correct_configs: + yield secret, hash + + @property + def _salt_bits(self): + """calculate number of salt bits in hash""" + # XXX: replace this with bitsize() method? + handler = self.handler + assert has_salt_info(handler), "need explicit bit-size for " + handler.name + + # FIXME: this may be off for case-insensitive hashes, but that accounts + # for ~1 bit difference, which is good enough for test_unique_salt() + return int( + handler.default_salt_size * math.log2(len(handler.default_salt_chars)) + ) + + def test_known_hashes(self) -> None: + for secret, hash in self._known_hashes: + assert self.handler.identify(hash) + assert self.handler.verify(secret=secret, hash=hash) + with ignore_deprecation_warnings(): + assert self.handler.genhash(secret=secret, config=hash) == hash + + # Test bytes + + assert self.handler.identify(as_bytes(hash)) + assert self.handler.verify(secret=as_bytes(secret), hash=as_bytes(hash)) + with ignore_deprecation_warnings(): + assert ( + self.handler.genhash(secret=as_bytes(secret), config=as_bytes(hash)) + == hash + ) + + def test_known_configs(self) -> None: + for config, secret, hash in self.known_correct_configs: + with pytest.raises(ValueError): + self.handler.verify(secret=secret, hash=config) + with ignore_deprecation_warnings(): + result = self.handler.genhash(secret, config) + assert result == hash + + def test_handler_attributes(self) -> None: + assert self.handler.name is not None # type: ignore[attr-defined] + assert self.handler.setting_kwds is not None # type: ignore[attr-defined] + assert self.handler.context_kwds is not None # type: ignore[attr-defined] + + def test_config_workflow(self) -> None: + with ignore_deprecation_warnings(): + config = self._genconfig() + assert isinstance(config, str) + + with ignore_deprecation_warnings(): + hash = self.handler.genhash("stlb", config) + assert isinstance(hash, str) + + self.handler.verify("", config) + assert self.handler.identify(config) + + def test_using(self) -> None: + handler = self.handler + subclass = handler.using() + assert subclass is not handler + assert subclass.name == handler.name # type: ignore[attr-defined] + + @pytest.mark.parametrize("transform", [as_bytes, as_str]) + def test_hash(self, transform: Callable[[str | bytes], str | bytes]) -> None: + wrong_password = "stub" + for secret in self.stock_passwords: + result = self.handler.hash(secret=transform(secret)) + assert isinstance(result, str) + + assert self.handler.verify(secret=secret, hash=transform(result)) + assert self.handler.verify(secret=transform(secret), hash=transform(result)) + assert not self.handler.verify(secret=wrong_password, hash=result) + + # genhash() should reproduce original hash + with ignore_deprecation_warnings(): + recreated_hash = self.handler.genhash( + secret=secret, config=transform(result) + ) + assert isinstance(recreated_hash, str) + assert recreated_hash == result + assert self.handler.identify(recreated_hash) + + # genhash() should NOT reproduce original hash for wrong password + with ignore_deprecation_warnings(): + recreated_hash = self.handler.genhash( + secret=wrong_password, config=transform(result) + ) + assert isinstance(recreated_hash, str) + assert recreated_hash != result + assert self.handler.identify(recreated_hash) + + def test_backends(self) -> None: + handler = self.handler + assert hasattr(handler, "backends") + assert hasattr(handler, "set_backend") + + assert issubclass(handler, BackendMixin) + + assert handler.backends is not None + + with restore_backend(handler): + # run through each backend, make sure it works + for backend in handler.backends: + assert isinstance(backend, str) + assert backend not in RESERVED_BACKEND_NAMES, ( + f"invalid backend name: {backend!r}" + ) + + has_backend = handler.has_backend(backend) + if has_backend: + # verify backend can be loaded + handler.set_backend(backend) + assert handler.get_backend() == backend + + else: + # verify backend CAN'T be loaded + with pytest.raises(MissingBackendError): + handler.set_backend(backend) + + def test_optional_salt_attributes(self) -> None: + handler = self.handler + assert issubclass(handler, HasSalt) + assert handler.setting_kwds + assert "salt" in handler.setting_kwds + + max_salt_size_is_set = handler.max_salt_size is not None + if max_salt_size_is_set: + assert handler.max_salt_size + assert handler.max_salt_size >= 1 + assert ( + handler.min_salt_size + <= handler.default_salt_size + <= handler.max_salt_size + ) + + assert handler.min_salt_size >= 0 + + if "salt_size" not in handler.setting_kwds and ( + not max_salt_size_is_set + or handler.default_salt_size < handler.max_salt_size + ): + raise AssertionError + + if handler.salt_chars: + assert handler.default_salt_chars + for char in handler.default_salt_chars: + assert char in handler.salt_chars + else: + assert handler.default_salt_chars + + def test_unique_salt(self) -> None: + samples = max(1, 4 + 12 - self._salt_bits) + + with ignore_deprecation_warnings(): + salts = set(self.handler.genconfig() for _ in range(samples)) + assert len(salts) == samples + + hashes = set(self.handler.hash(secret="stub") for _ in range(samples)) + assert len(hashes) == samples + + def test_min_salt_size(self) -> None: + handler = self.handler + assert issubclass(handler, HasSalt) + + assert handler.salt_chars + salt_char = handler.salt_chars[0:1] + min_size = handler.min_salt_size + + salt = salt_char * min_size + with ignore_deprecation_warnings(): + handler.genconfig(salt=salt) + + handler.using(salt_size=min_size).hash("stub") + + # + # check min-1 is rejected + # + if min_size > 0: + with pytest.raises(ValueError), ignore_deprecation_warnings(): + handler.genconfig(salt=salt[:-1]) + + with pytest.raises(ValueError): + handler.using(salt_size=min_size - 1).hash("stub") + + def test_max_salt_size(self) -> None: + handler = self.handler + assert issubclass(handler, HasSalt) + + max_size = handler.max_salt_size + assert handler.salt_chars + salt_char = handler.salt_chars[0:1] + + secret = "" + # NOTE: skipping this for hashes like argon2 since max_salt_size takes WAY too much memory + if max_size is None or max_size > 2**20: + # + # if it's not set, salt should never be truncated; so test it + # with an unreasonably large salt. + + s1 = salt_char * 1024 + + c1 = handler.using(salt=s1).hash(secret) + c2 = handler.using(salt=s1 + salt_char).hash(secret) # type: ignore[operator] + assert c1 != c2 + + handler.using(salt_size=1024).hash(secret) + + else: + # check max size is accepted + s1 = salt_char * max_size + c1 = handler.using(salt=s1).hash(secret) + + handler.using(salt_size=max_size).hash(secret) + + # check max size + 1 is rejected + s2 = s1 + salt_char # type: ignore[operator] + with pytest.raises(ValueError): + handler.using(salt=s2).hash(secret) + + with pytest.raises(ValueError): + handler.using(salt_size=max_size + 1).hash(secret) + + if has_relaxed_setting(handler): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=PasslibHashWarning) + + c2 = handler.using(salt=s2, relaxed=True).hash(secret) + assert c2 == c1 + + if handler.min_salt_size < max_size: + c3 = handler.using(salt=s1[:-1]).hash(secret) + assert c3 != c1 diff --git a/tests/test_handlers_bcrypt.py b/tests/test_handlers_bcrypt.py index b49c1fb..7991a2a 100644 --- a/tests/test_handlers_bcrypt.py +++ b/tests/test_handlers_bcrypt.py @@ -1,4 +1,3 @@ -import os import warnings from base64 import b64encode @@ -177,16 +176,6 @@ class _bcrypt_test(HandlerCase): ] def setUp(self): - # ensure builtin is enabled for duration of test. - if TEST_MODE("full") and self.backend == "builtin": - key = "PASSLIB_BUILTIN_BCRYPT" - orig = os.environ.get(key) - if orig: - self.addCleanup(os.environ.__setitem__, key, orig) - else: - self.addCleanup(os.environ.__delitem__, key) - os.environ[key] = "true" - super().setUp() # silence this warning, will come up a bunch during testing of old 2a hashes. @@ -195,9 +184,6 @@ def setUp(self): ) def populate_settings(self, kwds): - # builtin is still just way too slow. - if self.backend == "builtin": - kwds.setdefault("rounds", 4) super().populate_settings(kwds) fuzz_verifiers = HandlerCase.fuzz_verifiers + ("fuzz_verifier_bcrypt",) @@ -505,24 +491,12 @@ class _bcrypt_sha256_test(HandlerCase): ] def setUp(self): - # ensure builtin is enabled for duration of test. - if TEST_MODE("full") and self.backend == "builtin": - key = "PASSLIB_BUILTIN_BCRYPT" - orig = os.environ.get(key) - if orig: - self.addCleanup(os.environ.__setitem__, key, orig) - else: - self.addCleanup(os.environ.__delitem__, key) - os.environ[key] = "enabled" super().setUp() warnings.filterwarnings( "ignore", ".*backend is vulnerable to the bsd wraparound bug.*" ) def populate_settings(self, kwds): - # builtin is still just way too slow. - if self.backend == "builtin": - kwds.setdefault("rounds", 4) super().populate_settings(kwds) def require_many_idents(self): diff --git a/tests/utils.py b/tests/utils.py index b08847d..9eb791c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2353,11 +2353,9 @@ def test_72_configs(self): assert not self.known_correct_configs, ( "handler should not have config strings" ) - raise self.skipTest("hash has no settings") if not self.known_correct_configs: - # XXX: make this a requirement? - raise self.skipTest("no config strings provided") + return # make sure config strings work (hashes in list tested in test_70) if self.filter_config_warnings: