enable secret key rotation

This commit is contained in:
David Lord 2024-11-08 08:09:01 -08:00
parent 7522c4bcdb
commit e13373f838
No known key found for this signature in database
GPG Key ID: 43368A7AA8CC5926
9 changed files with 55 additions and 7 deletions

View File

@ -20,6 +20,9 @@ Unreleased
- ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files. - ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files.
``load_dotenv`` loads default files in addition to a path unless ``load_dotenv`` loads default files in addition to a path unless
``load_defaults=False`` is passed. :issue:`5628` ``load_defaults=False`` is passed. :issue:`5628`
- Support key rotation with the ``SECRET_KEY_FALLBACKS`` config, a list of old
secret keys that can still be used for unsigning. Extensions will need to
add support. :issue:`5621`
Version 3.0.3 Version 3.0.3

View File

@ -125,6 +125,22 @@ The following configuration values are used internally by Flask:
Default: ``None`` Default: ``None``
.. py:data:: SECRET_KEY_FALLBACKS
A list of old secret keys that can still be used for unsigning, most recent
first. This allows a project to implement key rotation without invalidating
active sessions or other recently-signed secrets.
Keys should be removed after an appropriate period of time, as checking each
additional key adds some overhead.
Flask's built-in secure cookie session supports this. Extensions that use
:data:`SECRET_KEY` may not support this yet.
Default: ``None``
.. versionadded:: 3.1
.. py:data:: SESSION_COOKIE_NAME .. py:data:: SESSION_COOKIE_NAME
The name of the session cookie. Can be changed in case you already have a The name of the session cookie. Can be changed in case you already have a

View File

@ -79,7 +79,7 @@ source = ["src", "*/site-packages"]
[tool.mypy] [tool.mypy]
python_version = "3.9" python_version = "3.9"
files = ["src/flask", "tests/typing"] files = ["src/flask", "tests/type_check"]
show_error_codes = true show_error_codes = true
pretty = true pretty = true
strict = true strict = true
@ -95,7 +95,7 @@ ignore_missing_imports = true
[tool.pyright] [tool.pyright]
pythonVersion = "3.9" pythonVersion = "3.9"
include = ["src/flask", "tests/typing"] include = ["src/flask", "tests/type_check"]
typeCheckingMode = "basic" typeCheckingMode = "basic"
[tool.ruff] [tool.ruff]

View File

@ -180,6 +180,7 @@ class Flask(App):
"TESTING": False, "TESTING": False,
"PROPAGATE_EXCEPTIONS": None, "PROPAGATE_EXCEPTIONS": None,
"SECRET_KEY": None, "SECRET_KEY": None,
"SECRET_KEY_FALLBACKS": None,
"PERMANENT_SESSION_LIFETIME": timedelta(days=31), "PERMANENT_SESSION_LIFETIME": timedelta(days=31),
"USE_X_SENDFILE": False, "USE_X_SENDFILE": False,
"SERVER_NAME": None, "SERVER_NAME": None,

View File

@ -315,14 +315,20 @@ class SecureCookieSessionInterface(SessionInterface):
def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None: def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None:
if not app.secret_key: if not app.secret_key:
return None return None
signer_kwargs = dict(
key_derivation=self.key_derivation, digest_method=self.digest_method keys: list[str | bytes] = [app.secret_key]
)
if fallbacks := app.config["SECRET_KEY_FALLBACKS"]:
keys.extend(fallbacks)
return URLSafeTimedSerializer( return URLSafeTimedSerializer(
app.secret_key, keys, # type: ignore[arg-type]
salt=self.salt, salt=self.salt,
serializer=self.serializer, serializer=self.serializer,
signer_kwargs=signer_kwargs, signer_kwargs={
"key_derivation": self.key_derivation,
"digest_method": self.digest_method,
},
) )
def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None: def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None:

View File

@ -1,5 +1,6 @@
import gc import gc
import re import re
import typing as t
import uuid import uuid
import warnings import warnings
import weakref import weakref
@ -369,6 +370,27 @@ def test_missing_session(app):
expect_exception(flask.session.pop, "foo") expect_exception(flask.session.pop, "foo")
def test_session_secret_key_fallbacks(app, client) -> None:
@app.post("/")
def set_session() -> str:
flask.session["a"] = 1
return ""
@app.get("/")
def get_session() -> dict[str, t.Any]:
return dict(flask.session)
# Set session with initial secret key
client.post()
assert client.get().json == {"a": 1}
# Change secret key, session can't be loaded and appears empty
app.secret_key = "new test key"
assert client.get().json == {}
# Add initial secret key as fallback, session can be loaded
app.config["SECRET_KEY_FALLBACKS"] = ["test key"]
assert client.get().json == {"a": 1}
def test_session_expiration(app, client): def test_session_expiration(app, client):
permanent = True permanent = True