From ac003909adf664be42a964d6867b8700c62252ff Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Sun, 13 Feb 2022 15:47:20 +0100
Subject: [PATCH] Argon2 for user password hashing

Argon2 is a modern password hashing algorithm. It is significantly more secure
than the previous algorithm (salted SHA512). User logins with Argon2 are
relativly slow and cause significant spikes in CPU and memory (100MB) usage.

Existing passwords are gradually migrated to Argon2 on login.
---
 README.md                   |  1 +
 debian/control              |  1 +
 setup.py                    |  1 +
 tests/test_password_hash.py | 25 +++++++++++++++++++++++++
 uffd/password_hash.py       | 28 +++++++++++++++++++++++++---
 5 files changed, 53 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index 78c6fca0..8bca22a3 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 072db47d..f9fef23b 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 5f45af54..ad1822a8 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 ed17b895..834b67ef 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 4862cc36..bd941ea1 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
-- 
GitLab