From cd64087b0d0479e5e40f441b867592e87316c7d9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:26:18 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20dev-=D1=81=D0=B5=D0=BA=D1=80=D0=B5?= =?UTF-8?q?=D1=82=20HS256=20=D0=BA=D0=BE=D1=80=D0=BE=D1=87=D0=B5=2032=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=B9=D1=82=20=D0=BE=D1=82=D0=B2=D0=B5=D1=80=D0=B3?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20(T16.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/core/auth.py | 5 +++++ api/tests/test_auth_core.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/api/src/core/auth.py b/api/src/core/auth.py index fe97015..19af572 100644 --- a/api/src/core/auth.py +++ b/api/src/core/auth.py @@ -36,6 +36,8 @@ JWKS_ALGORITHMS: tuple[str, ...] = ("RS256", "ES256") DEV_ALGORITHM = "HS256" # Допуск на расхождение часов с эмитентом токена JWKS_LEEWAY_SECONDS = 10 +# Минимальная длина dev-секрета HS256 (RFC 7518 §3.2: не короче выхода SHA-256) +DEV_SECRET_MIN_BYTES = 32 # Срок жизни dev-токена (требование спеки authn-authz) DEV_TOKEN_TTL = timedelta(hours=8) # Время жизни кэша набора ключей JWKS (PyJWKClient), секунды. Кэш отдельных ключей по kid (cache_keys) выключен @@ -134,6 +136,9 @@ class DevHs256Verifier: def __init__(self, secret: str, *, email_claim: str = "email") -> None: if not secret: raise ValueError("секрет dev-эмитента не задан") + if len(secret.encode()) < DEV_SECRET_MIN_BYTES: + # HS256 по RFC 7518 §3.2 требует ключ не короче хэша (32 байта); PyJWT иначе шумит предупреждением + raise RuntimeError(f"AUTH_DEV_SECRET короче {DEV_SECRET_MIN_BYTES} байт") self._secret = secret self._email_claim = email_claim diff --git a/api/tests/test_auth_core.py b/api/tests/test_auth_core.py index 2e98a96..5df00f9 100644 --- a/api/tests/test_auth_core.py +++ b/api/tests/test_auth_core.py @@ -210,6 +210,17 @@ async def test_dev_issue_token_custom_ttl_and_claim() -> None: assert claims.exp - datetime.now(UTC) < timedelta(minutes=11) +def test_dev_short_secret_forbidden() -> None: + """T16.7: секрет короче 32 байт отвергается (RuntimeError), 32 байта — допустимы; предупреждений PyJWT нет.""" + with pytest.raises(RuntimeError, match="AUTH_DEV_SECRET короче 32 байт"): + DevHs256Verifier("x" * 31) + # «байт», а не символов: 16 кириллических символов = 32 байта в UTF-8 + DevHs256Verifier("я" * 16) + with pytest.raises(RuntimeError, match="короче 32 байт"): + DevHs256Verifier("я" * 15) + DevHs256Verifier("x" * 32) + + def test_dev_empty_secret_forbidden() -> None: with pytest.raises(ValueError, match="секрет"): DevHs256Verifier("") @@ -635,6 +646,12 @@ def test_factory_dev_wins_over_jwks_when_both_flags( assert isinstance(build_verifier(s), DevHs256Verifier) +def test_factory_dev_short_secret_is_config_error(monkeypatch: pytest.MonkeyPatch) -> None: + s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET="short-secret", DEBUG=True) + with pytest.raises(RuntimeError, match="AUTH_DEV_SECRET короче 32 байт"): + build_verifier(s) + + def test_factory_dev_flags_without_secret_is_config_error(monkeypatch: pytest.MonkeyPatch) -> None: s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, DEBUG=True) with pytest.raises(RuntimeError, match="AUTH_DEV_SECRET"):