Files

123 lines
9.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 7.3: Security — ES256 Algorithm Upgrade - Context
**Gathered:** 2026-06-05
**Status:** Ready for planning
<domain>
## Phase Boundary
Phase 7.3 delivers three auth-security upgrades in one cohesive change:
1. **ES256 algorithm upgrade** — Replace HS256 with ECDSA P-256 (ES256) across all four JWT signing/verification sites in `services/auth.py`. Generate a P-256 key pair; store base64-encoded PEM keys as `JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` env vars. A leaked public key cannot forge tokens.
2. **Startup token rotation** — On first boot after the ES256 deploy, bulk-revoke all existing `RefreshToken` rows (set `revoked=True`). Detect the migration via the `system_settings` table; update the stored `jwt_algorithm` value after rotation so subsequent restarts do not re-revoke.
3. **"Remember me" session duration** — Default refresh token TTL changes from 30 days to 16 hours. A `remember_me: bool = False` param on the login endpoint (and a checkbox in `LoginView.vue`) opts the user into the existing 30-day TTL. Access token TTL stays at 15 minutes (CLAUDE.md non-negotiable).
No other changes are in scope for this phase.
</domain>
<decisions>
## Implementation Decisions
### Key Format and Storage
- **D-01:** Both `JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` are **required** env vars. Both store the PEM key **base64-encoded** as a single line — shell-safe, no newline-escape complications. `config.py` decodes each value before passing to PyJWT.
- **D-02:** `create_access_token` and `create_password_reset_token` sign with the private key using `algorithm="ES256"`. `decode_access_token` and `decode_password_reset_token` verify with the public key using `algorithms=["ES256"]`.
- **D-03:** `settings.secret_key` is **removed from all JWT code** after ES256 is in place. It is no longer used for JWT signing. The `SECRET_KEY` env var stays in docker-compose for any future non-JWT HMAC needs (Phase 7.4 fingerprinting), but `services/auth.py` must not reference it.
### Startup Token Rotation
- **D-04:** On FastAPI `lifespan` startup, compare the `jwt_algorithm` key in the `system_settings` table (or its absence) against the current algorithm `"ES256"`. If the stored value differs (e.g., was `"HS256"` or is missing), bulk-revoke all `RefreshToken` rows by executing `UPDATE refresh_tokens SET revoked = true WHERE revoked = false`, then upsert `jwt_algorithm = "ES256"` into `system_settings`. This is idempotent — safe to restart.
- **D-05:** Use the existing `system_settings` upsert pattern from `backend/services/ai_config.py` (`seed_system_settings_from_env`) as the reference for reading/writing system settings in the startup hook.
### Key Generation Workflow
- **D-06:** Document key generation as a **Python one-liner** in `README.md` using the `cryptography` library (already a project dependency). The snippet prints both base64-encoded keys ready to paste into `.env`. No extra tooling required.
- **D-07:** `docker-compose.yml` references `${JWT_PRIVATE_KEY}` and `${JWT_PUBLIC_KEY}` from the `.env` file — same pattern as the existing `${SECRET_KEY}`. No placeholder values in the repo.
### Session Duration / "Remember Me"
- **D-08:** Access token TTL: **15 minutes** — unchanged and non-negotiable (CLAUDE.md).
- **D-09:** Default refresh token TTL: **16 hours** (new config setting `refresh_token_expire_hours: int = 16`). This guarantees session expiry overnight and covers a full workday.
- **D-10:** "Remember me" opt-in: **30 days** (existing `refresh_token_expire_days: int = 30` kept as-is). When `remember_me=True` is passed to the login endpoint, `create_refresh_token` uses the 30-day TTL; otherwise it uses 16 hours.
- **D-11:** `POST /api/auth/login` request body gains `remember_me: bool = False`. `create_refresh_token` gains `remember_me: bool = False` param — selects between `timedelta(hours=settings.refresh_token_expire_hours)` and `timedelta(days=settings.refresh_token_expire_days)`.
- **D-12:** `LoginView.vue` gains a "Remember me for 30 days" checkbox that passes `remember_me` to `authStore.login()`. The store passes it through to the API call.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### JWT signing — target code (4 sites to change)
- `backend/services/auth.py` lines 84150 — `create_access_token` (line 86), `decode_access_token` (line 102), `create_password_reset_token` (line 121), `decode_password_reset_token` (line 135); all currently use `settings.secret_key` / `"HS256"`
### Config — where new settings land
- `backend/config.py` line 31 — current `secret_key: str = "CHANGEME"`; add `jwt_private_key: str`, `jwt_public_key: str`, `refresh_token_expire_hours: int = 16` here
### Startup hook — where rotation logic goes
- `backend/main.py` line 136 — `lifespan` function; the ES256 rotation check goes inside this existing startup block, after the Redis check and before the AI seed
- `backend/services/ai_config.py` — reference implementation for reading/writing `system_settings` key-value pairs at startup (`seed_system_settings_from_env` pattern)
### Models — token and settings tables
- `backend/db/models.py` line 87 — `RefreshToken` model with `revoked: Mapped[bool]` column (line 102) — target for bulk-revoke UPDATE
- `backend/db/models.py` line 340 — `SystemSettings` model — storage for `jwt_algorithm` detection key
### Login endpoint — remember_me param
- `backend/api/auth.py``POST /api/auth/login` handler; add `remember_me: bool = False` to the request schema and pass it through to `create_refresh_token`
### Frontend login form
- `frontend/src/views/auth/LoginView.vue` line 228 — `authStore.login(email, password)` call; extend to pass `remember_me` bool from new checkbox
### Security requirement
- `CLAUDE.md` §"Login token hardening" — access token TTL 15 min max, ES256 asymmetric algorithm, refresh token rotation; this phase enforces all three
- `.planning/codebase/CONCERNS.md` §"JWT Algorithm Downgrade: HS256 Instead of ES256" — full risk description and fix approach
### docker-compose env var pattern
- `docker-compose.yml` lines ~6065 — existing `SECRET_KEY=${SECRET_KEY}` pattern; `JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` follow the same form
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `lifespan` function in `backend/main.py:136` — already has a startup block with DB session, Redis check, and AI seed; the ES256 rotation check slots in as another startup task in the same block
- `SystemSettings` model + upsert pattern in `backend/services/ai_config.py` — exact pattern for reading and writing a key-value entry in `system_settings` at startup; copy this to avoid re-inventing the upsert
- `RefreshToken.revoked` column — already exists (migrations/0001); no schema change needed for bulk revoke
### Established Patterns
- Base64-encoded keys: `config.py` already handles `minio_secret_key` and similar string secrets from env — apply the same `base64.b64decode(settings.jwt_private_key).decode()` pattern before constructing the `EllipticCurvePrivateKey` for PyJWT
- Token hash: refresh tokens stored as `sha256(raw.encode()).hexdigest()` — unchanged by this phase; only the *access* token algorithm changes
- `create_refresh_token` in `services/auth.py` currently uses `timedelta(days=settings.refresh_token_expire_days)` — add a `remember_me` param that selects between hours and days TTL
### Integration Points
- `backend/services/auth.py` — primary change site; 4 signing/decode functions + `create_refresh_token` TTL param
- `backend/config.py` — add 3 new settings; `secret_key` stays but JWT code stops referencing it
- `backend/main.py:lifespan` — add startup rotation check (510 lines)
- `backend/api/auth.py` — login endpoint gains `remember_me: bool = False`; `create_refresh_token` call passes it through
- `frontend/src/views/auth/LoginView.vue` — add checkbox, pass `remember_me` to store/API
- `frontend/src/stores/auth.js` (or `.ts`) — `login()` action gains `remember_me` param and passes it to the API
</code_context>
<specifics>
## Specific Ideas
- The startup rotation bulk UPDATE should use a raw SQL `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` executed via `session.execute(text(...))` for efficiency — avoids loading all rows into Python (same pattern as other bulk updates in the codebase).
- The `system_settings` key for algorithm tracking: use `key="jwt_algorithm"`, `value="ES256"`, provider/model fields `NULL` or `""` (check existing SystemSettings schema for nullable constraints before inserting).
- PyJWT ES256 usage: `jwt.encode(payload, private_key_pem_str, algorithm="ES256")` and `jwt.decode(token, public_key_pem_str, algorithms=["ES256"])` — PyJWT accepts PEM strings directly; no need to use the `cryptography` key objects directly in the jwt calls.
- The "Remember me" checkbox label: "Stay signed in for 30 days" — clearer than "Remember me" about what it actually does.
</specifics>
<deferred>
## Deferred Ideas
- **Phase 7.4 — Token fingerprinting / token binding**: Add `fgp` (fingerprint) claim = `hmac(key, User-Agent + Accept-Language)[:16]`; validate in `get_current_user`. `SECRET_KEY` may serve as the HMAC key here. Tracked in `.planning/codebase/CONCERNS.md` §"No Token Fingerprint / Token Binding".
- **Key rotation ceremony**: A process for rotating the P-256 key pair in production (dual-key overlap period, gradual rollout) — not needed for the initial upgrade but worth documenting when this goes to production.
</deferred>
---
*Phase: 07.3-security-es256-algorithm-upgrade-inserted*
*Context gathered: 2026-06-05*