Browse Source

Merge pull request #1955 from pbiering/passlib-libpass-check-warn

Passlib libpass check related to bcrypt
Peter Bieringer 2 months ago
parent
commit
302b340bb2
5 changed files with 69 additions and 12 deletions
  1. 1 0
      DOCUMENTATION.md
  2. 16 6
      radicale/auth/htpasswd.py
  3. 9 2
      radicale/tests/__init__.py
  4. 12 3
      radicale/tests/test_auth.py
  5. 31 1
      radicale/utils.py

+ 1 - 0
DOCUMENTATION.md

@@ -1037,6 +1037,7 @@ 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).

+ 16 - 6
radicale/auth/htpasswd.py

@@ -3,7 +3,7 @@
 # Copyright © 2008 Pascal Halter
 # Copyright © 2008-2017 Guillaume Ayoub
 # Copyright © 2017-2019 Unrud <unrud@outlook.com>
-# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
+# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
 #
 # This library is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -43,7 +43,7 @@ out-of-the-box:
     - SHA256     (htpasswd -2 ...)
     - SHA512     (htpasswd -5 ...)
 
-When bcrypt is installed:
+When bcrypt is installed (bcrypt >= 5.0.0 requires passlib/libpass >= 1.9.3):
     - 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
+from radicale import auth, config, logger, utils
 
 
 class Auth(auth.BaseAuth):
@@ -120,12 +120,22 @@ 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:
-                self._has_bcrypt = True
+                [bcrypt_usable, info] = utils.passlib_libpass_supports_bcrypt()
+                if bcrypt_usable:
+                    self._has_bcrypt = True
+                    logger.debug(info)
+                else:
+                    logger.warning(info)
                 if self._encryption == "autodetect":
                     if self._htpasswd_bcrypt_use == 0:
-                        logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt 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:
-                        logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt 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":
                 self._verify = functools.partial(self._bcrypt, bcrypt)
             else:

+ 9 - 2
radicale/tests/__init__.py

@@ -1,7 +1,7 @@
 # This file is part of Radicale - CalDAV and CardDAV server
 # Copyright © 2012-2017 Guillaume Ayoub
 # Copyright © 2017-2023 Unrud <unrud@outlook.com>
-# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
+# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
 #
 # This library is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -23,6 +23,8 @@ Tests for Radicale.
 
 import base64
 import logging
+import os
+import platform
 import shutil
 import sys
 import tempfile
@@ -36,7 +38,7 @@ import defusedxml.ElementTree as DefusedET
 import vobject
 
 import radicale
-from radicale import app, config, types, xmlutils
+from radicale import app, config, types, utils, xmlutils
 
 RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]], vobject.base.Component]]
 
@@ -52,6 +54,11 @@ class BaseTest:
     application: app.Application
 
     def setup_method(self) -> None:
+        if os.environ.get("PYTHONPATH"):
+            info = "with PYTHONPATH=%r " % os.environ.get("PYTHONPATH")
+        else:
+            info = ""
+        logging.info("Testing Radicale %s(%s) as %s on %s", info, utils.packages_version(), utils.user_groups_as_string(), platform.platform())
         self.configuration = config.load()
         self.colpath = tempfile.mkdtemp()
         self.configure({

+ 12 - 3
radicale/tests/test_auth.py

@@ -2,7 +2,7 @@
 # Copyright © 2012-2016 Jean-Marc Martins
 # Copyright © 2012-2017 Guillaume Ayoub
 # Copyright © 2017-2022 Unrud <unrud@outlook.com>
-# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
+# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
 #
 # This library is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -30,7 +30,7 @@ from typing import Iterable, Tuple, Union
 
 import pytest
 
-from radicale import xmlutils
+from radicale import utils, xmlutils
 from radicale.tests import BaseTest
 
 
@@ -121,38 +121,47 @@ class TestBaseAuthRequests(BaseTest):
         self._test_htpasswd("autodetect", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
 
     @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")
+    @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")
     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("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_unicode(self) -> None:
         self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode")
 

+ 31 - 1
radicale/utils.py

@@ -2,7 +2,7 @@
 # Copyright © 2014 Jean-Marc Martins
 # Copyright © 2012-2017 Guillaume Ayoub
 # Copyright © 2017-2018 Unrud <unrud@outlook.com>
-# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
+# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
 #
 # This library is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -27,6 +27,8 @@ 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
 
@@ -85,6 +87,10 @@ def load_plugin(internal_types: Sequence[str], module_name: str,
 
 
 def package_version(name):
+    if name == "passlib":
+        # passlib(libpass) requires special handling as module name is unchanged, but metadata has new name
+        import passlib
+        return passlib.__version__
     return metadata.version(name)
 
 
@@ -99,6 +105,30 @@ 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]))