Fixed #31358 -- Increased salt entropy of password hashers.

Co-authored-by: Florian Apolloner <florian@apolloner.eu>
This commit is contained in:
Jon Moroney 2020-06-24 19:28:07 -07:00 committed by Mariusz Felisiak
parent 6bd206e1ff
commit 76ae6ccf85
5 changed files with 77 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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