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 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
|
||||||
|
|
||||||
|
|
|
@ -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`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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],
|
||||||
),
|
),
|
||||||
|
|
Loading…
Reference in New Issue