From b4250ea04a88f6c4fdf84dc8624baa1cf3e0f568 Mon Sep 17 00:00:00 2001 From: Bas Westerbaan Date: Sat, 26 Dec 2015 13:14:07 +0100 Subject: [PATCH] Fixed #26033 -- Added Argon2 password hasher. --- django/conf/global_settings.py | 1 + django/contrib/auth/hashers.py | 73 +++++++++++++++++++ .../contributing/writing-code/unit-tests.txt | 2 + docs/ref/settings.txt | 3 + docs/releases/1.10.txt | 4 + docs/topics/auth/passwords.txt | 71 +++++++++++++++++- setup.py | 1 + tests/auth_tests/test_hashers.py | 60 +++++++++++++++ tests/requirements/base.txt | 1 + 9 files changed, 215 insertions(+), 1 deletion(-) diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 06b525d355..1f2df0f093 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -500,6 +500,7 @@ PASSWORD_RESET_TIMEOUT_DAYS = 3 PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 'django.contrib.auth.hashers.BCryptPasswordHasher', ] diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index ad0045267c..7658379871 100644 --- a/django/contrib/auth/hashers.py +++ b/django/contrib/auth/hashers.py @@ -297,6 +297,79 @@ class PBKDF2SHA1PasswordHasher(PBKDF2PasswordHasher): digest = hashlib.sha1 +class Argon2PasswordHasher(BasePasswordHasher): + """ + Secure password hashing using the argon2 algorithm. + + This is the winner of the Password Hashing Competition 2013-2015 + (https://password-hashing.net). It requires the argon2-cffi library which + depends on native C code and might cause portability issues. + """ + algorithm = 'argon2' + library = 'argon2' + + time_cost = 2 + memory_cost = 512 + parallelism = 2 + + def encode(self, password, salt): + argon2 = self._load_library() + data = argon2.low_level.hash_secret( + force_bytes(password), + force_bytes(salt), + time_cost=self.time_cost, + memory_cost=self.memory_cost, + parallelism=self.parallelism, + hash_len=argon2.DEFAULT_HASH_LENGTH, + type=argon2.low_level.Type.I, + ) + return self.algorithm + data.decode('utf-8') + + def verify(self, password, encoded): + argon2 = self._load_library() + algorithm, data = encoded.split('$', 1) + assert algorithm == self.algorithm + try: + return argon2.low_level.verify_secret( + force_bytes('$' + data), + force_bytes(password), + type=argon2.low_level.Type.I, + ) + except argon2.exceptions.VerificationError: + 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(',')) + 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'])), + (_('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(',')]) + assert algorithm == self.algorithm + assert len(pars) == 3 and 't' in pars and 'm' in pars and 'p' in pars + return ( + self.time_cost != int(pars['t']) or + self.memory_cost != int(pars['m']) or + self.parallelism != int(pars['p']) + ) + + def harden_runtime(self, password, encoded): + # The runtime for Argon2 is too complicated to implement a sensible + # hardening algorithm. + pass + + class BCryptSHA256PasswordHasher(BasePasswordHasher): """ Secure password hashing using the bcrypt algorithm (recommended) diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index fb6fae1f72..37973261f4 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -137,6 +137,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+ * bcrypt_ * docutils_ * enum34_ (Python 2 only) @@ -171,6 +172,7 @@ and install the Geospatial libraries`. Each of these dependencies is optional. If you're missing any of them, the associated tests will be skipped. +.. _argon2-cffi: https://pypi.python.org/pypi/argon2_cffi .. _bcrypt: https://pypi.python.org/pypi/bcrypt .. _docutils: https://pypi.python.org/pypi/docutils .. _enum34: https://pypi.python.org/pypi/enum34 diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 9d94f53cfd..05276abae8 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2684,6 +2684,7 @@ Default:: [ 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 'django.contrib.auth.hashers.BCryptPasswordHasher', ] @@ -2702,6 +2703,8 @@ Default:: to strengthen the hashes in your database. If that's not feasible, add this setting to your project and add back any hashers that you need. + Also, the ``Argon2PasswordHasher`` was added. + .. setting:: AUTH_PASSWORD_VALIDATORS ``AUTH_PASSWORD_VALIDATORS`` diff --git a/docs/releases/1.10.txt b/docs/releases/1.10.txt index 47c964292f..2f617d3bea 100644 --- a/docs/releases/1.10.txt +++ b/docs/releases/1.10.txt @@ -70,6 +70,10 @@ Minor features :mod:`django.contrib.auth` ~~~~~~~~~~~~~~~~~~~~~~~~~~ +* Added support for the :ref:`Argon2 password hash `. It's + recommended over PBKDF2, however, it's not the default as it requires a + third-party library. + * The default iteration count for the PBKDF2 password hasher has been increased by 25%. This backwards compatible change will not affect users who have subclassed ``django.contrib.auth.hashers.PBKDF2PasswordHasher`` to change the diff --git a/docs/topics/auth/passwords.txt b/docs/topics/auth/passwords.txt index 47d6486dd9..578d917cf4 100644 --- a/docs/topics/auth/passwords.txt +++ b/docs/topics/auth/passwords.txt @@ -60,16 +60,53 @@ The default for :setting:`PASSWORD_HASHERS` is:: PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 'django.contrib.auth.hashers.BCryptPasswordHasher', ] This means that Django will use PBKDF2_ to store all passwords but will support -checking passwords stored with PBKDF2SHA1 and bcrypt_. +checking passwords stored with PBKDF2SHA1, argon2_, and bcrypt_. The next few sections describe a couple of common ways advanced users may want to modify this setting. +.. _argon2_usage: + +Using Argon2 with Django +------------------------ + +.. versionadded:: 1.10 + +Argon2_ is the winner of the 2015 `Password Hashing Competition`_, a community +organized open competition to select a next generation hashing algorithm. It's +designed not to be easier to compute on custom hardware than it is to compute +on an ordinary CPU. + +Argon2_ is not the default for Django because it requires a third-party +library. The Password Hashing Competition panel, however, recommends immediate +use of Argon2 rather than the other algorithms supported by Django. + +To use Argon2 as your default storage algorithm, do the following: + +1. Install the `argon2-cffi library`_. This can be done by running ``pip + install django[argon2]`` or by downloading the library and installing it + with ``python setup.py install``. + +2. Modify :setting:`PASSWORD_HASHERS` to list ``Argon2PasswordHasher`` first. + That is, in your settings file, you'd put:: + + PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.Argon2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + 'django.contrib.auth.hashers.BCryptPasswordHasher', + ] + + Keep and/or add any entries in this list if you need Django to :ref:`upgrade + passwords `. + .. _bcrypt_usage: Using ``bcrypt`` with Django @@ -94,6 +131,7 @@ To use Bcrypt as your default storage algorithm, do the following: 'django.contrib.auth.hashers.BCryptPasswordHasher', 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.Argon2PasswordHasher', ] Keep and/or add any entries in this list if you need Django to :ref:`upgrade @@ -132,6 +170,9 @@ algorithm. Increasing the work factor -------------------------- +PBKDF2 and bcrypt +~~~~~~~~~~~~~~~~~ + The PBKDF2 and bcrypt algorithms use a number of iterations or rounds of hashing. This deliberately slows down attackers, making attacks against hashed passwords harder. However, as computing power increases, the number of @@ -161,6 +202,7 @@ default PBKDF2 algorithm: 'myproject.hashers.MyPBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 'django.contrib.auth.hashers.BCryptPasswordHasher', ] @@ -168,6 +210,28 @@ default PBKDF2 algorithm: That's it -- now your Django install will use more iterations when it stores passwords using PBKDF2. +Argon2 +~~~~~~ + +Argon2 has three attributes that can be customized: + +#. ``time_cost`` controls the number of iterations within the hash. +#. ``memory_cost`` controls the size of memory that must be used during the + computation of the hash. +#. ``parallelism`` controls how many CPUs the computation of the hash can be + parallelized on. + +The default values of these attributes are probably fine for you. If you +determine that the password hash is too fast or too slow, you can tweak it as +follows: + +#. Choose ``parallelism`` to be the number of threads you can + spare computing the hash. +#. Choose ``memory_cost`` to be the KiB of memory you can spare. +#. Adjust ``time_cost`` and measure the time hashing a password takes. + Pick a ``time_cost`` that takes an acceptable time for you. + If ``time_cost`` set to 1 is unacceptably slow, lower ``memory_cost``. + .. _password-upgrades: Password upgrading @@ -286,6 +350,9 @@ Include any other hashers that your site uses in this list. .. _nist: http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf .. _bcrypt: https://en.wikipedia.org/wiki/Bcrypt .. _`bcrypt library`: https://pypi.python.org/pypi/bcrypt/ +.. _`argon2-cffi library`: https://pypi.python.org/pypi/argon2_cffi/ +.. _argon2: https://en.wikipedia.org/wiki/Argon2 +.. _`Password Hashing Competition`: https://password-hashing.net .. _auth-included-hashers: @@ -297,6 +364,7 @@ The full list of hashers included in Django is:: [ 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 'django.contrib.auth.hashers.BCryptPasswordHasher', 'django.contrib.auth.hashers.SHA1PasswordHasher', @@ -310,6 +378,7 @@ The corresponding algorithm names are: * ``pbkdf2_sha256`` * ``pbkdf2_sha1`` +* ``argon2`` * ``bcrypt_sha256`` * ``bcrypt`` * ``sha1`` diff --git a/setup.py b/setup.py index 8cbf4de8a7..3657a2c471 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ setup( ]}, extras_require={ "bcrypt": ["bcrypt"], + "argon2": ["argon2-cffi >= 16.0.0"], }, zip_safe=False, classifiers=[ diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py index ecd3f276a9..a43c170ec1 100644 --- a/tests/auth_tests/test_hashers.py +++ b/tests/auth_tests/test_hashers.py @@ -25,6 +25,11 @@ try: except ImportError: bcrypt = None +try: + import argon2 +except ImportError: + argon2 = None + class PBKDF2SingleIterationHasher(PBKDF2PasswordHasher): iterations = 1 @@ -434,3 +439,58 @@ class TestUtilsHashPass(SimpleTestCase): with six.assertRaisesRegex(self, ValueError, "Couldn't load 'PlainHasher' algorithm library: No module named '?plain'?"): PlainHasher()._load_library() + + +@skipUnless(argon2, "argon2-cffi not installed") +@override_settings(PASSWORD_HASHERS=PASSWORD_HASHERS) +class TestUtilsHashPassArgon2(SimpleTestCase): + + def test_argon2(self): + encoded = make_password('lètmein', hasher='argon2') + self.assertTrue(is_password_usable(encoded)) + self.assertTrue(encoded.startswith('argon2$')) + self.assertTrue(check_password('lètmein', encoded)) + self.assertFalse(check_password('lètmeinz', encoded)) + self.assertEqual(identify_hasher(encoded).algorithm, 'argon2') + # Blank passwords + blank_encoded = make_password('', hasher='argon2') + self.assertTrue(blank_encoded.startswith('argon2$')) + self.assertTrue(is_password_usable(blank_encoded)) + self.assertTrue(check_password('', blank_encoded)) + self.assertFalse(check_password(' ', blank_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_upgrade(self, attr, summary_key, new_value): + hasher = get_hasher('argon2') + self.assertEqual('argon2', hasher.algorithm) + self.assertNotEqual(getattr(hasher, attr), new_value) + + old_value = getattr(hasher, attr) + try: + # Generate hash with attr set to 1 + setattr(hasher, attr, new_value) + encoded = make_password('letmein', hasher='argon2') + attr_value = hasher.safe_summary(encoded)[summary_key] + self.assertEqual(attr_value, new_value) + + state = {'upgraded': False} + + def setter(password): + state['upgraded'] = True + + # Check that no upgrade is triggered. + self.assertTrue(check_password('letmein', encoded, setter, 'argon2')) + self.assertFalse(state['upgraded']) + + # Revert to the old rounds count and ... + setattr(hasher, attr, old_value) + + # ... check if the password would get updated to the new count. + self.assertTrue(check_password('letmein', encoded, setter, 'argon2')) + self.assertTrue(state['upgraded']) + finally: + setattr(hasher, attr, old_value) diff --git a/tests/requirements/base.txt b/tests/requirements/base.txt index 845aefbb87..4f702b5495 100644 --- a/tests/requirements/base.txt +++ b/tests/requirements/base.txt @@ -1,3 +1,4 @@ +argon2-cffi >= 16.0.0 bcrypt docutils geoip2