6 tests promoted from xfail to passing (ES256-01..05 + CFG-01); 3 remain xfail for Plan 03
189 lines
9.0 KiB
Markdown
189 lines
9.0 KiB
Markdown
---
|
|
phase: 07.3-security-es256-algorithm-upgrade-inserted
|
|
plan: "02"
|
|
subsystem: backend/auth
|
|
tags:
|
|
- security
|
|
- jwt
|
|
- es256
|
|
- algorithm-upgrade
|
|
- startup-hook
|
|
dependency_graph:
|
|
requires:
|
|
- 07.3-01
|
|
provides:
|
|
- ES256 JWT signing at all 4 token sites
|
|
- Startup token rotation hook (idempotent)
|
|
- Operator key generation documentation
|
|
affects:
|
|
- backend/config.py
|
|
- backend/services/auth.py
|
|
- backend/main.py
|
|
- docker-compose.yml
|
|
- README.md
|
|
- .env.example
|
|
- frontend/package.json
|
|
tech_stack:
|
|
added: []
|
|
patterns:
|
|
- "base64.b64decode(settings.jwt_*_key).decode() inline at each JWT call site"
|
|
- "Idempotent startup SystemSettings upsert pattern (matching ai_config.py seed)"
|
|
- "Raw SQL bulk UPDATE inside helper with session.commit() — no Python iteration"
|
|
key_files:
|
|
created: []
|
|
modified:
|
|
- path: backend/config.py
|
|
lines: "35-38"
|
|
note: "Added refresh_token_expire_hours=16, jwt_private_key='', jwt_public_key=''"
|
|
- path: backend/services/auth.py
|
|
lines: "20,100-101,109-110,133-134,142-143"
|
|
note: "Added import base64; 4 JWT sites swapped from HS256+secret_key to ES256+inline PEM decode"
|
|
- path: backend/main.py
|
|
lines: "1-3,133-175,225-232,295"
|
|
note: "Added import logging + select; _rotate_tokens_on_algorithm_change helper; lifespan call with try/except; version 0.1.1 -> 0.1.2"
|
|
- path: backend/tests/test_auth_es256.py
|
|
lines: "39-95,109-239,244-249"
|
|
note: "Promoted 6 tests from xfail to passing (ES256-01..05 + CFG-01)"
|
|
- path: docker-compose.yml
|
|
lines: "66-67,105-106"
|
|
note: "Added JWT_PRIVATE_KEY + JWT_PUBLIC_KEY env injection to backend and celery-worker"
|
|
- path: README.md
|
|
lines: "143-144,162-181"
|
|
note: "Added JWT key vars to env table; added JWT Key Generation section with Python one-liner"
|
|
- path: .env.example
|
|
lines: "33-37"
|
|
note: "Added JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY= with section header"
|
|
- path: frontend/package.json
|
|
lines: "3"
|
|
note: "Version bump 0.1.1 -> 0.1.2"
|
|
decisions:
|
|
- "Inline PEM decode at each call site (base64.b64decode inline) — no module-level PEM cache per RESEARCH.md Anti-Pattern; prevents leaked PEM via reload"
|
|
- "expire_all() in tests after _rotate_tokens_on_algorithm_change — conftest uses expire_on_commit=False; raw SQL UPDATE bypasses ORM identity map so session must be expired manually before re-query"
|
|
- "is_active=False on jwt_algorithm SystemSettings row — prevents AI provider loader from returning the metadata marker row"
|
|
metrics:
|
|
duration: "~25 minutes"
|
|
completed: "2026-06-06"
|
|
tasks_completed: 3
|
|
files_modified: 8
|
|
---
|
|
|
|
# Phase 07.3 Plan 02: ES256 Core Upgrade — JWT Sites + Startup Rotation + Operator Wiring Summary
|
|
|
|
ES256 algorithm upgrade: all 4 JWT signing/decoding sites use ECDSA P-256 with inline base64-decoded PEM keys, with idempotent startup bulk-revocation hook and full operator documentation.
|
|
|
|
---
|
|
|
|
## Tasks Completed
|
|
|
|
| Task | Name | Commit | Files |
|
|
|------|------|--------|-------|
|
|
| 1 | Add JWT key + TTL settings; swap all 4 JWT sites to ES256; remove secret_key from JWT code | fd3f611 | backend/config.py, backend/services/auth.py, backend/tests/test_auth_es256.py |
|
|
| 2 | Add lifespan _rotate_tokens_on_algorithm_change hook + promote startup rotation tests | 8d261b0 | backend/main.py, backend/tests/test_auth_es256.py |
|
|
| 3 | Wire JWT key env vars through docker-compose, README key-gen snippet, .env.example, version bump | 0d1ab05 | docker-compose.yml, README.md, .env.example, backend/main.py, frontend/package.json |
|
|
|
|
---
|
|
|
|
## Grep Gate Results (Acceptance Criteria Verified)
|
|
|
|
| Gate | Expected | Actual | Status |
|
|
|------|----------|--------|--------|
|
|
| `settings.secret_key` in services/auth.py | 0 | 0 | PASS |
|
|
| `algorithm="ES256"` in services/auth.py | 2 | 2 | PASS |
|
|
| `algorithms=["ES256"]` in services/auth.py | 2 | 2 | PASS |
|
|
| `"HS256"` in services/auth.py | 0 | 0 | PASS |
|
|
| `base64.b64decode(settings.jwt_` in services/auth.py | 4 | 4 | PASS |
|
|
| `import base64` in services/auth.py | 1 | 1 | PASS |
|
|
| `refresh_token_expire_hours: int = 16` in config.py | 1 | 1 | PASS |
|
|
| `jwt_private_key: str = ""` in config.py | 1 | 1 | PASS |
|
|
| `jwt_public_key: str = ""` in config.py | 1 | 1 | PASS |
|
|
| `async def _rotate_tokens_on_algorithm_change` in main.py | 1 | 1 | PASS |
|
|
| `await _rotate_tokens_on_algorithm_change(session)` in main.py | 1 | 1 | PASS |
|
|
| `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` in main.py | 1 | 1 | PASS |
|
|
| `provider_id == "jwt_algorithm"` in main.py | 1 | 1 | PASS |
|
|
| `is_active=False` in main.py | >= 1 | 2 | PASS |
|
|
| `ES256 rotation check skipped` in main.py | 1 | 1 | PASS |
|
|
| `JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}` in docker-compose.yml | 2 | 2 | PASS |
|
|
| `JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}` in docker-compose.yml | 2 | 2 | PASS |
|
|
| Literal base64 PEM in docker-compose.yml | 0 | 0 | PASS |
|
|
| `ec.generate_private_key(ec.SECP256R1())` in README.md | >= 1 | 1 | PASS |
|
|
| `Rotating these keys invalidates every active session` in README.md | 1 | 1 | PASS |
|
|
| `JWT_PRIVATE_KEY=` in .env.example (no value) | present | present | PASS |
|
|
| `JWT_PUBLIC_KEY=` in .env.example (no value) | present | present | PASS |
|
|
| `version="0.1.2"` in backend/main.py | 1 | 1 | PASS |
|
|
| `"version": "0.1.2"` in frontend/package.json | 1 | 1 | PASS |
|
|
| `docker compose config -q` exits 0 | 0 | 0 | PASS |
|
|
|
|
---
|
|
|
|
## Promoted Tests (xfail → PASSED)
|
|
|
|
| Test | Requirement | Before | After |
|
|
|------|-------------|--------|-------|
|
|
| test_access_token_uses_es256 | ES256-01 | XFAIL (strict) | PASSED |
|
|
| test_hs256_token_rejected | ES256-02 | XFAIL (strict) | PASSED |
|
|
| test_reset_token_uses_es256 | ES256-03 | XFAIL (strict) | PASSED |
|
|
| test_startup_rotation_revokes_tokens | ES256-04 | XFAIL (strict) | PASSED |
|
|
| test_startup_rotation_idempotent | ES256-05 | XFAIL (strict) | PASSED |
|
|
| test_settings_has_jwt_keys | CFG-01 | XFAIL (strict) | PASSED |
|
|
|
|
**Net suite delta:** `+6 PASSED`. Final count: `6 PASSED + 3 XFAILED (RM-01..03 remain for Plan 03)`.
|
|
`test_task1_models_config.py::test_settings_has_jwt_config` also passes (previously failing due to missing fields).
|
|
|
|
---
|
|
|
|
## Deviations from Plan
|
|
|
|
### Auto-fixed Issues
|
|
|
|
**1. [Rule 1 - Bug] SQLAlchemy identity map returns stale values after raw SQL UPDATE with expire_on_commit=False**
|
|
|
|
- **Found during:** Task 2 test execution
|
|
- **Issue:** `conftest.py` creates the test `AsyncTestSession` with `expire_on_commit=False`. After `_rotate_tokens_on_algorithm_change` runs a raw SQL `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` followed by `session.commit()`, SQLAlchemy's identity map still holds the pre-commit stale state of `RefreshToken` objects (revoked=False). Re-querying via `select()` returned the stale cached object instead of issuing a fresh DB query.
|
|
- **Fix:** Added `db_session.expire_all()` in both startup rotation tests immediately after `await _rotate_tokens_on_algorithm_change(db_session)`. This invalidates the identity map cache, forcing SQLAlchemy to re-fetch from DB on the subsequent `select()`. Production code is unchanged — the issue is test isolation specific to `expire_on_commit=False`.
|
|
- **Files modified:** `backend/tests/test_auth_es256.py`
|
|
- **Commit:** 8d261b0
|
|
|
|
---
|
|
|
|
## Remaining XFAIL Tests (Plan 03)
|
|
|
|
| Test | Requirement | Reason |
|
|
|------|-------------|--------|
|
|
| test_default_ttl_16_hours | RM-01 | remember_me param not yet added to create_refresh_token |
|
|
| test_remember_me_ttl_30_days | RM-02 | same |
|
|
| test_remember_me_cookie_max_age | RM-03 | _set_refresh_cookie + LoginView.vue changes deferred to Plan 03 |
|
|
|
|
---
|
|
|
|
## Operator Handoff
|
|
|
|
Operators must generate a P-256 key pair before the backend can sign or verify JWTs:
|
|
|
|
```bash
|
|
python3 -c "
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
from cryptography.hazmat.primitives import serialization
|
|
import base64
|
|
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()
|
|
print(f'JWT_PRIVATE_KEY={priv}')
|
|
print(f'JWT_PUBLIC_KEY={pub}')
|
|
"
|
|
```
|
|
|
|
Paste the two output lines into `.env`. On next `docker compose up`, the startup rotation hook will bulk-revoke all existing refresh tokens (forcing all users to re-login) and record the ES256 migration marker in `system_settings`. Subsequent boots are idempotent.
|
|
|
|
---
|
|
|
|
## Self-Check: PASSED
|
|
|
|
- `backend/config.py` exists and contains the three new fields: FOUND
|
|
- `backend/services/auth.py` uses ES256 at all 4 sites, zero HS256/secret_key references: FOUND
|
|
- `backend/main.py` contains `_rotate_tokens_on_algorithm_change` at module scope: FOUND
|
|
- `docker-compose.yml` has JWT_PRIVATE_KEY and JWT_PUBLIC_KEY in both services: FOUND
|
|
- `README.md` contains key generation snippet: FOUND
|
|
- `.env.example` contains JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY=: FOUND
|
|
- Commits fd3f611, 8d261b0, 0d1ab05 all exist: FOUND
|
|
- Test suite: 6 PASSED + 3 XFAILED, 0 FAILED: CONFIRMED
|