# 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 84–150 — `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 ~60–65 — 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 (5–10 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*
