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 functools
import hashlib import hashlib
import importlib import importlib
import math
import warnings import warnings
from django.conf import settings from django.conf import settings
@ -161,6 +162,11 @@ def mask_hash(hash, show=6, char="*"):
return masked 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: class BasePasswordHasher:
""" """
Abstract base class for password hashers Abstract base class for password hashers
@ -172,6 +178,7 @@ class BasePasswordHasher:
""" """
algorithm = None algorithm = None
library = None library = None
salt_entropy = 128
def _load_library(self): def _load_library(self):
if self.library is not None: if self.library is not None:
@ -189,9 +196,14 @@ class BasePasswordHasher:
self.__class__.__name__) self.__class__.__name__)
def salt(self): 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 Generate a cryptographically secure nonce salt in ASCII with an entropy
return get_random_string(12, RANDOM_STRING_CHARS) 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): def verify(self, password, encoded):
"""Check if the given password is correct.""" """Check if the given password is correct."""
@ -290,7 +302,8 @@ class PBKDF2PasswordHasher(BasePasswordHasher):
def must_update(self, encoded): def must_update(self, encoded):
decoded = self.decode(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): def harden_runtime(self, password, encoded):
decoded = self.decode(encoded) decoded = self.decode(encoded)
@ -383,12 +396,14 @@ class Argon2PasswordHasher(BasePasswordHasher):
} }
def must_update(self, encoded): def must_update(self, encoded):
current_params = self.decode(encoded)['params'] decoded = self.decode(encoded)
current_params = decoded['params']
new_params = self.params() new_params = self.params()
# Set salt_len to the salt_len of the current parameters because salt # Set salt_len to the salt_len of the current parameters because salt
# is explicitly passed to argon2. # is explicitly passed to argon2.
new_params.salt_len = current_params.salt_len 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): def harden_runtime(self, password, encoded):
# The runtime for Argon2 is too complicated to implement a sensible # The runtime for Argon2 is too complicated to implement a sensible
@ -531,6 +546,10 @@ class SHA1PasswordHasher(BasePasswordHasher):
_('hash'): mask_hash(decoded['hash']), _('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): def harden_runtime(self, password, encoded):
pass pass
@ -569,6 +588,10 @@ class MD5PasswordHasher(BasePasswordHasher):
_('hash'): mask_hash(decoded['hash']), _('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): def harden_runtime(self, password, encoded):
pass pass

View File

@ -212,6 +212,9 @@ Minor features
constrained environments. If this is the case, the existing hasher can be constrained environments. If this is the case, the existing hasher can be
subclassed to override the defaults. 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` :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 That's it -- now your Django install will use Bcrypt as the default storage
algorithm. 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-password-algorithm-work-factor:
Increasing the work factor Increasing the work factor

View File

@ -74,6 +74,12 @@ class TestUtilsHashPass(SimpleTestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(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']) @override_settings(PASSWORD_HASHERS=['django.contrib.auth.hashers.SHA1PasswordHasher'])
def test_sha1(self): def test_sha1(self):
@ -89,6 +95,12 @@ class TestUtilsHashPass(SimpleTestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(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']) @override_settings(PASSWORD_HASHERS=['django.contrib.auth.hashers.MD5PasswordHasher'])
def test_md5(self): def test_md5(self):
@ -104,6 +116,12 @@ class TestUtilsHashPass(SimpleTestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(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']) @override_settings(PASSWORD_HASHERS=['django.contrib.auth.hashers.UnsaltedMD5PasswordHasher'])
def test_unsalted_md5(self): def test_unsalted_md5(self):
@ -537,6 +555,12 @@ class TestUtilsHashPassArgon2(SimpleTestCase):
) )
self.assertIs(check_password('secret', encoded), True) self.assertIs(check_password('secret', encoded), True)
self.assertIs(check_password('wrong', encoded), False) 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): def test_argon2_decode(self):
salt = 'abcdefghijk' salt = 'abcdefghijk'

View File

@ -1269,7 +1269,7 @@ class ChangelistTests(AuthViewsTestCase):
self.assertContains( self.assertContains(
response, response,
'<strong>algorithm</strong>: %s\n\n' '<strong>algorithm</strong>: %s\n\n'
'<strong>salt</strong>: %s**********\n\n' '<strong>salt</strong>: %s********************\n\n'
'<strong>hash</strong>: %s**************************\n\n' % ( '<strong>hash</strong>: %s**************************\n\n' % (
algo, salt[:2], hash_string[:6], algo, salt[:2], hash_string[:6],
), ),