Browse Source

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

Co-authored-by: Florian Apolloner <florian@apolloner.eu>
Jon Moroney 4 years ago
parent
commit
76ae6ccf85

+ 29 - 6
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
 

+ 3 - 0
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`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 20 - 0
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

+ 24 - 0
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'

+ 1 - 1
tests/auth_tests/test_views.py

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