feat(07.3-02): ES256 JWT algorithm upgrade + startup rotation hook

- config.py: add refresh_token_expire_hours=16, jwt_private_key, jwt_public_key fields (D-01, D-09)
- services/auth.py: swap all 4 JWT sites to ES256 via base64-decoded PEM keys; remove HS256 (D-02, D-03)
- main.py: add _rotate_tokens_on_algorithm_change lifespan hook — bulk-revokes refresh tokens on algorithm change; idempotent on repeat boots (D-04, D-05)
- test_auth_es256.py: promote ES256-01..05 + CFG-01 stubs to 6 passing tests; RM-01..03 remain xfail
- docker-compose.yml: inject JWT_PRIVATE_KEY + JWT_PUBLIC_KEY into backend + celery-worker (D-07)
- README.md: add JWT key env vars + key generation Python one-liner snippet
- .env.example: add JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY= lines
- Version bump to 0.1.2
This commit is contained in:
curo1305
2026-06-06 17:19:50 +02:00
parent 99f55825aa
commit 8be792ab4c
8 changed files with 268 additions and 20 deletions
+5
View File
@@ -31,6 +31,11 @@ REDIS_URL=redis://:changeme_redis@redis:6379/0
# JWT signing secret — generate with: python3 -c "import secrets; print(secrets.token_hex(64))" # JWT signing secret — generate with: python3 -c "import secrets; print(secrets.token_hex(64))"
SECRET_KEY=CHANGEME-replace-with-64-char-random-hex SECRET_KEY=CHANGEME-replace-with-64-char-random-hex
# ── ES256 JWT Keypair (Phase 7.3) ─────────────────────────────────────────────
# Generated by running the Python snippet in README.md JWT Key Generation section.
JWT_PRIVATE_KEY=
JWT_PUBLIC_KEY=
# ── Admin Bootstrap (Phase 2 — D-04) ───────────────────────────────────────── # ── Admin Bootstrap (Phase 2 — D-04) ─────────────────────────────────────────
# First admin account created on startup if users table is empty. # First admin account created on startup if users table is empty.
# Both vars must be set; if missing, a WARNING is logged but app starts normally. # Both vars must be set; if missing, a WARNING is logged but app starts normally.
+26 -1
View File
@@ -140,7 +140,9 @@ Copy `.env.example` to `.env`. Only the fields marked **Required** must be set b
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `SECRET_KEY` | JWT signing secret — generate with `openssl rand -hex 32` | | `SECRET_KEY` | Legacy HMAC secret (kept for future use) — generate with `openssl rand -hex 32` |
| `JWT_PRIVATE_KEY` | base64-encoded PEM PKCS8 private key for ES256 JWT signing (required) |
| `JWT_PUBLIC_KEY` | base64-encoded PEM SubjectPublicKeyInfo public key for ES256 JWT verification (required) |
| `CLOUD_CREDS_KEY` | Master key for cloud credential encryption — generate with `openssl rand -hex 16` (pad to 32 chars) | | `CLOUD_CREDS_KEY` | Master key for cloud credential encryption — generate with `openssl rand -hex 16` (pad to 32 chars) |
| `ADMIN_EMAIL` | Bootstrap admin email | | `ADMIN_EMAIL` | Bootstrap admin email |
| `ADMIN_PASSWORD` | Bootstrap admin password (must pass strength check) | | `ADMIN_PASSWORD` | Bootstrap admin password (must pass strength check) |
@@ -159,6 +161,29 @@ Copy `.env.example` to `.env`. Only the fields marked **Required** must be set b
| `GOOGLE_CLIENT_ID/SECRET` | *(unset)* | Required only if using Google Drive backend | | `GOOGLE_CLIENT_ID/SECRET` | *(unset)* | Required only if using Google Drive backend |
| `ONEDRIVE_CLIENT_ID/SECRET` | *(unset)* | Required only if using OneDrive backend | | `ONEDRIVE_CLIENT_ID/SECRET` | *(unset)* | Required only if using OneDrive backend |
### JWT Key Generation
Phase 7.3 uses ES256 (ECDSA P-256) asymmetric signing. The private key signs tokens; the public key verifies them. A leaked public key cannot forge tokens.
Generate the keypair with a single Python command:
```bash
python3 -c "
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import base64
k = ec.generate_private_key(ec.SECP256R1())
priv = base64.b64encode(k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())).decode()
pub = base64.b64encode(k.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)).decode()
print(f'JWT_PRIVATE_KEY={priv}')
print(f'JWT_PUBLIC_KEY={pub}')
"
```
Paste the two output lines into your `.env` file at the project root.
> **Warning:** Rotating these keys invalidates every active session — the startup rotation hook will bulk-revoke all refresh tokens on the next boot.
--- ---
## Development ## Development
+4
View File
@@ -33,6 +33,10 @@ class Settings(BaseSettings):
# Auth / JWT (Phase 2) # Auth / JWT (Phase 2)
access_token_expire_minutes: int = 15 access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 30 refresh_token_expire_days: int = 30
# ES256 keypair + short-session TTL (Phase 7.3 — D-01, D-09)
refresh_token_expire_hours: int = 16 # default short session (16h workday)
jwt_private_key: str = "" # base64-encoded PKCS8 PEM; required at runtime
jwt_public_key: str = "" # base64-encoded SubjectPublicKeyInfo PEM; required at runtime
# SMTP (Phase 2 — D-01) # SMTP (Phase 2 — D-01)
smtp_host: str = "" smtp_host: str = ""
+58 -2
View File
@@ -12,7 +12,7 @@ from minio import Minio
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy import text from sqlalchemy import select, text
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse from starlette.responses import Response as StarletteResponse
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
@@ -130,6 +130,50 @@ class CorrelationIDMiddleware:
) )
# ── ES256 startup rotation helper ────────────────────────────────────────────
async def _rotate_tokens_on_algorithm_change(session) -> None:
"""Idempotent ES256 migration (Phase 7.3 D-04/D-05).
On every boot, compares the jwt_algorithm marker row in system_settings against
'ES256'. If absent or different, bulk-revokes all active refresh tokens via raw
SQL UPDATE and upserts the marker (with is_active=False so the AI provider loader
never returns this row). No-op on second boot.
"""
import logging as _logging
from db.models import SystemSettings # noqa: PLC0415
stmt = select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
result = await session.execute(stmt)
row = result.scalar_one_or_none()
if row is not None and row.model_name == "ES256":
return # idempotent — algorithm already matches
# Bulk-revoke all active refresh tokens (single raw SQL — no Python iteration)
_logging.getLogger(__name__).info(
"ES256 startup rotation: bulk-revoked all active refresh tokens"
)
await session.execute(
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
)
if row is None:
session.add(SystemSettings(
id=uuid.uuid4(),
provider_id="jwt_algorithm",
model_name="ES256",
context_chars=0,
is_active=False,
api_key_enc=None,
base_url=None,
))
else:
row.model_name = "ES256"
await session.commit()
# ── Lifespan ────────────────────────────────────────────────────────────────── # ── Lifespan ──────────────────────────────────────────────────────────────────
@asynccontextmanager @asynccontextmanager
@@ -179,6 +223,18 @@ async def lifespan(app: FastAPI):
"AI provider seed skipped (table may not exist yet): %s", _seed_exc "AI provider seed skipped (table may not exist yet): %s", _seed_exc
) )
# ES256 algorithm rotation (Phase 7.3 D-04/D-05): bulk-revoke on algorithm change.
# Wrapped in try/except — fresh containers may not have system_settings table yet
# (Pitfall 6 in RESEARCH.md).
try:
async with AsyncSessionLocal() as session:
await _rotate_tokens_on_algorithm_change(session)
except Exception as _es256_exc:
import logging as _logging
_logging.getLogger(__name__).warning(
"ES256 rotation check skipped (table may not exist yet): %s", _es256_exc
)
yield yield
# Shutdown: close pooled connections and Redis # Shutdown: close pooled connections and Redis
@@ -188,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ─────────────────────────────────────────────────────── # ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="0.1.1", lifespan=lifespan) app = FastAPI(title="Document Scanner API", version="0.1.2", lifespan=lifespan)
# Rate limiter state (slowapi) # Rate limiter state (slowapi)
app.state.limiter = auth_limiter app.state.limiter = auth_limiter
+9 -4
View File
@@ -17,6 +17,7 @@ Security invariants:
""" """
from __future__ import annotations from __future__ import annotations
import base64
import hashlib import hashlib
import hmac import hmac
import logging import logging
@@ -97,7 +98,8 @@ def create_access_token(user_id: str, role: str) -> str:
"exp": now + timedelta(minutes=settings.access_token_expire_minutes), "exp": now + timedelta(minutes=settings.access_token_expire_minutes),
"jti": str(uuid.uuid4()), "jti": str(uuid.uuid4()),
} }
return jwt.encode(payload, settings.secret_key, algorithm="HS256") private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256")
def decode_access_token(token: str) -> dict: def decode_access_token(token: str) -> dict:
@@ -107,7 +109,8 @@ def decode_access_token(token: str) -> dict:
tokens from being used as access tokens). tokens from being used as access tokens).
""" """
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) public_pem = base64.b64decode(settings.jwt_public_key).decode()
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc: except jwt.ExpiredSignatureError as exc:
raise ValueError("Token has expired") from exc raise ValueError("Token has expired") from exc
except jwt.PyJWTError as exc: except jwt.PyJWTError as exc:
@@ -130,7 +133,8 @@ def create_password_reset_token(user_id: str) -> str:
"iat": now, "iat": now,
"exp": now + timedelta(seconds=3600), "exp": now + timedelta(seconds=3600),
} }
return jwt.encode(payload, settings.secret_key, algorithm="HS256") private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256")
def decode_password_reset_token(token: str) -> str: def decode_password_reset_token(token: str) -> str:
@@ -139,7 +143,8 @@ def decode_password_reset_token(token: str) -> str:
Returns the user_id string. Returns the user_id string.
""" """
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) public_pem = base64.b64decode(settings.jwt_public_key).decode()
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc: except jwt.ExpiredSignatureError as exc:
raise ValueError("Reset token has expired") from exc raise ValueError("Reset token has expired") from exc
except jwt.PyJWTError as exc: except jwt.PyJWTError as exc:
+161 -12
View File
@@ -49,43 +49,189 @@ def es256_keys(monkeypatch):
# ── ES256-01: access token algorithm ───────────────────────────────────────── # ── ES256-01: access token algorithm ─────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="ES256-01: not yet implemented")
def test_access_token_uses_es256(): def test_access_token_uses_es256():
pytest.xfail("not yet implemented") from services.auth import create_access_token
token = create_access_token("u1", "user")
# Decode header (first segment of JWT)
segment = token.split(".")[0]
# Add padding
segment += "=" * (4 - len(segment) % 4)
header = json.loads(base64.urlsafe_b64decode(segment))
assert header["alg"] == "ES256"
# ── ES256-02: HS256 token rejected ─────────────────────────────────────────── # ── ES256-02: HS256 token rejected ───────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="ES256-02: not yet implemented")
def test_hs256_token_rejected(): def test_hs256_token_rejected():
pytest.xfail("not yet implemented") import jwt as _jwt
from services.auth import decode_access_token
hs256_token = _jwt.encode(
{"sub": "u1", "typ": "access", "exp": int(time.time()) + 60, "iat": int(time.time())},
"any-hs256-secret",
algorithm="HS256",
)
with pytest.raises(ValueError):
decode_access_token(hs256_token)
# ── ES256-03: password-reset token algorithm ────────────────────────────────── # ── ES256-03: password-reset token algorithm ──────────────────────────────────
@pytest.mark.xfail(strict=True, reason="ES256-03: not yet implemented")
def test_reset_token_uses_es256(): def test_reset_token_uses_es256():
pytest.xfail("not yet implemented") from services.auth import create_password_reset_token, decode_password_reset_token
token = create_password_reset_token("u1")
segment = token.split(".")[0]
segment += "=" * (4 - len(segment) % 4)
header = json.loads(base64.urlsafe_b64decode(segment))
assert header["alg"] == "ES256"
assert decode_password_reset_token(token) == "u1"
# ── ES256-04: startup rotation revokes tokens ───────────────────────────────── # ── ES256-04: startup rotation revokes tokens ─────────────────────────────────
@pytest.mark.xfail(strict=True, reason="ES256-04: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_startup_rotation_revokes_tokens(db_session): async def test_startup_rotation_revokes_tokens(db_session):
pytest.xfail("not yet implemented") from datetime import datetime, timezone, timedelta
import hashlib
import secrets
import uuid as _uuid
from sqlalchemy import select
from db.models import RefreshToken, SystemSettings, User, Quota
from main import _rotate_tokens_on_algorithm_change
from services.auth import hash_password
# Set up: create a user + two RefreshToken rows with revoked=False
user_id = _uuid.uuid4()
user = User(
id=user_id,
handle=f"rottest_{user_id.hex[:8]}",
email=f"rottest_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
now = datetime.now(timezone.utc)
rt1_id = _uuid.uuid4()
rt2_id = _uuid.uuid4()
rt1 = RefreshToken(
id=rt1_id,
user_id=user_id,
token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
rt2 = RefreshToken(
id=rt2_id,
user_id=user_id,
token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
db_session.add(rt1)
db_session.add(rt2)
await db_session.flush()
# No jwt_algorithm row in system_settings
# Act
await _rotate_tokens_on_algorithm_change(db_session)
# After the helper's commit, expire the identity map so we read fresh DB state
db_session.expire_all()
# Assert: both tokens revoked
result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id.in_([rt1_id, rt2_id]))
)
rows = result.scalars().all()
assert len(rows) == 2
assert all(r.revoked for r in rows)
# Assert: jwt_algorithm marker row created
result2 = await db_session.execute(
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
)
marker = result2.scalar_one_or_none()
assert marker is not None
assert marker.model_name == "ES256"
assert not marker.is_active
assert marker.context_chars == 0
# ── ES256-05: startup rotation is idempotent ───────────────────────────────── # ── ES256-05: startup rotation is idempotent ─────────────────────────────────
@pytest.mark.xfail(strict=True, reason="ES256-05: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_startup_rotation_idempotent(db_session): async def test_startup_rotation_idempotent(db_session):
pytest.xfail("not yet implemented") import uuid as _uuid
from sqlalchemy import select, func
from db.models import RefreshToken, SystemSettings, User, Quota
from main import _rotate_tokens_on_algorithm_change
from services.auth import hash_password
from datetime import datetime, timezone, timedelta
import hashlib
import secrets
# Set up: pre-existing jwt_algorithm marker row with model_name='ES256'
marker_id = _uuid.uuid4()
marker = SystemSettings(
id=marker_id,
provider_id="jwt_algorithm",
model_name="ES256",
context_chars=0,
is_active=False,
api_key_enc=None,
base_url=None,
)
db_session.add(marker)
# Create a user + one fresh RefreshToken with revoked=False
user_id = _uuid.uuid4()
user = User(
id=user_id,
handle=f"idem_{user_id.hex[:8]}",
email=f"idem_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
now = datetime.now(timezone.utc)
rt_id = _uuid.uuid4()
rt = RefreshToken(
id=rt_id,
user_id=user_id,
token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
db_session.add(rt)
await db_session.flush()
# Act: run rotation — should be no-op because model_name is already 'ES256'
await _rotate_tokens_on_algorithm_change(db_session)
# Assert: RefreshToken still not revoked
result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id == rt_id)
)
token_row = result.scalar_one_or_none()
assert token_row is not None
assert not token_row.revoked
# Assert: exactly one jwt_algorithm row (no duplicates)
result2 = await db_session.execute(
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
)
markers = result2.scalars().all()
assert len(markers) == 1
# ── RM-01: default TTL is 16 hours ─────────────────────────────────────────── # ── RM-01: default TTL is 16 hours ───────────────────────────────────────────
@@ -118,6 +264,9 @@ async def test_remember_me_cookie_max_age(async_client, auth_user):
# ── CFG-01 satellite: settings has jwt key fields ──────────────────────────── # ── CFG-01 satellite: settings has jwt key fields ────────────────────────────
@pytest.mark.xfail(strict=True, reason="CFG-01: not yet implemented")
def test_settings_has_jwt_keys(): def test_settings_has_jwt_keys():
pytest.xfail("not yet implemented") from config import settings
assert hasattr(settings, "jwt_private_key")
assert hasattr(settings, "jwt_public_key")
assert hasattr(settings, "refresh_token_expire_hours")
assert settings.refresh_token_expire_hours == 16
+4
View File
@@ -62,6 +62,8 @@ services:
- MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-localhost:9000} - MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-localhost:9000}
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
- ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173}
@@ -101,6 +103,8 @@ services:
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
- CLOUD_CREDS_KEY=${CLOUD_CREDS_KEY} - CLOUD_CREDS_KEY=${CLOUD_CREDS_KEY}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
- PYTHONDONTWRITEBYTECODE=1 - PYTHONDONTWRITEBYTECODE=1
- PYTHONPATH=/app - PYTHONPATH=/app
labels: labels:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "document-scanner-frontend", "name": "document-scanner-frontend",
"version": "0.1.1", "version": "0.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",