Fixed #31358 -- Increased salt entropy of password hashers.
Co-authored-by: Florian Apolloner <florian@apolloner.eu>
This commit is contained in:
parent
6bd206e1ff
commit
76ae6ccf85
|
@ -3,6 +3,7 @@ import binascii
|
|||
import functools
|
||||
import hashlib
|
||||
import importlib
|
||||
import math
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
|
@ -161,6 +162,11 @@ def mask_hash(hash, show=6, char="*"):
|
|||
return masked
|
||||
|
||||
|
||||
def must_update_salt(salt, expected_entropy):
|
||||
# Each character in the salt provides log_2(len(alphabet)) bits of entropy.
|
||||
return len(salt) * math.log2(len(RANDOM_STRING_CHARS)) < expected_entropy
|
||||
|
||||
|
||||
class BasePasswordHasher:
|
||||
"""
|
||||
Abstract base class for password hashers
|
||||
|
@ -172,6 +178,7 @@ class BasePasswordHasher:
|
|||
"""
|
||||
algorithm = None
|
||||
library = None
|
||||
salt_entropy = 128
|
||||
|
||||
def _load_library(self):
|
||||
if self.library is not None:
|
||||
|
@ -189,9 +196,14 @@ class BasePasswordHasher:
|
|||
self.__class__.__name__)
|
||||
|
||||
def salt(self):
|
||||
"""Generate a cryptographically secure nonce salt in ASCII."""
|
||||
# 12 returns a 71-bit value, log_2(len(RANDOM_STRING_CHARS)^12) =~ 71 bits
|
||||
return get_random_string(12, RANDOM_STRING_CHARS)
|
||||
"""
|
||||
Generate a cryptographically secure nonce salt in ASCII with an entropy
|
||||
of at least `salt_entropy` bits.
|
||||
"""
|
||||
# Each character in the salt provides
|
||||
# log_2(len(alphabet)) bits of entropy.
|
||||
char_count = math.ceil(self.salt_entropy / math.log2(len(RANDOM_STRING_CHARS)))
|
||||
return get_random_string(char_count, allowed_chars=RANDOM_STRING_CHARS)
|
||||
|
||||
def verify(self, password, encoded):
|
||||
"""Check if the given password is correct."""
|
||||
|
@ -290,7 +302,8 @@ class PBKDF2PasswordHasher(BasePasswordHasher):
|
|||
|
||||
def must_update(self, encoded):
|
||||
decoded = self.decode(encoded)
|
||||
return decoded['iterations'] != self.iterations
|
||||
update_salt = must_update_salt(decoded['salt'], self.salt_entropy)
|
||||
return (decoded['iterations'] != self.iterations) or update_salt
|
||||
|
||||
def harden_runtime(self, password, encoded):
|
||||
decoded = self.decode(encoded)
|
||||
|
@ -383,12 +396,14 @@ class Argon2PasswordHasher(BasePasswordHasher):
|
|||
}
|
||||
|
||||
def must_update(self, encoded):
|
||||
current_params = self.decode(encoded)['params']
|
||||
decoded = self.decode(encoded)
|
||||
current_params = decoded['params']
|
||||
new_params = self.params()
|
||||
# Set salt_len to the salt_len of the current parameters because salt
|
||||
# is explicitly passed to argon2.
|
||||
new_params.salt_len = current_params.salt_len
|
||||
return current_params != new_params
|
||||
update_salt = must_update_salt(decoded['salt'], self.salt_entropy)
|
||||
return (current_params != new_params) or update_salt
|
||||
|
||||
def harden_runtime(self, password, encoded):
|
||||
# The runtime for Argon2 is too complicated to implement a sensible
|
||||
|
@ -531,6 +546,10 @@ class SHA1PasswordHasher(BasePasswordHasher):
|
|||
_('hash'): mask_hash(decoded['hash']),
|
||||
}
|
||||
|
||||
def must_update(self, encoded):
|
||||
decoded = self.decode(encoded)
|
||||
return must_update_salt(decoded['salt'], self.salt_entropy)
|
||||
|
||||
def harden_runtime(self, password, encoded):
|
||||
pass
|
||||
|
||||
|
@ -569,6 +588,10 @@ class MD5PasswordHasher(BasePasswordHasher):
|
|||
_('hash'): mask_hash(decoded['hash']),
|
||||
}
|
||||
|
||||
def must_update(self, encoded):
|
||||
decoded = self.decode(encoded)
|
||||
return must_update_salt(decoded['salt'], self.salt_entropy)
|
||||
|
||||
def harden_runtime(self, password, encoded):
|
||||
pass
|
||||
|
||||
|
|
|
@ -212,6 +212,9 @@ Minor features
|
|||
constrained environments. If this is the case, the existing hasher can be
|
||||
subclassed to override the defaults.
|
||||
|
||||
* The default salt entropy for the Argon2, MD5, PBKDF2, SHA-1 password hashers
|
||||
is increased from 71 to 128 bits.
|
||||
|
||||
:mod:`django.contrib.contenttypes`
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
|
|
@ -137,6 +137,26 @@ 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.
|
||||
|
||||
Increasing the salt entropy
|
||||
---------------------------
|
||||
|
||||
.. versionadded:: 3.2
|
||||
|
||||
Most password hashes include a salt along with their password hash in order to
|
||||
protect against rainbow table attacks. The salt itself is a random value which
|
||||
increases the size and thus the cost of the rainbow table and is currently set
|
||||
at 128 bits with the ``salt_entropy`` value in the ``BasePasswordHasher``. As
|
||||
computing and storage costs decrease this value should be raised. When
|
||||
implementing your own password hasher you are free to override this value in
|
||||
order to use a desired entropy level for your password hashes. ``salt_entropy``
|
||||
is measured in bits.
|
||||
|
||||
.. admonition:: Implementation detail
|
||||
|
||||
Due to the method in which salt values are stored the ``salt_entropy``
|
||||
value is effectively a minimum value. For instance a value of 128 would
|
||||
provide a salt which would actually contain 131 bits of entropy.
|
||||
|
||||
.. _increasing-password-algorithm-work-factor:
|
||||
|
||||
Increasing the work factor
|
||||
|
|
|
@ -74,6 +74,12 @@ class TestUtilsHashPass(SimpleTestCase):
|
|||
self.assertTrue(is_password_usable(blank_encoded))
|
||||
self.assertTrue(check_password('', blank_encoded))
|
||||
self.assertFalse(check_password(' ', blank_encoded))
|
||||
# Salt entropy check.
|
||||
hasher = get_hasher('pbkdf2_sha256')
|
||||
encoded_weak_salt = make_password('lètmein', 'iodizedsalt', 'pbkdf2_sha256')
|
||||
encoded_strong_salt = make_password('lètmein', hasher.salt(), 'pbkdf2_sha256')
|
||||
self.assertIs(hasher.must_update(encoded_weak_salt), True)
|
||||
self.assertIs(hasher.must_update(encoded_strong_salt), False)
|
||||
|
||||
@override_settings(PASSWORD_HASHERS=['django.contrib.auth.hashers.SHA1PasswordHasher'])
|
||||
def test_sha1(self):
|
||||
|
@ -89,6 +95,12 @@ class TestUtilsHashPass(SimpleTestCase):
|
|||
self.assertTrue(is_password_usable(blank_encoded))
|
||||
self.assertTrue(check_password('', blank_encoded))
|
||||
self.assertFalse(check_password(' ', blank_encoded))
|
||||
# Salt entropy check.
|
||||
hasher = get_hasher('sha1')
|
||||
encoded_weak_salt = make_password('lètmein', 'iodizedsalt', 'sha1')
|
||||
encoded_strong_salt = make_password('lètmein', hasher.salt(), 'sha1')
|
||||
self.assertIs(hasher.must_update(encoded_weak_salt), True)
|
||||
self.assertIs(hasher.must_update(encoded_strong_salt), False)
|
||||
|
||||
@override_settings(PASSWORD_HASHERS=['django.contrib.auth.hashers.MD5PasswordHasher'])
|
||||
def test_md5(self):
|
||||
|
@ -104,6 +116,12 @@ class TestUtilsHashPass(SimpleTestCase):
|
|||
self.assertTrue(is_password_usable(blank_encoded))
|
||||
self.assertTrue(check_password('', blank_encoded))
|
||||
self.assertFalse(check_password(' ', blank_encoded))
|
||||
# Salt entropy check.
|
||||
hasher = get_hasher('md5')
|
||||
encoded_weak_salt = make_password('lètmein', 'iodizedsalt', 'md5')
|
||||
encoded_strong_salt = make_password('lètmein', hasher.salt(), 'md5')
|
||||
self.assertIs(hasher.must_update(encoded_weak_salt), True)
|
||||
self.assertIs(hasher.must_update(encoded_strong_salt), False)
|
||||
|
||||
@override_settings(PASSWORD_HASHERS=['django.contrib.auth.hashers.UnsaltedMD5PasswordHasher'])
|
||||
def test_unsalted_md5(self):
|
||||
|
@ -537,6 +555,12 @@ class TestUtilsHashPassArgon2(SimpleTestCase):
|
|||
)
|
||||
self.assertIs(check_password('secret', encoded), True)
|
||||
self.assertIs(check_password('wrong', encoded), False)
|
||||
# Salt entropy check.
|
||||
hasher = get_hasher('argon2')
|
||||
encoded_weak_salt = make_password('lètmein', 'iodizedsalt', 'argon2')
|
||||
encoded_strong_salt = make_password('lètmein', hasher.salt(), 'argon2')
|
||||
self.assertIs(hasher.must_update(encoded_weak_salt), True)
|
||||
self.assertIs(hasher.must_update(encoded_strong_salt), False)
|
||||
|
||||
def test_argon2_decode(self):
|
||||
salt = 'abcdefghijk'
|
||||
|
|
|
@ -1269,7 +1269,7 @@ class ChangelistTests(AuthViewsTestCase):
|
|||
self.assertContains(
|
||||
response,
|
||||
'<strong>algorithm</strong>: %s\n\n'
|
||||
'<strong>salt</strong>: %s**********\n\n'
|
||||
'<strong>salt</strong>: %s********************\n\n'
|
||||
'<strong>hash</strong>: %s**************************\n\n' % (
|
||||
algo, salt[:2], hash_string[:6],
|
||||
),
|
||||
|
|
Loading…
Reference in New Issue