diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py
index 7a751a694e..86ae7f42a8 100644
--- a/django/contrib/auth/hashers.py
+++ b/django/contrib/auth/hashers.py
@@ -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
diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt
index 3b1463be6f..307eb73d67 100644
--- a/docs/releases/3.2.txt
+++ b/docs/releases/3.2.txt
@@ -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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/topics/auth/passwords.txt b/docs/topics/auth/passwords.txt
index 00381ecdeb..28f22f048e 100644
--- a/docs/topics/auth/passwords.txt
+++ b/docs/topics/auth/passwords.txt
@@ -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
diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py
index 8cd70d6721..8bc61bc8b2 100644
--- a/tests/auth_tests/test_hashers.py
+++ b/tests/auth_tests/test_hashers.py
@@ -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'
diff --git a/tests/auth_tests/test_views.py b/tests/auth_tests/test_views.py
index 9d669c5d85..4fb61b9be5 100644
--- a/tests/auth_tests/test_views.py
+++ b/tests/auth_tests/test_views.py
@@ -1269,7 +1269,7 @@ class ChangelistTests(AuthViewsTestCase):
self.assertContains(
response,
'algorithm: %s\n\n'
- 'salt: %s**********\n\n'
+ 'salt: %s********************\n\n'
'hash: %s**************************\n\n' % (
algo, salt[:2], hash_string[:6],
),