Remove unsecure methods from htpasswd and make md5 default

This commit is contained in:
Unrud 2020-01-19 18:26:14 +01:00
parent 0a5fd94577
commit 6108d8d759
6 changed files with 21 additions and 98 deletions

View File

@ -33,7 +33,7 @@ notifications:
install: install:
- ${PYTHON} --version - ${PYTHON} --version
- ${PYTHON} -m pip install --upgrade coveralls - ${PYTHON} -m pip install --upgrade coveralls
- ${PYTHON} -m pip install --upgrade --editable .[test,md5,bcrypt] - ${PYTHON} -m pip install --upgrade --editable .[test,bcrypt]
- ${PYTHON} -m pip list - ${PYTHON} -m pip list
script: script:

8
config
View File

@ -68,11 +68,9 @@
#htpasswd_filename = /etc/radicale/users #htpasswd_filename = /etc/radicale/users
# Htpasswd encryption method # Htpasswd encryption method
# Value: plain | sha1 | ssha | crypt | bcrypt | md5 # Value: plain | bcrypt | md5
# Only bcrypt can be considered secure. # bcrypt requires the passlib[bcrypt] module.
# bcrypt requires the passlib[bcrypt] module and md5 requires #htpasswd_encryption = md5
# the passlib module.
#htpasswd_encryption = bcrypt
# Incorrect authentication delay (seconds) # Incorrect authentication delay (seconds)
#delay = 1 #delay = 1

View File

@ -22,11 +22,9 @@ Authentication backend that checks credentials with a htpasswd file.
Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html) Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html)
manages a file for storing user credentials. It can encrypt passwords using manages a file for storing user credentials. It can encrypt passwords using
different methods, e.g. BCRYPT, MD5-APR1 (a version of MD5 modified for different the methods BCRYPT or MD5-APR1 (a version of MD5 modified for
Apache), SHA1, or by using the system's CRYPT routine. The CRYPT and SHA1 Apache). MD5-APR1 provides medium security as of 2015. Only BCRYPT can be
encryption methods implemented by htpasswd are considered as insecure. MD5-APR1 considered secure by current standards.
provides medium security as of 2015. Only BCRYPT can be considered secure by
current standards.
MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it
is the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer. is the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer.
@ -41,22 +39,19 @@ The following htpasswd password encrpytion methods are supported by Radicale
out-of-the-box: out-of-the-box:
- plain-text (created by htpasswd -p...) -- INSECURE - plain-text (created by htpasswd -p...) -- INSECURE
- CRYPT (created by htpasswd -d...) -- INSECURE
- SHA1 (created by htpasswd -s...) -- INSECURE
When passlib (https://pypi.python.org/pypi/passlib) is importable, the
following significantly more secure schemes are parsable by Radicale:
- MD5-APR1 (htpasswd -m...) -- htpasswd's default method - MD5-APR1 (htpasswd -m...) -- htpasswd's default method
When passlib[bcrypt] is installed:
- BCRYPT (htpasswd -B...) -- Requires htpasswd 2.4.x - BCRYPT (htpasswd -B...) -- Requires htpasswd 2.4.x
""" """
import base64
import functools import functools
import hashlib
import hmac import hmac
from passlib.hash import apr_md5_crypt
from radicale import auth from radicale import auth
@ -66,40 +61,22 @@ class Auth(auth.BaseAuth):
self._filename = configuration.get("auth", "htpasswd_filename") self._filename = configuration.get("auth", "htpasswd_filename")
encryption = configuration.get("auth", "htpasswd_encryption") encryption = configuration.get("auth", "htpasswd_encryption")
if encryption == "ssha": if encryption == "plain":
self._verify = self._ssha
elif encryption == "sha1":
self._verify = self._sha1
elif encryption == "plain":
self._verify = self._plain self._verify = self._plain
elif encryption == "md5": elif encryption == "md5":
try: self._verify = self._md5apr1
from passlib.hash import apr_md5_crypt
except ImportError as e:
raise RuntimeError(
"The htpasswd encryption method 'md5' requires "
"the passlib module.") from e
self._verify = functools.partial(self._md5apr1, apr_md5_crypt)
elif encryption == "bcrypt": elif encryption == "bcrypt":
try: try:
from passlib.hash import bcrypt from passlib.hash import bcrypt
except ImportError as e: except ImportError as e:
raise RuntimeError( raise RuntimeError(
"The htpasswd encryption method 'bcrypt' requires " "The htpasswd encryption method 'bcrypt' requires "
"the passlib module with bcrypt support.") from e "the passlib[bcrypt] module.") from e
# A call to `encrypt` raises passlib.exc.MissingBackendError with a # A call to `encrypt` raises passlib.exc.MissingBackendError with a
# good error message if bcrypt backend is not available. Trigger # good error message if bcrypt backend is not available. Trigger
# this here. # this here.
bcrypt.hash("test-bcrypt-backend") bcrypt.hash("test-bcrypt-backend")
self._verify = functools.partial(self._bcrypt, bcrypt) self._verify = functools.partial(self._bcrypt, bcrypt)
elif encryption == "crypt":
try:
import crypt
except ImportError as e:
raise RuntimeError(
"The htpasswd encryption method 'crypt' requires "
"the crypt() system support.") from e
self._verify = functools.partial(self._crypt, crypt)
else: else:
raise RuntimeError("The htpasswd encryption method %r is not " raise RuntimeError("The htpasswd encryption method %r is not "
"supported." % encryption) "supported." % encryption)
@ -108,45 +85,11 @@ class Auth(auth.BaseAuth):
"""Check if ``hash_value`` and ``password`` match, plain method.""" """Check if ``hash_value`` and ``password`` match, plain method."""
return hmac.compare_digest(hash_value, password) return hmac.compare_digest(hash_value, password)
def _crypt(self, crypt, hash_value, password):
"""Check if ``hash_value`` and ``password`` match, crypt method."""
hash_value = hash_value.strip()
return hmac.compare_digest(crypt.crypt(password, hash_value),
hash_value)
def _sha1(self, hash_value, password):
"""Check if ``hash_value`` and ``password`` match, sha1 method."""
hash_value = base64.b64decode(hash_value.strip().replace(
"{SHA}", "").encode("ascii"))
password = password.encode(self.configuration.get("encoding", "stock"))
sha1 = hashlib.sha1()
sha1.update(password)
return hmac.compare_digest(sha1.digest(), hash_value)
def _ssha(self, hash_value, password):
"""Check if ``hash_value`` and ``password`` match, salted sha1 method.
This method is not directly supported by htpasswd, but it can be
written with e.g. openssl, and nginx can parse it.
"""
hash_value = base64.b64decode(hash_value.strip().replace(
"{SSHA}", "").encode("ascii"))
password = password.encode(self.configuration.get("encoding", "stock"))
salt_value = hash_value[20:]
hash_value = hash_value[:20]
sha1 = hashlib.sha1()
sha1.update(password)
sha1.update(salt_value)
return hmac.compare_digest(sha1.digest(), hash_value)
def _bcrypt(self, bcrypt, hash_value, password): def _bcrypt(self, bcrypt, hash_value, password):
hash_value = hash_value.strip() return bcrypt.verify(password, hash_value.strip())
return bcrypt.verify(password, hash_value)
def _md5apr1(self, md5_apr1, hash_value, password): def _md5apr1(self, hash_value, password):
hash_value = hash_value.strip() return apr_md5_crypt.verify(password, hash_value.strip())
return md5_apr1.verify(password, hash_value)
def login(self, login, password): def login(self, login, password):
"""Validate credentials. """Validate credentials.

View File

@ -160,7 +160,7 @@ DEFAULT_CONFIG_SCHEMA = OrderedDict([
"help": "htpasswd filename", "help": "htpasswd filename",
"type": filepath}), "type": filepath}),
("htpasswd_encryption", { ("htpasswd_encryption", {
"value": "bcrypt", "value": "md5",
"help": "htpasswd encryption method", "help": "htpasswd encryption method",
"type": str}), "type": str}),
("realm", { ("realm", {

View File

@ -80,32 +80,15 @@ class TestBaseAuthRequests(BaseTest):
self._test_htpasswd("plain", "tmp:be:po", ( self._test_htpasswd("plain", "tmp:be:po", (
("tmp", "be:po", 207), ("tmp", "bepo", 401))) ("tmp", "be:po", 207), ("tmp", "bepo", 401)))
def test_htpasswd_sha1(self):
self._test_htpasswd("sha1", "tmp:{SHA}UWRS3uSJJq2itZQEUyIH8rRajCM=")
def test_htpasswd_ssha(self):
self._test_htpasswd("ssha", "tmp:{SSHA}qbD1diw9RJKi0DnW4qO8WX9SE18W")
def test_htpasswd_md5(self): def test_htpasswd_md5(self):
try:
import passlib # noqa: F401
except ImportError:
pytest.skip("passlib is not installed")
self._test_htpasswd("md5", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/") self._test_htpasswd("md5", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/")
def test_htpasswd_crypt(self):
try:
import crypt # noqa: F401
except ImportError:
pytest.skip("crypt is not installed")
self._test_htpasswd("crypt", "tmp:dxUqxoThMs04k")
def test_htpasswd_bcrypt(self): def test_htpasswd_bcrypt(self):
try: try:
from passlib.hash import bcrypt from passlib.hash import bcrypt
from passlib.exc import MissingBackendError from passlib.exc import MissingBackendError
except ImportError: except ImportError:
pytest.skip("passlib is not installed") pytest.skip("passlib[bcrypt] is not installed")
try: try:
bcrypt.hash("test-bcrypt-backend") bcrypt.hash("test-bcrypt-backend")
except MissingBackendError: except MissingBackendError:

View File

@ -73,12 +73,11 @@ setup(
exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), exclude=["*.tests", "*.tests.*", "tests.*", "tests"]),
package_data={"radicale": WEB_FILES}, package_data={"radicale": WEB_FILES},
entry_points={"console_scripts": ["radicale = radicale.__main__:run"]}, entry_points={"console_scripts": ["radicale = radicale.__main__:run"]},
install_requires=["vobject>=0.9.6", "python-dateutil>=2.7.3"], install_requires=["passlib", "vobject>=0.9.6", "python-dateutil>=2.7.3"],
setup_requires=pytest_runner, setup_requires=pytest_runner,
tests_require=tests_require, tests_require=tests_require,
extras_require={ extras_require={
"test": tests_require, "test": tests_require,
"md5": "passlib",
"bcrypt": "passlib[bcrypt]"}, "bcrypt": "passlib[bcrypt]"},
keywords=["calendar", "addressbook", "CalDAV", "CardDAV"], keywords=["calendar", "addressbook", "CalDAV", "CardDAV"],
python_requires=">=3.5.2", python_requires=">=3.5.2",