docs(07.3): create phase plan — ES256 algorithm upgrade
3 plans (3 waves): Wave 0 xfail stubs, Wave 1 ES256 core + startup rotation, Wave 2 remember-me TTL split + LoginView checkbox. Verification passed (0 blockers, 1 minor doc warning fixed). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7ee87c001b
commit
b7994efd06
@@ -0,0 +1,413 @@
|
||||
---
|
||||
phase: 07.3-security-es256-algorithm-upgrade-inserted
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on:
|
||||
- 07.3-01
|
||||
files_modified:
|
||||
- backend/config.py
|
||||
- backend/services/auth.py
|
||||
- backend/main.py
|
||||
- backend/tests/test_auth_es256.py
|
||||
- docker-compose.yml
|
||||
- README.md
|
||||
- .env.example
|
||||
- frontend/package.json
|
||||
autonomous: true
|
||||
requirements:
|
||||
- ES256-01
|
||||
- ES256-02
|
||||
- ES256-03
|
||||
- ES256-04
|
||||
- ES256-05
|
||||
- CFG-01
|
||||
|
||||
user_setup:
|
||||
- service: jwt-keys
|
||||
why: "ES256 requires a P-256 keypair that operators MUST generate locally and place in .env before the backend can start"
|
||||
env_vars:
|
||||
- name: JWT_PRIVATE_KEY
|
||||
source: "Run the Python one-liner documented in README.md JWT Key Generation section"
|
||||
- name: JWT_PUBLIC_KEY
|
||||
source: "Output of the same one-liner"
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every JWT signed by the backend uses ES256 (verified by inspecting the alg header)"
|
||||
- "An HS256 token presented to decode_access_token raises ValueError (401 at the API layer)"
|
||||
- "settings.secret_key is no longer referenced by any function in services/auth.py for JWT operations"
|
||||
- "On first boot after upgrade, all active refresh_tokens rows have revoked=true"
|
||||
- "On second boot with no algorithm change, the bulk-revoke is skipped (idempotent)"
|
||||
- "Operators can generate the keypair with a single Python command from README.md"
|
||||
artifacts:
|
||||
- path: "backend/config.py"
|
||||
provides: "jwt_private_key, jwt_public_key, refresh_token_expire_hours settings"
|
||||
contains: "refresh_token_expire_hours"
|
||||
- path: "backend/services/auth.py"
|
||||
provides: "ES256 signing + verification at all 4 token sites"
|
||||
contains: "algorithm=\"ES256\""
|
||||
- path: "backend/main.py"
|
||||
provides: "_rotate_tokens_on_algorithm_change lifespan hook"
|
||||
contains: "_rotate_tokens_on_algorithm_change"
|
||||
- path: "docker-compose.yml"
|
||||
provides: "JWT_PRIVATE_KEY + JWT_PUBLIC_KEY env injection for backend + celery-worker services"
|
||||
contains: "JWT_PRIVATE_KEY"
|
||||
- path: "README.md"
|
||||
provides: "Operator key-generation snippet"
|
||||
contains: "JWT_PRIVATE_KEY"
|
||||
key_links:
|
||||
- from: "backend/services/auth.py"
|
||||
to: "backend/config.py"
|
||||
via: "base64.b64decode(settings.jwt_private_key).decode() inline at each signing site"
|
||||
pattern: "base64.b64decode\\(settings.jwt_(private|public)_key\\)"
|
||||
- from: "backend/main.py lifespan"
|
||||
to: "backend/db/models.py SystemSettings + RefreshToken"
|
||||
via: "select(SystemSettings) where provider_id=='jwt_algorithm' then text bulk UPDATE"
|
||||
pattern: "jwt_algorithm"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Land the core ES256 algorithm upgrade and the startup token rotation hook. After this plan:
|
||||
|
||||
1. backend/config.py exposes jwt_private_key, jwt_public_key, refresh_token_expire_hours (D-01, D-09).
|
||||
2. All 4 JWT signing/verification sites in backend/services/auth.py use ES256 with the private/public PEM derived from base64-encoded env vars (D-02, D-03).
|
||||
3. settings.secret_key is removed from every JWT call (D-03) — services/auth.py does not reference it for signing anywhere.
|
||||
4. backend/main.py lifespan runs _rotate_tokens_on_algorithm_change after the AI seed block: reads the jwt_algorithm marker row in system_settings, bulk-revokes all active refresh tokens via raw SQL UPDATE when the algorithm differs from "ES256", and upserts the marker (D-04, D-05) — idempotent on repeat boots.
|
||||
5. docker-compose.yml passes JWT_PRIVATE_KEY and JWT_PUBLIC_KEY to both backend and celery-worker services (D-07).
|
||||
6. README.md contains the cryptography-based Python one-liner that prints two base64-encoded PEM lines ready to paste into .env (D-06).
|
||||
7. .env.example documents the two new variables (no real values).
|
||||
|
||||
Tests promoted from Plan 01 to passing: ES256-01, ES256-02, ES256-03, ES256-04, ES256-05, CFG-01.
|
||||
|
||||
Purpose: Asymmetric JWT signing — a leaked public key cannot forge tokens. Closes the HS256 downgrade concern in CONCERNS.md.
|
||||
Output: Code changes above + 6 promoted tests, all in one phase boundary commit.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
||||
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md
|
||||
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-01-PLAN.md
|
||||
@backend/config.py
|
||||
@backend/services/auth.py
|
||||
@backend/main.py
|
||||
@backend/services/ai_config.py
|
||||
@backend/db/models.py
|
||||
@backend/tests/test_auth_es256.py
|
||||
@docker-compose.yml
|
||||
@README.md
|
||||
|
||||
<interfaces>
|
||||
Critical contracts the executor needs. Extracted from codebase.
|
||||
|
||||
From backend/services/auth.py (current — lines 86, 99, 102, 109, 120, 132, 135, 141, 154-172):
|
||||
- def create_access_token(user_id: str, role: str) -> str — currently jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
||||
- def decode_access_token(token: str) -> dict — currently jwt.decode(token, settings.secret_key, algorithms=["HS256"]); preserves typ == "access" check
|
||||
- def create_password_reset_token(user_id: str) -> str — currently HS256
|
||||
- def decode_password_reset_token(token: str) -> str — currently HS256; returns sub claim as str
|
||||
- async def create_refresh_token(session, user_id) -> str — currently timedelta(days=settings.refresh_token_expire_days); signature UNCHANGED in this plan (Plan 03 adds remember_me)
|
||||
- async def rotate_refresh_token(session, raw_token) -> str (line ~214) — internally calls create_refresh_token(session, row.user_id) with no remember_me; left untouched in this plan
|
||||
- Phase 7.2 adds jti claim to access token payload — preserved verbatim by PyJWT through ES256 (Assumption A3)
|
||||
|
||||
From backend/config.py (current Settings class JWT block — lines 31-35):
|
||||
- secret_key: str = "CHANGEME" — KEPT (Phase 7.4 fingerprinting may use it)
|
||||
- access_token_expire_minutes: int = 15 — KEPT
|
||||
- refresh_token_expire_days: int = 30 — KEPT (used by remember_me opt-in in Plan 03)
|
||||
|
||||
From backend/db/models.py (lines 340-376):
|
||||
- class SystemSettings(Base) — columns: id (UUID), provider_id (str, unique, NOT NULL), api_key_enc (Text nullable), base_url (Text nullable), model_name (Text NOT NULL default=""), context_chars (Integer NOT NULL default=8000), is_active (Boolean NOT NULL default=False), created_at, updated_at
|
||||
- class RefreshToken(Base) line 87 — column revoked: Mapped[bool] line 102; bulk UPDATE target
|
||||
|
||||
From backend/main.py (lifespan — lines 135-186):
|
||||
- @asynccontextmanager async def lifespan(app) — existing structure: MinIO bucket init → Redis init → admin bootstrap → seed_system_settings_from_env (wrapped in try/except for missing table) → yield → shutdown
|
||||
- Existing try/except wrapper pattern at lines 172-180 (copy this pattern for the ES256 block)
|
||||
- Existing import (line 15): from sqlalchemy import text (already imported — also add select to the same import line if not present)
|
||||
- from services.ai_config import seed_system_settings_from_env (line 25) — sibling import; new helper lives in main.py alongside lifespan
|
||||
|
||||
From backend/services/ai_config.py:seed_system_settings_from_env (lines 201-244):
|
||||
- Reference upsert pattern: select(SystemSettings).where(SystemSettings.provider_id == provider_id) → result.scalar_one_or_none() → insert or update → caller commits
|
||||
|
||||
From docker-compose.yml:
|
||||
- backend service injects SECRET_KEY=${SECRET_KEY} at line ~64
|
||||
- celery-worker service injects same at line ~103
|
||||
- Pattern: NAME=${NAME} — placeholders resolved from operator's .env
|
||||
|
||||
From cryptography library (already in requirements.txt):
|
||||
- from cryptography.hazmat.primitives.asymmetric import ec
|
||||
- from cryptography.hazmat.primitives import serialization
|
||||
- ec.generate_private_key(ec.SECP256R1())
|
||||
- serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add JWT key + TTL settings; swap all 4 JWT sites to ES256; remove secret_key from JWT code</name>
|
||||
<files>backend/config.py, backend/services/auth.py, backend/tests/test_auth_es256.py</files>
|
||||
<read_first>
|
||||
backend/config.py
|
||||
backend/services/auth.py
|
||||
backend/tests/test_auth_es256.py
|
||||
backend/tests/test_task1_models_config.py
|
||||
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||
</read_first>
|
||||
<behavior>
|
||||
- test_settings_has_jwt_config (existing): asserts refresh_token_expire_hours == 16, jwt_private_key/jwt_public_key fields present → must pass after config change
|
||||
- test_access_token_uses_es256 (promote from xfail): create_access_token('u1','user') → decode header → header['alg'] == 'ES256'
|
||||
- test_hs256_token_rejected (promote): construct an HS256 JWT signed with any HMAC secret with payload {sub: u1, typ: access, exp: future}; calling decode_access_token(that_token) raises ValueError (PyJWT InvalidAlgorithmError mapped to ValueError per existing handler)
|
||||
- test_reset_token_uses_es256 (promote): create_password_reset_token('u1') → decode header → header['alg'] == 'ES256'; decode_password_reset_token round-trips and returns 'u1'
|
||||
- test_settings_has_jwt_keys (promote): assert hasattr settings for jwt_private_key, jwt_public_key, refresh_token_expire_hours; assert refresh_token_expire_hours == 16
|
||||
</behavior>
|
||||
<action>
|
||||
Step 1 — backend/config.py: Inside the existing Settings class, immediately after the line refresh_token_expire_days: int = 30, add THREE new fields (in this order, with these exact identifiers and defaults):
|
||||
- refresh_token_expire_hours: int = 16 per D-09 — default short session; keep refresh_token_expire_days: int = 30 untouched
|
||||
- jwt_private_key: str = "" per D-01 — base64-encoded PEM; required at runtime, defaulted to empty string so import does not crash if env var missing
|
||||
- jwt_public_key: str = "" per D-01 — base64-encoded PEM; same default rationale
|
||||
|
||||
DO NOT add a method on Settings. DO NOT remove secret_key. Leave a one-line comment block above the new fields citing D-01 and D-09. The pydantic-settings auto-load picks up JWT_PRIVATE_KEY and JWT_PUBLIC_KEY env vars automatically by field name (uppercased) — no Field(alias=...) needed.
|
||||
|
||||
Step 2 — backend/services/auth.py: Add import base64 to the existing standard-library imports block (line 18-26 area; place it alphabetically among hashlib/hmac/secrets). Do NOT remove any existing import. Verify import jwt and from config import settings are still present.
|
||||
|
||||
Step 3 — backend/services/auth.py: Replace create_access_token (line 86 area) so that the existing payload construction is unchanged but the final line becomes:
|
||||
- decode key inline: private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||
- return jwt.encode(payload, private_pem, algorithm="ES256")
|
||||
Per D-02 / D-03. Do NOT cache private_pem as a module-level global (Anti-Pattern in RESEARCH.md). Phase 7.2's jti claim (already in payload) is preserved verbatim.
|
||||
|
||||
Step 4 — backend/services/auth.py: Replace decode_access_token (line 102 area) so that inside the existing try block:
|
||||
- public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||
- payload = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||
Per D-02. Keep the existing except jwt.ExpiredSignatureError / except jwt.PyJWTError clauses and the typ != "access" ValueError check EXACTLY as-is. The algorithms=["ES256"] whitelist is what makes HS256 tokens raise InvalidAlgorithmError → caught by existing PyJWTError handler → ValueError → 401 at API layer (per existing dependency).
|
||||
|
||||
Step 5 — backend/services/auth.py: Replace create_password_reset_token (line 120 area) — same pattern as Step 3: decode jwt_private_key inline, sign with algorithm="ES256". Per D-02.
|
||||
|
||||
Step 6 — backend/services/auth.py: Replace decode_password_reset_token (line 135 area) — same pattern as Step 4: decode jwt_public_key inline, verify with algorithms=["ES256"]. Keep the existing typ != "password_reset" check and the return payload["sub"] line untouched.
|
||||
|
||||
Step 7 — backend/services/auth.py: Verify that no other function in this file references settings.secret_key for jwt.encode or jwt.decode purposes. There MUST be zero hits for settings.secret_key in this file after the change (grep gate in acceptance_criteria). Per D-03. If grep finds any non-JWT use of settings.secret_key in services/auth.py, surface it in the SUMMARY (none currently expected — verified during planning).
|
||||
|
||||
Step 8 — backend/tests/test_auth_es256.py: Promote five tests by REMOVING the @pytest.mark.xfail(strict=True, ...) decorator from each AND replacing the pytest.xfail body with real assertions:
|
||||
|
||||
test_access_token_uses_es256: lazy import from services.auth import create_access_token; call with ('u1', 'user'); split the returned token on '.'; base64.urlsafe_b64decode the first segment with appropriate '=' padding; json.loads; assert header['alg'] == 'ES256'.
|
||||
|
||||
test_hs256_token_rejected: lazy import jwt, from services.auth import decode_access_token. Construct an HS256 token: jwt.encode({"sub": "u1", "typ": "access", "exp": int(time.time()) + 60, "iat": int(time.time())}, "any-hs256-secret", algorithm="HS256"). Call decode_access_token(token) inside with pytest.raises(ValueError):.
|
||||
|
||||
test_reset_token_uses_es256: lazy import from services.auth import create_password_reset_token, decode_password_reset_token; call with 'u1'; header alg assertion as above; assert decode_password_reset_token(token) == 'u1'.
|
||||
|
||||
test_settings_has_jwt_keys: lazy import from config import settings; assert hasattr for jwt_private_key, jwt_public_key, refresh_token_expire_hours; assert settings.refresh_token_expire_hours == 16.
|
||||
|
||||
Plan 01's test_settings_has_jwt_config extension in test_task1_models_config.py also flips green automatically once the three new config fields exist.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && pytest tests/test_auth_es256.py::test_access_token_uses_es256 tests/test_auth_es256.py::test_hs256_token_rejected tests/test_auth_es256.py::test_reset_token_uses_es256 tests/test_auth_es256.py::test_settings_has_jwt_keys tests/test_task1_models_config.py::test_settings_has_jwt_config -x -v</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -v '^#' backend/services/auth.py | grep -c 'settings.secret_key' returns 0 (D-03 enforced — no JWT code references secret_key)
|
||||
- grep -c 'algorithm="ES256"' backend/services/auth.py returns 2 (two encode sites: access + reset)
|
||||
- grep -c 'algorithms=\["ES256"\]' backend/services/auth.py returns 2 (two decode sites: access + reset)
|
||||
- grep -c '"HS256"' backend/services/auth.py returns 0 (no leftover HS256 string anywhere in the file)
|
||||
- grep -c 'base64.b64decode(settings.jwt_' backend/services/auth.py returns 4 (one decode per signing site)
|
||||
- grep -c 'import base64' backend/services/auth.py returns 1
|
||||
- backend/config.py contains 'refresh_token_expire_hours: int = 16' exactly once
|
||||
- backend/config.py contains 'jwt_private_key: str = ""' and 'jwt_public_key: str = ""' exactly once each
|
||||
- pytest tests/test_auth_es256.py::test_access_token_uses_es256 -x exits 0 (PASSED)
|
||||
- pytest tests/test_auth_es256.py::test_hs256_token_rejected -x exits 0 (PASSED)
|
||||
- pytest tests/test_auth_es256.py::test_reset_token_uses_es256 -x exits 0 (PASSED)
|
||||
- pytest tests/test_auth_es256.py::test_settings_has_jwt_keys -x exits 0 (PASSED)
|
||||
- pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x exits 0 (PASSED — confirms refresh_token_expire_hours == 16 and JWT key fields exist)
|
||||
- The five promoted tests no longer carry @pytest.mark.xfail decorators (grep -B1 'def test_(access_token_uses_es256|hs256_token_rejected|reset_token_uses_es256|settings_has_jwt_keys)' backend/tests/test_auth_es256.py shows no xfail decorator above each def)
|
||||
- Phase 7.2's jti claim still present in access tokens (verified implicitly by the full suite passing — existing Phase 7.2 jti tests still green)
|
||||
</acceptance_criteria>
|
||||
<done>Config exposes the three new fields; all four JWT sites use ES256; secret_key reference count in services/auth.py is 0; five ES256/CFG-01 tests pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Add lifespan _rotate_tokens_on_algorithm_change hook + promote startup rotation tests</name>
|
||||
<files>backend/main.py, backend/tests/test_auth_es256.py</files>
|
||||
<read_first>
|
||||
backend/main.py
|
||||
backend/services/ai_config.py
|
||||
backend/db/models.py
|
||||
backend/services/auth.py
|
||||
backend/tests/test_auth_es256.py
|
||||
backend/tests/conftest.py
|
||||
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
||||
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||
</read_first>
|
||||
<behavior>
|
||||
- test_startup_rotation_revokes_tokens (promote): seed two RefreshToken rows with revoked=False + ensure no jwt_algorithm row in system_settings → run _rotate_tokens_on_algorithm_change(db_session) → both tokens now have revoked=True; one new SystemSettings row exists with provider_id='jwt_algorithm', model_name='ES256', is_active=False, context_chars=0
|
||||
- test_startup_rotation_idempotent (promote): seed a jwt_algorithm row with model_name='ES256'; insert one fresh RefreshToken with revoked=False; run _rotate_tokens_on_algorithm_change(db_session); the fresh token's revoked remains False (no bulk update fired); system_settings row count for provider_id='jwt_algorithm' is still 1
|
||||
</behavior>
|
||||
<action>
|
||||
Step 1 — backend/main.py: Update the existing sqlalchemy import line (currently from sqlalchemy import text at line 15) to also import select: from sqlalchemy import select, text. If select is already imported via another line, do not add a duplicate — check first.
|
||||
|
||||
Step 2 — backend/main.py: Add a module-level async helper function _rotate_tokens_on_algorithm_change(session) defined ABOVE the lifespan function (between the existing helpers around line 130 and the @asynccontextmanager decorator at line 135). The function must follow the seed_system_settings_from_env upsert pattern from PATTERNS.md exactly:
|
||||
|
||||
- Docstring: explains "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."
|
||||
- Local imports inside the function body to avoid circular deps: from db.models import SystemSettings
|
||||
- Query: stmt = select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") → result = await session.execute(stmt) → row = result.scalar_one_or_none()
|
||||
- Early return: if row is not None and row.model_name == "ES256": return (idempotent)
|
||||
- Bulk revoke (single raw SQL — never iterate in Python): await session.execute(text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false"))
|
||||
- Upsert: 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)) per Pitfall 3 (model_name NOT NULL satisfied with "ES256"; context_chars NOT NULL satisfied with 0; is_active=False per Anti-Pattern in RESEARCH.md); else row.model_name = "ES256"
|
||||
- Commit: await session.commit() inside the helper (PATTERNS.md Pattern 3 explicitly calls commit inside the helper for self-containment)
|
||||
- Add import uuid at top of main.py if not already imported (check existing imports first)
|
||||
- Log on bulk-revoke: logging.getLogger(__name__).info("ES256 startup rotation: bulk-revoked all active refresh tokens") BEFORE the upsert. Do NOT log on the no-op idempotent path (would spam every boot)
|
||||
|
||||
Step 3 — backend/main.py: Inside the existing lifespan function, AFTER the AI seed try/except block (line 180 area) and BEFORE the yield, add a new try/except block following the EXACT same pattern (Pitfall 6 in RESEARCH.md). The block does:
|
||||
- try: async with AsyncSessionLocal() as session: await _rotate_tokens_on_algorithm_change(session)
|
||||
- except Exception as _es256_exc: log warning "ES256 rotation check skipped (table may not exist yet): %s" with the exception
|
||||
|
||||
The try/except wrap is REQUIRED — fresh containers may not have the system_settings table yet (per Pitfall 6).
|
||||
|
||||
Step 4 — backend/tests/test_auth_es256.py: Promote the two startup-rotation tests by REMOVING the @pytest.mark.xfail decorators and replacing the bodies with real assertions:
|
||||
|
||||
For test_startup_rotation_revokes_tokens(db_session):
|
||||
- Lazy imports: from main import _rotate_tokens_on_algorithm_change, from db.models import RefreshToken, SystemSettings, User, from sqlalchemy import select, import uuid, hashlib, secrets, from datetime import datetime, timezone, timedelta
|
||||
- Set-up: create a User row (minimal valid User — copy fixture pattern from conftest.py auth_user creation), insert two RefreshToken rows with revoked=False, expires_at = now + 1 day, distinct token_hash values; await db_session.flush(). Do not create any system_settings row for jwt_algorithm.
|
||||
- Act: await _rotate_tokens_on_algorithm_change(db_session)
|
||||
- Assert: re-query both RefreshToken rows via select(RefreshToken).where(RefreshToken.id.in_([...])) → both have revoked == True. Query SystemSettings where provider_id == "jwt_algorithm" → exactly one row exists, model_name == "ES256", is_active == False, context_chars == 0.
|
||||
|
||||
For test_startup_rotation_idempotent(db_session):
|
||||
- Set-up: insert a SystemSettings row with provider_id="jwt_algorithm", model_name="ES256", context_chars=0, is_active=False, id=uuid.uuid4(); create a User; insert one RefreshToken with revoked=False; await db_session.flush().
|
||||
- Act: await _rotate_tokens_on_algorithm_change(db_session)
|
||||
- Assert: the RefreshToken row's revoked is STILL False (bulk update did not fire); count of SystemSettings rows where provider_id='jwt_algorithm' is 1 (no duplicate upsert).
|
||||
|
||||
Note: tests use db_session fixture (existing conftest fixture). The helper does its own commit — if conftest.py rolls back the test transaction, assertions on rows still hold within the same session. If isolation conflicts occur, use a fresh AsyncSessionLocal() inside the test instead of altering the production helper.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens tests/test_auth_es256.py::test_startup_rotation_idempotent -x -v</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -c 'async def _rotate_tokens_on_algorithm_change' backend/main.py returns 1
|
||||
- grep -c 'await _rotate_tokens_on_algorithm_change(session)' backend/main.py returns 1 (called from lifespan)
|
||||
- grep -c 'UPDATE refresh_tokens SET revoked = true WHERE revoked = false' backend/main.py returns 1 (raw bulk SQL — no Python iteration)
|
||||
- grep -c "provider_id == \"jwt_algorithm\"" backend/main.py returns 1
|
||||
- grep -c 'is_active=False' backend/main.py returns at least 1 (the upsert — never True for the marker row per Anti-Pattern in RESEARCH.md)
|
||||
- grep -c 'ES256 rotation check skipped' backend/main.py returns 1 (try/except wrap per Pitfall 6)
|
||||
- pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens -x exits 0 (PASSED)
|
||||
- pytest tests/test_auth_es256.py::test_startup_rotation_idempotent -x exits 0 (PASSED)
|
||||
- The two promoted tests no longer carry @pytest.mark.xfail decorators
|
||||
- pytest tests/test_auth_es256.py -v reports 6 PASSED + 3 XFAILED (RM-01..03 remain for Plan 03) — no XPASS, no ERROR, no FAILED
|
||||
- Helper imports SystemSettings lazily inside the function body (no top-level cycle with services or db modules)
|
||||
</acceptance_criteria>
|
||||
<done>Lifespan hook bulk-revokes on algorithm change and is idempotent thereafter; both startup-rotation tests pass; only RM-01..03 stubs remain xfail.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Wire JWT key env vars through docker-compose, README key-gen snippet, .env.example, version bump</name>
|
||||
<files>docker-compose.yml, README.md, .env.example, backend/main.py, frontend/package.json</files>
|
||||
<read_first>
|
||||
docker-compose.yml
|
||||
README.md
|
||||
.env.example
|
||||
backend/main.py
|
||||
frontend/package.json
|
||||
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
||||
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
||||
</read_first>
|
||||
<action>
|
||||
Step 1 — docker-compose.yml: For the backend service environment block (around line 60-65, where SECRET_KEY=${SECRET_KEY} lives), add two new lines immediately after SECRET_KEY=${SECRET_KEY}:
|
||||
- JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
|
||||
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
|
||||
|
||||
Repeat the same two additions for the celery-worker service environment block (around line 99-103, after the existing SECRET_KEY=${SECRET_KEY} there). Celery worker reaches services/auth.create_access_token / create_password_reset_token through email/reset paths and must have the keys available.
|
||||
|
||||
Do NOT add the keys to the MinIO or PostgreSQL service blocks. Do NOT put literal key values in docker-compose.yml (D-07: no placeholders in repo).
|
||||
|
||||
Step 2 — README.md: Locate the environment-variable documentation section (search for "env" or "Environment" headings). If the README has an env-var table similar to a SECRET_KEY row, add two new rows for JWT_PRIVATE_KEY and JWT_PUBLIC_KEY with column values: "base64-encoded PEM private/public key for ES256 JWT signing (required)". If no table exists, add a new subsection under the existing env section.
|
||||
|
||||
Then add a NEW subsection titled "### JWT Key Generation" (match existing README heading style) containing:
|
||||
- One paragraph: Phase 7.3 uses ES256 (ECDSA P-256) and requires a key pair generated locally; the public key is safe to share, the private key never leaves the deployment.
|
||||
- A fenced bash code block containing the EXACT Python one-liner from RESEARCH.md Pattern 5. The one-liner: uses python3 -c with imports for ec, serialization, and base64; generates the P-256 private key with ec.generate_private_key(ec.SECP256R1()); serializes private as PEM PKCS8 NoEncryption then base64.b64encode; serializes public as PEM SubjectPublicKeyInfo then base64.b64encode; prints two lines formatted as JWT_PRIVATE_KEY=<value> and JWT_PUBLIC_KEY=<value>.
|
||||
- One follow-up line: "Paste the two output lines into your .env file at the project root."
|
||||
- One warning callout: "Rotating these keys invalidates every active session — the startup rotation hook will bulk-revoke all refresh tokens on the next boot."
|
||||
|
||||
Step 3 — .env.example: Add two new lines (if the file exists; create it if it does not, copying the existing pattern):
|
||||
- JWT_PRIVATE_KEY=
|
||||
- JWT_PUBLIC_KEY=
|
||||
Place them adjacent to the existing SECRET_KEY= line for discoverability. If .env.example does not exist in the repo, create it with at minimum these two new lines plus a comment header "# Generated by running the Python snippet in README.md JWT Key Generation section".
|
||||
|
||||
Step 4 — version bump per CLAUDE.md: Update backend/main.py FastAPI(...) constructor (line 191 area) from version="0.1.1" to version="0.1.2". Update frontend/package.json version field from 0.1.1 to 0.1.2. Per CLAUDE.md patch-bump rule because Phase 7.3 ships user-facing behavior (new algorithm upgrade and login changes via Plan 03).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c 'JWT_PRIVATE_KEY=' docker-compose.yml; grep -c 'JWT_PUBLIC_KEY=' docker-compose.yml; grep -c 'JWT_PRIVATE_KEY' README.md; grep -c 'ec.generate_private_key(ec.SECP256R1())' README.md; grep -c 'version="0.1.2"' backend/main.py; grep -c '"version": "0.1.2"' frontend/package.json</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -c 'JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}' docker-compose.yml returns 2 (backend service + celery-worker service)
|
||||
- grep -c 'JWT_PUBLIC_KEY=\${JWT_PUBLIC_KEY}' docker-compose.yml returns 2 (backend service + celery-worker service)
|
||||
- docker-compose.yml does NOT contain any literal base64 PEM value (grep -E "JWT_(PRIVATE|PUBLIC)_KEY=[A-Za-z0-9+/]{32,}" docker-compose.yml returns no matches)
|
||||
- README.md contains at least one occurrence of "JWT_PRIVATE_KEY" and the text "ec.generate_private_key(ec.SECP256R1())"
|
||||
- README.md contains the warning "Rotating these keys invalidates every active session"
|
||||
- .env.example contains lines "JWT_PRIVATE_KEY=" and "JWT_PUBLIC_KEY=" (no values after the equals sign)
|
||||
- grep -c 'version="0.1.2"' backend/main.py returns 1 (FastAPI constructor updated)
|
||||
- grep -c '"version": "0.1.2"' frontend/package.json returns 1
|
||||
- docker-compose config -q exits 0 (compose file syntax remains valid; run from project root)
|
||||
</acceptance_criteria>
|
||||
<done>Both backend and celery-worker services receive the two JWT env vars; README documents key generation; .env.example documents the variable names; backend + frontend version bumped to 0.1.2.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| operator host shell → backend container | JWT_PRIVATE_KEY and JWT_PUBLIC_KEY flow as env vars via Docker; private key never leaves the backend container |
|
||||
| client browser → /api/auth/* | All access and reset tokens crossing this boundary are signed with the ES256 private key; clients only see the signed JWT |
|
||||
| backend → PostgreSQL system_settings + refresh_tokens | Startup rotation reads system_settings.jwt_algorithm, performs raw SQL bulk UPDATE on refresh_tokens (parameter-free literal — no injection vector) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07.3-02-01 | Spoofing | Algorithm confusion (HS256 token accepted by ES256 decoder) | mitigate | jwt.decode(token, public_pem, algorithms=["ES256"]) — explicit whitelist raises InvalidAlgorithmError; verified by test_hs256_token_rejected |
|
||||
| T-07.3-02-02 | Spoofing | None-algorithm attack (alg: none header) | mitigate | PyJWT requires algorithms list; "none" never appears in the whitelist; verified implicitly by the algorithms=["ES256"] gate |
|
||||
| T-07.3-02-03 | Information Disclosure | Private key leaked via committed config | mitigate | docker-compose.yml uses ${JWT_PRIVATE_KEY} only — no literal values; .env stays in .gitignore; D-07 explicitly forbids placeholder values in repo |
|
||||
| T-07.3-02-04 | Tampering | Public key swapped at runtime to enable forgery | accept | Operator-controlled env var; outside this phase's scope; key rotation ceremony is a deferred item in CONTEXT.md |
|
||||
| T-07.3-02-05 | Elevation of Privilege | Stale HS256 access tokens remain valid post-deploy | mitigate | Bulk refresh-token revocation forces re-login on next refresh attempt; 15-minute access token TTL self-expires; documented disruption in README.md per Pitfall 2 |
|
||||
| T-07.3-02-06 | Tampering | startup rotation upserts is_active=True row that AI loader picks up | mitigate | is_active=False enforced in code (Anti-Pattern in RESEARCH.md); load_provider_config filters by is_active IS TRUE — never returns the jwt_algorithm marker row |
|
||||
| T-07.3-02-07 | Denial of Service | startup crashes when system_settings table missing on fresh container | mitigate | try/except wrapper around lifespan rotation block (Pitfall 6); logs warning and continues; next boot after migrations completes successfully |
|
||||
| T-07.3-02-08 | Information Disclosure | Decoded PEM cached at module scope leaking via reload | mitigate | base64.b64decode inline at each call site (Anti-Pattern in RESEARCH.md); no module-level globals |
|
||||
| T-07.3-02-SC | Tampering | npm/pip/cargo installs | accept | No new packages introduced; PyJWT and cryptography already pinned in requirements.txt (RESEARCH.md Package Legitimacy Audit confirms slopcheck not required) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- pytest tests/test_auth_es256.py -v reports 6 PASSED + 3 XFAILED (RM-01..03 remain for Plan 03)
|
||||
- pytest tests/test_task1_models_config.py -v all green
|
||||
- Full suite pytest -v shows zero new failures vs Phase 7.2 baseline; 6 net new PASSED relative to Plan 01 baseline
|
||||
- grep -v '^#' backend/services/auth.py | grep -c 'settings.secret_key' returns 0 (D-03 enforced)
|
||||
- grep -c '"HS256"' backend/services/auth.py returns 0
|
||||
- Docker compose config -q exits 0 (yaml still valid)
|
||||
- bandit -r backend/ — zero HIGH severity findings
|
||||
- Manual smoke: docker compose up after pasting freshly generated keys → backend starts → POST /api/auth/login returns 200 with new ES256 token; decoding header confirms alg=ES256
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- backend/config.py exposes refresh_token_expire_hours (=16), jwt_private_key, jwt_public_key
|
||||
- backend/services/auth.py has zero references to settings.secret_key and zero "HS256" strings; four ES256 sites use base64-decoded PEM at call time
|
||||
- backend/main.py defines and calls _rotate_tokens_on_algorithm_change inside lifespan with try/except wrapping
|
||||
- docker-compose.yml passes JWT_PRIVATE_KEY and JWT_PUBLIC_KEY to backend and celery-worker
|
||||
- README.md documents the Python one-liner key generation snippet
|
||||
- .env.example lists JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY=
|
||||
- Version bumped to 0.1.2 in backend/main.py and frontend/package.json
|
||||
- 6 tests promoted from xfail to passing (ES256-01..05 + CFG-01 via test_settings_has_jwt_keys + extended test_settings_has_jwt_config); 3 remain xfail (RM-01..03) for Plan 03
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-SUMMARY.md` documenting:
|
||||
- Files modified with line ranges
|
||||
- Promoted tests (before xfail → after PASSED) and net suite delta
|
||||
- Confirmation of grep gates (zero secret_key/HS256 references in services/auth.py)
|
||||
- Operator handoff: confirm README key-gen snippet runs and prints two base64 lines; .env updated; docker compose up succeeds with rotated tokens
|
||||
- Remaining xfail count and what Plan 03 will promote
|
||||
</output>
|
||||
Reference in New Issue
Block a user