From 1783b3cb24cdefd8e1e3d73acd1d1ef3011c96be Mon Sep 17 00:00:00 2001 From: ryowright <43687737+ryowright@users.noreply.github.com> Date: Sat, 26 Dec 2020 18:54:47 -0800 Subject: [PATCH] Fixed #32275 -- Added scrypt password hasher. Co-authored-by: Mariusz Felisiak --- AUTHORS | 1 + django/conf/global_settings.py | 1 + django/contrib/auth/hashers.py | 75 +++++++++++++++++++++++++++++ docs/releases/4.0.txt | 7 +++ docs/topics/auth/passwords.txt | 68 +++++++++++++++++++++++++++ tests/auth_tests/test_hashers.py | 81 +++++++++++++++++++++++++++++++- 6 files changed, 231 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index 72a87ce7b7..7ce3d2e08d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -82,6 +82,7 @@ answer newbie questions, and generally made Django that much better: Anssi Kääriäinen ant9000@netwise.it Anthony Briggs + Anthony Wright Anton Samarchyan Antoni Aloy Antonio Cavedoni diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 18c8d9330d..d9272f6cc1 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -520,6 +520,7 @@ PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + 'django.contrib.auth.hashers.ScryptPasswordHasher', ] AUTH_PASSWORD_VALIDATORS = [] diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index ccbac336dc..9946014ad2 100644 --- a/django/contrib/auth/hashers.py +++ b/django/contrib/auth/hashers.py @@ -517,6 +517,81 @@ class BCryptPasswordHasher(BCryptSHA256PasswordHasher): digest = None +class ScryptPasswordHasher(BasePasswordHasher): + """ + Secure password hashing using the Scrypt algorithm. + """ + algorithm = 'scrypt' + block_size = 8 + maxmem = 0 + parallelism = 1 + work_factor = 2 ** 14 + + def encode(self, password, salt, n=None, r=None, p=None): + self._check_encode_args(password, salt) + n = n or self.work_factor + r = r or self.block_size + p = p or self.parallelism + hash_ = hashlib.scrypt( + password.encode(), + salt=salt.encode(), + n=n, + r=r, + p=p, + maxmem=self.maxmem, + dklen=64, + ) + hash_ = base64.b64encode(hash_).decode('ascii').strip() + return '%s$%d$%s$%d$%d$%s' % (self.algorithm, n, salt, r, p, hash_) + + def decode(self, encoded): + algorithm, work_factor, salt, block_size, parallelism, hash_ = encoded.split('$', 6) + assert algorithm == self.algorithm + return { + 'algorithm': algorithm, + 'work_factor': int(work_factor), + 'salt': salt, + 'block_size': int(block_size), + 'parallelism': int(parallelism), + 'hash': hash_, + } + + def verify(self, password, encoded): + decoded = self.decode(encoded) + encoded_2 = self.encode( + password, + decoded['salt'], + decoded['work_factor'], + decoded['block_size'], + decoded['parallelism'], + ) + return constant_time_compare(encoded, encoded_2) + + def safe_summary(self, encoded): + decoded = self.decode(encoded) + return { + _('algorithm'): decoded['algorithm'], + _('work factor'): decoded['work_factor'], + _('block size'): decoded['block_size'], + _('parallelism'): decoded['parallelism'], + _('salt'): mask_hash(decoded['salt']), + _('hash'): mask_hash(decoded['hash']), + } + + def must_update(self, encoded): + decoded = self.decode(encoded) + return ( + decoded['work_factor'] != self.work_factor or + decoded['block_size'] != self.block_size or + decoded['parallelism'] != self.parallelism + ) + + def harden_runtime(self, password, encoded): + # The runtime for Scrypt is too complicated to implement a sensible + # hardening algorithm. + pass + + class SHA1PasswordHasher(BasePasswordHasher): """ The SHA1 password hashing algorithm (not recommended) diff --git a/docs/releases/4.0.txt b/docs/releases/4.0.txt index 9162588d07..7275e1712d 100644 --- a/docs/releases/4.0.txt +++ b/docs/releases/4.0.txt @@ -58,6 +58,13 @@ For example:: Functional unique constraints are added to models using the :attr:`Meta.constraints ` option. +``scrypt`` password hasher +-------------------------- + +The new :ref:`scrypt password hasher ` is more secure and +recommended over PBKDF2. However, it's not the default as it requires OpenSSL +1.1+ and more memory. + Minor features -------------- diff --git a/docs/topics/auth/passwords.txt b/docs/topics/auth/passwords.txt index 1d3d1653fa..c6383ed9a3 100644 --- a/docs/topics/auth/passwords.txt +++ b/docs/topics/auth/passwords.txt @@ -62,6 +62,7 @@ The default for :setting:`PASSWORD_HASHERS` is:: 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + 'django.contrib.auth.hashers.ScryptPasswordHasher', ] This means that Django will use PBKDF2_ to store all passwords but will support @@ -99,6 +100,7 @@ To use Argon2 as your default storage algorithm, do the following: 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + 'django.contrib.auth.hashers.ScryptPasswordHasher', ] Keep and/or add any entries in this list if you need Django to :ref:`upgrade @@ -129,6 +131,7 @@ To use Bcrypt as your default storage algorithm, do the following: 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 'django.contrib.auth.hashers.Argon2PasswordHasher', + 'django.contrib.auth.hashers.ScryptPasswordHasher', ] Keep and/or add any entries in this list if you need Django to :ref:`upgrade @@ -137,6 +140,41 @@ To use Bcrypt as your default storage algorithm, do the following: That's it -- now your Django install will use Bcrypt as the default storage algorithm. +.. _scrypt-usage: + +Using ``scrypt`` with Django +---------------------------- + +.. versionadded:: 4.0 + +scrypt_ is similar to PBKDF2 and bcrypt in utilizing a set number of iterations +to slow down brute-force attacks. However, because PBKDF2 and bcrypt do not +require a lot of memory, attackers with sufficient resources can launch +large-scale parallel attacks in order to speed up the attacking process. +scrypt_ is specifically designed to use more memory compared to other +password-based key derivation functions in order to limit the amount of +parallelism an attacker can use, see :rfc:`7914` for more details. + +To use scrypt_ as your default storage algorithm, do the following: + +#. Modify :setting:`PASSWORD_HASHERS` to list ``ScryptPasswordHasher`` first. + That is, in your settings file:: + + PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.ScryptPasswordHasher', + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.Argon2PasswordHasher', + 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + ] + + Keep and/or add any entries in this list if you need Django to :ref:`upgrade + passwords `. + +.. note:: + + ``scrypt`` requires OpenSSL 1.1+. + Increasing the salt entropy --------------------------- @@ -197,6 +235,7 @@ algorithm: 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + 'django.contrib.auth.hashers.ScryptPasswordHasher', ] That's it -- now your Django install will use more iterations when it @@ -235,6 +274,32 @@ follows: ``memory_cost`` parameter differently from the value that Django uses. The conversion is given by ``memory_cost == 2 ** memory_cost_commandline``. +``scrypt`` +~~~~~~~~~~ + +.. versionadded:: 4.0 + +scrypt_ has four attributes that can be customized: + +#. ``work_factor`` controls the number of iterations within the hash. +#. ``block_size`` +#. ``parallelism`` controls how many threads will run in parallel. +#. ``maxmem`` limits the maximum size of memory that can be used during the + computation of the hash. Defaults to ``0``, which means the default + limitation from the OpenSSL library. + +We've chosen reasonable defaults, but you may wish to tune it up or down, +depending on your security needs and available processing power. + +.. admonition:: Estimating memory usage + + The minimum memory requirement of scrypt_ is:: + + work_factor * 2 * block_size * 64 + + so you may need to tweak ``maxmem`` when changing the ``work_factor`` or + ``block_size`` values. + .. _password-upgrades: Password upgrading @@ -351,6 +416,7 @@ Include any other hashers that your site uses in this list. .. _`bcrypt library`: https://pypi.org/project/bcrypt/ .. _`argon2-cffi library`: https://pypi.org/project/argon2-cffi/ .. _argon2: https://en.wikipedia.org/wiki/Argon2 +.. _scrypt: https://en.wikipedia.org/wiki/Scrypt .. _`Password Hashing Competition`: https://www.password-hashing.net/ .. _auth-included-hashers: @@ -366,6 +432,7 @@ The full list of hashers included in Django is:: 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 'django.contrib.auth.hashers.BCryptPasswordHasher', + 'django.contrib.auth.hashers.ScryptPasswordHasher', 'django.contrib.auth.hashers.SHA1PasswordHasher', 'django.contrib.auth.hashers.MD5PasswordHasher', 'django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher', @@ -380,6 +447,7 @@ The corresponding algorithm names are: * ``argon2`` * ``bcrypt_sha256`` * ``bcrypt`` +* ``scrypt`` * ``sha1`` * ``md5`` * ``unsalted_sha1`` diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py index 46ac4062f3..81234a44f0 100644 --- a/tests/auth_tests/test_hashers.py +++ b/tests/auth_tests/test_hashers.py @@ -5,8 +5,8 @@ from django.contrib.auth.hashers import ( UNUSABLE_PASSWORD_PREFIX, UNUSABLE_PASSWORD_SUFFIX_LENGTH, BasePasswordHasher, BCryptPasswordHasher, BCryptSHA256PasswordHasher, MD5PasswordHasher, PBKDF2PasswordHasher, PBKDF2SHA1PasswordHasher, - SHA1PasswordHasher, check_password, get_hasher, identify_hasher, - is_password_usable, make_password, + ScryptPasswordHasher, SHA1PasswordHasher, check_password, get_hasher, + identify_hasher, is_password_usable, make_password, ) from django.test import SimpleTestCase from django.test.utils import override_settings @@ -480,6 +480,7 @@ class TestUtilsHashPass(SimpleTestCase): MD5PasswordHasher, PBKDF2PasswordHasher, PBKDF2SHA1PasswordHasher, + ScryptPasswordHasher, SHA1PasswordHasher, ] msg = 'salt must be provided and cannot contain $.' @@ -495,6 +496,7 @@ class TestUtilsHashPass(SimpleTestCase): MD5PasswordHasher, PBKDF2PasswordHasher, PBKDF2SHA1PasswordHasher, + ScryptPasswordHasher, SHA1PasswordHasher, ] msg = 'password must be provided.' @@ -662,3 +664,78 @@ class TestUtilsHashPassArgon2(SimpleTestCase): self.assertTrue(state['upgraded']) finally: setattr(hasher, attr, old_value) + + +@override_settings(PASSWORD_HASHERS=PASSWORD_HASHERS) +class TestUtilsHashPassScrypt(SimpleTestCase): + + def test_scrypt(self): + encoded = make_password('lètmein', 'seasalt', 'scrypt') + self.assertEqual( + encoded, + 'scrypt$16384$seasalt$8$1$Qj3+9PPyRjSJIebHnG81TMjsqtaIGxNQG/aEB/NY' + 'afTJ7tibgfYz71m0ldQESkXFRkdVCBhhY8mx7rQwite/Pw==' + ) + self.assertIs(is_password_usable(encoded), True) + self.assertIs(check_password('lètmein', encoded), True) + self.assertIs(check_password('lètmeinz', encoded), False) + self.assertEqual(identify_hasher(encoded).algorithm, "scrypt") + # Blank passwords. + blank_encoded = make_password('', 'seasalt', 'scrypt') + self.assertIs(blank_encoded.startswith('scrypt$'), True) + self.assertIs(is_password_usable(blank_encoded), True) + self.assertIs(check_password('', blank_encoded), True) + self.assertIs(check_password(' ', blank_encoded), False) + + def test_scrypt_decode(self): + encoded = make_password('lètmein', 'seasalt', 'scrypt') + hasher = get_hasher('scrypt') + decoded = hasher.decode(encoded) + tests = [ + ('block_size', hasher.block_size), + ('parallelism', hasher.parallelism), + ('salt', 'seasalt'), + ('work_factor', hasher.work_factor), + ] + for key, excepted in tests: + with self.subTest(key=key): + self.assertEqual(decoded[key], excepted) + + def _test_scrypt_upgrade(self, attr, summary_key, new_value): + hasher = get_hasher('scrypt') + self.assertEqual(hasher.algorithm, 'scrypt') + self.assertNotEqual(getattr(hasher, attr), new_value) + + old_value = getattr(hasher, attr) + try: + # Generate hash with attr set to the new value. + setattr(hasher, attr, new_value) + encoded = make_password('lètmein', 'seasalt', 'scrypt') + attr_value = hasher.safe_summary(encoded)[summary_key] + self.assertEqual(attr_value, new_value) + + state = {'upgraded': False} + + def setter(password): + state['upgraded'] = True + + # No update is triggered. + self.assertIs(check_password('lètmein', encoded, setter, 'scrypt'), True) + self.assertIs(state['upgraded'], False) + # Revert to the old value. + setattr(hasher, attr, old_value) + # Password is updated. + self.assertIs(check_password('lètmein', encoded, setter, 'scrypt'), True) + self.assertIs(state['upgraded'], True) + finally: + setattr(hasher, attr, old_value) + + def test_scrypt_upgrade(self): + tests = [ + ('work_factor', 'work factor', 2 ** 11), + ('block_size', 'block size', 10), + ('parallelism', 'parallelism', 2), + ] + for attr, summary_key, new_value in tests: + with self.subTest(attr=attr): + self._test_scrypt_upgrade(attr, summary_key, new_value)