Browse Source

Refs #26033 -- Added password hasher support for Argon2 v1.3.

The previous version of Argon2 uses encoded hashes of the form:
   $argon2d$m=8,t=1,p=1$<salt>$<data>

The new version of Argon2 adds its version into the hash:
   $argon2d$v=19$m=8,t=1,p=1$<salt>$<data>

This lets Django handle both version properly.
Bas Westerbaan 9 years ago
parent
commit
a5033dbc58

+ 42 - 14
django/contrib/auth/hashers.py

@@ -327,11 +327,11 @@ class Argon2PasswordHasher(BasePasswordHasher):
 
     def verify(self, password, encoded):
         argon2 = self._load_library()
-        algorithm, data = encoded.split('$', 1)
+        algorithm, rest = encoded.split('$', 1)
         assert algorithm == self.algorithm
         try:
             return argon2.low_level.verify_secret(
-                force_bytes('$' + data),
+                force_bytes('$' + rest),
                 force_bytes(password),
                 type=argon2.low_level.Type.I,
             )
@@ -339,29 +339,30 @@ class Argon2PasswordHasher(BasePasswordHasher):
             return False
 
     def safe_summary(self, encoded):
-        algorithm, variety, raw_pars, salt, data = encoded.split('$', 5)
-        pars = dict(bit.split('=', 1) for bit in raw_pars.split(','))
+        (algorithm, variety, version, time_cost, memory_cost, parallelism,
+            salt, data) = self._decode(encoded)
         assert algorithm == self.algorithm
-        assert len(pars) == 3 and 't' in pars and 'm' in pars and 'p' in pars
         return OrderedDict([
             (_('algorithm'), algorithm),
             (_('variety'), variety),
-            (_('memory cost'), int(pars['m'])),
-            (_('time cost'), int(pars['t'])),
-            (_('parallelism'), int(pars['p'])),
+            (_('version'), version),
+            (_('memory cost'), memory_cost),
+            (_('time cost'), time_cost),
+            (_('parallelism'), parallelism),
             (_('salt'), mask_hash(salt)),
             (_('hash'), mask_hash(data)),
         ])
 
     def must_update(self, encoded):
-        algorithm, variety, raw_pars, salt, data = encoded.split('$', 5)
-        pars = dict([bit.split('=', 1) for bit in raw_pars.split(',')])
+        (algorithm, variety, version, time_cost, memory_cost, parallelism,
+            salt, data) = self._decode(encoded)
         assert algorithm == self.algorithm
-        assert len(pars) == 3 and 't' in pars and 'm' in pars and 'p' in pars
+        argon2 = self._load_library()
         return (
-            self.time_cost != int(pars['t']) or
-            self.memory_cost != int(pars['m']) or
-            self.parallelism != int(pars['p'])
+            argon2.low_level.ARGON2_VERSION != version or
+            self.time_cost != time_cost or
+            self.memory_cost != memory_cost or
+            self.parallelism != parallelism
         )
 
     def harden_runtime(self, password, encoded):
@@ -369,6 +370,33 @@ class Argon2PasswordHasher(BasePasswordHasher):
         # hardening algorithm.
         pass
 
+    def _decode(self, encoded):
+        """
+        Split an encoded hash and return: (
+            algorithm, variety, version, time_cost, memory_cost,
+            parallelism, salt, data,
+        ).
+        """
+        bits = encoded.split('$')
+        if len(bits) == 5:
+            # Argon2 < 1.3
+            algorithm, variety, raw_params, salt, data = bits
+            version = 0x10
+        else:
+            assert len(bits) == 6
+            algorithm, variety, raw_version, raw_params, salt, data = bits
+            assert raw_version.startswith('v=')
+            version = int(raw_version[len('v='):])
+        params = dict(bit.split('=', 1) for bit in raw_params.split(','))
+        assert len(params) == 3 and all(x in params for x in ('t', 'm', 'p'))
+        time_cost = int(params['t'])
+        memory_cost = int(params['m'])
+        parallelism = int(params['p'])
+        return (
+            algorithm, variety, version, time_cost, memory_cost, parallelism,
+            salt, data,
+        )
+
 
 class BCryptSHA256PasswordHasher(BasePasswordHasher):
     """

+ 1 - 1
docs/internals/contributing/writing-code/unit-tests.txt

@@ -142,7 +142,7 @@ Running all the tests
 If you want to run the full suite of tests, you'll need to install a number of
 dependencies:
 
-*  argon2-cffi_ 16.0.0+
+*  argon2-cffi_ 16.1.0+
 *  bcrypt_
 *  docutils_
 *  enum34_ (Python 2 only)

+ 1 - 1
setup.py

@@ -49,7 +49,7 @@ setup(
     ]},
     extras_require={
         "bcrypt": ["bcrypt"],
-        "argon2": ["argon2-cffi >= 16.0.0"],
+        "argon2": ["argon2-cffi >= 16.1.0"],
     },
     zip_safe=False,
     classifiers=[

+ 32 - 0
tests/auth_tests/test_hashers.py

@@ -457,12 +457,44 @@ class TestUtilsHashPassArgon2(SimpleTestCase):
         self.assertTrue(is_password_usable(blank_encoded))
         self.assertTrue(check_password('', blank_encoded))
         self.assertFalse(check_password(' ', blank_encoded))
+        # Old hashes without version attribute
+        encoded = (
+            'argon2$argon2i$m=8,t=1,p=1$c29tZXNhbHQ$gwQOXSNhxiOxPOA0+PY10P9QFO'
+            '4NAYysnqRt1GSQLE55m+2GYDt9FEjPMHhP2Cuf0nOEXXMocVrsJAtNSsKyfg'
+        )
+        self.assertTrue(check_password('secret', encoded))
+        self.assertFalse(check_password('wrong', encoded))
 
     def test_argon2_upgrade(self):
         self._test_argon2_upgrade('time_cost', 'time cost', 1)
         self._test_argon2_upgrade('memory_cost', 'memory cost', 16)
         self._test_argon2_upgrade('parallelism', 'parallelism', 1)
 
+    def test_argon2_version_upgrade(self):
+        hasher = get_hasher('argon2')
+        state = {'upgraded': False}
+        encoded = (
+            'argon2$argon2i$m=8,t=1,p=1$c29tZXNhbHQ$gwQOXSNhxiOxPOA0+PY10P9QFO'
+            '4NAYysnqRt1GSQLE55m+2GYDt9FEjPMHhP2Cuf0nOEXXMocVrsJAtNSsKyfg'
+        )
+
+        def setter(password):
+            state['upgraded'] = True
+
+        old_m = hasher.memory_cost
+        old_t = hasher.time_cost
+        old_p = hasher.parallelism
+        try:
+            hasher.memory_cost = 8
+            hasher.time_cost = 1
+            hasher.parallelism = 1
+            self.assertTrue(check_password('secret', encoded, setter, 'argon2'))
+            self.assertTrue(state['upgraded'])
+        finally:
+            hasher.memory_cost = old_m
+            hasher.time_cost = old_t
+            hasher.parallelism = old_p
+
     def _test_argon2_upgrade(self, attr, summary_key, new_value):
         hasher = get_hasher('argon2')
         self.assertEqual('argon2', hasher.algorithm)

+ 1 - 1
tests/requirements/base.txt

@@ -1,4 +1,4 @@
-argon2-cffi == 16.0.0
+argon2-cffi >= 16.1.0
 bcrypt
 docutils
 geoip2