---
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>
