Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog.d/129.changed.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
6 changes: 4 additions & 2 deletions fastapi_cachex/session/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
31 changes: 14 additions & 17 deletions fastapi_cachex/session/token_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import importlib
import logging
import warnings
from datetime import datetime
from datetime import timezone
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 來說太短
Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

安全性注意事項:

Expand Down
66 changes: 51 additions & 15 deletions tests/session/test_token_serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import warnings
from datetime import datetime
from datetime import timezone
from typing import cast
Expand Down Expand Up @@ -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),
Expand All @@ -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)
Loading