From 7ccb726a3b5cefd37a9297872413e1130d9b7f6a Mon Sep 17 00:00:00 2001 From: allen0099 Date: Wed, 30 Sep 2026 16:40:02 +0000 Subject: [PATCH] feat(session)!: reject JWT HMAC secrets shorter than the hash output JWTTokenSerializer raises ValueError when jwt_algorithm is HS384 or HS512 and secret_key is shorter than 48 or 64 UTF-8 bytes (RFC 7518 section 3.2). The check runs before PyJWT is imported, so the configuration error comes first. A custom token_serializer holds its own key and is not checked. BREAKING CHANGE: a SessionManager with token_format="jwt", HS384/HS512 and a short secret_key no longer starts. 0.3.x warned with a UserWarning; use a longer key or HS256. Closes #129 --- changelog.d/129.changed.md | 6 ++ docs/MIGRATING_0_4.md | 2 +- docs/SESSION.md | 6 +- fastapi_cachex/session/manager.py | 6 +- fastapi_cachex/session/token_serializers.py | 31 +++++----- i18n/zh-TW/docs/MIGRATING_0_4.md | 2 +- i18n/zh-TW/docs/SESSION.md | 2 +- tests/session/test_token_serializers.py | 66 ++++++++++++++++----- 8 files changed, 81 insertions(+), 40 deletions(-) create mode 100644 changelog.d/129.changed.md diff --git a/changelog.d/129.changed.md b/changelog.d/129.changed.md new file mode 100644 index 0000000..022ca6d --- /dev/null +++ b/changelog.d/129.changed.md @@ -0,0 +1,6 @@ +**A JWT HMAC secret shorter than the hash output is rejected.** With +`token_format="jwt"`, `SessionManager` raises `ValueError` when it builds its +serializer if `jwt_algorithm` is `HS384` or `HS512` and `secret_key` is +shorter than 48 or 64 bytes in UTF-8 (RFC 7518 section 3.2). 0.3.x only +warned. Use a longer key or `HS256`. See the +[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#jwt-secret). diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index dfe66ee..86b0da9 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -167,7 +167,7 @@ app.add_middleware( ### JWT secret length {#jwt-secret} -With `token_format="jwt"`, 0.4.0 raises at startup when `jwt_algorithm` is `HS384` or `HS512` and `secret_key` is shorter than 48 or 64 bytes (RFC 7518 section 3.2) ([#129](https://github.com/allen0099/FastAPI-CacheX/issues/129)). 0.3.x emits a `UserWarning` when `JWTTokenSerializer` is built. Use a longer key, or `HS256`: +With `token_format="jwt"`, 0.4.0 raises `ValueError` at startup, when `SessionManager` builds its `JWTTokenSerializer`, if `jwt_algorithm` is `HS384` or `HS512` and `secret_key` is shorter than 48 or 64 bytes in UTF-8 (RFC 7518 section 3.2) ([#129](https://github.com/allen0099/FastAPI-CacheX/issues/129)). 0.3.x emitted a `UserWarning` there instead. Use a longer key, or `HS256`: ```python # Before: 32 characters, too short for HS512 diff --git a/docs/SESSION.md b/docs/SESSION.md index e680463..1c73a5b 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -348,9 +348,9 @@ only supports `HS256`, `HS384` and `HS512`: with an asymmetric algorithm, `Sessi An HMAC key must be at least as long as the hash output (RFC 7518 §3.2): 32 bytes for `HS256`, 48 for `HS384` and 64 for `HS512`, counted after UTF-8 encoding. `secret_key` only has to be 32 -characters, so with `HS384` or `HS512` a shorter key makes the serializer emit a `UserWarning` -once when it is built (PyJWT 2.11 and later also warn with `InsecureKeyLengthWarning` whenever they -sign or verify a token). Use a longer key, for example `secrets.token_urlsafe(64)`. +characters, so with `HS384` or `HS512` a shorter key makes `SessionManager` raise `ValueError` +when it builds the built-in serializer (a custom `token_serializer` holds its own key). Use a longer key, for example `secrets.token_urlsafe(64)`, or +`HS256`. Security notes: diff --git a/fastapi_cachex/session/manager.py b/fastapi_cachex/session/manager.py index fddc0e0..5483e1e 100644 --- a/fastapi_cachex/session/manager.py +++ b/fastapi_cachex/session/manager.py @@ -53,8 +53,10 @@ def __init__( overrides the built-in selection (simple/jwt). Raises: - ValueError: If ``config.token_format`` is ``"jwt"`` with an - asymmetric ``jwt_algorithm`` and no ``token_serializer``. + ValueError: If ``config.token_format`` is ``"jwt"`` and no + ``token_serializer`` is given, with an asymmetric + ``jwt_algorithm``, or with a ``secret_key`` shorter in UTF-8 + bytes than the HMAC hash output (48 for HS384, 64 for HS512). ImportError: If ``config.token_format`` is ``"jwt"``, no ``token_serializer`` is given and PyJWT is not installed. """ diff --git a/fastapi_cachex/session/token_serializers.py b/fastapi_cachex/session/token_serializers.py index ff29f72..54499a1 100644 --- a/fastapi_cachex/session/token_serializers.py +++ b/fastapi_cachex/session/token_serializers.py @@ -12,7 +12,6 @@ import importlib import logging -import warnings from datetime import datetime from datetime import timezone from typing import TYPE_CHECKING @@ -99,26 +98,27 @@ def from_string(self, token_str: str) -> SessionToken: ) -def _warn_if_key_too_short(secret: str, algorithm: str) -> None: - """Warn once when ``secret`` is shorter than ``algorithm``'s hash output. +def _check_key_length(secret: str, algorithm: str) -> None: + """Reject ``secret`` if it is shorter than ``algorithm``'s hash output. ``SessionConfig`` only requires 32 characters, enough for HS256 but not - for HS384 (48 bytes) or HS512 (64 bytes). PyJWT 2.11 and later warn about a - short key on every token they sign or verify; this names the setting to - fix, once, when the serializer is built. + for HS384 (48 bytes) or HS512 (64 bytes). 0.3.x warned; 0.4.0 refuses + such a key when the serializer is built (#129). + + Raises: + ValueError: If ``secret`` is shorter in UTF-8 bytes than the hash output """ min_bytes = _HMAC_MIN_KEY_BYTES[algorithm] key_bytes = len(secret.encode("utf-8")) if key_bytes < min_bytes: - warnings.warn( + msg = ( f"secret_key is {key_bytes} bytes, shorter than the {min_bytes} " f"bytes RFC 7518 section 3.2 requires for {algorithm}. Use a longer " f"secret_key (e.g. secrets.token_urlsafe({min_bytes})) or " - f'jwt_algorithm="HS256". Version 0.4.0 will reject a shorter key ' - f"(https://github.com/allen0099/FastAPI-CacheX/issues/129).", - UserWarning, - stacklevel=3, + f'jwt_algorithm="HS256" ' + f"(https://github.com/allen0099/FastAPI-CacheX/issues/129)." ) + raise ValueError(msg) class JWTTokenSerializer: @@ -148,13 +148,10 @@ def __init__(self, config: SessionConfig, jwt_module: Any | None = None) -> None serializer signs and verifies with the ``secret_key`` string, which only the HMAC algorithms can use; an asymmetric algorithm needs a custom ``token_serializer`` that holds the - key pair. + key pair. Also if ``secret_key`` is shorter in UTF-8 bytes + than the HMAC hash output (48 for HS384, 64 for HS512). ImportError: If no ``jwt_module`` is given and PyJWT (the ``jwt`` extra) is not installed. - - Warns: - UserWarning: If ``secret_key`` is shorter in UTF-8 bytes than the - HMAC hash output (48 for HS384, 64 for HS512). """ if config.jwt_algorithm not in JWT_HMAC_ALGORITHMS: supported = ", ".join(sorted(JWT_HMAC_ALGORITHMS)) @@ -165,6 +162,7 @@ def __init__(self, config: SessionConfig, jwt_module: Any | None = None) -> None f"SessionManager" ) raise ValueError(msg) + _check_key_length(config.secret_key.get_secret_value(), config.jwt_algorithm) if jwt_module is not None: self.jwt_encoder = jwt_module @@ -177,7 +175,6 @@ def __init__(self, config: SessionConfig, jwt_module: Any | None = None) -> None # Copy required parameters self._secret = config.secret_key.get_secret_value() - _warn_if_key_too_short(self._secret, config.jwt_algorithm) self._algorithm = config.jwt_algorithm self._issuer = config.jwt_issuer self._audience = config.jwt_audience diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 6f06fd4..b799f4b 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -166,7 +166,7 @@ app.add_middleware( ### JWT 密鑰長度 {#jwt-secret} -使用 `token_format="jwt"` 時,若 `jwt_algorithm` 為 `HS384` 或 `HS512`,而 `secret_key` 短於 48 或 64 位元組(RFC 7518 第 3.2 節),0.4.0 會在啟動時拋出例外([#129](https://github.com/allen0099/FastAPI-CacheX/issues/129))。0.3.x 在建立 `JWTTokenSerializer` 時會發出 `UserWarning`。請使用較長的密鑰,或改用 `HS256`: +使用 `token_format="jwt"` 時,若 `jwt_algorithm` 為 `HS384` 或 `HS512`,而 `secret_key` 以 UTF-8 編碼後短於 48 或 64 位元組(RFC 7518 第 3.2 節),0.4.0 會在啟動時、`SessionManager` 建立 `JWTTokenSerializer` 的當下拋出 `ValueError`([#129](https://github.com/allen0099/FastAPI-CacheX/issues/129))。0.3.x 則是在同一處發出 `UserWarning`。請使用較長的密鑰,或改用 `HS256`: ```python # 修改前:32 個字元,對 HS512 來說太短 diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index dd9e991..f98c039 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -213,7 +213,7 @@ config = SessionConfig( `jwt_algorithm` 必須是 `HS256`、`HS384`、`HS512`、`RS256`、`RS384`、`RS512`、`ES256`、`ES384`、`ES512`、`PS256`、`PS384`、`PS512` 或 `EdDSA` 其中之一;其他任何值(包括 `none`)都會拋出 `ValidationError`。內建的序列化器以同一把 `secret_key` 簽署與驗證,因此只支援 `HS256`、`HS384` 與 `HS512`:使用非對稱演算法時,除非你傳入持有金鑰對的自訂 `token_serializer`,否則 `SessionManager` 會拋出 `ValueError`。 -HMAC 金鑰的長度至少須等於雜湊輸出(RFC 7518 §3.2):`HS256` 為 32 位元組、`HS384` 為 48、`HS512` 為 64,以 UTF-8 編碼後計算。`secret_key` 只要求 32 個字元,因此搭配 `HS384` 或 `HS512` 時,較短的金鑰會讓序列化器在建立時發出一次 `UserWarning`(PyJWT 2.11 以上版本每次簽署或驗證權杖時也會發出 `InsecureKeyLengthWarning`)。請使用更長的金鑰,例如 `secrets.token_urlsafe(64)`。 +HMAC 金鑰的長度至少須等於雜湊輸出(RFC 7518 §3.2):`HS256` 為 32 位元組、`HS384` 為 48、`HS512` 為 64,以 UTF-8 編碼後計算。`secret_key` 只要求 32 個字元,因此搭配 `HS384` 或 `HS512` 時,較短的金鑰會讓 `SessionManager` 在建立內建序列化器時拋出 `ValueError`(自訂的 `token_serializer` 自行持有金鑰)。請使用更長的金鑰,例如 `secrets.token_urlsafe(64)`,或改用 `HS256`。 安全性注意事項: diff --git a/tests/session/test_token_serializers.py b/tests/session/test_token_serializers.py index 2ea338f..d1aee03 100644 --- a/tests/session/test_token_serializers.py +++ b/tests/session/test_token_serializers.py @@ -1,4 +1,3 @@ -import warnings from datetime import datetime from datetime import timezone from typing import cast @@ -312,7 +311,7 @@ def test_jwt_serializer_round_trips_hmac_algorithms(algorithm: str) -> None: @pytest.mark.parametrize( - ("algorithm", "secret", "warns"), + ("algorithm", "secret", "rejected"), [ ("HS256", "a" * 32, False), ("HS384", "a" * 47, True), @@ -321,25 +320,62 @@ def test_jwt_serializer_round_trips_hmac_algorithms(algorithm: str) -> None: ("HS512", "a" * 64, False), # 32 characters but 64 UTF-8 bytes: the key length is counted in bytes. ("HS512", "é" * 32, False), + # 32 characters but 47 UTF-8 bytes: one byte short of HS384. + ("HS384", "é" * 15 + "a" * 17, True), ], ) -def test_jwt_serializer_warns_once_about_a_short_hmac_key( - algorithm: str, secret: str, warns: bool +def test_jwt_serializer_rejects_a_short_hmac_key( + algorithm: str, secret: str, rejected: bool ) -> None: - """A secret shorter than the hash output warns when the serializer is built (#116).""" + """A secret shorter than the hash output is refused when the serializer is built (#129).""" config = SessionConfig( secret_key=SecretStr(secret), token_format="jwt", jwt_algorithm=algorithm ) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") + if rejected: + with pytest.raises(ValueError, match=f"requires for {algorithm}"): + JWTTokenSerializer(config, jwt_module=StubJWTModule()) + else: JWTTokenSerializer(config, jwt_module=StubJWTModule()) - messages = [str(w.message) for w in caught if w.category is UserWarning] - if warns: - assert len(messages) == 1 - assert f"requires for {algorithm}" in messages[0] - assert "Version 0.4.0 will reject" in messages[0] - assert caught[0].filename == __file__ - else: - assert messages == [] + +def test_a_short_hmac_key_is_rejected_when_the_manager_is_built() -> None: + """The check runs at startup, before any token is signed.""" + config = SessionConfig( + secret_key=SecretStr("a" * 32), token_format="jwt", jwt_algorithm="HS512" + ) + + with pytest.raises(ValueError, match=r"secrets\.token_urlsafe\(64\)"): + SessionManager(MemoryBackend(), config) + + +def test_a_short_hmac_key_is_rejected_before_pyjwt_is_needed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The configuration error comes first, not the missing extra.""" + import importlib + + def no_jwt(name: str, package: str | None = None) -> object: + msg = "No module named 'jwt'" + raise ImportError(msg) + + monkeypatch.setattr(importlib, "import_module", no_jwt) + config = SessionConfig( + secret_key=SecretStr("a" * 32), token_format="jwt", jwt_algorithm="HS512" + ) + + with pytest.raises(ValueError, match="requires for HS512"): + JWTTokenSerializer(config) + + +def test_a_custom_serializer_holds_its_own_key() -> None: + """The length check guards the built-in serializer's use of secret_key only.""" + config = SessionConfig( + secret_key=SecretStr("a" * 32), token_format="jwt", jwt_algorithm="HS512" + ) + + manager = SessionManager( + MemoryBackend(), config, token_serializer=SimpleTokenSerializer() + ) + + assert isinstance(manager._serializer, SimpleTokenSerializer)