From fd3f6115468eae29d1dd0fbc83f33c7c1a08b525 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 6 Jun 2026 16:59:38 +0200 Subject: [PATCH 1/4] =?UTF-8?q?security(07.3-02):=20ES256=20signing=20?= =?UTF-8?q?=E2=80=94=20swap=204=20JWT=20sites=20+=20config=20keys=20+=20pr?= =?UTF-8?q?omote=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/config.py: add refresh_token_expire_hours=16, jwt_private_key, jwt_public_key - backend/services/auth.py: add import base64; all 4 JWT sites use ES256 with inline PEM decode; zero HS256/secret_key references - backend/tests/test_auth_es256.py: promote ES256-01..03 + CFG-01 tests from xfail to passing --- backend/config.py | 4 +++ backend/services/auth.py | 13 ++++++--- backend/tests/test_auth_es256.py | 50 +++++++++++++++++++++++++------- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/backend/config.py b/backend/config.py index 0844e33..8eb95f6 100644 --- a/backend/config.py +++ b/backend/config.py @@ -33,6 +33,10 @@ class Settings(BaseSettings): # Auth / JWT (Phase 2) access_token_expire_minutes: int = 15 refresh_token_expire_days: int = 30 + # Phase 7.3 — D-01, D-09: ES256 key pair + short-session TTL + refresh_token_expire_hours: int = 16 # default short session + jwt_private_key: str = "" # base64-encoded PEM; required in production + jwt_public_key: str = "" # base64-encoded PEM; required in production # SMTP (Phase 2 — D-01) smtp_host: str = "" diff --git a/backend/services/auth.py b/backend/services/auth.py index a51da1b..2ddf3bf 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -17,6 +17,7 @@ Security invariants: """ from __future__ import annotations +import base64 import hashlib import hmac 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), "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: @@ -107,7 +109,8 @@ def decode_access_token(token: str) -> dict: tokens from being used as access tokens). """ 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: raise ValueError("Token has expired") from exc except jwt.PyJWTError as exc: @@ -130,7 +133,8 @@ def create_password_reset_token(user_id: str) -> str: "iat": now, "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: @@ -139,7 +143,8 @@ def decode_password_reset_token(token: str) -> str: Returns the user_id string. """ 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: raise ValueError("Reset token has expired") from exc except jwt.PyJWTError as exc: diff --git a/backend/tests/test_auth_es256.py b/backend/tests/test_auth_es256.py index 5132d38..feb3e31 100644 --- a/backend/tests/test_auth_es256.py +++ b/backend/tests/test_auth_es256.py @@ -36,21 +36,47 @@ def es256_keys(monkeypatch): monkeypatch.setattr("config.settings.jwt_public_key", pub, raising=False) -# ── ES256 algorithm stubs ───────────────────────────────────────────────────── +# ── ES256 algorithm tests ───────────────────────────────────────────────────── -@pytest.mark.xfail(strict=True, reason="ES256-01: not yet implemented") def test_access_token_uses_es256(): - pytest.xfail("not yet implemented") + """ES256-01: access token header must declare alg=ES256.""" + from services.auth import create_access_token + token = create_access_token("u1", "user") + # Decode the header (first segment of the JWT) + header_b64 = token.split(".")[0] + # Add padding to make it valid base64 + padding = "=" * (4 - len(header_b64) % 4) + header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) + assert header["alg"] == "ES256" -@pytest.mark.xfail(strict=True, reason="ES256-02: not yet implemented") def test_hs256_token_rejected(): - pytest.xfail("not yet implemented") + """ES256-02: an HS256 token must raise ValueError when decoded.""" + 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) -@pytest.mark.xfail(strict=True, reason="ES256-03: not yet implemented") def test_reset_token_uses_es256(): - pytest.xfail("not yet implemented") + """ES256-03: password-reset token header must declare alg=ES256 and round-trips.""" + from services.auth import create_password_reset_token, decode_password_reset_token + token = create_password_reset_token("u1") + header_b64 = token.split(".")[0] + padding = "=" * (4 - len(header_b64) % 4) + header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) + assert header["alg"] == "ES256" + assert decode_password_reset_token(token) == "u1" # ── Startup token rotation stubs ────────────────────────────────────────────── @@ -87,8 +113,12 @@ async def test_remember_me_cookie_max_age(async_client, auth_user): pytest.xfail("not yet implemented") -# ── Config field stubs ──────────────────────────────────────────────────────── +# ── Config field tests ──────────────────────────────────────────────────────── -@pytest.mark.xfail(strict=True, reason="CFG-01: not yet implemented") def test_settings_has_jwt_keys(): - pytest.xfail("not yet implemented") + """CFG-01: Settings must expose jwt_private_key, jwt_public_key, and refresh_token_expire_hours=16.""" + 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 From 8d261b050939ca021d9a7f9d0c153f9129604013 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 6 Jun 2026 17:02:45 +0200 Subject: [PATCH 2/4] security(07.3-02): add _rotate_tokens_on_algorithm_change lifespan hook + promote tests - backend/main.py: add import logging, select; add _rotate_tokens_on_algorithm_change helper above lifespan; call from lifespan with try/except wrap - backend/tests/test_auth_es256.py: promote ES256-04 and ES256-05 startup rotation tests from xfail to passing; suite reports 6 PASSED + 3 XFAILED --- backend/main.py | 58 ++++++++++++- backend/tests/test_auth_es256.py | 135 +++++++++++++++++++++++++++++-- 2 files changed, 187 insertions(+), 6 deletions(-) diff --git a/backend/main.py b/backend/main.py index 26c9c5f..24c65d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,5 @@ import asyncio +import logging import time import uuid from contextlib import asynccontextmanager @@ -12,7 +13,7 @@ from minio import Minio from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware -from sqlalchemy import text +from sqlalchemy import select, text from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response as StarletteResponse from starlette.types import ASGIApp, Receive, Scope, Send @@ -130,6 +131,49 @@ class CorrelationIDMiddleware: ) +# ── ES256 startup rotation ──────────────────────────────────────────────────── + +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. + """ + from db.models import SystemSettings # local import to avoid circular deps + + 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 # Already migrated — idempotent no-op + + # Bulk-revoke all active refresh tokens (single raw SQL — no Python iteration) + await session.execute( + text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false") + ) + logging.getLogger(__name__).info( + "ES256 startup rotation: bulk-revoked all active refresh tokens" + ) + + 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 ────────────────────────────────────────────────────────────────── @asynccontextmanager @@ -179,6 +223,18 @@ async def lifespan(app: FastAPI): "AI provider seed skipped (table may not exist yet): %s", _seed_exc ) + # ES256 startup rotation (Phase 7.3 — D-04): + # If jwt_algorithm in system_settings differs from "ES256" (or is absent), + # bulk-revoke all refresh tokens and record the new algorithm. + # Wrapped in try/except so a missing table before migrations doesn't crash. + try: + async with AsyncSessionLocal() as session: + await _rotate_tokens_on_algorithm_change(session) + except Exception as _es256_exc: + logging.getLogger(__name__).warning( + "ES256 rotation check skipped (table may not exist yet): %s", _es256_exc + ) + yield # Shutdown: close pooled connections and Redis diff --git a/backend/tests/test_auth_es256.py b/backend/tests/test_auth_es256.py index feb3e31..93f9a0c 100644 --- a/backend/tests/test_auth_es256.py +++ b/backend/tests/test_auth_es256.py @@ -79,18 +79,143 @@ def test_reset_token_uses_es256(): assert decode_password_reset_token(token) == "u1" -# ── Startup token rotation stubs ────────────────────────────────────────────── +# ── Startup token rotation tests ────────────────────────────────────────────── -@pytest.mark.xfail(strict=True, reason="ES256-04: not yet implemented") @pytest.mark.asyncio async def test_startup_rotation_revokes_tokens(db_session): - pytest.xfail("not yet implemented") + """ES256-04: on first run, all active refresh tokens are bulk-revoked and jwt_algorithm row is upserted.""" + import secrets as _secrets + from main import _rotate_tokens_on_algorithm_change + from db.models import RefreshToken, SystemSettings, User + from sqlalchemy import select + + # Create a minimal user row (required by RefreshToken FK) + user_id = uuid.uuid4() + user = User( + id=user_id, + handle=f"testrotate_{user_id.hex[:8]}", + email=f"testrotate_{user_id.hex[:8]}@example.com", + password_hash="fakehash", + role="user", + is_active=True, + password_must_change=False, + ) + db_session.add(user) + await db_session.flush() + + # Insert two active refresh tokens + now = datetime.now(timezone.utc) + tok1_id = uuid.uuid4() + tok2_id = uuid.uuid4() + tok1 = RefreshToken( + id=tok1_id, + user_id=user_id, + token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), + expires_at=now + timedelta(days=1), + revoked=False, + ) + tok2 = RefreshToken( + id=tok2_id, + user_id=user_id, + token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), + expires_at=now + timedelta(days=1), + revoked=False, + ) + db_session.add(tok1) + db_session.add(tok2) + await db_session.flush() + + # Act: no jwt_algorithm row exists yet + await _rotate_tokens_on_algorithm_change(db_session) + # expire_all() required: raw SQL UPDATE bypasses ORM identity map; expire_on_commit=False + # (set in conftest.py) means SQLAlchemy won't auto-reload stale objects after commit. + db_session.expire_all() + + # Assert: both tokens are now revoked + result = await db_session.execute( + select(RefreshToken).where(RefreshToken.id.in_([tok1_id, tok2_id])) + ) + rows = result.scalars().all() + assert len(rows) == 2 + assert all(r.revoked is True for r in rows) + + # Assert: jwt_algorithm row was created correctly + ss_result = await db_session.execute( + select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") + ) + ss_row = ss_result.scalar_one_or_none() + assert ss_row is not None + assert ss_row.model_name == "ES256" + assert ss_row.is_active is False + assert ss_row.context_chars == 0 -@pytest.mark.xfail(strict=True, reason="ES256-05: not yet implemented") @pytest.mark.asyncio async def test_startup_rotation_idempotent(db_session): - pytest.xfail("not yet implemented") + """ES256-05: when jwt_algorithm row already has model_name=ES256, no tokens are revoked.""" + import secrets as _secrets + from main import _rotate_tokens_on_algorithm_change + from db.models import RefreshToken, SystemSettings, User + from sqlalchemy import select, func + + # Create a user + user_id = uuid.uuid4() + user = User( + id=user_id, + handle=f"testidempotent_{user_id.hex[:8]}", + email=f"testidempotent_{user_id.hex[:8]}@example.com", + password_hash="fakehash", + role="user", + is_active=True, + password_must_change=False, + ) + db_session.add(user) + await db_session.flush() + + # Seed the jwt_algorithm row as already migrated + existing_marker = SystemSettings( + id=uuid.uuid4(), + provider_id="jwt_algorithm", + model_name="ES256", + context_chars=0, + is_active=False, + api_key_enc=None, + base_url=None, + ) + db_session.add(existing_marker) + + # Insert one fresh active token + now = datetime.now(timezone.utc) + tok_id = uuid.uuid4() + tok = RefreshToken( + id=tok_id, + user_id=user_id, + token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), + expires_at=now + timedelta(days=1), + revoked=False, + ) + db_session.add(tok) + await db_session.flush() + + # Act + await _rotate_tokens_on_algorithm_change(db_session) + # expire_all() to clear identity map cache (same reason as test_startup_rotation_revokes_tokens) + db_session.expire_all() + + # Assert: the fresh token was NOT revoked (bulk update did not fire) + result = await db_session.execute( + select(RefreshToken).where(RefreshToken.id == tok_id) + ) + fresh_tok = result.scalar_one_or_none() + assert fresh_tok is not None + assert fresh_tok.revoked is False + + # Assert: still exactly one jwt_algorithm row (no duplicate upsert) + count_result = await db_session.execute( + select(func.count()).where(SystemSettings.provider_id == "jwt_algorithm") + ) + count = count_result.scalar_one() + assert count == 1 # ── Remember-me TTL stubs ───────────────────────────────────────────────────── From 0d1ab05e459b048e27535ffb8a25e70d73a6846a Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 6 Jun 2026 17:03:59 +0200 Subject: [PATCH 3/4] chore(07.3-02): wire JWT env vars in docker-compose, README key-gen, .env.example, bump to 0.1.2 - docker-compose.yml: add JWT_PRIVATE_KEY + JWT_PUBLIC_KEY to backend and celery-worker service env blocks - README.md: add JWT_PRIVATE_KEY/JWT_PUBLIC_KEY to required env vars table; add JWT Key Generation section with Python one-liner and warning about session revocation - .env.example: add JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY= adjacent to SECRET_KEY - backend/main.py: version bump 0.1.1 -> 0.1.2 - frontend/package.json: version bump 0.1.1 -> 0.1.2 --- .env.example | 5 +++++ README.md | 25 +++++++++++++++++++++++++ backend/main.py | 2 +- docker-compose.yml | 4 ++++ frontend/package.json | 2 +- 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 65da9ae..84763b5 100644 --- a/.env.example +++ b/.env.example @@ -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))" SECRET_KEY=CHANGEME-replace-with-64-char-random-hex +# ── JWT Key Pair (Phase 7.3 — ES256) ───────────────────────────────────────── +# 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) ───────────────────────────────────────── # 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. diff --git a/README.md b/README.md index 4a0b5e0..9da0283 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ Copy `.env.example` to `.env`. Only the fields marked **Required** must be set b | Variable | Description | |----------|-------------| | `SECRET_KEY` | JWT signing secret — generate with `openssl rand -hex 32` | +| `JWT_PRIVATE_KEY` | Base64-encoded PEM private key for ES256 JWT signing (required) — see [JWT Key Generation](#jwt-key-generation) | +| `JWT_PUBLIC_KEY` | Base64-encoded PEM public key for ES256 JWT verification (required) — see [JWT Key Generation](#jwt-key-generation) | | `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_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 | | `ONEDRIVE_CLIENT_ID/SECRET` | *(unset)* | Required only if using OneDrive backend | +### JWT Key Generation + +Phase 7.3 uses ES256 (ECDSA P-256) for JWT signing. Unlike HS256, ES256 is asymmetric — the private key signs tokens and the public key verifies them. A leaked public key cannot forge tokens. + +Generate the key pair with this Python one-liner (requires `cryptography`, already in `requirements.txt`): + +```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 diff --git a/backend/main.py b/backend/main.py index 24c65d0..8f7e7c6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -244,7 +244,7 @@ async def lifespan(app: FastAPI): # ── 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) app.state.limiter = auth_limiter diff --git a/docker-compose.yml b/docker-compose.yml index 3c625d7..3ba7f4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,8 @@ services: - MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-localhost:9000} - REDIS_URL=${REDIS_URL} - SECRET_KEY=${SECRET_KEY} + - JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY} + - JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY} - ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173} @@ -101,6 +103,8 @@ services: - REDIS_URL=${REDIS_URL} - CLOUD_CREDS_KEY=${CLOUD_CREDS_KEY} - SECRET_KEY=${SECRET_KEY} + - JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY} + - JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY} - PYTHONDONTWRITEBYTECODE=1 - PYTHONPATH=/app labels: diff --git a/frontend/package.json b/frontend/package.json index 91a6092..b18eb21 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "document-scanner-frontend", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", From e7e3f527a5d5ca578944493a387f1fdb8122a820 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 6 Jun 2026 17:05:31 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(07.3-02):=20complete=20plan=2002=20SUM?= =?UTF-8?q?MARY=20=E2=80=94=20ES256=20core=20upgrade=20+=20startup=20rotat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 tests promoted from xfail to passing (ES256-01..05 + CFG-01); 3 remain xfail for Plan 03 --- .../07.3-02-SUMMARY.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-SUMMARY.md diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-SUMMARY.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-SUMMARY.md new file mode 100644 index 0000000..19acf39 --- /dev/null +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-SUMMARY.md @@ -0,0 +1,188 @@ +--- +phase: 07.3-security-es256-algorithm-upgrade-inserted +plan: "02" +subsystem: backend/auth +tags: + - security + - jwt + - es256 + - algorithm-upgrade + - startup-hook +dependency_graph: + requires: + - 07.3-01 + provides: + - ES256 JWT signing at all 4 token sites + - Startup token rotation hook (idempotent) + - Operator key generation documentation + affects: + - backend/config.py + - backend/services/auth.py + - backend/main.py + - docker-compose.yml + - README.md + - .env.example + - frontend/package.json +tech_stack: + added: [] + patterns: + - "base64.b64decode(settings.jwt_*_key).decode() inline at each JWT call site" + - "Idempotent startup SystemSettings upsert pattern (matching ai_config.py seed)" + - "Raw SQL bulk UPDATE inside helper with session.commit() — no Python iteration" +key_files: + created: [] + modified: + - path: backend/config.py + lines: "35-38" + note: "Added refresh_token_expire_hours=16, jwt_private_key='', jwt_public_key=''" + - path: backend/services/auth.py + lines: "20,100-101,109-110,133-134,142-143" + note: "Added import base64; 4 JWT sites swapped from HS256+secret_key to ES256+inline PEM decode" + - path: backend/main.py + lines: "1-3,133-175,225-232,295" + note: "Added import logging + select; _rotate_tokens_on_algorithm_change helper; lifespan call with try/except; version 0.1.1 -> 0.1.2" + - path: backend/tests/test_auth_es256.py + lines: "39-95,109-239,244-249" + note: "Promoted 6 tests from xfail to passing (ES256-01..05 + CFG-01)" + - path: docker-compose.yml + lines: "66-67,105-106" + note: "Added JWT_PRIVATE_KEY + JWT_PUBLIC_KEY env injection to backend and celery-worker" + - path: README.md + lines: "143-144,162-181" + note: "Added JWT key vars to env table; added JWT Key Generation section with Python one-liner" + - path: .env.example + lines: "33-37" + note: "Added JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY= with section header" + - path: frontend/package.json + lines: "3" + note: "Version bump 0.1.1 -> 0.1.2" +decisions: + - "Inline PEM decode at each call site (base64.b64decode inline) — no module-level PEM cache per RESEARCH.md Anti-Pattern; prevents leaked PEM via reload" + - "expire_all() in tests after _rotate_tokens_on_algorithm_change — conftest uses expire_on_commit=False; raw SQL UPDATE bypasses ORM identity map so session must be expired manually before re-query" + - "is_active=False on jwt_algorithm SystemSettings row — prevents AI provider loader from returning the metadata marker row" +metrics: + duration: "~25 minutes" + completed: "2026-06-06" + tasks_completed: 3 + files_modified: 8 +--- + +# Phase 07.3 Plan 02: ES256 Core Upgrade — JWT Sites + Startup Rotation + Operator Wiring Summary + +ES256 algorithm upgrade: all 4 JWT signing/decoding sites use ECDSA P-256 with inline base64-decoded PEM keys, with idempotent startup bulk-revocation hook and full operator documentation. + +--- + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | Add JWT key + TTL settings; swap all 4 JWT sites to ES256; remove secret_key from JWT code | fd3f611 | backend/config.py, backend/services/auth.py, backend/tests/test_auth_es256.py | +| 2 | Add lifespan _rotate_tokens_on_algorithm_change hook + promote startup rotation tests | 8d261b0 | backend/main.py, backend/tests/test_auth_es256.py | +| 3 | Wire JWT key env vars through docker-compose, README key-gen snippet, .env.example, version bump | 0d1ab05 | docker-compose.yml, README.md, .env.example, backend/main.py, frontend/package.json | + +--- + +## Grep Gate Results (Acceptance Criteria Verified) + +| Gate | Expected | Actual | Status | +|------|----------|--------|--------| +| `settings.secret_key` in services/auth.py | 0 | 0 | PASS | +| `algorithm="ES256"` in services/auth.py | 2 | 2 | PASS | +| `algorithms=["ES256"]` in services/auth.py | 2 | 2 | PASS | +| `"HS256"` in services/auth.py | 0 | 0 | PASS | +| `base64.b64decode(settings.jwt_` in services/auth.py | 4 | 4 | PASS | +| `import base64` in services/auth.py | 1 | 1 | PASS | +| `refresh_token_expire_hours: int = 16` in config.py | 1 | 1 | PASS | +| `jwt_private_key: str = ""` in config.py | 1 | 1 | PASS | +| `jwt_public_key: str = ""` in config.py | 1 | 1 | PASS | +| `async def _rotate_tokens_on_algorithm_change` in main.py | 1 | 1 | PASS | +| `await _rotate_tokens_on_algorithm_change(session)` in main.py | 1 | 1 | PASS | +| `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` in main.py | 1 | 1 | PASS | +| `provider_id == "jwt_algorithm"` in main.py | 1 | 1 | PASS | +| `is_active=False` in main.py | >= 1 | 2 | PASS | +| `ES256 rotation check skipped` in main.py | 1 | 1 | PASS | +| `JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}` in docker-compose.yml | 2 | 2 | PASS | +| `JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}` in docker-compose.yml | 2 | 2 | PASS | +| Literal base64 PEM in docker-compose.yml | 0 | 0 | PASS | +| `ec.generate_private_key(ec.SECP256R1())` in README.md | >= 1 | 1 | PASS | +| `Rotating these keys invalidates every active session` in README.md | 1 | 1 | PASS | +| `JWT_PRIVATE_KEY=` in .env.example (no value) | present | present | PASS | +| `JWT_PUBLIC_KEY=` in .env.example (no value) | present | present | PASS | +| `version="0.1.2"` in backend/main.py | 1 | 1 | PASS | +| `"version": "0.1.2"` in frontend/package.json | 1 | 1 | PASS | +| `docker compose config -q` exits 0 | 0 | 0 | PASS | + +--- + +## Promoted Tests (xfail → PASSED) + +| Test | Requirement | Before | After | +|------|-------------|--------|-------| +| test_access_token_uses_es256 | ES256-01 | XFAIL (strict) | PASSED | +| test_hs256_token_rejected | ES256-02 | XFAIL (strict) | PASSED | +| test_reset_token_uses_es256 | ES256-03 | XFAIL (strict) | PASSED | +| test_startup_rotation_revokes_tokens | ES256-04 | XFAIL (strict) | PASSED | +| test_startup_rotation_idempotent | ES256-05 | XFAIL (strict) | PASSED | +| test_settings_has_jwt_keys | CFG-01 | XFAIL (strict) | PASSED | + +**Net suite delta:** `+6 PASSED`. Final count: `6 PASSED + 3 XFAILED (RM-01..03 remain for Plan 03)`. +`test_task1_models_config.py::test_settings_has_jwt_config` also passes (previously failing due to missing fields). + +--- + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] SQLAlchemy identity map returns stale values after raw SQL UPDATE with expire_on_commit=False** + +- **Found during:** Task 2 test execution +- **Issue:** `conftest.py` creates the test `AsyncTestSession` with `expire_on_commit=False`. After `_rotate_tokens_on_algorithm_change` runs a raw SQL `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` followed by `session.commit()`, SQLAlchemy's identity map still holds the pre-commit stale state of `RefreshToken` objects (revoked=False). Re-querying via `select()` returned the stale cached object instead of issuing a fresh DB query. +- **Fix:** Added `db_session.expire_all()` in both startup rotation tests immediately after `await _rotate_tokens_on_algorithm_change(db_session)`. This invalidates the identity map cache, forcing SQLAlchemy to re-fetch from DB on the subsequent `select()`. Production code is unchanged — the issue is test isolation specific to `expire_on_commit=False`. +- **Files modified:** `backend/tests/test_auth_es256.py` +- **Commit:** 8d261b0 + +--- + +## Remaining XFAIL Tests (Plan 03) + +| Test | Requirement | Reason | +|------|-------------|--------| +| test_default_ttl_16_hours | RM-01 | remember_me param not yet added to create_refresh_token | +| test_remember_me_ttl_30_days | RM-02 | same | +| test_remember_me_cookie_max_age | RM-03 | _set_refresh_cookie + LoginView.vue changes deferred to Plan 03 | + +--- + +## Operator Handoff + +Operators must generate a P-256 key pair before the backend can sign or verify JWTs: + +```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 `.env`. On next `docker compose up`, the startup rotation hook will bulk-revoke all existing refresh tokens (forcing all users to re-login) and record the ES256 migration marker in `system_settings`. Subsequent boots are idempotent. + +--- + +## Self-Check: PASSED + +- `backend/config.py` exists and contains the three new fields: FOUND +- `backend/services/auth.py` uses ES256 at all 4 sites, zero HS256/secret_key references: FOUND +- `backend/main.py` contains `_rotate_tokens_on_algorithm_change` at module scope: FOUND +- `docker-compose.yml` has JWT_PRIVATE_KEY and JWT_PUBLIC_KEY in both services: FOUND +- `README.md` contains key generation snippet: FOUND +- `.env.example` contains JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY=: FOUND +- Commits fd3f611, 8d261b0, 0d1ab05 all exist: FOUND +- Test suite: 6 PASSED + 3 XFAILED, 0 FAILED: CONFIRMED