mirror of https://github.com/django/django.git
Fixed #32275 -- Added scrypt password hasher.
Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
This commit is contained in:
parent
65b880b726
commit
1783b3cb24
1
AUTHORS
1
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/>
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
--------------
|
||||
|
||||
|
|
|
@ -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``
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue