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>
34 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | user_setup | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07.3-security-es256-algorithm-upgrade-inserted | 02 | execute | 1 |
|
|
true |
|
|
|
- backend/config.py exposes jwt_private_key, jwt_public_key, refresh_token_expire_hours (D-01, D-09).
- 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).
- settings.secret_key is removed from every JWT call (D-03) — services/auth.py does not reference it for signing anywhere.
- 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.
- docker-compose.yml passes JWT_PRIVATE_KEY and JWT_PUBLIC_KEY to both backend and celery-worker services (D-07).
- README.md contains the cryptography-based Python one-liner that prints two base64-encoded PEM lines ready to paste into .env (D-06).
- .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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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
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.
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.
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).
<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> |
<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>