Files

9.5 KiB
Raw Permalink Blame History

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_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.pyPOST /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>

## 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