summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorryowright <43687737+ryowright@users.noreply.github.com>2020-12-26 18:54:47 -0800
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2021-07-22 12:40:33 +0200
commit1783b3cb24cdefd8e1e3d73acd1d1ef3011c96be (patch)
tree3b71a9e0a089dd7c59275cd22b741013863f8f84
parent65b880b7268dd8fe97fde5af77bede46305eb499 (diff)
Fixed #32275 -- Added scrypt password hasher.
Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
-rw-r--r--AUTHORS1
-rw-r--r--django/conf/global_settings.py1
-rw-r--r--django/contrib/auth/hashers.py75
-rw-r--r--docs/releases/4.0.txt7
-rw-r--r--docs/topics/auth/passwords.txt68
-rw-r--r--tests/auth_tests/test_hashers.py81
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 <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/>
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 <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
--------------
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 <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``
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)