From b7994efd06eb578c5e78ca67627c650c1fce2cf1 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Fri, 5 Jun 2026 19:07:25 +0200 Subject: [PATCH] =?UTF-8?q?docs(07.3):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=20ES256=20algorithm=20upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .planning/ROADMAP.md | 18 +- .planning/STATE.md | 16 +- .../07.3-01-PLAN.md | 224 +++++++ .../07.3-02-PLAN.md | 413 +++++++++++++ .../07.3-03-PLAN.md | 338 +++++++++++ .../07.3-PATTERNS.md | 562 ++++++++++++++++++ .../07.3-RESEARCH.md | 13 +- .../07.3-VALIDATION.md | 82 +++ 8 files changed, 1649 insertions(+), 17 deletions(-) create mode 100644 .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-01-PLAN.md create mode 100644 .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-PLAN.md create mode 100644 .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-03-PLAN.md create mode 100644 .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md create mode 100644 .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index d3b12a5..1d65924 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -481,7 +481,21 @@ Before any phase is marked complete, all three gates must pass: **Depends on**: Phase 7.2 **Requirements**: Tracked in `.planning/codebase/CONCERNS.md` §"JWT Algorithm Downgrade: HS256 Instead of ES256" -**Status:** Not planned yet +**Plans**: 3 plans + +**Wave 0** — Test scaffolds (no production code) + +- [ ] 07.3-01-PLAN.md — Wave 0 xfail stubs: test_auth_es256.py (9 stubs covering ES256-01..05 + RM-01..03 + CFG-01 satellite) + extend test_settings_has_jwt_config for refresh_token_expire_hours + +**Wave 1** *(blocked on Wave 0)* — ES256 core + startup rotation + +- [ ] 07.3-02-PLAN.md — config.py jwt_private_key/jwt_public_key/refresh_token_expire_hours + services/auth.py 4 sites to ES256 + main.py _rotate_tokens_on_algorithm_change lifespan hook + docker-compose JWT_PRIVATE_KEY/JWT_PUBLIC_KEY + README key-gen snippet + .env.example + version bump 0.1.2 + +**Wave 2** *(blocked on Wave 1)* — "Remember me" 16h/30d TTL split + +- [ ] 07.3-03-PLAN.md — create_refresh_token remember_me param + LoginRequest.remember_me + _set_refresh_cookie max_age conditional + LoginView.vue "Stay signed in for 30 days" checkbox + stores/auth.js + api/client.js forwarding + human checkpoint + +**Status:** Planned (2026-06-05) --- @@ -511,5 +525,5 @@ Before any phase is marked complete, all three gates must pass: | 7. Redo and optimize LLM integration | 5/5 | Complete | 2026-06-05 | | 7.1. Security: session revocation on privilege change (CR-01..03) | 0/2 | Planned | — | | 7.2. Security: JTI claim + Redis access-token revocation | 0/? | Not planned (INSERTED) | — | -| 7.3. Security: ES256 algorithm upgrade | 0/? | Not planned (INSERTED) | — | +| 7.3. Security: ES256 algorithm upgrade | 0/3 | Planned | — | | 7.4. Security: token fingerprinting / token binding | 0/? | Not planned (INSERTED) | — | diff --git a/.planning/STATE.md b/.planning/STATE.md index f0761c3..5a4893f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,13 +2,13 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: Redo and Optimize LLM Integration -current_phase: 7.1 (complete) -status: completed -last_updated: "2026-06-05T12:42:08.692Z" +current_phase: 07.2 +status: executing +last_updated: "2026-06-05T16:59:58.593Z" progress: total_phases: 7 completed_phases: 4 - total_plans: 14 + total_plans: 19 completed_plans: 14 percent: 57 --- @@ -16,8 +16,8 @@ progress: # Project State **Project:** DocuVault -**Status:** v1.0 Milestone COMPLETE — All 7 phases done -**Current Phase:** 7.1 (complete) +**Status:** Executing Phase 07.2 +**Current Phase:** 07.2 **Last Updated:** 2026-06-05 ## Phase Status @@ -35,10 +35,12 @@ progress: | 7 | Redo and Optimize LLM Integration | ✓ Complete (5/5 plans, UAT 11/11 passed, security gate passed) | | 7.1 | Security: session revocation on privilege change (CR-01..03) | ✓ Complete (2/2 plans, 3 new tests, frontend toasts) | | 7.2 | Security: JTI claim + Redis access-token revocation | ◆ Planned (3 plans, 3 waves) — ready to execute | +| 7.3 | Security: ES256 algorithm upgrade | ◆ Planned (3 plans, 3 waves) — ready to execute | ## Current Position -Phase: 7 (redo-and-optimize-llm-integration) — COMPLETE +Phase: 07.2 (security-jti-claim-redis-access-token-revocation-inserted) — EXECUTING +Plan: 1 of 3 Phase: 7.1 (security-session-revocation-on-privilege-change) — COMPLETE (2/2 plans) **Progress:** [██████████] 100% (v1.0 base complete; Phase 7.1 inserted as urgent follow-up) diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-01-PLAN.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-01-PLAN.md new file mode 100644 index 0000000..131721b --- /dev/null +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-01-PLAN.md @@ -0,0 +1,224 @@ +--- +phase: 07.3-security-es256-algorithm-upgrade-inserted +plan: 01 +type: execute +wave: 0 +depends_on: [] +files_modified: + - backend/tests/test_auth_es256.py + - backend/tests/test_task1_models_config.py +autonomous: true +requirements: + - ES256-01 + - ES256-02 + - ES256-03 + - ES256-04 + - ES256-05 + - RM-01 + - RM-02 + - RM-03 + - CFG-01 + +must_haves: + truths: + - "Every Phase 7.3 verifiable behavior has a failing pytest stub before any production code change" + - "Existing test_settings_has_jwt_config covers the new refresh_token_expire_hours setting" + - "Tests are red (xfail) when run, not silently skipped" + artifacts: + - path: "backend/tests/test_auth_es256.py" + provides: "9 xfail stubs covering ES256-01..05 + RM-01..03" + contains: "test_access_token_uses_es256, test_hs256_token_rejected, test_reset_token_uses_es256, test_startup_rotation_revokes_tokens, test_startup_rotation_idempotent, test_default_ttl_16_hours, test_remember_me_ttl_30_days, test_remember_me_cookie_max_age, es256_keys fixture" + - path: "backend/tests/test_task1_models_config.py" + provides: "Asserts refresh_token_expire_hours == 16 and jwt key fields present (will xfail until Plan 02)" + contains: "refresh_token_expire_hours" + key_links: + - from: "backend/tests/test_auth_es256.py" + to: "backend/services/auth.py + backend/main.py + backend/api/auth.py" + via: "Imports create_access_token / create_password_reset_token / FastAPI test client" + pattern: "from services.auth import create_access_token" +--- + + +Create the Wave 0 Nyquist test scaffold for Phase 7.3 (ES256 + startup rotation + remember_me). Every requirement ID listed in this phase MUST have a failing-on-purpose test stub before any production code changes. This is the test infrastructure that subsequent plans promote from xfail to passing. + +Purpose: Enforce TDD discipline; guarantee every locked decision (D-01..D-12) has an automated check. +Output: One new test file (`test_auth_es256.py`) with 9 xfail stubs + a reusable ES256 key fixture, plus an extension to the existing config test asserting `refresh_token_expire_hours == 16`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 +@backend/tests/test_task1_models_config.py +@backend/tests/test_task2_auth_service.py +@backend/tests/test_auth_api.py +@backend/tests/conftest.py + + + + +From backend/tests/conftest.py: +- pytest_asyncio.fixture `async_client` — httpx.AsyncClient bound to the FastAPI app (used for integration tests against /api/auth/login) +- pytest_asyncio.fixture `db_session` — AsyncSession for direct row inspection (used to assert RefreshToken.expires_at after login) +- pytest_asyncio.fixture `auth_user` — returns dict with `email`, `password`, `id` for a real registered user + +From backend/tests/test_auth_api.py (lines 47-97 area): +- class `FakeRedis` — minimal in-memory stub exposing `get(key)`, `set(key, value, ex=...)`, `delete(key)` used to bypass real Redis during integration tests +- helper `_register(client, email, password)` — registers a user via POST /api/auth/register +- helper `_login(client, email, password, **extra)` — POSTs /api/auth/login and returns the response + +From backend/services/auth.py: +``` +def create_access_token(user_id: str, role: str) -> str # currently HS256 — Plan 02 swaps to ES256 +def create_password_reset_token(user_id: str) -> str # currently HS256 — Plan 02 swaps to ES256 +def decode_access_token(token: str) -> dict # currently HS256 — Plan 02 swaps to ES256 +async def create_refresh_token(session, user_id) -> str # Plan 03 adds remember_me param +``` + +From backend/config.py (current JWT block, lines 33-35): +``` +access_token_expire_minutes: int = 15 +refresh_token_expire_days: int = 30 +# Plan 02 adds: refresh_token_expire_hours: int = 16, jwt_private_key: str = "", jwt_public_key: str = "" +``` + +From cryptography library (already in requirements.txt): +- `ec.generate_private_key(ec.SECP256R1())` — generate P-256 private key +- `serialization.Encoding.PEM`, `serialization.PrivateFormat.PKCS8`, `serialization.PublicFormat.SubjectPublicKeyInfo` — PEM serialization formats + + + + + + + Task 1: Create test_auth_es256.py with 9 xfail stubs + ES256 key fixture + backend/tests/test_auth_es256.py + + backend/tests/test_task2_auth_service.py + backend/tests/test_auth_api.py + backend/tests/conftest.py + .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 + + + Create a new pytest module at backend/tests/test_auth_es256.py. Module docstring: "TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation, and remember_me session TTL — all stubs xfail strict=True until promoted." Module imports: pytest, pytest_asyncio, base64, json, uuid, hashlib, time, datetime (timezone, timedelta), and (lazily inside test bodies where applicable) services.auth and config.settings. + + Define an `autouse=True` module-level fixture `es256_keys(monkeypatch)` that (a) generates a P-256 private key with `ec.generate_private_key(ec.SECP256R1())` from cryptography.hazmat.primitives.asymmetric, (b) serializes private key as PEM PKCS8 NoEncryption then base64.b64encode().decode(), (c) serializes public key as PEM SubjectPublicKeyInfo then base64.b64encode().decode(), (d) monkeypatches `config.settings.jwt_private_key` and `config.settings.jwt_public_key` to these base64 strings. This fixture exists in Wave 0 even though those settings fields do not exist yet — `monkeypatch.setattr` with `raising=False` so it does not error before Plan 02 lands. + + Declare exactly these 9 stubs, each guarded with `@pytest.mark.xfail(strict=True, reason=": not yet implemented")`. Function bodies contain ONLY `pytest.xfail("not yet implemented")` (single-line body) — no test logic. The reason strings and function names MUST match the VALIDATION.md per-task table exactly: + + 1. `test_access_token_uses_es256()` — ES256-01 — sync, no fixtures beyond es256_keys + 2. `test_hs256_token_rejected()` — ES256-02 — sync; will craft HS256 token with PyJWT and pass to decode_access_token + 3. `test_reset_token_uses_es256()` — ES256-03 — sync + 4. `test_startup_rotation_revokes_tokens(db_session)` — ES256-04 — `@pytest.mark.asyncio async def`; uses db_session + 5. `test_startup_rotation_idempotent(db_session)` — ES256-05 — `@pytest.mark.asyncio async def` + 6. `test_default_ttl_16_hours(async_client, db_session, auth_user)` — RM-01 — `@pytest.mark.asyncio async def` + 7. `test_remember_me_ttl_30_days(async_client, db_session, auth_user)` — RM-02 — `@pytest.mark.asyncio async def` + 8. `test_remember_me_cookie_max_age(async_client, auth_user)` — RM-03 — `@pytest.mark.asyncio async def` + 9. `test_settings_has_jwt_keys()` — CFG-01 satellite for jwt_private_key/jwt_public_key presence in settings — sync + + Each stub MUST be reachable by `pytest --collect-only`. Use `strict=True` on xfail so that any premature pass breaks CI and forces explicit promotion. Do NOT import services.auth or main at module top level (it would crash before Plan 02 adds settings fields) — perform imports lazily inside test bodies where needed (already redundant because bodies only call `pytest.xfail`). + + Do not write any assertion code. Do not implement the test bodies — Plans 02 and 03 promote each stub by replacing the `xfail` body with real assertions in the same task that lands the corresponding production change. + + + cd backend && pytest tests/test_auth_es256.py --collect-only -q 2>&1 | grep -E "test_(access_token_uses_es256|hs256_token_rejected|reset_token_uses_es256|startup_rotation_revokes_tokens|startup_rotation_idempotent|default_ttl_16_hours|remember_me_ttl_30_days|remember_me_cookie_max_age|settings_has_jwt_keys)" | wc -l | tr -d ' ' + + + - File backend/tests/test_auth_es256.py exists + - `grep -c "@pytest.mark.xfail(strict=True" backend/tests/test_auth_es256.py` returns 9 + - `grep -c "def es256_keys" backend/tests/test_auth_es256.py` returns 1 + - `grep -c "pytest.xfail" backend/tests/test_auth_es256.py` returns at least 9 (one per stub body) + - `pytest tests/test_auth_es256.py --collect-only -q` lists all 9 tests by name + - `pytest tests/test_auth_es256.py -v` exits 0 with all 9 tests reported as XFAIL (none XPASS, none ERROR) + - File contains zero `assert` statements outside of the xfail body convention (greps `grep -c "^ assert " backend/tests/test_auth_es256.py` returns 0) + - Module top-level imports do NOT include `from services.auth import` (lazy imports only inside test bodies to avoid load-time failure before Plan 02) + + 9 xfail stubs collected, all reported XFAIL on pytest run, fixture defined, no assertions present. + + + + Task 2: Extend test_settings_has_jwt_config for refresh_token_expire_hours + JWT key fields + backend/tests/test_task1_models_config.py + + backend/tests/test_task1_models_config.py + backend/config.py + .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md + + + Append assertions to the existing `test_settings_has_jwt_config` function in backend/tests/test_task1_models_config.py. Do NOT replace existing assertions — keep `assert hasattr(settings, "refresh_token_expire_days")` and `assert settings.refresh_token_expire_days == 30` intact (Pitfall 5 in RESEARCH.md). + + Add the following new assertions inside the same test function (after the existing ones), each on its own line: + - `assert hasattr(settings, "refresh_token_expire_hours")` — CFG-01 + - `assert settings.refresh_token_expire_hours == 16` — CFG-01 / D-09 + - `assert hasattr(settings, "jwt_private_key")` — CFG-01 / D-01 + - `assert hasattr(settings, "jwt_public_key")` — CFG-01 / D-01 + + These assertions will FAIL until Plan 02 adds the three new fields to backend/config.py — that is intentional (Nyquist). Do NOT wrap this test in xfail. The test must hard-fail today and hard-pass after Plan 02. Plan 02 promotes by simply adding the fields; the test then passes automatically without code changes here. + + Do not change any other tests in this file. Do not add new imports beyond what is already present (`from config import settings`). + + + cd backend && grep -c "refresh_token_expire_hours == 16" tests/test_task1_models_config.py + + + - `grep "assert settings.refresh_token_expire_hours == 16" backend/tests/test_task1_models_config.py` returns exactly one match + - `grep "assert hasattr(settings, \"jwt_private_key\")" backend/tests/test_task1_models_config.py` returns exactly one match + - `grep "assert hasattr(settings, \"jwt_public_key\")" backend/tests/test_task1_models_config.py` returns exactly one match + - `grep "assert settings.refresh_token_expire_days == 30" backend/tests/test_task1_models_config.py` still returns one match (existing assertion preserved) + - Running `cd backend && pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x -v` FAILS with AttributeError on `refresh_token_expire_hours` (or on `jwt_private_key`) — this is the expected red-on-purpose state before Plan 02 + - No new top-level imports added to the file beyond what already exists + + Test extended to assert all three new config fields; test fails red until Plan 02 lands the fields. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| pytest runner → backend modules | Test code imports production modules; lazy imports prevent load-time crashes before Plan 02 | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-07.3-01-01 | Tampering | Wave 0 stubs flipping to xpass silently | mitigate | `strict=True` on every `@pytest.mark.xfail` forces CI failure on unexpected pass; promotion is explicit (Plan 02 / Plan 03 must edit the file) | +| T-07.3-01-02 | Spoofing | Test fixture leaking real JWT keys into other tests | mitigate | `es256_keys` fixture uses freshly-generated P-256 keypair per module via `monkeypatch` — auto-reverted after each test; never reads real env vars | +| T-07.3-01-03 | Denial of Service | Module-level import of services.auth crashing before Plan 02 | mitigate | All imports of `services.auth` and `config.settings.jwt_*` are lazy (inside test bodies); module collects cleanly even before Plan 02 | +| T-07.3-01-SC | Tampering | npm/pip/cargo installs | accept | No new packages introduced; PyJWT and cryptography already pinned in requirements.txt (per RESEARCH.md §Package Legitimacy Audit — slopcheck not required) | + + + +- `pytest tests/test_auth_es256.py --collect-only -q` lists exactly 9 tests +- `pytest tests/test_auth_es256.py -v` reports 9 XFAILED, 0 XPASSED, 0 ERROR, 0 PASSED +- `pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x` FAILS (intentional red state) with `AttributeError` on a missing settings field +- Full suite `pytest -v` shows same total + 9 new XFAILED + 1 new FAILED (existing 373 passed becomes 372 passed + 1 failed + 9 xfailed) — confirmed in Plan 02 verify step + + + +- backend/tests/test_auth_es256.py exists with 9 xfail stubs, all strict=True +- es256_keys autouse fixture generates a fresh P-256 keypair and monkeypatches settings.jwt_private_key / jwt_public_key (uses raising=False) +- backend/tests/test_task1_models_config.py::test_settings_has_jwt_config asserts refresh_token_expire_hours == 16 and presence of jwt_private_key / jwt_public_key +- No production source files modified in this plan +- pytest collects all stubs cleanly; collection does not require Plan 02 fields to exist + + + +Create `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-01-SUMMARY.md` documenting: +- Files added/modified +- Test counts before/after (e.g., 373 passed → 372 passed + 1 failed + 9 xfailed) +- Confirmation that no production code was touched +- Promotion plan: which task in Plan 02 / Plan 03 will flip each xfail to a real assertion + diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-PLAN.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-PLAN.md new file mode 100644 index 0000000..569000d --- /dev/null +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + + + + Task 1: Add JWT key + TTL settings; swap all 4 JWT sites to ES256; remove secret_key from JWT code + backend/config.py, backend/services/auth.py, backend/tests/test_auth_es256.py + + 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 + + + - 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 + + + 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. + + + 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 + + + - 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) + + 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. + + + + Task 2: Add lifespan _rotate_tokens_on_algorithm_change hook + promote startup rotation tests + backend/main.py, backend/tests/test_auth_es256.py + + 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 + + + - 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 + + + 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. + + + cd backend && pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens tests/test_auth_es256.py::test_startup_rotation_idempotent -x -v + + + - 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) + + Lifespan hook bulk-revokes on algorithm change and is idempotent thereafter; both startup-rotation tests pass; only RM-01..03 stubs remain xfail. + + + + Task 3: Wire JWT key env vars through docker-compose, README key-gen snippet, .env.example, version bump + docker-compose.yml, README.md, .env.example, backend/main.py, frontend/package.json + + 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 + + + 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= and JWT_PUBLIC_KEY=. + - 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). + + + 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 + + + - 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) + + 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. + + + + + +## 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) | + + + +- 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 + + + +- 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 + + + +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 + diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-03-PLAN.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-03-PLAN.md new file mode 100644 index 0000000..df72ace --- /dev/null +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-03-PLAN.md @@ -0,0 +1,338 @@ +--- +phase: 07.3-security-es256-algorithm-upgrade-inserted +plan: 03 +type: execute +wave: 2 +depends_on: + - 07.3-02 +files_modified: + - backend/services/auth.py + - backend/api/auth.py + - backend/tests/test_auth_es256.py + - frontend/src/api/client.js + - frontend/src/stores/auth.js + - frontend/src/views/auth/LoginView.vue +autonomous: false +requirements: + - RM-01 + - RM-02 + - RM-03 + +must_haves: + truths: + - "Login without remember_me issues a refresh token with TTL = 16 hours (DB expires_at within ~16h)" + - "Login with remember_me=True issues a refresh token with TTL = 30 days (DB expires_at within ~30d)" + - "Login with remember_me=True sets cookie Max-Age = 30 * 86400 = 2592000" + - "Login without remember_me sets cookie Max-Age = 16 * 3600 = 57600" + - "LoginView.vue shows a 'Stay signed in for 30 days' checkbox below the password field; unchecked by default" + - "Checkbox state flows: LoginView ref → authStore.login(options.rememberMe) → api.login body.remember_me → backend LoginRequest.remember_me → create_refresh_token(remember_me=...)" + artifacts: + - path: "backend/services/auth.py" + provides: "create_refresh_token gains remember_me param selecting between hours and days TTL" + contains: "remember_me" + - path: "backend/api/auth.py" + provides: "LoginRequest.remember_me, _set_refresh_cookie remember_me param, login handler threading" + contains: "remember_me" + - path: "frontend/src/views/auth/LoginView.vue" + provides: "Stay-signed-in checkbox and pass-through to authStore.login" + contains: "Stay signed in for 30 days" + - path: "frontend/src/stores/auth.js" + provides: "login() forwards options.rememberMe to api.login body.remember_me" + contains: "remember_me" + - path: "frontend/src/api/client.js" + provides: "api.login accepts and forwards remember_me field" + contains: "remember_me" + key_links: + - from: "frontend/src/views/auth/LoginView.vue" + to: "frontend/src/stores/auth.js" + via: "authStore.login(email, password, { rememberMe: rememberMe.value })" + pattern: "rememberMe: rememberMe.value" + - from: "frontend/src/stores/auth.js" + to: "backend/api/auth.py LoginRequest" + via: "api.login body.remember_me = options.rememberMe ?? false" + pattern: "remember_me: options.rememberMe" + - from: "backend/api/auth.py login handler" + to: "backend/services/auth.py create_refresh_token" + via: "create_refresh_token(session, user.id, remember_me=body.remember_me) + _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)" + pattern: "remember_me=body.remember_me" +--- + + +Ship the "Stay signed in for 30 days" opt-in. After this plan: + +1. backend/services/auth.py:create_refresh_token gains remember_me: bool = False; selects timedelta(hours=settings.refresh_token_expire_hours) by default and timedelta(days=settings.refresh_token_expire_days) when True. (D-09, D-10, D-11) +2. backend/api/auth.py LoginRequest gains remember_me: bool = False. (D-11) +3. backend/api/auth.py _set_refresh_cookie gains remember_me: bool = False and computes max_age accordingly. (D-11) +4. The login handler passes body.remember_me through to both create_refresh_token and _set_refresh_cookie. (D-11) +5. frontend/src/views/auth/LoginView.vue gains a "Stay signed in for 30 days" checkbox; its state flows through submitPassword/submitTotp/submitBackupCode into authStore.login(options.rememberMe). (D-12) +6. frontend/src/stores/auth.js login() forwards options.rememberMe as remember_me into the api.login body. (D-12) +7. frontend/src/api/client.js api.login accepts a body shape with remember_me. (D-12 — implicit; api.login is a thin wrapper today and simply forwards the body, so verify it does not drop the field.) +8. Refresh-token rotation (rotate_refresh_token in services/auth.py) is intentionally NOT updated — rotated sessions revert to the 16-hour default (per Pitfall 4 and Open Question 1 in RESEARCH.md; CONTEXT.md is silent → simpler path chosen). + +Tests promoted from Plan 01 to passing: RM-01, RM-02, RM-03. + +This plan ends the phase. After this plan completes and the human checkpoint passes, Phase 7.3 is shippable. + +Purpose: Bound default session lifetime to a workday; let users explicitly opt into a 30-day session when convenient. +Output: Backend + frontend changes + 3 promoted tests + human verification of the checkbox and cookie Max-Age. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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-02-PLAN.md +@backend/services/auth.py +@backend/api/auth.py +@backend/tests/test_auth_es256.py +@backend/tests/test_auth_api.py +@frontend/src/views/auth/LoginView.vue +@frontend/src/stores/auth.js +@frontend/src/api/client.js + + +From backend/services/auth.py (post Plan 02): +- async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str — Plan 03 changes signature to add remember_me: bool = False as the final keyword arg +- async def rotate_refresh_token(session: AsyncSession, raw_token: str) -> str — internally calls create_refresh_token(session, row.user_id) with no kwargs; LEFT UNTOUCHED in this plan (rotated tokens default to 16h per Pitfall 4) + +From backend/api/auth.py: +- class LoginRequest(BaseModel) (lines 56-60) — email, password, totp_code Optional, backup_code Optional; Plan 03 adds remember_me: bool = False +- def _set_refresh_cookie(response, raw_token) -> None (lines 70-80) — sets refresh_token cookie with httponly, secure, samesite=strict, path=/api/auth/refresh, max_age=settings.refresh_token_expire_days * 86400; Plan 03 changes to (response, raw_token, remember_me=False) and computes max_age conditionally +- Login handler call sites (lines 277-279): + - access_token = auth_service.create_access_token(str(user.id), user.role) + - raw_refresh = await auth_service.create_refresh_token(session, user.id) + - _set_refresh_cookie(response, raw_refresh) +- Refresh handler call site (line 349) — _set_refresh_cookie(response, new_raw); LEFT UNTOUCHED (rotation does not preserve remember_me) + +From frontend/src/api/client.js: +- export function login(body) { return request('/api/auth/login', { method: 'POST', body: JSON.stringify(body) }) } — pass-through; verify it forwards the body without filtering remember_me out + +From frontend/src/stores/auth.js (lines 60-88): +- async function login(email, password, options = {}) → currently passes email, password, totp_code: options.totpCode ?? null, backup_code: options.backupCode ?? null in the api.login body; Plan 03 adds remember_me: options.rememberMe ?? false to the same body + +From frontend/src/views/auth/LoginView.vue: +- Existing form structure: step-based (password step → optional TOTP step → optional backup-code step) +- Existing refs (lines 188-193): email, password, totpInput, backupCodeInput, loading, error +- Existing handlers: + - submitPassword (lines 224-235): calls authStore.login(email.value, password.value) + - submitTotp (lines 237-250): calls authStore.login(email.value, password.value, { totpCode: totpInput.value }) + - submitBackupCode (lines 252-265): calls authStore.login(email.value, password.value, { backupCode: backupCodeInput.value }) + +From backend/tests/test_auth_api.py: +- FakeRedis class (lines ~47-97 area) — in-memory Redis stub for integration tests +- _register and _login helpers — used to register and log in real users via httpx.AsyncClient + + + + + + + Task 1: Backend remember_me — create_refresh_token TTL param + LoginRequest + cookie max_age + handler threading + promote 3 tests + backend/services/auth.py, backend/api/auth.py, backend/tests/test_auth_es256.py + + backend/services/auth.py + backend/api/auth.py + backend/tests/test_auth_es256.py + backend/tests/test_auth_api.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-CONTEXT.md + + + - test_default_ttl_16_hours (promote): POST /api/auth/login with no remember_me field (or remember_me=False) → look up the new RefreshToken row → expires_at - now is in (15.5h, 16.5h) window (allowing test runtime jitter) + - test_remember_me_ttl_30_days (promote): POST /api/auth/login with remember_me=True → look up the new RefreshToken row → expires_at - now is in (29.5d, 30.5d) window + - test_remember_me_cookie_max_age (promote): POST /api/auth/login with remember_me=True → response Set-Cookie header for refresh_token contains Max-Age=2592000 (30 * 86400); default login contains Max-Age=57600 (16 * 3600) + + + Step 1 — backend/services/auth.py: Update create_refresh_token signature from `async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str` to `async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False) -> str`. Per D-11. + + Inside the function body, replace the line currently computing the TTL/expires_at: + - currently: expires_at=now + timedelta(days=settings.refresh_token_expire_days) + - new: insert a local `ttl = timedelta(days=settings.refresh_token_expire_days) if remember_me else timedelta(hours=settings.refresh_token_expire_hours)` BEFORE constructing the RefreshToken row; use `expires_at=now + ttl` in the row constructor. Per D-09 / D-10. + + Do NOT touch rotate_refresh_token (Pitfall 4 acceptance — rotated tokens revert to 16h default). The internal call `await create_refresh_token(session, row.user_id)` continues to omit remember_me → defaults to False → 16h TTL. This is the documented acceptable behavior. + + Step 2 — backend/api/auth.py: In the LoginRequest BaseModel definition (lines 56-60 area), add one new field after backup_code: `remember_me: bool = False`. Per D-11. Default False so existing clients that omit the field receive the short-session behavior. + + Step 3 — backend/api/auth.py: Update _set_refresh_cookie signature (line 70 area) from `def _set_refresh_cookie(response: Response, raw_token: str) -> None` to `def _set_refresh_cookie(response: Response, raw_token: str, remember_me: bool = False) -> None`. Inside the function body, replace `max_age=settings.refresh_token_expire_days * 86400` with a conditional: + - max_age = settings.refresh_token_expire_days * 86400 if remember_me else settings.refresh_token_expire_hours * 3600 + Per D-11 / RM-03. All other cookie attributes (key, value, httponly, secure, samesite, path) remain UNCHANGED. + + Step 4 — backend/api/auth.py: In the login handler (line 277-279 area), thread remember_me through both downstream calls: + - raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me) + - _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me) + Per D-11. + + Step 5 — backend/api/auth.py: Do NOT change the refresh handler call site at line 349. _set_refresh_cookie(response, new_raw) continues to call with default remember_me=False → rotated sessions get the 16h short cookie. This is the documented acceptable trade-off (Pitfall 4; Open Question 1 in RESEARCH.md). + + Step 6 — backend/tests/test_auth_es256.py: Promote the three RM tests by REMOVING the @pytest.mark.xfail decorators and replacing the bodies with real assertions: + + For test_default_ttl_16_hours(async_client, db_session, auth_user): + - Lazy imports: from db.models import RefreshToken, from sqlalchemy import select, from datetime import datetime, timezone, timedelta + - Act: POST /api/auth/login via async_client with body {"email": auth_user["email"], "password": auth_user["password"]} (no remember_me) — handle TOTP/backup-code chain if auth_user is TOTP-enrolled by checking the existing test_auth_api login helpers + - Assert response status == 200; fetch the most-recent RefreshToken for auth_user.id via select(RefreshToken).where(RefreshToken.user_id == uid).order_by(RefreshToken.id.desc()) → row.expires_at - datetime.now(tz=timezone.utc) is in (timedelta(hours=15, minutes=30), timedelta(hours=16, minutes=30)) + + For test_remember_me_ttl_30_days(async_client, db_session, auth_user): + - Same flow; body includes "remember_me": True + - Assert row.expires_at - now is in (timedelta(days=29, hours=23), timedelta(days=30, hours=1)) + + For test_remember_me_cookie_max_age(async_client, auth_user): + - First call: POST /api/auth/login WITHOUT remember_me → response.headers.get_list("set-cookie") (httpx exposes this via headers.get_list or response.cookies) contains a refresh_token cookie with Max-Age=57600 (16*3600) + - Second call: POST /api/auth/login with remember_me=True → Set-Cookie Max-Age=2592000 (30*86400) + - If httpx ASGITransport response cookies normalize Max-Age, parse the raw "set-cookie" header text via response.headers.raw or response.headers.get_list("set-cookie") and assert the substring "Max-Age=57600" / "Max-Age=2592000" appears in the matching cookie line + + Use the existing FakeRedis pattern from test_auth_api.py for any rate-limit/Redis dependencies. If auth_user fixture creates a TOTP-enrolled user, follow the same multi-step login the existing test_auth_api login flow uses (post password → use TOTP code via pyotp.TOTP(secret).now()) — verify by reading the existing test_auth_api login fixtures in the read_first list. + + + cd backend && pytest tests/test_auth_es256.py::test_default_ttl_16_hours tests/test_auth_es256.py::test_remember_me_ttl_30_days tests/test_auth_es256.py::test_remember_me_cookie_max_age -x -v + + + - grep -c 'remember_me: bool = False' backend/services/auth.py returns at least 1 (create_refresh_token signature) + - grep -c 'timedelta(hours=settings.refresh_token_expire_hours)' backend/services/auth.py returns 1 (default TTL branch) + - grep -c 'timedelta(days=settings.refresh_token_expire_days)' backend/services/auth.py returns 1 (remember_me TTL branch) + - grep -c 'remember_me: bool = False' backend/api/auth.py returns at least 2 (LoginRequest field + _set_refresh_cookie param) + - grep -c 'remember_me=body.remember_me' backend/api/auth.py returns 2 (login handler passes through to both create_refresh_token and _set_refresh_cookie) + - grep -c 'refresh_token_expire_hours \* 3600' backend/api/auth.py returns 1 (default cookie max_age) + - grep -c 'refresh_token_expire_days \* 86400' backend/api/auth.py returns 1 (remember_me cookie max_age) + - rotate_refresh_token in backend/services/auth.py is UNCHANGED (no remember_me kwarg threading): grep -A20 'async def rotate_refresh_token' backend/services/auth.py | grep -c 'remember_me' returns 0 + - Refresh handler call site at backend/api/auth.py line ~349 is UNCHANGED: the _set_refresh_cookie call there does NOT include a remember_me kwarg (verify by reading surrounding lines) + - pytest tests/test_auth_es256.py::test_default_ttl_16_hours -x exits 0 (PASSED) + - pytest tests/test_auth_es256.py::test_remember_me_ttl_30_days -x exits 0 (PASSED) + - pytest tests/test_auth_es256.py::test_remember_me_cookie_max_age -x exits 0 (PASSED) + - The three promoted tests no longer carry @pytest.mark.xfail decorators + - pytest tests/test_auth_es256.py -v reports 9 PASSED + 0 XFAILED + 0 XPASS + 0 FAILED + + Backend honors remember_me end-to-end through create_refresh_token TTL and cookie Max-Age; all 9 phase tests pass; rotated sessions intentionally default to 16h. + + + + Task 2: Frontend remember_me — checkbox in LoginView, ref threading, store + api.login pass-through + frontend/src/views/auth/LoginView.vue, frontend/src/stores/auth.js, frontend/src/api/client.js + + frontend/src/views/auth/LoginView.vue + frontend/src/stores/auth.js + frontend/src/api/client.js + .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md + .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md + + + Step 1 — frontend/src/api/client.js: Locate the existing export function login(body) (line ~154). Verify it forwards the body verbatim to request('/api/auth/login', { method: 'POST', body: JSON.stringify(body) }) and does NOT filter or remap fields. If the current implementation already passes the full body object through, no change is needed. If it picks specific fields, extend the picked set to include remember_me. Document the file in the SUMMARY even if zero-line-change. + + Step 2 — frontend/src/stores/auth.js: In the login(email, password, options = {}) action (lines 60-88), extend the body passed to api.login to include one new field on the SAME object literal alongside totp_code and backup_code: + - remember_me: options.rememberMe ?? false + Place it after backup_code for symmetry with backend LoginRequest field order. Per D-12. Do NOT change the function signature — options is already optional and additive. + + Step 3 — frontend/src/views/auth/LoginView.vue: Add a new reactive ref alongside the existing form refs (lines 188-193 area): const rememberMe = ref(false). Per D-12. + + Step 4 — frontend/src/views/auth/LoginView.vue: Add a checkbox markup inside the password step form. Insert between the password input block and the error/submit elements. Match existing Tailwind class conventions found elsewhere in this file. Use these exact identifiers: + - input element: v-model="rememberMe", id="remember-me", type="checkbox", class follows existing input styling (Tailwind: "rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" or equivalent matching adjacent inputs) + - label element: for="remember-me", class="text-sm text-gray-600" or whatever the existing label scheme is, text content: "Stay signed in for 30 days" (per CONTEXT.md Specifics, clearer than "Remember me") + - Wrap input + label in a containing div with flex layout: class="flex items-center gap-2" + + Step 5 — frontend/src/views/auth/LoginView.vue: Update the three submit handlers to pass rememberMe through the options object: + - submitPassword (line 228 area): change authStore.login(email.value, password.value) → authStore.login(email.value, password.value, { rememberMe: rememberMe.value }) + - submitTotp (line 241 area): change authStore.login(email.value, password.value, { totpCode: totpInput.value }) → authStore.login(email.value, password.value, { totpCode: totpInput.value, rememberMe: rememberMe.value }) + - submitBackupCode (line 256 area): change authStore.login(email.value, password.value, { backupCode: backupCodeInput.value }) → authStore.login(email.value, password.value, { backupCode: backupCodeInput.value, rememberMe: rememberMe.value }) + + The single ref persists across TOTP/backup-code steps because it lives at component scope; users who tick the checkbox before submitting password do not need to re-tick on the second step. + + Do NOT add the checkbox to the TOTP or backup-code step forms — D-12 specifies the password step only. The state from the first step is carried forward by the ref. + + + grep -c 'remember_me: options.rememberMe' frontend/src/stores/auth.js; grep -c 'Stay signed in for 30 days' frontend/src/views/auth/LoginView.vue; grep -c "v-model=\"rememberMe\"" frontend/src/views/auth/LoginView.vue; grep -c 'rememberMe: rememberMe.value' frontend/src/views/auth/LoginView.vue + + + - grep -c 'remember_me: options.rememberMe ?? false' frontend/src/stores/auth.js returns 1 + - grep -c 'const rememberMe = ref(false)' frontend/src/views/auth/LoginView.vue returns 1 + - grep -c 'Stay signed in for 30 days' frontend/src/views/auth/LoginView.vue returns 1 + - grep -c 'v-model="rememberMe"' frontend/src/views/auth/LoginView.vue returns 1 + - grep -c 'id="remember-me"' frontend/src/views/auth/LoginView.vue returns at least 1 (input + label for) + - grep -c 'rememberMe: rememberMe.value' frontend/src/views/auth/LoginView.vue returns 3 (submitPassword, submitTotp, submitBackupCode) + - frontend/src/api/client.js login(body) function still exists and forwards body fields without filtering (manual verification recorded in SUMMARY): grep -c 'export function login' frontend/src/api/client.js returns 1 + - cd frontend && npm run build exits 0 (Vite build succeeds) + - cd frontend && npm test 2>/dev/null || true — pre-existing test suite still passes (no new test required for plain markup; behavior verified by backend integration tests in Task 1) + + Login form shows the checkbox; ref threads through all three submit paths; store forwards remember_me to backend; build succeeds. + + + + Task 3: Human checkpoint — verify "Stay signed in" UX and cookie Max-Age in browser + + - Backend ES256 JWT signing (Plan 02) + remember_me TTL split (Task 1) + frontend checkbox (Task 2) + - "Stay signed in for 30 days" checkbox on LoginView under the password field, unchecked by default + - Login without checkbox issues a refresh cookie with Max-Age ≈ 57600 (16h) + - Login with checkbox issues a refresh cookie with Max-Age = 2592000 (30d) + + + 1. Start the stack: docker compose up (ensure .env contains freshly generated JWT_PRIVATE_KEY and JWT_PUBLIC_KEY from the README snippet) + 2. Open http://localhost:5173/login in a private/incognito window + 3. CONFIRM: the password step shows the "Stay signed in for 30 days" checkbox below the password input; the checkbox is unchecked by default; the label text matches exactly + 4. Open DevTools → Network → preserve log + 5. Log in with valid credentials WITHOUT ticking the checkbox; complete TOTP / backup code if prompted + 6. Open DevTools → Application → Cookies → http://localhost:5173 → find the refresh_token cookie; CONFIRM Max-Age is in the range 57000-58000 (≈ 16h, allowing for round-trip timing) + 7. Log out (via the existing logout control); clear cookies + 8. Log in again, this time TICKING the checkbox; CONFIRM the refresh_token cookie Max-Age is 2592000 (30 days) + 9. While still logged in, in DevTools → Network tab, find the POST /api/auth/login response → CONFIRM Set-Cookie header contains "Max-Age=2592000" and the request body JSON contains "remember_me": true + 10. CONFIRM no console errors in the browser DevTools console during the flow + 11. CONFIRM that the existing TOTP / backup-code flow still works after checking the box (the ref persists across steps; the second-step submit also includes remember_me) + + Type "approved" or describe issues observed (checkbox missing, wrong Max-Age, console errors, broken TOTP flow, etc.) + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client form → /api/auth/login | remember_me is a typed Pydantic bool (defaults False) — non-boolean values rejected by FastAPI validation | +| backend → browser cookie store | refresh_token cookie remains httpOnly, Secure, SameSite=Strict regardless of remember_me — only TTL changes | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-07.3-03-01 | Elevation of Privilege | Long-lived remember_me session stolen via cookie theft | mitigate | Cookie remains httpOnly Secure SameSite=Strict; refresh-token family revocation (RFC 9700) on reuse still applies; 30-day TTL is bounded, not infinite | +| T-07.3-03-02 | Spoofing | Client tampers with remember_me to forge a 30-day session despite not opting in | accept | remember_me is operator-visible flag from the same user; no privilege escalation occurs (user always gets their own session); attacker only extending their own session length | +| T-07.3-03-03 | Information Disclosure | Cookie Max-Age leaks the user's "remember me" preference to network observers | accept | TLS protects the cookie in flight; Max-Age is observable only via local DevTools (same trust boundary as the cookie itself) | +| T-07.3-03-04 | Tampering | Frontend ref leaked across components, default value flips to true | mitigate | rememberMe ref is scoped to LoginView.vue setup block; default `ref(false)` enforced by code; checkbox unchecked-by-default confirmed in human checkpoint | +| T-07.3-03-05 | Denial of Service | Refresh handler downgrade — remember_me=True session silently shortened to 16h after rotation | accept | Documented in CONTEXT.md / RESEARCH.md Open Question 1 / Pitfall 4; behavior is conservative (shorter is more secure); future phase can preserve TTL by adding remember_me column to RefreshToken | +| T-07.3-03-SC | Tampering | npm/pip/cargo installs | accept | No new packages introduced; existing PyJWT and cryptography pins unchanged | + + + +- pytest tests/test_auth_es256.py -v reports 9 PASSED + 0 XFAILED (all phase tests green) +- Full suite pytest -v: zero new failures vs Plan 02 baseline; 3 net new PASSED +- bandit -r backend/ — zero HIGH severity findings +- npm audit --audit-level=high — zero high/critical (existing baseline preserved) +- Human checkpoint approved: checkbox visible, default unchecked, two distinct Max-Age values observed in DevTools, TOTP flow still works + + + +- create_refresh_token in backend/services/auth.py accepts remember_me=False keyword and selects between hours and days TTL +- LoginRequest in backend/api/auth.py exposes remember_me: bool = False +- _set_refresh_cookie in backend/api/auth.py honors remember_me and sets max_age accordingly +- rotate_refresh_token and the refresh handler are intentionally NOT updated — rotated sessions revert to 16h default +- LoginView.vue shows the "Stay signed in for 30 days" checkbox; ref threads through all three submit handlers +- stores/auth.js and api/client.js forward remember_me to the backend +- All 9 tests in test_auth_es256.py pass (ES256-01..05, RM-01..03, CFG-01 satellite) +- Human checkpoint approved with documented Max-Age observations + + + +Create `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-03-SUMMARY.md` documenting: +- Files modified with line ranges +- Promoted tests (RM-01, RM-02, RM-03) and final phase test totals (9/9 phase tests green; full suite delta) +- Human-checkpoint observations (default Max-Age, remember_me Max-Age, console clean, TOTP flow OK) +- Note on rotated-session TTL behavior (Pitfall 4 documented, deferred for future preservation work) +- Phase 7.3 ready for /gsd:verify-work + diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md new file mode 100644 index 0000000..c468f8c --- /dev/null +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md @@ -0,0 +1,562 @@ +# Phase 07.3: Security — ES256 Algorithm Upgrade - Pattern Map + +**Mapped:** 2026-06-05 +**Files analyzed:** 7 new/modified files +**Analogs found:** 7 / 7 + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `backend/services/auth.py` | service | request-response | self (4 signing sites + TTL param) | exact — modify in-place | +| `backend/config.py` | config | — | self (add 3 fields) | exact — modify in-place | +| `backend/main.py` | config/startup | event-driven | `backend/services/ai_config.py` (`seed_system_settings_from_env`) | exact — same startup-hook + upsert pattern | +| `backend/api/auth.py` | controller | request-response | self (`LoginRequest` + `_set_refresh_cookie`) | exact — modify in-place | +| `frontend/src/views/auth/LoginView.vue` | component | request-response | self (step-based login form) | exact — modify in-place | +| `frontend/src/stores/auth.js` | store | request-response | self (`login()` action) | exact — modify in-place | +| `backend/tests/test_auth_es256.py` | test | — | `backend/tests/test_task2_auth_service.py` + `backend/tests/test_auth_api.py` | role-match — same unit + integration pattern | + +--- + +## Pattern Assignments + +### `backend/services/auth.py` — ES256 signing sites + TTL param + +**Analog:** self — 4 sites to change in-place + +**Current imports block** (lines 18–38): +```python +from __future__ import annotations + +import hashlib +import hmac +import logging +import re +import secrets +import uuid +from datetime import datetime, timezone, timedelta +from typing import Optional + +import httpx +import jwt +import pyotp +from pwdlib import PasswordHash +from pwdlib.hashers.argon2 import Argon2Hasher +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from config import settings +from db.models import BackupCode, Quota, RefreshToken, User +``` +Add `import base64` to this block (no new packages). + +**Site 1 — `create_access_token`** (lines 86–99, current HS256): +```python +def create_access_token(user_id: str, role: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "role": role, + "typ": "access", + "iat": now, + "exp": now + timedelta(minutes=settings.access_token_expire_minutes), + } + return jwt.encode(payload, settings.secret_key, algorithm="HS256") +``` +Change last line to: +```python + private_pem = base64.b64decode(settings.jwt_private_key).decode() + return jwt.encode(payload, private_pem, algorithm="ES256") +``` + +**Site 2 — `decode_access_token`** (lines 102–117, current HS256): +```python +def decode_access_token(token: str) -> dict: + try: + payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) + except jwt.ExpiredSignatureError as exc: + raise ValueError("Token has expired") from exc + except jwt.PyJWTError as exc: + raise ValueError(f"Invalid token: {exc}") from exc + + if payload.get("typ") != "access": + raise ValueError("Token type mismatch: expected 'access'") + return payload +``` +Change the `jwt.decode` call to: +```python + public_pem = base64.b64decode(settings.jwt_public_key).decode() + payload = jwt.decode(token, public_pem, algorithms=["ES256"]) +``` + +**Site 3 — `create_password_reset_token`** (lines 120–132): +```python + return jwt.encode(payload, settings.secret_key, algorithm="HS256") +``` +Change to: +```python + private_pem = base64.b64decode(settings.jwt_private_key).decode() + return jwt.encode(payload, private_pem, algorithm="ES256") +``` + +**Site 4 — `decode_password_reset_token`** (lines 135–149): +```python + payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) +``` +Change to: +```python + public_pem = base64.b64decode(settings.jwt_public_key).decode() + payload = jwt.decode(token, public_pem, algorithms=["ES256"]) +``` + +**`create_refresh_token` TTL param** (lines 154–172 — current signature): +```python +async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str: + raw = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(raw.encode()).hexdigest() + now = datetime.now(timezone.utc) + row = RefreshToken( + id=uuid.uuid4(), + user_id=user_id, + token_hash=token_hash, + expires_at=now + timedelta(days=settings.refresh_token_expire_days), + revoked=False, + ) + session.add(row) + await session.commit() + return raw +``` +Add `remember_me: bool = False` param; change TTL line to: +```python + ttl = ( + timedelta(days=settings.refresh_token_expire_days) + if remember_me + else timedelta(hours=settings.refresh_token_expire_hours) + ) + ... + expires_at=now + ttl, +``` + +--- + +### `backend/config.py` — add 3 new settings + +**Analog:** self — add after `refresh_token_expire_days` at line 35 + +**Current JWT block** (lines 33–35): +```python + # Auth / JWT (Phase 2) + access_token_expire_minutes: int = 15 + refresh_token_expire_days: int = 30 +``` +Extend to: +```python + # Auth / JWT (Phase 2) + access_token_expire_minutes: int = 15 + refresh_token_expire_days: int = 30 + refresh_token_expire_hours: int = 16 # Phase 7.3: default short session + + # JWT key pair (Phase 7.3 — ES256; both required in production) + # Values are base64-encoded PEM strings (single-line, shell-safe). + jwt_private_key: str = "" # JWT_PRIVATE_KEY env var — required + jwt_public_key: str = "" # JWT_PUBLIC_KEY env var — required +``` +No methods on Settings. Callers decode inline with `base64.b64decode(settings.jwt_private_key).decode()` — consistent with how other base64 secrets are handled in this codebase (e.g., HKDF in `ai_config.py` decodes `cloud_creds_key` inline). + +--- + +### `backend/main.py` — lifespan ES256 rotation check + +**Primary analog:** `backend/services/ai_config.py:seed_system_settings_from_env` (lines 201–244) — exact pattern for reading and writing a `SystemSettings` key-value row at startup. + +**Secondary analog:** existing lifespan try/except block (lines 172–180) — exact wrapping pattern for skipping when the table doesn't exist yet. + +**Existing startup try/except block to copy** (lines 172–180): +```python + try: + async with AsyncSessionLocal() as session: + await seed_system_settings_from_env(session) + await session.commit() + except Exception as _seed_exc: + import logging as _logging + _logging.getLogger(__name__).warning( + "AI provider seed skipped (table may not exist yet): %s", _seed_exc + ) +``` + +**Pattern to follow for the new rotation block** — slot in after the AI seed block (after line 180, before `yield`): +```python + # ES256 startup rotation (Phase 7.3 — D-04): + # If jwt_algorithm in system_settings differs from "ES256" (or is absent), + # bulk-revoke all refresh tokens and record the new algorithm. + # Wrapped in try/except so a missing table before migrations doesn't crash. + try: + async with AsyncSessionLocal() as session: + await _rotate_tokens_on_algorithm_change(session) + except Exception as _es256_exc: + import logging as _logging + _logging.getLogger(__name__).warning( + "ES256 rotation check skipped (table may not exist yet): %s", _es256_exc + ) +``` + +**`_rotate_tokens_on_algorithm_change` helper** — define as a module-level async function above `lifespan`, using the `seed_system_settings_from_env` upsert pattern as the direct template: + +```python +# seed_system_settings_from_env upsert pattern (ai_config.py lines 219–244): +stmt = select(SystemSettings).where(SystemSettings.provider_id == provider_id) +result = await session.execute(stmt) +existing = result.scalar_one_or_none() +if existing is not None: + return +row = SystemSettings( + provider_id=provider_id, + model_name=model_name, + context_chars=context_chars, + is_active=True, + api_key_enc=None, + base_url=None, +) +session.add(row) +``` + +For the rotation helper, use `provider_id="jwt_algorithm"`, `model_name="ES256"`, `context_chars=0`, `is_active=False` (D-03 anti-pattern: never `is_active=True` for metadata rows). Add `text()` bulk UPDATE before the upsert when the algorithm differs: + +```python +from sqlalchemy import text, select +from db.models import SystemSettings, RefreshToken + +async def _rotate_tokens_on_algorithm_change(session) -> None: + """Idempotent ES256 migration. Bulk-revokes tokens if jwt_algorithm changed.""" + result = await session.execute( + select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") + ) + row = result.scalar_one_or_none() + if row is not None and row.model_name == "ES256": + return # Already migrated + # Bulk-revoke all active refresh tokens (single SQL statement, no Python iteration) + await session.execute( + text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false") + ) + if row is None: + import uuid as _uuid + session.add(SystemSettings( + id=_uuid.uuid4(), + provider_id="jwt_algorithm", + model_name="ES256", + context_chars=0, + is_active=False, + api_key_enc=None, + base_url=None, + )) + else: + row.model_name = "ES256" + await session.commit() +``` + +**Required import addition at top of `main.py`** (alongside existing `from sqlalchemy import text`): +```python +from sqlalchemy import text, select # text already imported; add select +``` + +--- + +### `backend/api/auth.py` — LoginRequest + `_set_refresh_cookie` + +**Analog:** self — modify `LoginRequest` at lines 56–60 and `_set_refresh_cookie` at lines 70–80; thread `remember_me` through the login handler at line 278–279. + +**Current `LoginRequest`** (lines 56–60): +```python +class LoginRequest(BaseModel): + email: EmailStr + password: str + totp_code: Optional[str] = None + backup_code: Optional[str] = None +``` +Add one field: +```python + remember_me: bool = False +``` + +**Current `_set_refresh_cookie`** (lines 70–80): +```python +def _set_refresh_cookie(response: Response, raw_token: str) -> None: + """Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).""" + response.set_cookie( + key="refresh_token", + value=raw_token, + httponly=True, + secure=True, + samesite="strict", + path="/api/auth/refresh", + max_age=settings.refresh_token_expire_days * 86400, + ) +``` +Add `remember_me: bool = False` param; change `max_age` to: +```python + max_age=( + settings.refresh_token_expire_days * 86400 + if remember_me + else settings.refresh_token_expire_hours * 3600 + ), +``` + +**Login handler token-issue block** (lines 277–279): +```python + access_token = auth_service.create_access_token(str(user.id), user.role) + raw_refresh = await auth_service.create_refresh_token(session, user.id) + _set_refresh_cookie(response, raw_refresh) +``` +Pass `remember_me` through: +```python + access_token = auth_service.create_access_token(str(user.id), user.role) + raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me) + _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me) +``` + +--- + +### `frontend/src/views/auth/LoginView.vue` — "Stay signed in" checkbox + +**Analog:** self — the password step form (lines 1–59 of template); the `submitPassword` / `submitTotp` / `submitBackupCode` functions (lines 224–265). + +**Current reactive state** (lines 188–193): +```javascript +const email = ref('') +const password = ref('') +const totpInput = ref('') +const backupCodeInput = ref('') +const loading = ref(false) +const error = ref(null) +``` +Add one ref: +```javascript +const rememberMe = ref(false) +``` + +**Checkbox markup** — insert inside the password step form (`
`), between the password `
` block and the error `
`. Copy existing input style (Tailwind classes) from the form: +```html +
+ + +
+``` + +**`submitPassword` update** (lines 224–235): +```javascript +async function submitPassword() { + loading.value = true + error.value = null + try { + const result = await authStore.login(email.value, password.value) + await handleLoginResult(result) + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } +} +``` +Change the `authStore.login` call to pass `rememberMe`: +```javascript + const result = await authStore.login(email.value, password.value, { + rememberMe: rememberMe.value, + }) +``` + +**`submitTotp` and `submitBackupCode`** — these already pass `options` objects (lines 241–264). Add `rememberMe: rememberMe.value` to each call's options object, alongside the existing `totpCode` / `backupCode` keys. The checkbox state persists across steps since it is a single `ref` scoped to the component. + +--- + +### `frontend/src/stores/auth.js` — `login()` action + +**Analog:** self — `login()` at lines 60–88. + +**Current `login()` action** (lines 60–88): +```javascript +async function login(email, password, options = {}) { + loading.value = true + error.value = null + try { + const data = await api.login({ + email, + password, + totp_code: options.totpCode ?? null, + backup_code: options.backupCode ?? null, + }) + // ... response handling unchanged + } +} +``` +Add `remember_me` to the API call body: +```javascript + remember_me: options.rememberMe ?? false, +``` +Insert after `backup_code`. The rest of `login()` is unchanged. + +--- + +### `backend/tests/test_auth_es256.py` — NEW test file + +**Analog:** `backend/tests/test_task2_auth_service.py` (unit pattern) + `backend/tests/test_auth_api.py` (integration + FakeRedis pattern) + +**File header pattern** (from `test_task2_auth_service.py` lines 1–7): +```python +""" +TDD tests for Phase 7.3: ES256 algorithm upgrade, startup token rotation, +and remember_me session TTL. +""" +import pytest +import pytest_asyncio +``` + +**Unit test structure** (from `test_task2_auth_service.py` lines 28–57 — no async, no DB): +```python +def test_access_token_uses_es256(): + from services.auth import create_access_token + t = create_access_token("test-uid", "user") + # Header is base64url({"alg":"ES256",...}) — check alg claim + import base64, json + header = json.loads(base64.urlsafe_b64decode(t.split(".")[0] + "==")) + assert header["alg"] == "ES256" +``` + +**xfail stub pattern** — for tests needing DB fixtures not yet wired, use `pytest.mark.xfail(strict=True, reason="...")`. Follow the existing xfail convention in the test suite (used in Phase 7.2 test stubs). Stubs that need `db_session` use the `async def` + `pytest_asyncio.fixture` pattern from `conftest.py`: +```python +@pytest.mark.xfail(strict=True, reason="ES256-04: not yet implemented") +@pytest.mark.asyncio +async def test_startup_rotation_revokes_tokens(db_session): + pytest.xfail("not yet implemented") +``` + +**FakeRedis pattern** — for integration tests hitting the login endpoint, import and apply the `FakeRedis` from `test_auth_api.py`: +```python +from test_auth_api import FakeRedis, _register, _login +``` +Or copy the minimal `FakeRedis` class and `_login` helper into the new file — the `test_auth_api.py` pattern is lines 47–97 of that file. + +**Key env var fixture** — because ES256 needs real key material in `settings`, add a module-level fixture that patches settings before the module runs: +```python +import base64 +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization + +@pytest.fixture(autouse=True) +def es256_keys(monkeypatch): + """Patch settings with a freshly generated P-256 key pair for each test.""" + k = ec.generate_private_key(ec.SECP256R1()) + priv = base64.b64encode( + k.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + ).decode() + pub = base64.b64encode( + k.public_key().public_bytes(serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo) + ).decode() + monkeypatch.setattr("config.settings.jwt_private_key", priv) + monkeypatch.setattr("config.settings.jwt_public_key", pub) +``` + +**`conftest.py` `auth_user` fixture** (lines 207–245) — the integration tests that assert on refresh-token TTL need a `db_session` + `async_client`. Reuse these fixtures from `conftest.py` directly (they are autoimported by pytest from the package-level conftest). + +--- + +## Shared Patterns + +### Base64-decoded secret inline at call site +**Source:** `backend/services/ai_config.py` lines 63–71 (HKDF derive pattern) +**Apply to:** All 4 JWT signing/decoding sites in `services/auth.py` +```python +# Pattern: decode inline, never cache as module-level global +private_pem = base64.b64decode(settings.jwt_private_key).decode() +public_pem = base64.b64decode(settings.jwt_public_key).decode() +``` + +### Startup idempotency check (SystemSettings read + upsert) +**Source:** `backend/services/ai_config.py:seed_system_settings_from_env` lines 219–244 +**Apply to:** `_rotate_tokens_on_algorithm_change` in `main.py` +```python +stmt = select(SystemSettings).where(SystemSettings.provider_id == "") +result = await session.execute(stmt) +existing = result.scalar_one_or_none() +if existing is not None: + return # idempotent — skip +# ... insert or update +``` + +### Startup try/except wrapping for missing table +**Source:** `backend/main.py` lines 172–180 +**Apply to:** ES256 rotation block in `main.py` +```python +try: + async with AsyncSessionLocal() as session: + await (session) +except Exception as _exc: + import logging as _logging + _logging.getLogger(__name__).warning( + " skipped (table may not exist yet): %s", _exc + ) +``` + +### httpOnly cookie set pattern +**Source:** `backend/api/auth.py:_set_refresh_cookie` lines 70–80 +**Apply to:** modified `_set_refresh_cookie` signature (add `remember_me` param) +```python +response.set_cookie( + key="refresh_token", + value=raw_token, + httponly=True, + secure=True, + samesite="strict", + path="/api/auth/refresh", + max_age=, +) +``` + +### Vue 3 Options API checkbox (script setup) +**Source:** `frontend/src/views/auth/LoginView.vue` lines 177–193 (existing ref pattern) +**Apply to:** new `rememberMe` ref + checkbox in `LoginView.vue` +```javascript +// Existing pattern: all form state is a ref() +const rememberMe = ref(false) + +// Existing options object pattern (submitTotp, line 241): +const result = await authStore.login(email.value, password.value, { + totpCode: totpInput.value, +}) +// Extend with: + rememberMe: rememberMe.value, +``` + +### Bulk SQL update (no Python-layer iteration) +**Source:** `backend/services/auth.py:revoke_all_refresh_tokens` provides context; the **preferred pattern for bulk updates** in this codebase is a single `session.execute(text(...))` as documented in CONTEXT.md §Specific Ideas +**Apply to:** `_rotate_tokens_on_algorithm_change` bulk revoke +```python +await session.execute( + text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false") +) +``` + +--- + +## No Analog Found + +All files have close analogs. No gaps. + +--- + +## Metadata + +**Analog search scope:** `backend/services/`, `backend/api/`, `backend/config.py`, `backend/main.py`, `frontend/src/stores/`, `frontend/src/views/auth/`, `backend/tests/` +**Files read:** 12 source files + 2 planning documents +**Pattern extraction date:** 2026-06-05 diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md index 1b24d64..13e6db6 100644 --- a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md @@ -562,17 +562,14 @@ async function login(email, password, options = {}) { --- -## Open Questions +## Open Questions (RESOLVED) -1. **TTL preservation across refresh token rotation** +1. **TTL preservation across refresh token rotation** (RESOLVED) - What we know: `rotate_refresh_token` calls `create_refresh_token` internally with no `remember_me` param (default=False → 16 hours) - - What's unclear: Should a user who selected "stay signed in 30 days" get a new 30-day token on each rotation, or is 16-hour degradation acceptable? - - Recommendation: Accept 16-hour degradation on rotation for Phase 7.3 (simpler implementation). If users report confusion, Phase 7.4 or a follow-up can add TTL preservation by reading original `expires_at` or adding a `remember_me` column to `RefreshToken`. + - RESOLVED: Accept 16-hour degradation on rotation for Phase 7.3 (simpler implementation). `rotate_refresh_token` is intentionally NOT updated. Documented in Plan 07.3-03 objective and acceptance criteria. -2. **Test `test_settings_has_jwt_config` assertion on `refresh_token_expire_days == 30`** - - What we know: The test at `backend/tests/test_task1_models_config.py:31` asserts the existing field equals 30. This field is NOT changed. - - What's unclear: Nothing — this test continues to pass. A new test should assert `refresh_token_expire_hours == 16`. - - Recommendation: Add assertion for `refresh_token_expire_hours` in the same test function or a new one. +2. **Test `test_settings_has_jwt_config` assertion on `refresh_token_expire_days == 30`** (RESOLVED) + - RESOLVED: Nothing — this test continues to pass. Plan 07.3-01-T2 adds assertion for `refresh_token_expire_hours == 16` to the same test function. --- diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md new file mode 100644 index 0000000..d3efa01 --- /dev/null +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md @@ -0,0 +1,82 @@ +--- +phase: 7.3 +slug: 07.3-security-es256-algorithm-upgrade-inserted +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-06-05 +--- + +# Phase 7.3 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | pytest + pytest-asyncio | +| **Config file** | `backend/pytest.ini` | +| **Quick run command** | `cd backend && pytest tests/test_auth_es256.py tests/test_task1_models_config.py -x -v` | +| **Full suite command** | `cd backend && pytest -v` | +| **Estimated runtime** | ~30 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `cd backend && pytest tests/test_auth_es256.py tests/test_task1_models_config.py -x -v` +- **After every plan wave:** Run `cd backend && pytest -v` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 30 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 7.3-01-01 | 01 | 0 | ES256-01..RM-03 | T-7.3-01 | Wave 0 xfail stubs for all 9 test cases | unit/integration | `pytest tests/test_auth_es256.py -x` | ❌ Wave 0 | ⬜ pending | +| 7.3-01-02 | 01 | 1 | CFG-01 | — | `settings.jwt_private_key`, `settings.jwt_public_key`, `settings.refresh_token_expire_hours` all present | unit | `pytest tests/test_task1_models_config.py -x` | ✅ (extend) | ⬜ pending | +| 7.3-01-03 | 01 | 1 | ES256-01 | T-7.3-01 | `create_access_token` returns token with `alg=ES256` in header | unit | `pytest tests/test_auth_es256.py::test_access_token_uses_es256 -x` | ❌ Wave 0 | ⬜ pending | +| 7.3-01-04 | 01 | 1 | ES256-02 | T-7.3-02 | HS256 token presented to ES256 verifier raises 401 | unit | `pytest tests/test_auth_es256.py::test_hs256_token_rejected -x` | ❌ Wave 0 | ⬜ pending | +| 7.3-01-05 | 01 | 1 | ES256-03 | T-7.3-01 | `create_password_reset_token` uses ES256 | unit | `pytest tests/test_auth_es256.py::test_reset_token_uses_es256 -x` | ❌ Wave 0 | ⬜ pending | +| 7.3-01-06 | 01 | 1 | ES256-04 | T-7.3-03 | Startup rotation bulk-revokes all refresh tokens when algorithm changes | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens -x` | ❌ Wave 0 | ⬜ pending | +| 7.3-01-07 | 01 | 1 | ES256-05 | T-7.3-03 | Startup rotation skips revocation on second boot (idempotent) | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_idempotent -x` | ❌ Wave 0 | ⬜ pending | +| 7.3-02-01 | 02 | 2 | RM-01 | T-7.3-04 | Default login (no remember_me) issues 16-hour refresh token | integration | `pytest tests/test_auth_es256.py::test_default_ttl_16_hours -x` | ❌ Wave 0 | ⬜ pending | +| 7.3-02-02 | 02 | 2 | RM-02 | T-7.3-04 | `remember_me=True` issues 30-day refresh token | integration | `pytest tests/test_auth_es256.py::test_remember_me_ttl_30_days -x` | ❌ Wave 0 | ⬜ pending | +| 7.3-02-03 | 02 | 2 | RM-03 | T-7.3-04 | `remember_me=True` sets `Set-Cookie: Max-Age=2592000` | integration | `pytest tests/test_auth_es256.py::test_remember_me_cookie_max_age -x` | ❌ Wave 0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `backend/tests/test_auth_es256.py` — 9 xfail stubs covering ES256-01 through RM-03 +- [ ] `backend/tests/test_task1_models_config.py` — extend `test_settings_has_jwt_config` to assert `refresh_token_expire_hours == 16` + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| "Stay signed in for 30 days" checkbox visible on login form | D-12 | Frontend visual verification | Load `http://localhost:5173/login`; checkbox appears below password field; unchecked by default | +| Login without checkbox issues ~16h cookie | RM-03 | Browser cookie inspection | Login without checkbox; open DevTools → Application → Cookies; `refresh_token` `Max-Age` ≈ 57600 | +| Login with checkbox issues 30-day cookie | RM-03 | Browser cookie inspection | Login with checkbox checked; `refresh_token` `Max-Age` = 2592000 | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 30s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending