From a5033dbc58d6e09d28b8abe3acda20b9c60e0b8e Mon Sep 17 00:00:00 2001 From: Bas Westerbaan Date: Fri, 22 Apr 2016 13:26:41 +0200 Subject: [PATCH] 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$$ The new version of Argon2 adds its version into the hash: $argon2d$v=19$m=8,t=1,p=1$$ This lets Django handle both version properly. --- django/contrib/auth/hashers.py | 56 ++++++++++++++----- .../contributing/writing-code/unit-tests.txt | 2 +- setup.py | 2 +- tests/auth_tests/test_hashers.py | 32 +++++++++++ tests/requirements/base.txt | 2 +- 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index 6cd57f197a0..8673b95cef9 100644 --- a/django/contrib/auth/hashers.py +++ b/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): """ diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index b4ec5780cf9..1eea23500d8 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/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) diff --git a/setup.py b/setup.py index 3657a2c471b..4b18495bea9 100644 --- a/setup.py +++ b/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=[ diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py index 2525c2cae09..5b4ef7b3783 100644 --- a/tests/auth_tests/test_hashers.py +++ b/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) diff --git a/tests/requirements/base.txt b/tests/requirements/base.txt index 4e996890bbf..2cb272ff155 100644 --- a/tests/requirements/base.txt +++ b/tests/requirements/base.txt @@ -1,4 +1,4 @@ -argon2-cffi == 16.0.0 +argon2-cffi >= 16.1.0 bcrypt docutils geoip2