浏览代码

Fixed #30360 -- Added support for secret key rotation.

Thanks Florian Apolloner for the implementation idea.

Co-authored-by: Andreas Pelme <andreas@pelme.se>
Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es>
Co-authored-by: Vuyisile Ndlovu <terrameijar@gmail.com>
tschilling 3 年之前
父节点
当前提交
0dcd549bbe

+ 1 - 0
django/conf/__init__.py

@@ -188,6 +188,7 @@ class Settings:
             "INSTALLED_APPS",
             "INSTALLED_APPS",
             "TEMPLATE_DIRS",
             "TEMPLATE_DIRS",
             "LOCALE_PATHS",
             "LOCALE_PATHS",
+            "SECRET_KEY_FALLBACKS",
         )
         )
         self._explicit_settings = set()
         self._explicit_settings = set()
         for setting in dir(mod):
         for setting in dir(mod):

+ 4 - 0
django/conf/global_settings.py

@@ -272,6 +272,10 @@ IGNORABLE_404_URLS = []
 # loudly.
 # loudly.
 SECRET_KEY = ''
 SECRET_KEY = ''
 
 
+# List of secret keys used to verify the validity of signatures. This allows
+# secret key rotation.
+SECRET_KEY_FALLBACKS = []
+
 # Default file storage mechanism that holds media.
 # Default file storage mechanism that holds media.
 DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
 DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
 
 

+ 25 - 4
django/contrib/auth/tokens.py

@@ -13,6 +13,7 @@ class PasswordResetTokenGenerator:
     key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
     key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
     algorithm = None
     algorithm = None
     _secret = None
     _secret = None
+    _secret_fallbacks = None
 
 
     def __init__(self):
     def __init__(self):
         self.algorithm = self.algorithm or 'sha256'
         self.algorithm = self.algorithm or 'sha256'
@@ -25,12 +26,26 @@ class PasswordResetTokenGenerator:
 
 
     secret = property(_get_secret, _set_secret)
     secret = property(_get_secret, _set_secret)
 
 
+    def _get_fallbacks(self):
+        if self._secret_fallbacks is None:
+            return settings.SECRET_KEY_FALLBACKS
+        return self._secret_fallbacks
+
+    def _set_fallbacks(self, fallbacks):
+        self._secret_fallbacks = fallbacks
+
+    secret_fallbacks = property(_get_fallbacks, _set_fallbacks)
+
     def make_token(self, user):
     def make_token(self, user):
         """
         """
         Return a token that can be used once to do a password reset
         Return a token that can be used once to do a password reset
         for the given user.
         for the given user.
         """
         """
-        return self._make_token_with_timestamp(user, self._num_seconds(self._now()))
+        return self._make_token_with_timestamp(
+            user,
+            self._num_seconds(self._now()),
+            self.secret,
+        )
 
 
     def check_token(self, user, token):
     def check_token(self, user, token):
         """
         """
@@ -50,7 +65,13 @@ class PasswordResetTokenGenerator:
             return False
             return False
 
 
         # Check that the timestamp/uid has not been tampered with
         # Check that the timestamp/uid has not been tampered with
-        if not constant_time_compare(self._make_token_with_timestamp(user, ts), token):
+        for secret in [self.secret, *self.secret_fallbacks]:
+            if constant_time_compare(
+                self._make_token_with_timestamp(user, ts, secret),
+                token,
+            ):
+                break
+        else:
             return False
             return False
 
 
         # Check the timestamp is within limit.
         # Check the timestamp is within limit.
@@ -59,14 +80,14 @@ class PasswordResetTokenGenerator:
 
 
         return True
         return True
 
 
-    def _make_token_with_timestamp(self, user, timestamp):
+    def _make_token_with_timestamp(self, user, timestamp, secret):
         # timestamp is number of seconds since 2001-1-1. Converted to base 36,
         # timestamp is number of seconds since 2001-1-1. Converted to base 36,
         # this gives us a 6 digit string until about 2069.
         # this gives us a 6 digit string until about 2069.
         ts_b36 = int_to_base36(timestamp)
         ts_b36 = int_to_base36(timestamp)
         hash_string = salted_hmac(
         hash_string = salted_hmac(
             self.key_salt,
             self.key_salt,
             self._make_hash_value(user, timestamp),
             self._make_hash_value(user, timestamp),
-            secret=self.secret,
+            secret=secret,
             algorithm=self.algorithm,
             algorithm=self.algorithm,
         ).hexdigest()[::2]  # Limit to shorten the URL.
         ).hexdigest()[::2]  # Limit to shorten the URL.
         return "%s-%s" % (ts_b36, hash_string)
         return "%s-%s" % (ts_b36, hash_string)

+ 39 - 14
django/core/checks/security/base.py

@@ -16,6 +16,15 @@ SECRET_KEY_INSECURE_PREFIX = 'django-insecure-'
 SECRET_KEY_MIN_LENGTH = 50
 SECRET_KEY_MIN_LENGTH = 50
 SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
 SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
 
 
+SECRET_KEY_WARNING_MSG = (
+    f"Your %s has less than {SECRET_KEY_MIN_LENGTH} characters, less than "
+    f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS} unique characters, or it's prefixed "
+    f"with '{SECRET_KEY_INSECURE_PREFIX}' indicating that it was generated "
+    f"automatically by Django. Please generate a long and random value, "
+    f"otherwise many of Django's security-critical features will be "
+    f"vulnerable to attack."
+)
+
 W001 = Warning(
 W001 = Warning(
     "You do not have 'django.middleware.security.SecurityMiddleware' "
     "You do not have 'django.middleware.security.SecurityMiddleware' "
     "in your MIDDLEWARE so the SECURE_HSTS_SECONDS, "
     "in your MIDDLEWARE so the SECURE_HSTS_SECONDS, "
@@ -72,15 +81,7 @@ W008 = Warning(
 )
 )
 
 
 W009 = Warning(
 W009 = Warning(
-    "Your SECRET_KEY has less than %(min_length)s characters, less than "
-    "%(min_unique_chars)s unique characters, or it's prefixed with "
-    "'%(insecure_prefix)s' indicating that it was generated automatically by "
-    "Django. Please generate a long and random SECRET_KEY, otherwise many of "
-    "Django's security-critical features will be vulnerable to attack." % {
-        'min_length': SECRET_KEY_MIN_LENGTH,
-        'min_unique_chars': SECRET_KEY_MIN_UNIQUE_CHARACTERS,
-        'insecure_prefix': SECRET_KEY_INSECURE_PREFIX,
-    },
+    SECRET_KEY_WARNING_MSG % 'SECRET_KEY',
     id='security.W009',
     id='security.W009',
 )
 )
 
 
@@ -131,6 +132,8 @@ E024 = Error(
     id='security.E024',
     id='security.E024',
 )
 )
 
 
+W025 = Warning(SECRET_KEY_WARNING_MSG, id='security.W025')
+
 
 
 def _security_middleware():
 def _security_middleware():
     return 'django.middleware.security.SecurityMiddleware' in settings.MIDDLEWARE
     return 'django.middleware.security.SecurityMiddleware' in settings.MIDDLEWARE
@@ -196,6 +199,14 @@ def check_ssl_redirect(app_configs, **kwargs):
     return [] if passed_check else [W008]
     return [] if passed_check else [W008]
 
 
 
 
+def _check_secret_key(secret_key):
+    return (
+        len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS and
+        len(secret_key) >= SECRET_KEY_MIN_LENGTH and
+        not secret_key.startswith(SECRET_KEY_INSECURE_PREFIX)
+    )
+
+
 @register(Tags.security, deploy=True)
 @register(Tags.security, deploy=True)
 def check_secret_key(app_configs, **kwargs):
 def check_secret_key(app_configs, **kwargs):
     try:
     try:
@@ -203,14 +214,28 @@ def check_secret_key(app_configs, **kwargs):
     except (ImproperlyConfigured, AttributeError):
     except (ImproperlyConfigured, AttributeError):
         passed_check = False
         passed_check = False
     else:
     else:
-        passed_check = (
-            len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS and
-            len(secret_key) >= SECRET_KEY_MIN_LENGTH and
-            not secret_key.startswith(SECRET_KEY_INSECURE_PREFIX)
-        )
+        passed_check = _check_secret_key(secret_key)
     return [] if passed_check else [W009]
     return [] if passed_check else [W009]
 
 
 
 
+@register(Tags.security, deploy=True)
+def check_secret_key_fallbacks(app_configs, **kwargs):
+    warnings = []
+    try:
+        fallbacks = settings.SECRET_KEY_FALLBACKS
+    except (ImproperlyConfigured, AttributeError):
+        warnings.append(
+            Warning(W025.msg % 'SECRET_KEY_FALLBACKS', id=W025.id)
+        )
+    else:
+        for index, key in enumerate(fallbacks):
+            if not _check_secret_key(key):
+                warnings.append(
+                    Warning(W025.msg % f'SECRET_KEY_FALLBACKS[{index}]', id=W025.id)
+                )
+    return warnings
+
+
 @register(Tags.security, deploy=True)
 @register(Tags.security, deploy=True)
 def check_debug(app_configs, **kwargs):
 def check_debug(app_configs, **kwargs):
     passed_check = not settings.DEBUG
     passed_check = not settings.DEBUG

+ 42 - 9
django/core/signing.py

@@ -97,10 +97,18 @@ def base64_hmac(salt, value, key, algorithm='sha1'):
     return b64_encode(salted_hmac(salt, value, key, algorithm=algorithm).digest()).decode()
     return b64_encode(salted_hmac(salt, value, key, algorithm=algorithm).digest()).decode()
 
 
 
 
+def _cookie_signer_key(key):
+    # SECRET_KEYS items may be str or bytes.
+    return b'django.http.cookies' + force_bytes(key)
+
+
 def get_cookie_signer(salt='django.core.signing.get_cookie_signer'):
 def get_cookie_signer(salt='django.core.signing.get_cookie_signer'):
     Signer = import_string(settings.SIGNING_BACKEND)
     Signer = import_string(settings.SIGNING_BACKEND)
-    key = force_bytes(settings.SECRET_KEY)  # SECRET_KEY may be str or bytes.
-    return Signer(b'django.http.cookies' + key, salt=salt)
+    return Signer(
+        key=_cookie_signer_key(settings.SECRET_KEY),
+        fallback_keys=map(_cookie_signer_key, settings.SECRET_KEY_FALLBACKS),
+        salt=salt,
+    )
 
 
 
 
 class JSONSerializer:
 class JSONSerializer:
@@ -135,18 +143,41 @@ def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer,
     return TimestampSigner(key, salt=salt).sign_object(obj, serializer=serializer, compress=compress)
     return TimestampSigner(key, salt=salt).sign_object(obj, serializer=serializer, compress=compress)
 
 
 
 
-def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None):
+def loads(
+    s,
+    key=None,
+    salt='django.core.signing',
+    serializer=JSONSerializer,
+    max_age=None,
+    fallback_keys=None,
+):
     """
     """
     Reverse of dumps(), raise BadSignature if signature fails.
     Reverse of dumps(), raise BadSignature if signature fails.
 
 
     The serializer is expected to accept a bytestring.
     The serializer is expected to accept a bytestring.
     """
     """
-    return TimestampSigner(key, salt=salt).unsign_object(s, serializer=serializer, max_age=max_age)
+    return TimestampSigner(key, salt=salt, fallback_keys=fallback_keys).unsign_object(
+        s,
+        serializer=serializer,
+        max_age=max_age,
+    )
 
 
 
 
 class Signer:
 class Signer:
-    def __init__(self, key=None, sep=':', salt=None, algorithm=None):
+    def __init__(
+        self,
+        key=None,
+        sep=':',
+        salt=None,
+        algorithm=None,
+        fallback_keys=None,
+    ):
         self.key = key or settings.SECRET_KEY
         self.key = key or settings.SECRET_KEY
+        self.fallback_keys = (
+            fallback_keys
+            if fallback_keys is not None
+            else settings.SECRET_KEY_FALLBACKS
+        )
         self.sep = sep
         self.sep = sep
         if _SEP_UNSAFE.match(self.sep):
         if _SEP_UNSAFE.match(self.sep):
             raise ValueError(
             raise ValueError(
@@ -156,8 +187,9 @@ class Signer:
         self.salt = salt or '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
         self.salt = salt or '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
         self.algorithm = algorithm or 'sha256'
         self.algorithm = algorithm or 'sha256'
 
 
-    def signature(self, value):
-        return base64_hmac(self.salt + 'signer', value, self.key, algorithm=self.algorithm)
+    def signature(self, value, key=None):
+        key = key or self.key
+        return base64_hmac(self.salt + 'signer', value, key, algorithm=self.algorithm)
 
 
     def sign(self, value):
     def sign(self, value):
         return '%s%s%s' % (value, self.sep, self.signature(value))
         return '%s%s%s' % (value, self.sep, self.signature(value))
@@ -166,8 +198,9 @@ class Signer:
         if self.sep not in signed_value:
         if self.sep not in signed_value:
             raise BadSignature('No "%s" found in value' % self.sep)
             raise BadSignature('No "%s" found in value' % self.sep)
         value, sig = signed_value.rsplit(self.sep, 1)
         value, sig = signed_value.rsplit(self.sep, 1)
-        if constant_time_compare(sig, self.signature(value)):
-            return value
+        for key in [self.key, *self.fallback_keys]:
+            if constant_time_compare(sig, self.signature(value, key)):
+                return value
         raise BadSignature('Signature "%s" does not match' % sig)
         raise BadSignature('Signature "%s" does not match' % sig)
 
 
     def sign_object(self, obj, serializer=JSONSerializer, compress=False):
     def sign_object(self, obj, serializer=JSONSerializer, compress=False):

+ 16 - 0
docs/howto/deployment/checklist.txt

@@ -59,6 +59,22 @@ or from a file::
     with open('/etc/secret_key.txt') as f:
     with open('/etc/secret_key.txt') as f:
         SECRET_KEY = f.read().strip()
         SECRET_KEY = f.read().strip()
 
 
+If rotating secret keys, you may use :setting:`SECRET_KEY_FALLBACKS`::
+
+    import os
+    SECRET_KEY = os.environ['CURRENT_SECRET_KEY']
+    SECRET_KEY_FALLBACKS = [
+        os.environ['OLD_SECRET_KEY'],
+    ]
+
+Ensure that old secret keys are removed from ``SECRET_KEY_FALLBACKS`` in a
+timely manner.
+
+.. versionchanged:: 4.1
+
+    The ``SECRET_KEY_FALLBACKS`` setting was added to support rotating secret
+    keys.
+
 :setting:`DEBUG`
 :setting:`DEBUG`
 ----------------
 ----------------
 
 

+ 8 - 2
docs/ref/checks.txt

@@ -457,8 +457,8 @@ The following checks are run if you use the :option:`check --deploy` option:
 * **security.W009**: Your :setting:`SECRET_KEY` has less than 50 characters,
 * **security.W009**: Your :setting:`SECRET_KEY` has less than 50 characters,
   less than 5 unique characters, or it's prefixed with ``'django-insecure-'``
   less than 5 unique characters, or it's prefixed with ``'django-insecure-'``
   indicating that it was generated automatically by Django. Please generate a
   indicating that it was generated automatically by Django. Please generate a
-  long and random ``SECRET_KEY``, otherwise many of Django's security-critical
-  features will be vulnerable to attack.
+  long and random value, otherwise many of Django's security-critical features
+  will be vulnerable to attack.
 * **security.W010**: You have :mod:`django.contrib.sessions` in your
 * **security.W010**: You have :mod:`django.contrib.sessions` in your
   :setting:`INSTALLED_APPS` but you have not set
   :setting:`INSTALLED_APPS` but you have not set
   :setting:`SESSION_COOKIE_SECURE` to ``True``. Using a secure-only session
   :setting:`SESSION_COOKIE_SECURE` to ``True``. Using a secure-only session
@@ -511,6 +511,12 @@ The following checks are run if you use the :option:`check --deploy` option:
   to an invalid value.
   to an invalid value.
 * **security.E024**: You have set the
 * **security.E024**: You have set the
   :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` setting to an invalid value.
   :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` setting to an invalid value.
+* **security.W025**: Your
+  :setting:`SECRET_KEY_FALLBACKS[n] <SECRET_KEY_FALLBACKS>` has less than 50
+  characters, less than 5 unique characters, or it's prefixed with
+  ``'django-insecure-'`` indicating that it was generated automatically by
+  Django. Please generate a long and random value, otherwise many of Django's
+  security-critical features will be vulnerable to attack.
 
 
 The following checks verify that your security-related settings are correctly
 The following checks verify that your security-related settings are correctly
 configured:
 configured:

+ 36 - 3
docs/ref/settings.txt

@@ -2291,9 +2291,11 @@ The secret key is used for:
 * Any usage of :doc:`cryptographic signing </topics/signing>`, unless a
 * Any usage of :doc:`cryptographic signing </topics/signing>`, unless a
   different key is provided.
   different key is provided.
 
 
-If you rotate your secret key, all of the above will be invalidated.
-Secret keys are not used for passwords of users and key rotation will not
-affect them.
+When a secret key is no longer set as :setting:`SECRET_KEY` or contained within
+:setting:`SECRET_KEY_FALLBACKS` all of the above will be invalidated. When
+rotating your secret key, you should move the old key to
+:setting:`SECRET_KEY_FALLBACKS` temporarily. Secret keys are not used for
+passwords of users and key rotation will not affect them.
 
 
 .. note::
 .. note::
 
 
@@ -2301,6 +2303,36 @@ affect them.
     startproject <startproject>` creates a unique ``SECRET_KEY`` for
     startproject <startproject>` creates a unique ``SECRET_KEY`` for
     convenience.
     convenience.
 
 
+.. setting:: SECRET_KEY_FALLBACKS
+
+``SECRET_KEY_FALLBACKS``
+------------------------
+
+.. versionadded:: 4.1
+
+Default: ``[]``
+
+A list of fallback secret keys for a particular Django installation. These are
+used to allow rotation of the ``SECRET_KEY``.
+
+In order to rotate your secret keys, set a new ``SECRET_KEY`` and move the
+previous value to the beginning of ``SECRET_KEY_FALLBACKS``. Then remove the
+old values from the end of the ``SECRET_KEY_FALLBACKS`` when you are ready to
+expire the sessions, password reset tokens, and so on, that make use of them.
+
+.. note::
+
+    Signing operations are computationally expensive. Having multiple old key
+    values in ``SECRET_KEY_FALLBACKS`` adds additional overhead to all checks
+    that don't match an earlier key.
+
+    As such, fallback values should be removed after an appropriate period,
+    allowing for key rotation.
+
+Uses of the secret key values shouldn't assume that they are text or bytes.
+Every use should go through :func:`~django.utils.encoding.force_str` or
+:func:`~django.utils.encoding.force_bytes` to convert it to the desired type.
+
 .. setting:: SECURE_CONTENT_TYPE_NOSNIFF
 .. setting:: SECURE_CONTENT_TYPE_NOSNIFF
 
 
 ``SECURE_CONTENT_TYPE_NOSNIFF``
 ``SECURE_CONTENT_TYPE_NOSNIFF``
@@ -3725,6 +3757,7 @@ Security
   * :setting:`CSRF_USE_SESSIONS`
   * :setting:`CSRF_USE_SESSIONS`
 
 
 * :setting:`SECRET_KEY`
 * :setting:`SECRET_KEY`
+* :setting:`SECRET_KEY_FALLBACKS`
 * :setting:`X_FRAME_OPTIONS`
 * :setting:`X_FRAME_OPTIONS`
 
 
 Serialization
 Serialization

+ 2 - 1
docs/releases/4.1.txt

@@ -254,7 +254,8 @@ Requests and Responses
 Security
 Security
 ~~~~~~~~
 ~~~~~~~~
 
 
-* ...
+* The new :setting:`SECRET_KEY_FALLBACKS` setting allows providing a list of
+  values for secret key rotation.
 
 
 Serialization
 Serialization
 ~~~~~~~~~~~~~
 ~~~~~~~~~~~~~

+ 3 - 2
docs/topics/auth/default.txt

@@ -912,8 +912,9 @@ function.
 
 
     Since
     Since
     :meth:`~django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash()`
     :meth:`~django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash()`
-    is based on :setting:`SECRET_KEY`, updating your site to use a new secret
-    will invalidate all existing sessions.
+    is based on :setting:`SECRET_KEY`, secret key values must be
+    rotated to avoid invalidating existing sessions when updating your site to
+    use a new secret. See :setting:`SECRET_KEY_FALLBACKS` for details.
 
 
 .. _built-in-auth-views:
 .. _built-in-auth-views:
 
 

+ 16 - 13
docs/topics/http/sessions.txt

@@ -123,13 +123,15 @@ and the :setting:`SECRET_KEY` setting.
 
 
 .. warning::
 .. warning::
 
 
-    **If the SECRET_KEY is not kept secret and you are using the**
-    ``django.contrib.sessions.serializers.PickleSerializer``, **this can
-    lead to arbitrary remote code execution.**
+    **If the ``SECRET_KEY`` or ``SECRET_KEY_FALLBACKS`` are not kept secret and
+    you are using the**
+    ``django.contrib.sessions.serializers.PickleSerializer``, **this can lead
+    to arbitrary remote code execution.**
 
 
-    An attacker in possession of the :setting:`SECRET_KEY` can not only
-    generate falsified session data, which your site will trust, but also
-    remotely execute arbitrary code, as the data is serialized using pickle.
+    An attacker in possession of the :setting:`SECRET_KEY` or
+    :setting:`SECRET_KEY_FALLBACKS` can not only generate falsified session
+    data, which your site will trust, but also remotely execute arbitrary code,
+    as the data is serialized using pickle.
 
 
     If you use cookie-based sessions, pay extra care that your secret key is
     If you use cookie-based sessions, pay extra care that your secret key is
     always kept completely secret, for any system which might be remotely
     always kept completely secret, for any system which might be remotely
@@ -323,11 +325,12 @@ cookie backend*.
 
 
 For example, here's an attack scenario if you use :mod:`pickle` to serialize
 For example, here's an attack scenario if you use :mod:`pickle` to serialize
 session data. If you're using the :ref:`signed cookie session backend
 session data. If you're using the :ref:`signed cookie session backend
-<cookie-session-backend>` and :setting:`SECRET_KEY` is known by an attacker
-(there isn't an inherent vulnerability in Django that would cause it to leak),
-the attacker could insert a string into their session which, when unpickled,
-executes arbitrary code on the server. The technique for doing so is simple and
-easily available on the internet. Although the cookie session storage signs the
+<cookie-session-backend>` and :setting:`SECRET_KEY` (or any key of
+:setting:`SECRET_KEY_FALLBACKS`) is known by an attacker (there isn't an
+inherent vulnerability in Django that would cause it to leak), the attacker
+could insert a string into their session which, when unpickled, executes
+arbitrary code on the server. The technique for doing so is simple and easily
+available on the internet. Although the cookie session storage signs the
 cookie-stored data to prevent tampering, a :setting:`SECRET_KEY` leak
 cookie-stored data to prevent tampering, a :setting:`SECRET_KEY` leak
 immediately escalates to a remote code execution vulnerability.
 immediately escalates to a remote code execution vulnerability.
 
 
@@ -359,8 +362,8 @@ Bundled serializers
 .. class:: serializers.PickleSerializer
 .. class:: serializers.PickleSerializer
 
 
     Supports arbitrary Python objects, but, as described above, can lead to a
     Supports arbitrary Python objects, but, as described above, can lead to a
-    remote code execution vulnerability if :setting:`SECRET_KEY` becomes known
-    by an attacker.
+    remote code execution vulnerability if :setting:`SECRET_KEY` or any key of
+    :setting:`SECRET_KEY_FALLBACKS` becomes known by an attacker.
 
 
     .. deprecated:: 4.1
     .. deprecated:: 4.1
 
 

+ 2 - 1
docs/topics/security.txt

@@ -296,7 +296,8 @@ security protection of the web server, operating system and other components.
 * Django does not throttle requests to authenticate users. To protect against
 * Django does not throttle requests to authenticate users. To protect against
   brute-force attacks against the authentication system, you may consider
   brute-force attacks against the authentication system, you may consider
   deploying a Django plugin or web server module to throttle these requests.
   deploying a Django plugin or web server module to throttle these requests.
-* Keep your :setting:`SECRET_KEY` a secret.
+* Keep your :setting:`SECRET_KEY`, and :setting:`SECRET_KEY_FALLBACKS` if in
+  use, secret.
 * It is a good idea to limit the accessibility of your caching system and
 * It is a good idea to limit the accessibility of your caching system and
   database using a firewall.
   database using a firewall.
 * Take a look at the Open Web Application Security Project (OWASP) `Top 10
 * Take a look at the Open Web Application Security Project (OWASP) `Top 10

+ 23 - 5
docs/topics/signing.txt

@@ -25,8 +25,8 @@ You may also find signing useful for the following:
   protected resource, for example a downloadable file that a user has
   protected resource, for example a downloadable file that a user has
   paid for.
   paid for.
 
 
-Protecting the ``SECRET_KEY``
-=============================
+Protecting ``SECRET_KEY`` and ``SECRET_KEY_FALLBACKS``
+======================================================
 
 
 When you create a new Django project using :djadmin:`startproject`, the
 When you create a new Django project using :djadmin:`startproject`, the
 ``settings.py`` file is generated automatically and gets a random
 ``settings.py`` file is generated automatically and gets a random
@@ -34,6 +34,14 @@ When you create a new Django project using :djadmin:`startproject`, the
 data -- it is vital you keep this secure, or attackers could use it to
 data -- it is vital you keep this secure, or attackers could use it to
 generate their own signed values.
 generate their own signed values.
 
 
+:setting:`SECRET_KEY_FALLBACKS` can be used to rotate secret keys. The
+values will not be used to sign data, but if specified, they will be used to
+validate signed data and must be kept secure.
+
+.. versionchanged:: 4.1
+
+    The ``SECRET_KEY_FALLBACKS`` setting was added.
+
 Using the low-level API
 Using the low-level API
 =======================
 =======================
 
 
@@ -93,13 +101,19 @@ generate signatures. You can use a different secret by passing it to the
     >>> value
     >>> value
     'My string:EkfQJafvGyiofrdGnuthdxImIJw'
     'My string:EkfQJafvGyiofrdGnuthdxImIJw'
 
 
-.. class:: Signer(key=None, sep=':', salt=None, algorithm=None)
+.. class:: Signer(key=None, sep=':', salt=None, algorithm=None, fallback_keys=None)
 
 
     Returns a signer which uses ``key`` to generate signatures and ``sep`` to
     Returns a signer which uses ``key`` to generate signatures and ``sep`` to
     separate values. ``sep`` cannot be in the :rfc:`URL safe base64 alphabet
     separate values. ``sep`` cannot be in the :rfc:`URL safe base64 alphabet
     <4648#section-5>`. This alphabet contains alphanumeric characters, hyphens,
     <4648#section-5>`. This alphabet contains alphanumeric characters, hyphens,
     and underscores. ``algorithm`` must be an algorithm supported by
     and underscores. ``algorithm`` must be an algorithm supported by
-    :py:mod:`hashlib`, it defaults to ``'sha256'``.
+    :py:mod:`hashlib`, it defaults to ``'sha256'``. ``fallback_keys`` is a list
+    of additional values used to validate signed data, defaults to
+    :setting:`SECRET_KEY_FALLBACKS`.
+
+    .. versionchanged:: 4.1
+
+        The ``fallback_keys`` argument was added.
 
 
 Using the ``salt`` argument
 Using the ``salt`` argument
 ---------------------------
 ---------------------------
@@ -221,7 +235,11 @@ and tuples) if you pass in a tuple, you will get a list from
     Returns URL-safe, signed base64 compressed JSON string. Serialized object
     Returns URL-safe, signed base64 compressed JSON string. Serialized object
     is signed using :class:`~TimestampSigner`.
     is signed using :class:`~TimestampSigner`.
 
 
-.. function:: loads(string, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None)
+.. function:: loads(string, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None, fallback_keys=None)
 
 
     Reverse of ``dumps()``, raises ``BadSignature`` if signature fails.
     Reverse of ``dumps()``, raises ``BadSignature`` if signature fails.
     Checks ``max_age`` (in seconds) if given.
     Checks ``max_age`` (in seconds) if given.
+
+    .. versionchanged:: 4.1
+
+        The ``fallback_keys`` argument was added.

+ 36 - 0
tests/auth_tests/test_tokens.py

@@ -140,3 +140,39 @@ class TokenGeneratorTest(TestCase):
         msg = 'The SECRET_KEY setting must not be empty.'
         msg = 'The SECRET_KEY setting must not be empty.'
         with self.assertRaisesMessage(ImproperlyConfigured, msg):
         with self.assertRaisesMessage(ImproperlyConfigured, msg):
             default_token_generator.secret
             default_token_generator.secret
+
+    def test_check_token_secret_fallbacks(self):
+        user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
+        p1 = PasswordResetTokenGenerator()
+        p1.secret = 'secret'
+        tk = p1.make_token(user)
+        p2 = PasswordResetTokenGenerator()
+        p2.secret = 'newsecret'
+        p2.secret_fallbacks = ['secret']
+        self.assertIs(p1.check_token(user, tk), True)
+        self.assertIs(p2.check_token(user, tk), True)
+
+    @override_settings(
+        SECRET_KEY='secret',
+        SECRET_KEY_FALLBACKS=['oldsecret'],
+    )
+    def test_check_token_secret_key_fallbacks(self):
+        user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
+        p1 = PasswordResetTokenGenerator()
+        p1.secret = 'oldsecret'
+        tk = p1.make_token(user)
+        p2 = PasswordResetTokenGenerator()
+        self.assertIs(p2.check_token(user, tk), True)
+
+    @override_settings(
+        SECRET_KEY='secret',
+        SECRET_KEY_FALLBACKS=['oldsecret'],
+    )
+    def test_check_token_secret_key_fallbacks_override(self):
+        user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
+        p1 = PasswordResetTokenGenerator()
+        p1.secret = 'oldsecret'
+        tk = p1.make_token(user)
+        p2 = PasswordResetTokenGenerator()
+        p2.secret_fallbacks = []
+        self.assertIs(p2.check_token(user, tk), False)

+ 74 - 1
tests/check_framework/test_security.py

@@ -1,5 +1,5 @@
 from django.conf import settings
 from django.conf import settings
-from django.core.checks.messages import Error
+from django.core.checks.messages import Error, Warning
 from django.core.checks.security import base, csrf, sessions
 from django.core.checks.security import base, csrf, sessions
 from django.core.management.utils import get_random_secret_key
 from django.core.management.utils import get_random_secret_key
 from django.test import SimpleTestCase
 from django.test import SimpleTestCase
@@ -414,6 +414,79 @@ class CheckSecretKeyTest(SimpleTestCase):
         self.assertEqual(base.check_secret_key(None), [base.W009])
         self.assertEqual(base.check_secret_key(None), [base.W009])
 
 
 
 
+class CheckSecretKeyFallbacksTest(SimpleTestCase):
+    @override_settings(SECRET_KEY_FALLBACKS=[('abcdefghijklmnopqrstuvwx' * 2) + 'ab'])
+    def test_okay_secret_key_fallbacks(self):
+        self.assertEqual(
+            len(settings.SECRET_KEY_FALLBACKS[0]),
+            base.SECRET_KEY_MIN_LENGTH,
+        )
+        self.assertGreater(
+            len(set(settings.SECRET_KEY_FALLBACKS[0])),
+            base.SECRET_KEY_MIN_UNIQUE_CHARACTERS,
+        )
+        self.assertEqual(base.check_secret_key_fallbacks(None), [])
+
+    def test_no_secret_key_fallbacks(self):
+        with self.settings(SECRET_KEY_FALLBACKS=None):
+            del settings.SECRET_KEY_FALLBACKS
+            self.assertEqual(base.check_secret_key_fallbacks(None), [
+                Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS', id=base.W025.id),
+            ])
+
+    @override_settings(SECRET_KEY_FALLBACKS=[
+        base.SECRET_KEY_INSECURE_PREFIX + get_random_secret_key()
+    ])
+    def test_insecure_secret_key_fallbacks(self):
+        self.assertEqual(base.check_secret_key_fallbacks(None), [
+            Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[0]', id=base.W025.id),
+        ])
+
+    @override_settings(SECRET_KEY_FALLBACKS=[('abcdefghijklmnopqrstuvwx' * 2) + 'a'])
+    def test_low_length_secret_key_fallbacks(self):
+        self.assertEqual(
+            len(settings.SECRET_KEY_FALLBACKS[0]),
+            base.SECRET_KEY_MIN_LENGTH - 1,
+        )
+        self.assertEqual(base.check_secret_key_fallbacks(None), [
+            Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[0]', id=base.W025.id),
+        ])
+
+    @override_settings(SECRET_KEY_FALLBACKS=['abcd' * 20])
+    def test_low_entropy_secret_key_fallbacks(self):
+        self.assertGreater(
+            len(settings.SECRET_KEY_FALLBACKS[0]),
+            base.SECRET_KEY_MIN_LENGTH,
+        )
+        self.assertLess(
+            len(set(settings.SECRET_KEY_FALLBACKS[0])),
+            base.SECRET_KEY_MIN_UNIQUE_CHARACTERS,
+        )
+        self.assertEqual(base.check_secret_key_fallbacks(None), [
+            Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[0]', id=base.W025.id),
+        ])
+
+    @override_settings(SECRET_KEY_FALLBACKS=[
+        ('abcdefghijklmnopqrstuvwx' * 2) + 'ab',
+        'badkey',
+    ])
+    def test_multiple_keys(self):
+        self.assertEqual(base.check_secret_key_fallbacks(None), [
+            Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[1]', id=base.W025.id),
+        ])
+
+    @override_settings(SECRET_KEY_FALLBACKS=[
+        ('abcdefghijklmnopqrstuvwx' * 2) + 'ab',
+        'badkey1',
+        'badkey2',
+    ])
+    def test_multiple_bad_keys(self):
+        self.assertEqual(base.check_secret_key_fallbacks(None), [
+            Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[1]', id=base.W025.id),
+            Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[2]', id=base.W025.id),
+        ])
+
+
 class CheckDebugTest(SimpleTestCase):
 class CheckDebugTest(SimpleTestCase):
     @override_settings(DEBUG=True)
     @override_settings(DEBUG=True)
     def test_debug_true(self):
     def test_debug_true(self):

+ 1 - 0
tests/settings_tests/tests.py

@@ -483,6 +483,7 @@ class TestListSettings(SimpleTestCase):
         "INSTALLED_APPS",
         "INSTALLED_APPS",
         "TEMPLATE_DIRS",
         "TEMPLATE_DIRS",
         "LOCALE_PATHS",
         "LOCALE_PATHS",
+        "SECRET_KEY_FALLBACKS",
     )
     )
 
 
     def test_tuple_settings(self):
     def test_tuple_settings(self):

+ 34 - 1
tests/signing/tests.py

@@ -1,7 +1,7 @@
 import datetime
 import datetime
 
 
 from django.core import signing
 from django.core import signing
-from django.test import SimpleTestCase
+from django.test import SimpleTestCase, override_settings
 from django.test.utils import freeze_time
 from django.test.utils import freeze_time
 from django.utils.crypto import InvalidAlgorithm
 from django.utils.crypto import InvalidAlgorithm
 
 
@@ -178,6 +178,39 @@ class TestSigner(SimpleTestCase):
             with self.assertRaisesMessage(ValueError, msg % sep):
             with self.assertRaisesMessage(ValueError, msg % sep):
                 signing.Signer(sep=sep)
                 signing.Signer(sep=sep)
 
 
+    def test_verify_with_non_default_key(self):
+        old_signer = signing.Signer('secret')
+        new_signer = signing.Signer('newsecret', fallback_keys=['othersecret', 'secret'])
+        signed = old_signer.sign('abc')
+        self.assertEqual(new_signer.unsign(signed), 'abc')
+
+    def test_sign_unsign_multiple_keys(self):
+        """The default key is a valid verification key."""
+        signer = signing.Signer('secret', fallback_keys=['oldsecret'])
+        signed = signer.sign('abc')
+        self.assertEqual(signer.unsign(signed), 'abc')
+
+    @override_settings(
+        SECRET_KEY='secret',
+        SECRET_KEY_FALLBACKS=['oldsecret'],
+    )
+    def test_sign_unsign_ignore_secret_key_fallbacks(self):
+        old_signer = signing.Signer('oldsecret')
+        signed = old_signer.sign('abc')
+        signer = signing.Signer(fallback_keys=[])
+        with self.assertRaises(signing.BadSignature):
+            signer.unsign(signed)
+
+    @override_settings(
+        SECRET_KEY='secret',
+        SECRET_KEY_FALLBACKS=['oldsecret'],
+    )
+    def test_default_keys_verification(self):
+        old_signer = signing.Signer('oldsecret')
+        signed = old_signer.sign('abc')
+        signer = signing.Signer()
+        self.assertEqual(signer.unsign(signed), 'abc')
+
 
 
 class TestTimestampSigner(SimpleTestCase):
 class TestTimestampSigner(SimpleTestCase):
 
 

+ 2 - 0
tests/view_tests/tests/test_debug.py

@@ -1459,6 +1459,7 @@ class ExceptionReporterFilterTests(ExceptionReportTestMixin, LoggingCaptureMixin
         """
         """
         sensitive_settings = [
         sensitive_settings = [
             'SECRET_KEY',
             'SECRET_KEY',
+            'SECRET_KEY_FALLBACKS',
             'PASSWORD',
             'PASSWORD',
             'API_KEY',
             'API_KEY',
             'AUTH_TOKEN',
             'AUTH_TOKEN',
@@ -1475,6 +1476,7 @@ class ExceptionReporterFilterTests(ExceptionReportTestMixin, LoggingCaptureMixin
         """
         """
         sensitive_settings = [
         sensitive_settings = [
             'SECRET_KEY',
             'SECRET_KEY',
+            'SECRET_KEY_FALLBACKS',
             'PASSWORD',
             'PASSWORD',
             'API_KEY',
             'API_KEY',
             'AUTH_TOKEN',
             'AUTH_TOKEN',