security: JWT aud/iss validation, token blacklist, encryption key crash-early

C-2 (CRITICAL): JWT decode_token now validates aud, iss, iat, jti claims.
  Tokens without these claims are rejected. Audience 'ss-tools-api' and
  issuer 'ss-tools' prevent cross-service token reuse.

H-2 (HIGH): Server-side JWT revocation via token_blacklist table.
  - New TokenBlacklist model (SHA-256 hash only, never raw token)
  - blacklist_token() on logout — revokes current session
  - is_token_blacklisted() check in get_current_user
  - Expired entries auto-pruned via _prune_blacklist()

H-3 (HIGH): Encryption key auto-generation removed — crash-early instead.
  Previously, ensure_encryption_key() would auto-generate a Fernet key
  on first run and write it to .env. This made encrypted data unrecoverable
  when the key changed between deployments. Now raises RuntimeError with
  a clear command to generate the key explicitly.

Also: update orthogonal security test for crash-early behavior.
This commit is contained in:
2026-05-26 15:08:55 +03:00
parent a9a453109c
commit 23719ecfbc
7 changed files with 212 additions and 53 deletions

View File

@@ -234,31 +234,27 @@ class TestEncryptionKeyLifecycle:
assert result == expected
# #endregion test_ensure_encryption_key_from_env
# #region test_ensure_encryption_key_creates_file [C:2] [TYPE Function]
# @BRIEF When neither env nor .env has a key, generate one and persist to .env.
def test_ensure_encryption_key_creates_file(self):
# #region test_ensure_encryption_key_missing_raises [C:2] [TYPE Function]
# @BRIEF When neither env nor .env has a key, crash-early with RuntimeError.
# @TEST_EDGE: missing_encryption_key -> RuntimeError with clear message
def test_ensure_encryption_key_missing_raises(self):
from src.core.encryption_key import ensure_encryption_key
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
f.write("# empty .env\n")
env_path = Path(f.name)
try:
# Remove any existing ENCRYPTION_KEY from env
old = os.environ.pop("ENCRYPTION_KEY", None)
try:
result = ensure_encryption_key(env_path)
assert len(result) > 0
# Key should be in the file
content = env_path.read_text()
assert "ENCRYPTION_KEY=" in content
assert result in content
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY"):
ensure_encryption_key(env_path)
finally:
if old is not None:
os.environ["ENCRYPTION_KEY"] = old
finally:
env_path.unlink(missing_ok=True)
# #endregion test_ensure_encryption_key_creates_file
# #endregion test_ensure_encryption_key_missing_raises
# #region test_ensure_encryption_key_loads_from_file [C:2] [TYPE Function]
# @BRIEF When env is empty but .env file has the key, load it.