From e77a1c5187ff9d800cbb3523851985a0f94fa8ff Mon Sep 17 00:00:00 2001 From: Henning Schild Date: Tue, 14 Apr 2026 13:04:42 +0200 Subject: [PATCH 1/6] test/auth: add bcrypt max len test The maximum password length of bcrypt is 72 chars, any longer password will create the same hash. Some password/hashing lib combinations truncate for us, for others we might catch exceptions. So test all that to make sure we can handle all combinations. Related-to: #1896 Signed-off-by: Henning Schild --- radicale/tests/test_auth.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index 90925f6d..ea3de970 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -42,6 +42,8 @@ class TestBaseAuthRequests(BaseTest): """ + BCRYPT_MAX_PWLEN = 72 + # test for available bcrypt module try: import bcrypt @@ -174,6 +176,21 @@ class TestBaseAuthRequests(BaseTest): def test_htpasswd_bcrypt_unicode(self) -> None: self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode") + @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") + def test_htpasswd_bcrypt_long(self) -> None: + import bcrypt + m = ( + # full available len + ("tmp", "a" * self.BCRYPT_MAX_PWLEN, True), + # longer than valid -> same password + ("tmp", "a" * (self.BCRYPT_MAX_PWLEN + 42), True), + # shorter that before -> some other password + ("tmp", "a" * (self.BCRYPT_MAX_PWLEN - 1), False), + ) + hash = bcrypt.hashpw(("a" * self.BCRYPT_MAX_PWLEN).encode(), bcrypt.gensalt()) + self._test_htpasswd("bcrypt", "tmp:" + hash.decode('utf-8'), m) + @pytest.mark.skipif(has_argon2 == 0, reason="No argon2 module installed") def test_htpasswd_argon2_i(self) -> None: self._test_htpasswd("argon2", "tmp:$argon2i$v=19$m=65536,t=3,p=4$NgZg7F1rzRkDoNSaMwag9A$qmsvMKEn5zOXHm8e3O5fKzzcRo0UESwaDr/cETe5YPI") From eb49553b207650ef4c1bc7ae510858b5179fc034 Mon Sep 17 00:00:00 2001 From: Henning Schild Date: Tue, 14 Apr 2026 17:33:52 +0200 Subject: [PATCH 2/6] test/auth: add another bcrypt max len test This time the other way around just to be extra sure. Radicale currently does not ever call bcrypt.hashpw, let alone with an overlong password. Related-to: #1896 Signed-off-by: Henning Schild --- radicale/tests/test_auth.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index ea3de970..7385edbc 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -191,6 +191,21 @@ class TestBaseAuthRequests(BaseTest): hash = bcrypt.hashpw(("a" * self.BCRYPT_MAX_PWLEN).encode(), bcrypt.gensalt()) self._test_htpasswd("bcrypt", "tmp:" + hash.decode('utf-8'), m) + @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") + def test_bcrypt_hash_long(self) -> None: + import bcrypt + salt = bcrypt.gensalt() + h0 = bcrypt.hashpw(("a" * self.BCRYPT_MAX_PWLEN).encode(), salt) + try: + h1 = bcrypt.hashpw(("a" * (self.BCRYPT_MAX_PWLEN + 1)).encode(), salt) + # bcrypt >= 5, truncated for us + except ValueError: + return + + # overlong password produced same hash as maxlen + assert h0 == h1 + @pytest.mark.skipif(has_argon2 == 0, reason="No argon2 module installed") def test_htpasswd_argon2_i(self) -> None: self._test_htpasswd("argon2", "tmp:$argon2i$v=19$m=65536,t=3,p=4$NgZg7F1rzRkDoNSaMwag9A$qmsvMKEn5zOXHm8e3O5fKzzcRo0UESwaDr/cETe5YPI") From 80b0aaf24f8c3943c2532ab1e2005312723f0c30 Mon Sep 17 00:00:00 2001 From: Henning Schild Date: Tue, 14 Apr 2026 13:30:11 +0200 Subject: [PATCH 3/6] auth/htpasswd: deal with bcrypt maximum password length bcrypt < 5 always truncated overlong passwords for us, since version 5 we need to do that on our own or hope our password library does it for us. In order to support both passlib and libpass with bcrypt >= 5 we simply truncate if needed so it does not matter which of the two libraries is used. Closes: #1896 Signed-off-by: Henning Schild --- radicale/auth/htpasswd.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/radicale/auth/htpasswd.py b/radicale/auth/htpasswd.py index dd27fdec..0c25be11 100644 --- a/radicale/auth/htpasswd.py +++ b/radicale/auth/htpasswd.py @@ -66,6 +66,8 @@ from radicale import auth, config, logger, utils class Auth(auth.BaseAuth): + BCRYPT_MAX_PWLEN = 72 + _filename: str _encoding: str _htpasswd: dict # login -> digest @@ -176,16 +178,21 @@ class Auth(auth.BaseAuth): """Check if ``hash_value`` and ``password`` match, plain method.""" return ("PLAIN", hmac.compare_digest(hash_value.encode(), password.encode())) - def _plain_fallback(self, method_orig, hash_value: str, password: str) -> tuple[str, bool]: + def _plain_fallback(self, method_orig, hash_value: bytes, password: bytes) -> tuple[str, bool]: """Check if ``hash_value`` and ``password`` match, plain method / fallback in case of hash length is not matching on autodetection.""" info = "PLAIN/fallback as hash length not matching for " + method_orig + ": " + str(len(hash_value)) - return (info, hmac.compare_digest(hash_value.encode(), password.encode())) + return (info, hmac.compare_digest(hash_value, password)) def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> tuple[str, bool]: - if self._encryption == "autodetect" and len(hash_value) != 60: - return self._plain_fallback("BCRYPT", hash_value, password) + pw = password.encode() + hv = hash_value.encode() + if len(pw) > self.BCRYPT_MAX_PWLEN: + pw = pw[:self.BCRYPT_MAX_PWLEN] + logger.warning("Bcrypt passwords can not be longer than %d characters, truncated" % self.BCRYPT_MAX_PWLEN) + if self._encryption == "autodetect" and len(hv) != 60: + return self._plain_fallback("BCRYPT", hv, pw) else: - return ("BCRYPT", bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hash_value.encode())) + return ("BCRYPT", bcrypt.checkpw(password=pw, hashed_password=hv)) def _argon2(self, argon2: Any, hash_value: str, password: str) -> tuple[str, bool]: return ("ARGON2", argon2.verify(password, hash_value.strip())) From 7b5c0304bb16e69074a845748a6a8e24bc31666f Mon Sep 17 00:00:00 2001 From: Henning Schild Date: Tue, 14 Apr 2026 18:18:16 +0200 Subject: [PATCH 4/6] radicale/tests: use BCRYPT_MAX_PWLEN from auth.htpasswd Do not define the magic value twice, instead reuse the value from the auth code also in the related tests. Signed-off-by: Henning Schild --- radicale/tests/test_auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index 7385edbc..e7c8409d 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -32,6 +32,7 @@ from typing import Iterable, Tuple, Union import pytest from radicale import utils, xmlutils +from radicale.auth import htpasswd from radicale.tests import BaseTest @@ -42,7 +43,7 @@ class TestBaseAuthRequests(BaseTest): """ - BCRYPT_MAX_PWLEN = 72 + BCRYPT_MAX_PWLEN = htpasswd.Auth.BCRYPT_MAX_PWLEN # test for available bcrypt module try: From 6690e9e3ca80c12ac9425354da715fff1851b28a Mon Sep 17 00:00:00 2001 From: Henning Schild Date: Tue, 14 Apr 2026 12:09:57 +0200 Subject: [PATCH 5/6] radicale/utils: drop bcrypt compat check We now support passlib and bcrypt5 so the test can be dropped. Related-to: #1980 Signed-off-by: Henning Schild --- DOCUMENTATION.md | 1 - pyproject.toml | 2 +- radicale/auth/htpasswd.py | 16 +++------------- radicale/tests/test_auth.py | 15 ++------------- radicale/utils.py | 26 -------------------------- setup.py.legacy | 2 +- 6 files changed, 7 insertions(+), 55 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 5276063d..6f1a570b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1052,7 +1052,6 @@ Available methods: * `bcrypt` This uses a modified version of the Blowfish stream cipher, which is considered very secure. The installation of Python's **bcrypt** module is required for this to work. - Also consider version of passlib(libpass): bcrypt >= 5.0.0 requires passlib(libpass) >= 1.9.3 * `md5` Use an iterated MD5 digest of the password with salt (nowadays insecure). diff --git a/pyproject.toml b/pyproject.toml index 45d68916..8ef5c452 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ ] urls = {Homepage = "https://radicale.org/"} requires-python = ">=3.9.0" -# Hint: if bcyrpt < 5.0.0 is used, passlib(libpass) dependency can be downgraded/reverted by: sed -i 's|libpass[^"]*|passlib|' pyproject.toml +# Hint: passlib(libpass) dependency can be downgraded/reverted by: sed -i 's|libpass[^"]*|passlib|' pyproject.toml dependencies = [ "defusedxml", "libpass>=1.9.3", diff --git a/radicale/auth/htpasswd.py b/radicale/auth/htpasswd.py index 0c25be11..a179f3cb 100644 --- a/radicale/auth/htpasswd.py +++ b/radicale/auth/htpasswd.py @@ -43,7 +43,7 @@ out-of-the-box: - SHA256 (htpasswd -2 ...) - SHA512 (htpasswd -5 ...) -When bcrypt is installed (bcrypt >= 5.0.0 requires passlib(libpass) >= 1.9.3): +When bcrypt is installed: - BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x When argon2 is installed: @@ -61,7 +61,7 @@ from typing import Any, Tuple from passlib.hash import apr_md5_crypt, sha256_crypt, sha512_crypt -from radicale import auth, config, logger, utils +from radicale import auth, config, logger class Auth(auth.BaseAuth): @@ -122,22 +122,12 @@ class Auth(auth.BaseAuth): "The htpasswd encryption method 'bcrypt' or 'autodetect' requires " "the bcrypt module (entries found: %d)." % self._htpasswd_bcrypt_use) from e else: - [bcrypt_usable, info] = utils.passlib_libpass_supports_bcrypt() - if bcrypt_usable: - self._has_bcrypt = True - logger.info(info) - else: - logger.warning(info) + self._has_bcrypt = True if self._encryption == "autodetect": if self._htpasswd_bcrypt_use == 0: logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bcrypt module found, but currently not required", self._encryption) else: logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bcrypt module found (bcrypt entries found: %d)", self._encryption, self._htpasswd_bcrypt_use) - if not bcrypt_usable: - raise RuntimeError("The htpasswd encryption 'autodetect' requires the bcrypt module but not usuable") - else: - if not bcrypt_usable: - raise RuntimeError("The htpasswd encryption method 'bcrypt' requires the bcrypt module but not usuable") if self._encryption == "bcrypt": self._verify = functools.partial(self._bcrypt, bcrypt) else: diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index e7c8409d..8051e0f5 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -31,7 +31,7 @@ from typing import Iterable, Tuple, Union import pytest -from radicale import utils, xmlutils +from radicale import xmlutils from radicale.auth import htpasswd from radicale.tests import BaseTest @@ -133,52 +133,42 @@ class TestBaseAuthRequests(BaseTest): self._test_htpasswd("autodetect", "tmp:$6$rounds=2500$A1H/cZUl3CBnsplz$bSKYCDQ/YGR..YhxaZcM1eKmAi/jlnpbENKU8a.9kE95JBIpyUss3.cUyss0xQnhjD4PReN4sAzmdziWmoCsg/") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2a(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu") - @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed or incompatibe") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") + @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") def test_htpasswd_bcrypt_2a_autodetect(self) -> None: self._test_htpasswd("autodetect", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2b(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2b_autodetect(self) -> None: self._test_htpasswd("autodetect", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2y(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2y_autodetect(self) -> None: self._test_htpasswd("autodetect", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_C10(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_C10_autodetect(self) -> None: self._test_htpasswd("autodetect", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_unicode(self) -> None: self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_long(self) -> None: import bcrypt m = ( @@ -193,7 +183,6 @@ class TestBaseAuthRequests(BaseTest): self._test_htpasswd("bcrypt", "tmp:" + hash.decode('utf-8'), m) @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") - @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_bcrypt_hash_long(self) -> None: import bcrypt salt = bcrypt.gensalt() diff --git a/radicale/utils.py b/radicale/utils.py index ca4da132..fd56f286 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -27,8 +27,6 @@ from importlib import import_module, metadata from string import ascii_letters, digits, punctuation from typing import Callable, Sequence, Tuple, Type, TypeVar, Union -from packaging.version import Version - from radicale import config from radicale.log import logger @@ -109,30 +107,6 @@ def vobject_supports_vcard4() -> bool: return False -def passlib_libpass_supports_bcrypt() -> Tuple[bool, str]: - """Check if passlib(libpass) version supports bcrypt version.""" - info = "" - try: - version_bcrypt = package_version("bcrypt") - version_bcrypt_check = "5.0.0" - version_passlib = package_version("passlib") - version_passlib_check = "1.9.3" - if Version(version_bcrypt) >= Version(version_bcrypt_check): - # bcrypt >= 5.0.0 has issues with passlib(libpass) < 1.9.3 - if Version(version_passlib) < Version(version_passlib_check): - info = "bcrypt module version %r >= %r and passlib(libpass) module version %r < %r found => incompatible, downgrade bcrypt or upgrade passlib(libpass)" % (version_bcrypt, version_bcrypt_check, version_passlib, version_passlib_check) - return (False, info) - else: - info = "bcrypt module version %r >= %r and passlib(libpass) module version %r >= %r found => ok" % (version_bcrypt, version_bcrypt_check, version_passlib, version_passlib_check) - return (True, info) - else: - info = "bcrypt module version %r < %r and passlib(libpass) module version %r found => ok" % (version_bcrypt, version_bcrypt_check, version_passlib) - return (True, info) - except Exception: - info = "bcrypt module version or passlib(libpass) module version %r not found => problem" - return (False, info) - - def packages_version(): versions = [] versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])) diff --git a/setup.py.legacy b/setup.py.legacy index bba4e406..bae2e432 100644 --- a/setup.py.legacy +++ b/setup.py.legacy @@ -37,7 +37,7 @@ web_files = ["web/internal_data/css/icon.png", "web/internal_data/js/utils/*.js", "web/internal_data/index.html"] -# Hint: if bcyrpt < 5.0.0 is used, passlib(libpass) dependency can be downgraded/reverted by: sed -i 's|libpass[^"]*|passlib|' setup.py.legacy +# Hint: passlib(libpass) dependency can be downgraded/reverted by: sed -i 's|libpass[^"]*|passlib|' setup.py.legacy install_requires = ["defusedxml", "libpass>=1.9.3", "vobject>=0.9.6", "pika>=1.1.0", "requests", From 383246383041ada9fb79bd75486ea973cfc6383f Mon Sep 17 00:00:00 2001 From: Henning Schild Date: Tue, 14 Apr 2026 19:11:38 +0200 Subject: [PATCH 6/6] workflow/test: add a test for legacy passlib compat support Closes: #1980 Signed-off-by: Henning Schild --- .github/workflows/test.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5e6ffac9..04e4400b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,26 @@ jobs: - name: Test with newest Python on latest Ubuntu run: tox -c pyproject.toml -e py + test-ubuntu-python-newest-with-passlib: + name: Test Ubuntu:latest Python:newest passlib + needs: lint + strategy: + matrix: + os: [ubuntu-latest] + python-version: ['3.14'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install Test dependencies + run: pip install tox + - name: Switch back to passlib + run: sed -i 's|libpass[^"]*|passlib|' pyproject.toml + - name: Test with newest Python on latest Ubuntu + run: tox -c pyproject.toml -e py + test-python-32bit: name: Test Ubuntu:latest Python:32-bit needs: [lint, test-ubuntu-python-newest, integ-test]