3 plans (3 waves): Wave 0 xfail stubs, Wave 1 ES256 core + startup rotation, Wave 2 remember-me TTL split + LoginView checkbox. Verification passed (0 blockers, 1 minor doc warning fixed). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
225 lines
15 KiB
Markdown
225 lines
15 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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`.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/STATE.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
|
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
|
|
@.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md
|
|
@backend/tests/test_task1_models_config.py
|
|
@backend/tests/test_task2_auth_service.py
|
|
@backend/tests/test_auth_api.py
|
|
@backend/tests/conftest.py
|
|
|
|
<interfaces>
|
|
<!-- Existing test fixtures and helpers the new file relies on. Extracted from codebase. -->
|
|
|
|
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
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create test_auth_es256.py with 9 xfail stubs + ES256 key fixture</name>
|
|
<files>backend/tests/test_auth_es256.py</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<action>
|
|
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="<REQ-ID>: 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.
|
|
</action>
|
|
<verify>
|
|
<automated>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 ' '</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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)
|
|
</acceptance_criteria>
|
|
<done>9 xfail stubs collected, all reported XFAIL on pytest run, fixture defined, no assertions present.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Extend test_settings_has_jwt_config for refresh_token_expire_hours + JWT key fields</name>
|
|
<files>backend/tests/test_task1_models_config.py</files>
|
|
<read_first>
|
|
backend/tests/test_task1_models_config.py
|
|
backend/config.py
|
|
.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
|
|
</read_first>
|
|
<action>
|
|
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`).
|
|
</action>
|
|
<verify>
|
|
<automated>cd backend && grep -c "refresh_token_expire_hours == 16" tests/test_task1_models_config.py</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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
|
|
</acceptance_criteria>
|
|
<done>Test extended to assert all three new config fields; test fails red until Plan 02 lands the fields.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## 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) |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `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
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- 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
|
|
</success_criteria>
|
|
|
|
<output>
|
|
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
|
|
</output>
|