docs(07.3): capture phase context

This commit is contained in:
curo1305
2026-06-05 14:42:05 +02:00
parent 50b9ffc1f1
commit 77135df803
2 changed files with 257 additions and 0 deletions
@@ -0,0 +1,122 @@
# 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*
@@ -0,0 +1,135 @@
# Phase 7.3: Security — ES256 Algorithm Upgrade - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-05
**Phase:** 07.3-security-es256-algorithm-upgrade-inserted
**Areas discussed:** Key format in env vars, Startup refresh-token rotation, Key generation workflow, JWT TTL / Remember Me
---
## Key Format in Env Vars
### Q1: How should P-256 PEM keys be stored in env vars?
| Option | Description | Selected |
|--------|-------------|----------|
| Base64-encoded | Single line, shell-safe, decoded in config.py | ✓ |
| Escaped `\n` in .env | python-dotenv expands `\n`; loader-dependent | |
| Multiline .env block | Breaks in docker-compose environment: block | |
**User's choice:** Base64-encoded
**Notes:** Standard 12-factor approach; no loader ambiguity.
---
### Q2: Both keys required, or derive public from private at startup?
| Option | Description | Selected |
|--------|-------------|----------|
| Both required env vars | Clean separation; workers can hold public key only | ✓ |
| Private key only — derive public | Fewer env vars but more magic in config.py | |
**User's choice:** Both required env vars
---
### Q3: What happens to SECRET_KEY after ES256?
| Option | Description | Selected |
|--------|-------------|----------|
| Remove from JWT code | SECRET_KEY no longer used for signing; stays in env for future HMAC | ✓ |
| Repurpose as HMAC key for Phase 7.4 | Forward-compatible but couples phases | |
**User's choice:** Remove SECRET_KEY from JWT code
---
## Startup Refresh-Token Rotation
### Q1: How to implement startup rotation?
| Option | Description | Selected |
|--------|-------------|----------|
| DELETE all refresh_tokens | Simple, one-shot; no schema change | |
| Bulk revoke (set revoked=True) | Cleaner audit trail; same user impact | ✓ |
| Let natural expiry handle it | Doesn't satisfy ROADMAP requirement | |
**User's choice:** Bulk revoke (set revoked=True)
---
### Q2: How to detect "first boot after ES256 migration"?
| Option | Description | Selected |
|--------|-------------|----------|
| Check system_settings table | Stores `jwt_algorithm`; idempotent on restart | ✓ |
| New env var JWT_TOKEN_GENERATION=N | Simple but requires operator bump on deploy | |
| One-time Alembic migration | Runs at migrate time, not startup | |
**User's choice:** Check system_settings table (`jwt_algorithm` key)
---
## Key Generation Workflow
### Q1: How should developers generate the key pair?
| Option | Description | Selected |
|--------|-------------|----------|
| Python one-liner in README | Uses existing `cryptography` dep; no extra tooling | ✓ |
| openssl CLI command | Familiar to ops but multi-step, requires openssl | |
| Auto-generate on first boot | Convenient for dev; dangerous in prod (ephemeral keys) | |
**User's choice:** Python one-liner documented in README
---
### Q2: docker-compose key placeholder vs. .env reference?
| Option | Description | Selected |
|--------|-------------|----------|
| .env file reference only | Follows existing ${SECRET_KEY} pattern | ✓ |
| Placeholder comment in docker-compose | Helpful but could mislead | |
**User's choice:** .env file reference only
---
## JWT TTL / Remember Me
### Q1 (freeform): What token should default to 12 hours?
User note: "JWT Tokens should only be valid for 12 hours and the user can opt-in to create a token which is valid for 30 days."
Asked for clarification on whether this applied to the access token or refresh token. User asked: "What do you think is the most secure way to handle tokens?"
**Claude's recommendation:** Keep access token at 15 min (CLAUDE.md requirement). Change refresh token default to 1624 hours with explicit "remember me" opt-in for 30 days.
**User's decision:** 16 hours — covers a full workday; guarantees session is revoked overnight.
---
### Q2: Fold "remember me" into Phase 7.3 or separate phase?
| Option | Description | Selected |
|--------|-------------|----------|
| Fold into Phase 7.3 | Already touching token creation code; incremental work | ✓ |
| Separate phase 7.5 | Keeps 7.3 purely as ES256 upgrade | |
**User's choice:** Fold into Phase 7.3
---
## Claude's Discretion
- PyJWT API form: `jwt.encode(payload, pem_str, algorithm="ES256")` — PEM strings passed directly, no key object conversion needed
- Bulk revoke via raw SQL `UPDATE` rather than ORM row-by-row — efficiency decision
- "Stay signed in for 30 days" as checkbox label (clearer than "Remember me")
- system_settings key name: `jwt_algorithm`, value `"ES256"`
## Deferred Ideas
- **Phase 7.4:** Token fingerprinting / token binding (`fgp` claim = HMAC of User-Agent + Accept-Language)
- **Key rotation ceremony:** Dual-key overlap process for rotating P-256 keys in production — deferred until production hardening milestone