diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md new file mode 100644 index 0000000..c684c3b --- /dev/null +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md @@ -0,0 +1,122 @@ +# Phase 7.3: Security — ES256 Algorithm Upgrade - Context + +**Gathered:** 2026-06-05 +**Status:** Ready for planning + + +## 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. + + + + +## 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. + + + + +## 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 + + + + +## 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 + + + + +## 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. + + + + +## 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. + + + +--- + +*Phase: 07.3-security-es256-algorithm-upgrade-inserted* +*Context gathered: 2026-06-05* diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-DISCUSSION-LOG.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-DISCUSSION-LOG.md new file mode 100644 index 0000000..c2f41a7 --- /dev/null +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-DISCUSSION-LOG.md @@ -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 16–24 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