Merge pull request #2089 from henning-schild/henning/staging1

deal with bcrypt>=5 72 chars limit for libpass+passlib
This commit is contained in:
Peter Bieringer
2026-04-15 07:09:09 +02:00
committed by GitHub
7 changed files with 70 additions and 58 deletions

View File

@@ -47,6 +47,26 @@ jobs:
- name: Test with newest Python on latest Ubuntu - name: Test with newest Python on latest Ubuntu
run: tox -c pyproject.toml -e py 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: test-python-32bit:
name: Test Ubuntu:latest Python:32-bit name: Test Ubuntu:latest Python:32-bit
needs: [lint, test-ubuntu-python-newest, integ-test] needs: [lint, test-ubuntu-python-newest, integ-test]

View File

@@ -1052,7 +1052,6 @@ Available methods:
* `bcrypt` * `bcrypt`
This uses a modified version of the Blowfish stream cipher, which is considered very secure. 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. 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` * `md5`
Use an iterated MD5 digest of the password with salt (nowadays insecure). Use an iterated MD5 digest of the password with salt (nowadays insecure).

View File

@@ -28,7 +28,7 @@ classifiers = [
] ]
urls = {Homepage = "https://radicale.org/"} urls = {Homepage = "https://radicale.org/"}
requires-python = ">=3.9.0" 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 = [ dependencies = [
"defusedxml", "defusedxml",
"libpass>=1.9.3", "libpass>=1.9.3",

View File

@@ -43,7 +43,7 @@ out-of-the-box:
- SHA256 (htpasswd -2 ...) - SHA256 (htpasswd -2 ...)
- SHA512 (htpasswd -5 ...) - 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 - BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x
When argon2 is installed: When argon2 is installed:
@@ -61,11 +61,13 @@ from typing import Any, Tuple
from passlib.hash import apr_md5_crypt, sha256_crypt, sha512_crypt 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): class Auth(auth.BaseAuth):
BCRYPT_MAX_PWLEN = 72
_filename: str _filename: str
_encoding: str _encoding: str
_htpasswd: dict # login -> digest _htpasswd: dict # login -> digest
@@ -120,22 +122,12 @@ class Auth(auth.BaseAuth):
"The htpasswd encryption method 'bcrypt' or 'autodetect' requires " "The htpasswd encryption method 'bcrypt' or 'autodetect' requires "
"the bcrypt module (entries found: %d)." % self._htpasswd_bcrypt_use) from e "the bcrypt module (entries found: %d)." % self._htpasswd_bcrypt_use) from e
else: else:
[bcrypt_usable, info] = utils.passlib_libpass_supports_bcrypt() self._has_bcrypt = True
if bcrypt_usable:
self._has_bcrypt = True
logger.info(info)
else:
logger.warning(info)
if self._encryption == "autodetect": if self._encryption == "autodetect":
if self._htpasswd_bcrypt_use == 0: 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) logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bcrypt module found, but currently not required", self._encryption)
else: 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) 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": if self._encryption == "bcrypt":
self._verify = functools.partial(self._bcrypt, bcrypt) self._verify = functools.partial(self._bcrypt, bcrypt)
else: else:
@@ -176,16 +168,21 @@ class Auth(auth.BaseAuth):
"""Check if ``hash_value`` and ``password`` match, plain method.""" """Check if ``hash_value`` and ``password`` match, plain method."""
return ("PLAIN", hmac.compare_digest(hash_value.encode(), password.encode())) 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.""" """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)) 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]: def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> tuple[str, bool]:
if self._encryption == "autodetect" and len(hash_value) != 60: pw = password.encode()
return self._plain_fallback("BCRYPT", hash_value, password) 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: 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]: def _argon2(self, argon2: Any, hash_value: str, password: str) -> tuple[str, bool]:
return ("ARGON2", argon2.verify(password, hash_value.strip())) return ("ARGON2", argon2.verify(password, hash_value.strip()))

View File

@@ -31,7 +31,8 @@ from typing import Iterable, Tuple, Union
import pytest import pytest
from radicale import utils, xmlutils from radicale import xmlutils
from radicale.auth import htpasswd
from radicale.tests import BaseTest from radicale.tests import BaseTest
@@ -42,6 +43,8 @@ class TestBaseAuthRequests(BaseTest):
""" """
BCRYPT_MAX_PWLEN = htpasswd.Auth.BCRYPT_MAX_PWLEN
# test for available bcrypt module # test for available bcrypt module
try: try:
import bcrypt import bcrypt
@@ -130,50 +133,69 @@ class TestBaseAuthRequests(BaseTest):
self._test_htpasswd("autodetect", "tmp:$6$rounds=2500$A1H/cZUl3CBnsplz$bSKYCDQ/YGR..YhxaZcM1eKmAi/jlnpbENKU8a.9kE95JBIpyUss3.cUyss0xQnhjD4PReN4sAzmdziWmoCsg/") 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(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: def test_htpasswd_bcrypt_2a(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu") 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(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_autodetect(self) -> None: def test_htpasswd_bcrypt_2a_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu") 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(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: def test_htpasswd_bcrypt_2b(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu") 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(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: def test_htpasswd_bcrypt_2b_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu") 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(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: def test_htpasswd_bcrypt_2y(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq") self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") @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: def test_htpasswd_bcrypt_2y_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq") self._test_htpasswd("autodetect", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") @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: def test_htpasswd_bcrypt_C10(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a") self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") @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: def test_htpasswd_bcrypt_C10_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a") self._test_htpasswd("autodetect", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") @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: def test_htpasswd_bcrypt_unicode(self) -> None:
self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode") self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
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_bcrypt == 0, reason="No bcrypt module installed")
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") @pytest.mark.skipif(has_argon2 == 0, reason="No argon2 module installed")
def test_htpasswd_argon2_i(self) -> None: def test_htpasswd_argon2_i(self) -> None:
self._test_htpasswd("argon2", "tmp:$argon2i$v=19$m=65536,t=3,p=4$NgZg7F1rzRkDoNSaMwag9A$qmsvMKEn5zOXHm8e3O5fKzzcRo0UESwaDr/cETe5YPI") self._test_htpasswd("argon2", "tmp:$argon2i$v=19$m=65536,t=3,p=4$NgZg7F1rzRkDoNSaMwag9A$qmsvMKEn5zOXHm8e3O5fKzzcRo0UESwaDr/cETe5YPI")

View File

@@ -27,8 +27,6 @@ from importlib import import_module, metadata
from string import ascii_letters, digits, punctuation from string import ascii_letters, digits, punctuation
from typing import Callable, Sequence, Tuple, Type, TypeVar, Union from typing import Callable, Sequence, Tuple, Type, TypeVar, Union
from packaging.version import Version
from radicale import config from radicale import config
from radicale.log import logger from radicale.log import logger
@@ -109,30 +107,6 @@ def vobject_supports_vcard4() -> bool:
return False 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(): def packages_version():
versions = [] versions = []
versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])) versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2]))

View File

@@ -37,7 +37,7 @@ web_files = ["web/internal_data/css/icon.png",
"web/internal_data/js/utils/*.js", "web/internal_data/js/utils/*.js",
"web/internal_data/index.html"] "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", install_requires = ["defusedxml", "libpass>=1.9.3", "vobject>=0.9.6",
"pika>=1.1.0", "pika>=1.1.0",
"requests", "requests",