diff --git a/README.md b/README.md index 78c6fca0eb7792bd7d7d92b85d1822c0a1c2f41a..8bca22a345a07c3364b944a5d35167e632fc56db 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Please note that we refer to Debian packages here and **not** pip packages. - python3-fido2 (version 0.5.0 or 0.9.1, optional) - python3-oauthlib - python3-flask-babel +- python3-argon2 - python3-mysqldb or python3-pymysql for MySQL/MariaDB support Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Buster or Bullseye. diff --git a/debian/control b/debian/control index 072db47ddbbc16afa0d8b6e34537c8754e6e3855..f9fef23b94d2f0720ab7773a2c0aae6ab26c3cdd 100644 --- a/debian/control +++ b/debian/control @@ -25,6 +25,7 @@ Depends: python3-fido2, python3-oauthlib, python3-flask-babel, + python3-argon2, uwsgi, uwsgi-plugin-python3, Recommends: diff --git a/setup.py b/setup.py index 5f45af544072bf886d049bfe1a9528ad96455adc..ad1822a8314c01583dede68d10682cc3da49c05f 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ setup( 'Flask-Migrate==2.1.1', 'Flask-Babel==0.11.2', 'alembic==1.0.0', + 'argon2-cffi==18.3.0', # The main dependencies on their own lead to version collisions and pip is # not very good at resolving them, so we pin the versions from Debian Buster diff --git a/tests/test_password_hash.py b/tests/test_password_hash.py index ed17b895457b450ef643e39b1d5d255c12c9061d..834b67ef76868427a180099265766ea59e002ffa 100644 --- a/tests/test_password_hash.py +++ b/tests/test_password_hash.py @@ -130,6 +130,31 @@ class TestCryptPasswordHash(unittest.TestCase): self.assertTrue(obj.verify('password')) self.assertFalse(obj.verify('notpassword')) +class TestArgon2PasswordHash(unittest.TestCase): + def test_verify(self): + obj = Argon2PasswordHash('{argon2}$argon2id$v=19$m=102400,t=2,p=8$Jc8LpCgPLjwlN/7efHLvwQ$ZqSg3CFb2/hBb3X8hOq4aw') + self.assertTrue(obj.verify('password')) + self.assertFalse(obj.verify('notpassword')) + obj = Argon2PasswordHash('{argon2}$invalid$') + self.assertFalse(obj.verify('password')) + + def test_from_password(self): + obj = Argon2PasswordHash.from_password('password') + self.assertIsNotNone(obj.value) + self.assertTrue(obj.value.startswith('{argon2}')) + self.assertTrue(obj.verify('password')) + self.assertFalse(obj.verify('notpassword')) + + def test_needs_rehash(self): + obj = Argon2PasswordHash('{argon2}$argon2id$v=19$m=102400,t=2,p=8$Jc8LpCgPLjwlN/7efHLvwQ$ZqSg3CFb2/hBb3X8hOq4aw') + self.assertFalse(obj.needs_rehash) + obj = Argon2PasswordHash('{argon2}$argon2id$v=19$m=102400,t=2,p=8$Jc8LpCgPLjwlN/7efHLvwQ$ZqSg3CFb2/hBb3X8hOq4aw', target_cls=PlaintextPasswordHash) + self.assertTrue(obj.needs_rehash) + obj = Argon2PasswordHash('{argon2}$argon2d$v=19$m=102400,t=2,p=8$kshPgLU1+h72l/Z8QWh8Ig$tYerKCe/5I2BCPKu8hCl2w') + self.assertTrue(obj.needs_rehash) + obj = Argon2PasswordHash('{argon2}$argon2id$v=19$m=102400,t=1,p=8$aa6i4vg/szKX5xHVGFaAeQ$v6j0ltuVqQaZlmuepaVJ1A') + self.assertTrue(obj.needs_rehash) + class TestInvalidPasswordHash(unittest.TestCase): def test(self): obj = InvalidPasswordHash('test') diff --git a/uffd/password_hash.py b/uffd/password_hash.py index 4862cc36562612604aaf35d5f9ff7d61309e7faa..bd941ea1a76befb7575e6435686bec1c7bfa17d1 100644 --- a/uffd/password_hash.py +++ b/uffd/password_hash.py @@ -2,6 +2,7 @@ import secrets import hashlib import base64 from crypt import crypt +import argon2 def build_value(method_name, data): return '{' + method_name + '}' + data @@ -179,6 +180,28 @@ class CryptPasswordHash(PasswordHash): def verify(self, password): return secrets.compare_digest(crypt(password, self.data), self.data) +@registry.register +class Argon2PasswordHash(PasswordHash): + METHOD_NAME = 'argon2' + + hasher = argon2.PasswordHasher() + + @classmethod + def from_password(cls, password): + return cls(build_value(cls.METHOD_NAME, cls.hasher.hash(password))) + + def verify(self, password): + try: + return self.hasher.verify(self.data, password) + except argon2.exceptions.Argon2Error: + return False + except argon2.exceptions.InvalidHash: + return False + + @property + def needs_rehash(self): + return super().needs_rehash or self.hasher.check_needs_rehash(self.data) + class InvalidPasswordHash: def __init__(self, value=None): self.value = value @@ -262,9 +285,8 @@ class PasswordHashAttribute: setattr(obj, self.attribute_name, value.value) # Hashing method for (potentially) low entropy secrets like user passwords. Is -# usually slow and uses salting to make dictionary attacks difficult. Note -# that SSHA512 is not slow and should be replaced with a modern alternative. -LowEntropyPasswordHash = SaltedSHA512PasswordHash +# usually slow and uses salting to make dictionary attacks difficult. +LowEntropyPasswordHash = Argon2PasswordHash # Hashing method for high entropy secrets like API keys. The secrets are # generated instead of user-selected to ensure a high level of entropy. Is