From 0246f478882c26bc1fe293224653074cd46a90d0 Mon Sep 17 00:00:00 2001 From: SaJH Date: Fri, 22 Aug 2025 15:32:09 +0200 Subject: [PATCH] Fixed #36546 -- Deprecated django.utils.crypto.constant_time_compare() in favor of hmac.compare_digest(). Signed-off-by: SaJH --- django/contrib/auth/__init__.py | 14 +++++++------- django/contrib/auth/hashers.py | 16 ++++++---------- django/contrib/auth/tokens.py | 5 +++-- django/core/signing.py | 5 +++-- django/middleware/csrf.py | 5 +++-- django/utils/crypto.py | 9 ++++++++- docs/internals/deprecation.txt | 2 ++ docs/releases/6.0.txt | 3 +++ tests/utils_tests/test_crypto.py | 13 +++++++++++++ 9 files changed, 48 insertions(+), 24 deletions(-) diff --git a/django/contrib/auth/__init__.py b/django/contrib/auth/__init__.py index d752e3172b..f83007ac94 100644 --- a/django/contrib/auth/__init__.py +++ b/django/contrib/auth/__init__.py @@ -1,3 +1,4 @@ +import hmac import inspect import re import warnings @@ -6,7 +7,6 @@ from django.apps import apps as django_apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.middleware.csrf import rotate_token -from django.utils.crypto import constant_time_compare from django.utils.deprecation import RemovedInDjango61Warning from django.utils.module_loading import import_string from django.views.decorators.debug import sensitive_variables @@ -175,7 +175,7 @@ def login(request, user, backend=None): if SESSION_KEY in request.session: if _get_user_session_key(request) != user.pk or ( session_auth_hash - and not constant_time_compare( + and not hmac.compare_digest( request.session.get(HASH_SESSION_KEY, ""), session_auth_hash ) ): @@ -217,7 +217,7 @@ async def alogin(request, user, backend=None): if await request.session.ahas_key(SESSION_KEY): if await _aget_user_session_key(request) != user.pk or ( session_auth_hash - and not constant_time_compare( + and not hmac.compare_digest( await request.session.aget(HASH_SESSION_KEY, ""), session_auth_hash, ) @@ -323,7 +323,7 @@ def get_user(request): session_hash_verified = False else: session_auth_hash = user.get_session_auth_hash() - session_hash_verified = constant_time_compare( + session_hash_verified = hmac.compare_digest( session_hash, session_auth_hash ) if not session_hash_verified: @@ -331,7 +331,7 @@ def get_user(request): # with the fallback secrets and stop when a matching one is # found. if session_hash and any( - constant_time_compare(session_hash, fallback_auth_hash) + hmac.compare_digest(session_hash, fallback_auth_hash) for fallback_auth_hash in user.get_session_auth_fallback_hash() ): request.session.cycle_key() @@ -364,7 +364,7 @@ async def aget_user(request): session_hash_verified = False else: session_auth_hash = user.get_session_auth_hash() - session_hash_verified = constant_time_compare( + session_hash_verified = hmac.compare_digest( session_hash, session_auth_hash ) if not session_hash_verified: @@ -372,7 +372,7 @@ async def aget_user(request): # with the fallback secrets and stop when a matching one is # found. if session_hash and any( - constant_time_compare(session_hash, fallback_auth_hash) + hmac.compare_digest(session_hash, fallback_auth_hash) for fallback_auth_hash in user.get_session_auth_fallback_hash() ): await request.session.acycle_key() diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index 4bb518cb89..4767ad560b 100644 --- a/django/contrib/auth/hashers.py +++ b/django/contrib/auth/hashers.py @@ -2,6 +2,7 @@ import base64 import binascii import functools import hashlib +import hmac import importlib import math import warnings @@ -12,12 +13,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.signals import setting_changed from django.dispatch import receiver -from django.utils.crypto import ( - RANDOM_STRING_CHARS, - constant_time_compare, - get_random_string, - pbkdf2, -) +from django.utils.crypto import RANDOM_STRING_CHARS, get_random_string, pbkdf2 from django.utils.encoding import force_bytes, force_str from django.utils.module_loading import import_string from django.utils.translation import gettext_noop as _ @@ -349,7 +345,7 @@ class PBKDF2PasswordHasher(BasePasswordHasher): def verify(self, password, encoded): decoded = self.decode(encoded) encoded_2 = self.encode(password, decoded["salt"], decoded["iterations"]) - return constant_time_compare(encoded, encoded_2) + return hmac.compare_digest(encoded, encoded_2) def safe_summary(self, encoded): decoded = self.decode(encoded) @@ -533,7 +529,7 @@ class BCryptSHA256PasswordHasher(BasePasswordHasher): algorithm, data = encoded.split("$", 1) assert algorithm == self.algorithm encoded_2 = self.encode(password, data.encode("ascii")) - return constant_time_compare(encoded, encoded_2) + return hmac.compare_digest(encoded, encoded_2) def safe_summary(self, encoded): decoded = self.decode(encoded) @@ -628,7 +624,7 @@ class ScryptPasswordHasher(BasePasswordHasher): decoded["block_size"], decoded["parallelism"], ) - return constant_time_compare(encoded, encoded_2) + return hmac.compare_digest(encoded, encoded_2) def safe_summary(self, encoded): decoded = self.decode(encoded) @@ -681,7 +677,7 @@ class MD5PasswordHasher(BasePasswordHasher): def verify(self, password, encoded): decoded = self.decode(encoded) encoded_2 = self.encode(password, decoded["salt"]) - return constant_time_compare(encoded, encoded_2) + return hmac.compare_digest(encoded, encoded_2) def safe_summary(self, encoded): decoded = self.decode(encoded) diff --git a/django/contrib/auth/tokens.py b/django/contrib/auth/tokens.py index 09cc2b5195..8e5d95cefd 100644 --- a/django/contrib/auth/tokens.py +++ b/django/contrib/auth/tokens.py @@ -1,7 +1,8 @@ +import hmac from datetime import datetime from django.conf import settings -from django.utils.crypto import constant_time_compare, salted_hmac +from django.utils.crypto import salted_hmac from django.utils.http import base36_to_int, int_to_base36 @@ -67,7 +68,7 @@ class PasswordResetTokenGenerator: # Check that the timestamp/uid has not been tampered with for secret in [self.secret, *self.secret_fallbacks]: - if constant_time_compare( + if hmac.compare_digest( self._make_token_with_timestamp(user, ts, secret), token, ): diff --git a/django/core/signing.py b/django/core/signing.py index ed56ce0908..222710efee 100644 --- a/django/core/signing.py +++ b/django/core/signing.py @@ -36,12 +36,13 @@ These functions make use of all of them. import base64 import datetime +import hmac import json import time import zlib from django.conf import settings -from django.utils.crypto import constant_time_compare, salted_hmac +from django.utils.crypto import salted_hmac from django.utils.encoding import force_bytes from django.utils.module_loading import import_string from django.utils.regex_helper import _lazy_re_compile @@ -209,7 +210,7 @@ class Signer: raise BadSignature('No "%s" found in value' % self.sep) value, sig = signed_value.rsplit(self.sep, 1) for key in [self.key, *self.fallback_keys]: - if constant_time_compare(sig, self.signature(value, key)): + if hmac.compare_digest(sig, self.signature(value, key)): return value raise BadSignature('Signature "%s" does not match' % sig) diff --git a/django/middleware/csrf.py b/django/middleware/csrf.py index c2800cfad4..113db56196 100644 --- a/django/middleware/csrf.py +++ b/django/middleware/csrf.py @@ -5,6 +5,7 @@ This module provides a middleware that implements protection against request forgeries from other sites. """ +import hmac import logging import string from collections import defaultdict @@ -15,7 +16,7 @@ from django.core.exceptions import DisallowedHost, ImproperlyConfigured from django.http import HttpHeaders, UnreadablePostError from django.urls import get_callable from django.utils.cache import patch_vary_headers -from django.utils.crypto import constant_time_compare, get_random_string +from django.utils.crypto import get_random_string from django.utils.deprecation import MiddlewareMixin from django.utils.functional import cached_property from django.utils.http import is_same_domain @@ -154,7 +155,7 @@ def _does_token_match(request_csrf_token, csrf_secret): if len(request_csrf_token) == CSRF_TOKEN_LENGTH: request_csrf_token = _unmask_cipher_token(request_csrf_token) assert len(request_csrf_token) == CSRF_SECRET_LENGTH - return constant_time_compare(request_csrf_token, csrf_secret) + return hmac.compare_digest(request_csrf_token, csrf_secret) class RejectRequest(Exception): diff --git a/django/utils/crypto.py b/django/utils/crypto.py index 4b8146695a..b6145709c3 100644 --- a/django/utils/crypto.py +++ b/django/utils/crypto.py @@ -5,8 +5,10 @@ Django's standard crypto functions and utilities. import hashlib import hmac import secrets +import warnings from django.conf import settings +from django.utils.deprecation import RemovedInDjango70Warning from django.utils.encoding import force_bytes @@ -64,7 +66,12 @@ def get_random_string(length, allowed_chars=RANDOM_STRING_CHARS): def constant_time_compare(val1, val2): """Return True if the two strings are equal, False otherwise.""" - return secrets.compare_digest(force_bytes(val1), force_bytes(val2)) + warnings.warn( + "constant_time_compare() is deprecated. Use hmac.compare_digest() instead.", + RemovedInDjango70Warning, + stacklevel=2, + ) + return hmac.compare_digest(val1, val2) def pbkdf2(password, salt, iterations, dklen=0, digest=None): diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 0e2a7ffb36..43c78ca041 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -53,6 +53,8 @@ details on these changes. * The ``django.core.mail.forbid_multi_line_headers()`` and ``django.core.mail.message.sanitize_address()`` functions will be removed. +* The ``django.utils.crypto.constant_time_compare()`` function will be removed. + .. _deprecation-removed-in-6.1: 6.1 diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index e54b9788ce..f131ac7c0d 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -570,6 +570,9 @@ Miscellaneous * The undocumented ``django.core.mail.forbid_multi_line_headers()`` and ``django.core.mail.message.sanitize_address()`` functions are deprecated. +* The ``django.utils.crypto.constant_time_compare()`` function is deprecated + because it is merely an alias of :py:func:`hmac.compare_digest`. + Features removed in 6.0 ======================= diff --git a/tests/utils_tests/test_crypto.py b/tests/utils_tests/test_crypto.py index ed3ed25a9d..bbedb3080d 100644 --- a/tests/utils_tests/test_crypto.py +++ b/tests/utils_tests/test_crypto.py @@ -2,15 +2,19 @@ import hashlib import unittest from django.test import SimpleTestCase +from django.test.utils import ignore_warnings from django.utils.crypto import ( InvalidAlgorithm, constant_time_compare, pbkdf2, salted_hmac, ) +from django.utils.deprecation import RemovedInDjango70Warning class TestUtilsCryptoMisc(SimpleTestCase): + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_constant_time_compare(self): # It's hard to test for constant time, just test the result. self.assertTrue(constant_time_compare(b"spam", b"spam")) @@ -18,6 +22,15 @@ class TestUtilsCryptoMisc(SimpleTestCase): self.assertTrue(constant_time_compare("spam", "spam")) self.assertFalse(constant_time_compare("spam", "eggs")) + def test_constant_time_compare_deprecated(self): + msg = ( + "constant_time_compare() is deprecated. " + "Use hmac.compare_digest() instead." + ) + with self.assertWarnsMessage(RemovedInDjango70Warning, msg) as ctx: + constant_time_compare(b"spam", b"spam") + self.assertEqual(ctx.filename, __file__) + def test_salted_hmac(self): tests = [ ((b"salt", b"value"), {}, "b51a2e619c43b1ca4f91d15c57455521d71d61eb"),