Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed #32275 -- Added scrypt password hasher. #13799

Merged
merged 1 commit into from
Jul 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -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/>
Expand Down
1 change: 1 addition & 0 deletions django/conf/global_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ def gettext_noop(s):
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
'django.contrib.auth.hashers.ScryptPasswordHasher',
ryowright marked this conversation as resolved.
Show resolved Hide resolved
]

AUTH_PASSWORD_VALIDATORS = []
Expand Down
75 changes: 75 additions & 0 deletions django/contrib/auth/hashers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
felixxm marked this conversation as resolved.
Show resolved Hide resolved
)

def harden_runtime(self, password, encoded):
felixxm marked this conversation as resolved.
Show resolved Hide resolved
# The runtime for Scrypt is too complicated to implement a sensible
# hardening algorithm.
pass


class SHA1PasswordHasher(BasePasswordHasher):
"""
The SHA1 password hashing algorithm (not recommended)
Expand Down
7 changes: 7 additions & 0 deletions docs/releases/4.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------

Expand Down
68 changes: 68 additions & 0 deletions docs/topics/auth/passwords.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
---------------------------

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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',
Expand All @@ -380,6 +447,7 @@ The corresponding algorithm names are:
* ``argon2``
* ``bcrypt_sha256``
* ``bcrypt``
* ``scrypt``
* ``sha1``
* ``md5``
* ``unsalted_sha1``
Expand Down
81 changes: 79 additions & 2 deletions tests/auth_tests/test_hashers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
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
Expand Down Expand Up @@ -480,6 +480,7 @@ def test_encode_invalid_salt(self):
MD5PasswordHasher,
PBKDF2PasswordHasher,
PBKDF2SHA1PasswordHasher,
ScryptPasswordHasher,
SHA1PasswordHasher,
]
msg = 'salt must be provided and cannot contain $.'
Expand All @@ -495,6 +496,7 @@ def test_encode_password_required(self):
MD5PasswordHasher,
PBKDF2PasswordHasher,
PBKDF2SHA1PasswordHasher,
ScryptPasswordHasher,
SHA1PasswordHasher,
]
msg = 'password must be provided.'
Expand Down Expand Up @@ -662,3 +664,78 @@ def setter(password):
self.assertTrue(state['upgraded'])
finally:
setattr(hasher, attr, old_value)


@override_settings(PASSWORD_HASHERS=PASSWORD_HASHERS)
felixxm marked this conversation as resolved.
Show resolved Hide resolved
class TestUtilsHashPassScrypt(SimpleTestCase):

def test_scrypt(self):
encoded = make_password('lètmein', 'seasalt', 'scrypt')
self.assertEqual(
felixxm marked this conversation as resolved.
Show resolved Hide resolved
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)