Fixed #32275 -- Added scrypt password hasher.

Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
This commit is contained in:
ryowright 2020-12-26 18:54:47 -08:00 committed by Mariusz Felisiak
parent 65b880b726
commit 1783b3cb24
6 changed files with 231 additions and 2 deletions

View File

@ -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/>

View File

@ -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 = []

View File

@ -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)

View File

@ -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
--------------

View File

@ -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``

View File

@ -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)