Skip to content

Commit 2c0c0a7

Browse files
authored
Merge pull request #1772 from pbiering/add-argon2
Add argon2 password hash support
2 parents 0d834da + 0bcd5d2 commit 2c0c0a7

File tree

5 files changed

+83
-11
lines changed

5 files changed

+83
-11
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ dependencies = [
3838

3939

4040
[project.optional-dependencies]
41-
test = ["pytest>=7", "waitress", "bcrypt"]
41+
test = ["pytest>=7", "waitress", "bcrypt", "argon2-cffi"]
4242
bcrypt = ["bcrypt"]
43+
argon2 = ["argon2-cffi"]
4344
ldap = ["ldap3"]
4445

4546
[project.scripts]

radicale/auth/htpasswd.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@
4646
When bcrypt is installed:
4747
- BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x
4848
49+
When argon2 is installed:
50+
- ARGON2 (python -c 'from passlib.hash import argon2; print(argon2.using(type="ID").hash("password"))')
51+
4952
"""
5053

5154
import functools
@@ -72,8 +75,10 @@ class Auth(auth.BaseAuth):
7275
_htpasswd_not_ok_time: float
7376
_htpasswd_not_ok_reminder_seconds: int
7477
_htpasswd_bcrypt_use: int
78+
_htpasswd_argon2_use: int
7579
_htpasswd_cache: bool
7680
_has_bcrypt: bool
81+
_has_argon2: bool
7782
_encryption: str
7883
_lock: threading.Lock
7984

@@ -89,9 +94,10 @@ def __init__(self, configuration: config.Configuration) -> None:
8994
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s'", self._encryption)
9095

9196
self._has_bcrypt = False
97+
self._has_argon2 = False
9298
self._htpasswd_ok = False
9399
self._htpasswd_not_ok_reminder_seconds = 60 # currently hardcoded
94-
(self._htpasswd_ok, self._htpasswd_bcrypt_use, self._htpasswd, self._htpasswd_size, self._htpasswd_mtime_ns) = self._read_htpasswd(True, False)
100+
(self._htpasswd_ok, self._htpasswd_bcrypt_use, self._htpasswd_argon2_use, self._htpasswd, self._htpasswd_size, self._htpasswd_mtime_ns) = self._read_htpasswd(True, False)
95101
self._lock = threading.Lock()
96102

97103
if self._encryption == "plain":
@@ -102,7 +108,8 @@ def __init__(self, configuration: config.Configuration) -> None:
102108
self._verify = self._sha256
103109
elif self._encryption == "sha512":
104110
self._verify = self._sha512
105-
elif self._encryption == "bcrypt" or self._encryption == "autodetect":
111+
112+
if self._encryption == "bcrypt" or self._encryption == "autodetect":
106113
try:
107114
import bcrypt
108115
except ImportError as e:
@@ -125,7 +132,33 @@ def __init__(self, configuration: config.Configuration) -> None:
125132
self._verify = self._autodetect
126133
if self._htpasswd_bcrypt_use:
127134
self._verify_bcrypt = functools.partial(self._bcrypt, bcrypt)
128-
else:
135+
136+
if self._encryption == "argon2" or self._encryption == "autodetect":
137+
try:
138+
import argon2
139+
from passlib.hash import argon2 # noqa: F811
140+
except ImportError as e:
141+
if (self._encryption == "autodetect") and (self._htpasswd_argon2_use == 0):
142+
logger.warning("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' which can require argon2 module, but currently no entries found", self._encryption)
143+
else:
144+
raise RuntimeError(
145+
"The htpasswd encryption method 'argon2' or 'autodetect' requires "
146+
"the argon2 module (entries found: %d)." % self._htpasswd_argon2_use) from e
147+
else:
148+
self._has_argon2 = True
149+
if self._encryption == "autodetect":
150+
if self._htpasswd_argon2_use == 0:
151+
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and argon2 module found, but currently not required", self._encryption)
152+
else:
153+
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and argon2 module found (argon2 entries found: %d)", self._encryption, self._htpasswd_argon2_use)
154+
if self._encryption == "argon2":
155+
self._verify = functools.partial(self._argon2, argon2)
156+
else:
157+
self._verify = self._autodetect
158+
if self._htpasswd_argon2_use:
159+
self._verify_argon2 = functools.partial(self._argon2, argon2)
160+
161+
if not hasattr(self, '_verify'):
129162
raise RuntimeError("The htpasswd encryption method %r is not "
130163
"supported." % self._encryption)
131164

@@ -144,6 +177,9 @@ def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> tuple[str, boo
144177
else:
145178
return ("BCRYPT", bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hash_value.encode()))
146179

180+
def _argon2(self, argon2: Any, hash_value: str, password: str) -> tuple[str, bool]:
181+
return ("ARGON2", argon2.verify(password, hash_value.strip()))
182+
147183
def _md5apr1(self, hash_value: str, password: str) -> tuple[str, bool]:
148184
if self._encryption == "autodetect" and len(hash_value) != 37:
149185
return self._plain_fallback("MD5-APR1", hash_value, password)
@@ -169,6 +205,9 @@ def _autodetect(self, hash_value: str, password: str) -> tuple[str, bool]:
169205
elif re.match(r"^\$2(a|b|x|y)?\$", hash_value):
170206
# BCRYPT
171207
return self._verify_bcrypt(hash_value, password)
208+
elif re.match(r"^\$argon2(i|d|id)\$", hash_value):
209+
# ARGON2
210+
return self._verify_argon2(hash_value, password)
172211
elif hash_value.startswith("$5$", 0, 3):
173212
# SHA-256
174213
return self._sha256(hash_value, password)
@@ -178,7 +217,7 @@ def _autodetect(self, hash_value: str, password: str) -> tuple[str, bool]:
178217
else:
179218
return self._plain(hash_value, password)
180219

181-
def _read_htpasswd(self, init: bool, suppress: bool) -> Tuple[bool, int, dict, int, int]:
220+
def _read_htpasswd(self, init: bool, suppress: bool) -> Tuple[bool, int, int, dict, int, int]:
182221
"""Read htpasswd file
183222
184223
init == True: stop on error
@@ -189,6 +228,7 @@ def _read_htpasswd(self, init: bool, suppress: bool) -> Tuple[bool, int, dict, i
189228
"""
190229
htpasswd_ok = True
191230
bcrypt_use = 0
231+
argon2_use = 0
192232
if (init is True) or (suppress is True):
193233
info = "Read"
194234
else:
@@ -237,6 +277,14 @@ def _read_htpasswd(self, init: bool, suppress: bool) -> Tuple[bool, int, dict, i
237277
logger.warning("htpasswd file contains bcrypt digest login: '%s' (line: %d / ignored because module is not loaded)", login, line_num)
238278
skip = True
239279
htpasswd_ok = False
280+
if re.match(r"^\$argon2(i|d|id)\$", digest):
281+
if init is True:
282+
argon2_use += 1
283+
else:
284+
if self._has_argon2 is False:
285+
logger.warning("htpasswd file contains argon2 digest login: '%s' (line: %d / ignored because module is not loaded)", login, line_num)
286+
skip = True
287+
htpasswd_ok = False
240288
if skip is False:
241289
htpasswd[login] = digest
242290
entries += 1
@@ -259,7 +307,7 @@ def _read_htpasswd(self, init: bool, suppress: bool) -> Tuple[bool, int, dict, i
259307
self._htpasswd_not_ok_time = 0
260308
else:
261309
self._htpasswd_not_ok_time = time.time()
262-
return (htpasswd_ok, bcrypt_use, htpasswd, htpasswd_size, htpasswd_mtime_ns)
310+
return (htpasswd_ok, bcrypt_use, argon2_use, htpasswd, htpasswd_size, htpasswd_mtime_ns)
263311

264312
def _login(self, login: str, password: str) -> str:
265313
"""Validate credentials.
@@ -280,7 +328,7 @@ def _login(self, login: str, password: str) -> str:
280328
htpasswd_size = os.stat(self._filename).st_size
281329
htpasswd_mtime_ns = os.stat(self._filename).st_mtime_ns
282330
if (htpasswd_size != self._htpasswd_size) or (htpasswd_mtime_ns != self._htpasswd_mtime_ns):
283-
(self._htpasswd_ok, self._htpasswd_bcrypt_use, self._htpasswd, self._htpasswd_size, self._htpasswd_mtime_ns) = self._read_htpasswd(False, False)
331+
(self._htpasswd_ok, self._htpasswd_bcrypt_use, self._htpasswd_argon2_use, self._htpasswd, self._htpasswd_size, self._htpasswd_mtime_ns) = self._read_htpasswd(False, False)
284332
self._htpasswd_not_ok_time = 0
285333

286334
# log reminder of problemantic file every interval
@@ -298,7 +346,7 @@ def _login(self, login: str, password: str) -> str:
298346
login_ok = True
299347
else:
300348
# read file on every request
301-
(htpasswd_ok, htpasswd_bcrypt_use, htpasswd, htpasswd_size, htpasswd_mtime_ns) = self._read_htpasswd(False, True)
349+
(htpasswd_ok, htpasswd_bcrypt_use, htpasswd_argon2_use, htpasswd, htpasswd_size, htpasswd_mtime_ns) = self._read_htpasswd(False, True)
302350
if htpasswd.get(login):
303351
digest = htpasswd[login]
304352
login_ok = True
@@ -307,7 +355,7 @@ def _login(self, login: str, password: str) -> str:
307355
try:
308356
(method, password_ok) = self._verify(digest, password)
309357
except ValueError as e:
310-
logger.error("Login verification failed for user: '%s' (htpasswd/%s) with errror '%s'", login, self._encryption, e)
358+
logger.error("Login verification failed for user: '%s' (htpasswd/%s) with error '%s'", login, self._encryption, e)
311359
return ""
312360
if password_ok:
313361
logger.debug("Login verification successful for user: '%s' (htpasswd/%s/%s)", login, self._encryption, method)

radicale/tests/test_auth.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ class TestBaseAuthRequests(BaseTest):
4949
else:
5050
has_bcrypt = 1
5151

52+
# test for available argon2 module
53+
try:
54+
import argon2
55+
from passlib.hash import argon2 # noqa: F811
56+
except ImportError:
57+
has_argon2 = 0
58+
else:
59+
has_argon2 = 1
60+
5261
def _test_htpasswd(self, htpasswd_encryption: str, htpasswd_content: str,
5362
test_matrix: Union[str, Iterable[Tuple[str, str, bool]]]
5463
= "ascii") -> None:
@@ -147,6 +156,18 @@ def test_htpasswd_bcrypt_C10_autodetect(self) -> None:
147156
def test_htpasswd_bcrypt_unicode(self) -> None:
148157
self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode")
149158

159+
@pytest.mark.skipif(has_argon2 == 0, reason="No argon2 module installed")
160+
def test_htpasswd_argon2_i(self) -> None:
161+
self._test_htpasswd("argon2", "tmp:$argon2i$v=19$m=65536,t=3,p=4$NgZg7F1rzRkDoNSaMwag9A$qmsvMKEn5zOXHm8e3O5fKzzcRo0UESwaDr/cETe5YPI")
162+
163+
@pytest.mark.skipif(has_argon2 == 0, reason="No argon2 module installed")
164+
def test_htpasswd_argon2_d(self) -> None:
165+
self._test_htpasswd("argon2", "tmp:$argon2d$v=19$m=65536,t=3,p=4$ufe+txYiJKR0zlkLwVirVQ$MjGqRyVLes38hA6CEOkloMcTYCuLjxCKgIjtfYZ3iSM")
166+
167+
@pytest.mark.skipif(has_argon2 == 0, reason="No argon2 module installed")
168+
def test_htpasswd_argon2_id(self) -> None:
169+
self._test_htpasswd("argon2", "tmp:$argon2id$v=19$m=65536,t=3,p=4$t7bWuneOkdIa45xTqjXGmA$ORnRJyz9kHogJs6bDgZrTBPlzi4+p023PSEABb3xX1g")
170+
150171
def test_htpasswd_multi(self) -> None:
151172
self._test_htpasswd("plain", "ign:ign\ntmp:bepo")
152173

radicale/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
RADICALE_MODULES: Sequence[str] = ("radicale", "vobject", "passlib", "defusedxml",
3131
"bcrypt",
32+
"argon2-cffi",
3233
"pika",
3334
"ldap",
3435
"ldap3",

setup.py.legacy

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
4141
"requests",
4242
]
4343
bcrypt_requires = ["bcrypt"]
44+
argon2_requires = ["argon2-cffi"]
4445
ldap_requires = ["ldap3"]
45-
test_requires = ["pytest>=7", "waitress", *bcrypt_requires]
46+
test_requires = ["pytest>=7", "waitress", *bcrypt_requires, *argon2_requires]
4647

4748
setup(
4849
name="Radicale",
@@ -60,7 +61,7 @@ setup(
6061
package_data={"radicale": [*web_files, "py.typed"]},
6162
entry_points={"console_scripts": ["radicale = radicale.__main__:run"]},
6263
install_requires=install_requires,
63-
extras_require={"test": test_requires, "bcrypt": bcrypt_requires, "ldap": ldap_requires},
64+
extras_require={"test": test_requires, "bcrypt": bcrypt_requires, "argon2": argon2_requires, "ldap": ldap_requires},
6465
keywords=["calendar", "addressbook", "CalDAV", "CardDAV"],
6566
python_requires=">=3.9.0",
6667
classifiers=[

0 commit comments

Comments
 (0)