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