diff --git a/django/core/signing.py b/django/core/signing.py index da45ce11b2..652694bb99 100644 --- a/django/core/signing.py +++ b/django/core/signing.py @@ -68,8 +68,8 @@ def b64_decode(s): return base64.urlsafe_b64decode(s + pad) -def base64_hmac(salt, value, key): - return b64_encode(salted_hmac(salt, value, key).digest()).decode() +def base64_hmac(salt, value, key, algorithm='sha1'): + return b64_encode(salted_hmac(salt, value, key, algorithm=algorithm).digest()).decode() def get_cookie_signer(salt='django.core.signing.get_cookie_signer'): @@ -92,8 +92,9 @@ class JSONSerializer: def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer, compress=False): """ - Return URL-safe, hmac/SHA1 signed base64 compressed JSON string. If key is - None, use settings.SECRET_KEY instead. + Return URL-safe, hmac signed base64 compressed JSON string. If key is + None, use settings.SECRET_KEY instead. The hmac algorithm is the default + Signer algorithm. If compress is True (not the default), check if compressing using zlib can save some space. Prepend a '.' to signify compression. This is included @@ -143,8 +144,10 @@ def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, ma class Signer: + # RemovedInDjango40Warning. + legacy_algorithm = 'sha1' - def __init__(self, key=None, sep=':', salt=None): + def __init__(self, key=None, sep=':', salt=None, algorithm='sha256'): self.key = key or settings.SECRET_KEY self.sep = sep if _SEP_UNSAFE.match(self.sep): @@ -153,9 +156,14 @@ class Signer: 'only A-z0-9-_=)' % sep, ) self.salt = salt or '%s.%s' % (self.__class__.__module__, self.__class__.__name__) + self.algorithm = algorithm def signature(self, value): - return base64_hmac(self.salt + 'signer', value, self.key) + return base64_hmac(self.salt + 'signer', value, self.key, algorithm=self.algorithm) + + def _legacy_signature(self, value): + # RemovedInDjango40Warning. + return base64_hmac(self.salt + 'signer', value, self.key, algorithm=self.legacy_algorithm) def sign(self, value): return '%s%s%s' % (value, self.sep, self.signature(value)) @@ -164,7 +172,12 @@ class Signer: if self.sep not in signed_value: raise BadSignature('No "%s" found in value' % self.sep) value, sig = signed_value.rsplit(self.sep, 1) - if constant_time_compare(sig, self.signature(value)): + if ( + constant_time_compare(sig, self.signature(value)) or ( + self.legacy_algorithm and + constant_time_compare(sig, self._legacy_signature(value)) + ) + ): return value raise BadSignature('Signature "%s" does not match' % sig) diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 257b9a3bf4..76b379ef5a 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -54,6 +54,9 @@ details on these changes. * Support for the pre-Django 3.1 encoding format of sessions will be removed. +* Support for the pre-Django 3.1 ``django.core.signing.Signer`` signatures + (encoded with the SHA-1 algorithm) will be removed. + * The ``get_request`` argument for ``django.utils.deprecation.MiddlewareMixin.__init__()`` will be required and won't accept ``None``. diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index 384fa43dfb..55f539d534 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -404,6 +404,14 @@ Security origins. If you need the previous behavior, explicitly set :setting:`SECURE_REFERRER_POLICY` to ``None``. +* The default :class:`django.core.signing.Signer` algorithm is changed to the + SHA-256. Support for signatures made with the old SHA-1 algorithm remains + until Django 4.0. + + Also, the new ``algorithm`` parameter of the + :class:`~django.core.signing.Signer` allows customizing the hashing + algorithm. + Serialization ~~~~~~~~~~~~~ diff --git a/docs/topics/signing.txt b/docs/topics/signing.txt index 5fa537c579..7927333914 100644 --- a/docs/topics/signing.txt +++ b/docs/topics/signing.txt @@ -81,12 +81,17 @@ generate signatures. You can use a different secret by passing it to the >>> value 'My string:EkfQJafvGyiofrdGnuthdxImIJw' -.. class:: Signer(key=None, sep=':', salt=None) +.. class:: Signer(key=None, sep=':', salt=None, algorithm='sha256') Returns a signer which uses ``key`` to generate signatures and ``sep`` to separate values. ``sep`` cannot be in the `URL safe base64 alphabet `_. This alphabet contains - alphanumeric characters, hyphens, and underscores. + alphanumeric characters, hyphens, and underscores. ``algorithm`` must be an + algorithm supported by :py:mod:`hashlib`. + + .. versionchanged:: 3.1 + + The ``algorithm`` parameter was added. Using the ``salt`` argument --------------------------- @@ -139,7 +144,7 @@ created within a specified period of time:: >>> signer.unsign(value, max_age=timedelta(seconds=20)) 'hello' -.. class:: TimestampSigner(key=None, sep=':', salt=None) +.. class:: TimestampSigner(key=None, sep=':', salt=None, algorithm='sha256') .. method:: sign(value) @@ -151,6 +156,10 @@ created within a specified period of time:: otherwise raises ``SignatureExpired``. The ``max_age`` parameter can accept an integer or a :py:class:`datetime.timedelta` object. + .. versionchanged:: 3.1 + + The ``algorithm`` parameter was added. + Protecting complex data structures ---------------------------------- diff --git a/tests/signing/tests.py b/tests/signing/tests.py index d0767c0703..6b7268179d 100644 --- a/tests/signing/tests.py +++ b/tests/signing/tests.py @@ -3,6 +3,7 @@ import datetime from django.core import signing from django.test import SimpleTestCase from django.test.utils import freeze_time +from django.utils.crypto import InvalidAlgorithm class TestSigner(SimpleTestCase): @@ -18,7 +19,12 @@ class TestSigner(SimpleTestCase): ): self.assertEqual( signer.signature(s), - signing.base64_hmac(signer.salt + 'signer', s, 'predictable-secret') + signing.base64_hmac( + signer.salt + 'signer', + s, + 'predictable-secret', + algorithm=signer.algorithm, + ) ) self.assertNotEqual(signer.signature(s), signer2.signature(s)) @@ -27,12 +33,39 @@ class TestSigner(SimpleTestCase): signer = signing.Signer('predictable-secret', salt='extra-salt') self.assertEqual( signer.signature('hello'), - signing.base64_hmac('extra-salt' + 'signer', 'hello', 'predictable-secret') + signing.base64_hmac( + 'extra-salt' + 'signer', + 'hello', + 'predictable-secret', + algorithm=signer.algorithm, + ) ) self.assertNotEqual( signing.Signer('predictable-secret', salt='one').signature('hello'), signing.Signer('predictable-secret', salt='two').signature('hello')) + def test_custom_algorithm(self): + signer = signing.Signer('predictable-secret', algorithm='sha512') + self.assertEqual( + signer.signature('hello'), + 'Usf3uVQOZ9m6uPfVonKR-EBXjPe7bjMbp3_Fq8MfsptgkkM1ojidN0BxYaT5HAEN1' + 'VzO9_jVu7R-VkqknHYNvw', + ) + + def test_invalid_algorithm(self): + signer = signing.Signer('predictable-secret', algorithm='whatever') + msg = "'whatever' is not an algorithm accepted by the hashlib module." + with self.assertRaisesMessage(InvalidAlgorithm, msg): + signer.sign('hello') + + def test_legacy_signature(self): + # RemovedInDjango40Warning: pre-Django 3.1 signatures won't be + # supported. + signer = signing.Signer() + sha1_sig = 'foo:l-EMM5FtewpcHMbKFeQodt3X9z8' + self.assertNotEqual(signer.sign('foo'), sha1_sig) + self.assertEqual(signer.unsign(sha1_sig), 'foo') + def test_sign_unsign(self): "sign/unsign should be reversible" signer = signing.Signer('predictable-secret') @@ -115,13 +148,19 @@ class TestSigner(SimpleTestCase): binary_key = b'\xe7' # Set some binary (non-ASCII key) s = signing.Signer(binary_key) - self.assertEqual('foo:6NB0fssLW5RQvZ3Y-MTerq2rX7w', s.sign('foo')) + self.assertEqual( + 'foo:EE4qGC5MEKyQG5msxYA0sBohAxLC0BJf8uRhemh0BGU', + s.sign('foo'), + ) def test_valid_sep(self): separators = ['/', '*sep*', ','] for sep in separators: signer = signing.Signer('predictable-secret', sep=sep) - self.assertEqual('foo%ssH9B01cZcJ9FoT_jEVkRkNULrl8' % sep, signer.sign('foo')) + self.assertEqual( + 'foo%sjZQoX_FtSO70jX9HLRGg2A_2s4kdDBxz1QoO_OpEQb0' % sep, + signer.sign('foo'), + ) def test_invalid_sep(self): """should warn on invalid separator"""