Browse Source

Fixed #32275 -- Added scrypt password hasher.

Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
ryowright 4 years ago
parent
commit
1783b3cb24

+ 1 - 0
AUTHORS

@@ -82,6 +82,7 @@ answer newbie questions, and generally made Django that much better:
     Anssi Kääriäinen <akaariai@gmail.com>
     ant9000@netwise.it
     Anthony Briggs <anthony.briggs@gmail.com>
+    Anthony Wright <ryow.college@gmail.com>
     Anton Samarchyan <desecho@gmail.com>
     Antoni Aloy
     Antonio Cavedoni <http://cavedoni.com/>

+ 1 - 0
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 = []

+ 75 - 0
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)

+ 7 - 0
docs/releases/4.0.txt

@@ -58,6 +58,13 @@ For example::
 Functional unique constraints are added to models using the
 :attr:`Meta.constraints <django.db.models.Options.constraints>` option.
 
+``scrypt`` password hasher
+--------------------------
+
+The new :ref:`scrypt password hasher <scrypt-usage>` is more secure and
+recommended over PBKDF2. However, it's not the default as it requires OpenSSL
+1.1+ and more memory.
+
 Minor features
 --------------
 

+ 68 - 0
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 <password-upgrades>`.
+
+.. 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``

+ 79 - 2
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)