diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md new file mode 100644 index 0000000..1b24d64 --- /dev/null +++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md @@ -0,0 +1,702 @@ +# Phase 07.3: Security — ES256 Algorithm Upgrade - Research + +**Researched:** 2026-06-05 +**Domain:** JWT cryptographic algorithm upgrade, ECDSA P-256, FastAPI lifespan startup hooks, Vue 3 login form +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**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. + +**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. + +**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. + +**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`). + +**D-10:** "Remember me" opt-in: **30 days** (existing `refresh_token_expire_days: int = 30` kept as-is). + +**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 "Stay signed in for 30 days" checkbox that passes `remember_me` to `authStore.login()`. The store passes it through to the API call. + +### Claude's Discretion + +None specified. + +### Deferred Ideas (OUT OF SCOPE) + +- **Phase 7.4 — Token fingerprinting / token binding**: Add `fgp` (fingerprint) claim = `hmac(key, User-Agent + Accept-Language)[:16]`. +- **Key rotation ceremony**: Dual-key overlap period for production P-256 key rotation. + + +--- + +## Project Constraints (from CLAUDE.md) + +| Directive | Impact on Phase 7.3 | +|-----------|---------------------| +| JWT access token in Pinia memory only — never localStorage | No change; access token delivery pattern unchanged | +| Access token TTL: 15 minutes maximum (non-negotiable) | D-08 locked; `access_token_expire_minutes` stays at 15 | +| Refresh token: httpOnly Strict cookie; rotated on every use | Cookie-setting helpers must pass `remember_me` through | +| Admin endpoints never return `password_hash`, `credentials_enc`, or document content | Not directly relevant; no new admin endpoints | +| No raw string interpolation in DB queries | Bulk revoke uses `text("UPDATE …")` — parameterized; no user input injected | +| Service layer raises `ValueError`, never `HTTPException` | ES256 config validation in `config.py` validates before service layer, not inside it | +| No function in `services/auth.py` imports `HTTPException` | ES256 key decode failures must raise `ValueError` or let startup crash with a descriptive message | +| Existing test gate: `pytest -v` zero failures before phase advances | `test_settings_has_jwt_config` asserts `refresh_token_expire_days == 30` — this passes since we keep that field; new `refresh_token_expire_hours` assertion needed | + +--- + +## Summary + +Phase 7.3 replaces HS256 with ES256 (ECDSA P-256) across the four JWT signing/verification sites in `backend/services/auth.py`, adds a first-boot startup hook to bulk-revoke all active refresh tokens whenever the algorithm changes, and introduces a "remember me" opt-in that changes the default refresh token TTL from 30 days to 16 hours. + +**The algorithmic change is surgical.** PyJWT 2.13.0 (installed and verified on this system) supports ES256 natively via `jwt.algorithms.ECAlgorithm`. The `cryptography` library (already a project dependency, `>=41.0.0`) provides the P-256 key generation primitives. No new packages are required. The upgrade touches 4 lines in `services/auth.py`, 3 settings in `config.py`, 2 lines in `docker-compose.yml`, and one `README.md` key-generation snippet. + +**The startup token rotation is idempotent.** It uses the `system_settings` table's `provider_id` uniqueness to track the deployed algorithm (`provider_id = "jwt_algorithm"`, `model_name = "ES256"`). On every boot, the lifespan reads this value; if it differs from `"ES256"`, it bulk-revokes all active refresh tokens and writes the new value. Subsequent restarts skip the revocation. The bulk revoke uses a single raw `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` via `session.execute(text(...))` — no Python-layer iteration, consistent with the project pattern for bulk operations. + +**The "remember me" change is a controlled TTL expansion.** The default session drops from 30 days to 16 hours (`refresh_token_expire_hours: int = 16` added to config). An opt-in `remember_me: bool = False` field on the `LoginRequest` Pydantic model controls which TTL path `create_refresh_token` takes. Both the cookie `max_age` and the `expires_at` DB column must be updated consistently. + +**Primary recommendation:** Implement in three independent but ordered waves: (1) ES256 key infrastructure + 4 signing sites, (2) startup rotation check, (3) remember-me TTL + frontend checkbox. Each wave is independently testable. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| JWT signing key pair storage | Backend config (env vars) | — | Private key must never leave the backend; env var is the standard secret injection pattern | +| JWT encoding (ES256) | Backend service (`services/auth.py`) | — | All token creation is encapsulated in the service layer per CLAUDE.md rules | +| JWT decoding / verification | Backend deps (`deps/auth.py` → `services/auth.py`) | — | `get_current_user` dependency calls `decode_access_token` from service layer | +| Startup token rotation | Backend lifespan (`main.py`) | DB (`system_settings`) | Lifespan owns startup tasks; DB tracks idempotency state | +| Refresh token TTL selection | Backend service (`services/auth.py:create_refresh_token`) | Backend API (`api/auth.py:login`) | Service makes TTL decision; API passes the flag through | +| Cookie max_age alignment | Backend API (`api/auth.py:_set_refresh_cookie`) | — | Cookie must mirror DB `expires_at` TTL exactly | +| "Stay signed in" checkbox UI | Frontend view (`LoginView.vue`) | Frontend store (`stores/auth.js`) | View owns form state; store owns API call forwarding | + +--- + +## Standard Stack + +### Core (already installed — no new packages) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| PyJWT | `>=2.8.0` (2.13.0 installed) | JWT encode/decode with ES256 | Official Python JWT library; `ECAlgorithm` class confirmed present; ES256 encode/decode verified working on this system [VERIFIED: pip show PyJWT] | +| cryptography | `>=41.0.0` | P-256 key generation and PEM serialization | Already project dependency for HKDF/Fernet; `ec.generate_private_key(ec.SECP256R1())` and `serialization.Encoding.PEM` APIs verified working on this system [VERIFIED: runtime test] | + +### No New Packages Required + +All cryptographic primitives for ES256 are covered by the two libraries above, both already pinned in `requirements.txt`. No additional installs needed for this phase. + +**Version verification:** +```bash +pip show PyJWT # → 2.13.0 +pip show cryptography # → already installed (41.x or higher) +``` + +--- + +## Package Legitimacy Audit + +No new packages are introduced in this phase. All cryptographic work uses `PyJWT` (already pinned) and `cryptography` (already pinned). Slopcheck is not required. + +| Package | Status | Notes | +|---------|--------|-------| +| PyJWT | Already in requirements.txt — no change | ES256 support confirmed [VERIFIED: runtime test] | +| cryptography | Already in requirements.txt — no change | P-256 key generation confirmed [VERIFIED: runtime test] | + +**Packages removed due to slopcheck verdict:** none +**Packages flagged as suspicious:** none + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +.env file + JWT_PRIVATE_KEY (base64 PEM) + JWT_PUBLIC_KEY (base64 PEM) + │ + ▼ +config.py (Settings) + jwt_private_key: str → base64.b64decode().decode() → PEM string + jwt_public_key: str → base64.b64decode().decode() → PEM string + refresh_token_expire_hours: int = 16 + refresh_token_expire_days: int = 30 + │ + ├──► services/auth.py + │ create_access_token() jwt.encode(payload, private_pem, algorithm="ES256") + │ decode_access_token() jwt.decode(token, public_pem, algorithms=["ES256"]) + │ create_password_reset_token() jwt.encode(payload, private_pem, algorithm="ES256") + │ decode_password_reset_token() jwt.decode(token, public_pem, algorithms=["ES256"]) + │ create_refresh_token(remember_me=False) + │ TTL = timedelta(hours=16) if not remember_me + │ = timedelta(days=30) if remember_me + │ + ├──► main.py lifespan (startup) + │ ES256 rotation check: + │ SELECT model_name FROM system_settings WHERE provider_id = 'jwt_algorithm' + │ if missing or != 'ES256': + │ UPDATE refresh_tokens SET revoked = true WHERE revoked = false + │ UPSERT system_settings (provider_id='jwt_algorithm', model_name='ES256') + │ + └──► api/auth.py + LoginRequest.remember_me: bool = False + _set_refresh_cookie(response, raw_token, remember_me) + max_age = 30*86400 if remember_me else 16*3600 + +Frontend: + LoginView.vue + [ ] Stay signed in for 30 days ← new checkbox + submitPassword() / submitTotp() / submitBackupCode() + → authStore.login(email, password, { ..., rememberMe: bool }) + + stores/auth.js + login(email, password, options) + api.login({ ..., remember_me: options.rememberMe ?? false }) +``` + +### Recommended Project Structure + +No new directories. Changes are concentrated in: +``` +backend/ +├── config.py # +jwt_private_key, +jwt_public_key, +refresh_token_expire_hours +├── services/auth.py # 4 jwt.encode/decode sites; create_refresh_token TTL +├── main.py # lifespan: +ES256 rotation block +├── api/auth.py # LoginRequest +remember_me; _set_refresh_cookie +remember_me +frontend/ +├── src/views/auth/LoginView.vue # +checkbox, +rememberMe ref, pass through to store +└── src/stores/auth.js # login() +rememberMe param pass-through +``` + +### Pattern 1: ES256 Key Decode in config.py + +```python +# Source: verified via runtime test + PyJWT docs +import base64 + +class Settings(BaseSettings): + # ... existing fields ... + jwt_private_key: str = "" # base64-encoded PEM — required in production + jwt_public_key: str = "" # base64-encoded PEM — required in production + refresh_token_expire_hours: int = 16 # default short session (D-09) + # refresh_token_expire_days: int = 30 already exists — keep unchanged + + def get_jwt_private_key_pem(self) -> str: + """Decode base64-encoded private key PEM string for PyJWT.""" + return base64.b64decode(self.jwt_private_key).decode() + + def get_jwt_public_key_pem(self) -> str: + """Decode base64-encoded public key PEM string for PyJWT.""" + return base64.b64decode(self.jwt_public_key).decode() +``` + +**Alternative (simpler, no property):** Decode inline at call site in `services/auth.py`: +```python +import base64 +private_pem = base64.b64decode(settings.jwt_private_key).decode() +public_pem = base64.b64decode(settings.jwt_public_key).decode() +``` + +Either approach is acceptable. The inline pattern avoids adding methods to Settings and is consistent with how other base64 fields (like cloud credentials) are handled in this codebase. + +### Pattern 2: ES256 JWT Signing/Verification (4 sites) + +```python +# Source: verified via runtime test — PyJWT 2.13.0 + cryptography 41.x +import base64, jwt +from config import settings + +# BEFORE (HS256): +# return jwt.encode(payload, settings.secret_key, algorithm="HS256") +# payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) + +# AFTER (ES256): +def create_access_token(user_id: str, role: str) -> str: + # ... payload construction unchanged ... + private_pem = base64.b64decode(settings.jwt_private_key).decode() + return jwt.encode(payload, private_pem, algorithm="ES256") + +def decode_access_token(token: str) -> dict: + public_pem = base64.b64decode(settings.jwt_public_key).decode() + try: + payload = jwt.decode(token, public_pem, algorithms=["ES256"]) + except jwt.ExpiredSignatureError as exc: + raise ValueError("Token has expired") from exc + except jwt.PyJWTError as exc: + raise ValueError(f"Invalid token: {exc}") from exc + # ... type check unchanged ... + return payload +``` + +The same pattern applies identically to `create_password_reset_token` (sign) and `decode_password_reset_token` (verify). + +### Pattern 3: Startup Token Rotation (lifespan hook) + +```python +# Source: D-04/D-05 from CONTEXT.md; modeled after seed_system_settings_from_env pattern +from sqlalchemy import text, select +from db.models import SystemSettings + +async def _rotate_tokens_on_algorithm_change(session: AsyncSession) -> None: + """Idempotent ES256 migration: bulk-revoke tokens if algorithm changed.""" + stmt = select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + current_alg = row.model_name if row is not None else None + if current_alg == "ES256": + return # Already migrated — skip + + # Algorithm changed or first boot: bulk-revoke all active refresh tokens + await session.execute( + text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false") + ) + + # Upsert the algorithm marker + if row is None: + import uuid as _uuid + session.add(SystemSettings( + id=_uuid.uuid4(), + provider_id="jwt_algorithm", + model_name="ES256", + context_chars=0, + is_active=False, + api_key_enc=None, + base_url=None, + )) + else: + row.model_name = "ES256" + + await session.commit() +``` + +This function slots into `main.py:lifespan` after the AI seed block, wrapped in the same `try/except` pattern (so a missing `system_settings` table before migrations doesn't crash startup). + +### Pattern 4: remember_me TTL Selection + +```python +# Source: D-09/D-10/D-11 from CONTEXT.md + +async def create_refresh_token( + session: AsyncSession, + user_id: uuid.UUID, + remember_me: bool = False, # NEW PARAM +) -> str: + raw = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(raw.encode()).hexdigest() + now = datetime.now(timezone.utc) + # Select TTL based on remember_me + if remember_me: + ttl = timedelta(days=settings.refresh_token_expire_days) # 30 days + else: + ttl = timedelta(hours=settings.refresh_token_expire_hours) # 16 hours + row = RefreshToken( + id=uuid.uuid4(), + user_id=user_id, + token_hash=token_hash, + expires_at=now + ttl, + revoked=False, + ) + session.add(row) + await session.commit() + return raw +``` + +The `_set_refresh_cookie` helper also needs a `remember_me` param to set `max_age` consistently: +```python +def _set_refresh_cookie(response: Response, raw_token: str, remember_me: bool = False) -> None: + max_age = ( + settings.refresh_token_expire_days * 86400 + if remember_me + else settings.refresh_token_expire_hours * 3600 + ) + response.set_cookie( + key="refresh_token", + value=raw_token, + httponly=True, + secure=True, + samesite="strict", + path="/api/auth/refresh", + max_age=max_age, + ) +``` + +### Pattern 5: Key Generation One-Liner (for README.md) + +```python +# Source: verified via runtime test — cryptography library +python3 -c " +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization +import base64 +k = ec.generate_private_key(ec.SECP256R1()) +priv = base64.b64encode(k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())).decode() +pub = base64.b64encode(k.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)).decode() +print(f'JWT_PRIVATE_KEY={priv}') +print(f'JWT_PUBLIC_KEY={pub}') +" +``` + +This outputs two single-line values ready to paste into `.env`. The `cryptography` library is already a project dependency so no install is needed. + +### Anti-Patterns to Avoid + +- **Caching decoded PEM strings as module-level globals:** If the env var changes between tests or deployments and the module was already imported, stale keys would be used. Decode inline at each call site or in a `@cached_property` that is tied to the Settings instance. +- **Passing `settings.secret_key` to any JWT function after ES256 is deployed:** D-03 explicitly removes this coupling. If any site still uses `secret_key`, tests will catch it (HS256 tokens rejected by ES256 verifier). +- **Calling `create_refresh_token` in `rotate_refresh_token` without forwarding `remember_me`:** `rotate_refresh_token` calls `create_refresh_token(session, row.user_id)` internally. This internal call should NOT receive `remember_me` — token rotation preserves the session type from the original login. The new `remember_me` param defaults to `False`, so the rotation call is unchanged; a rotated token always gets the default 16-hour TTL (which is acceptable security behavior — the client gets a fresh cookie with the new token). +- **Inserting a `jwt_algorithm` row into `system_settings` with `is_active=True`:** This would make the AI provider loader (`load_provider_config`) potentially attempt to use "jwt_algorithm" as a provider. Always insert with `is_active=False`. +- **Using `model_name` for arbitrary key-value storage without documenting the overloading:** The `SystemSettings.model_name` column is `NOT NULL TEXT` — it can hold "ES256" safely. Document in the startup function's docstring that this is a metadata row, not an AI provider row. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| ECDSA key generation | Custom byte manipulation | `ec.generate_private_key(ec.SECP256R1())` from `cryptography` | cryptography handles FIPS-compliant randomness, correct curve encoding, and PEM serialization | +| ES256 JWT signing | Manual ECDSA signature + base64url encode | `jwt.encode(payload, pem, algorithm="ES256")` from PyJWT | PyJWT handles all RFC 7518 compliance, header formatting, and signature encoding | +| ES256 JWT verification | Manual signature extraction + ECDSA verify | `jwt.decode(token, pem, algorithms=["ES256"])` from PyJWT | PyJWT validates expiry, algorithm, and signature in one call; algorithm-restriction list prevents downgrade attacks | +| Bulk token revocation | Python loop loading all RefreshToken rows | `session.execute(text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false"))` | Single SQL statement vs. N Python-layer round trips; consistent with project bulk-operation pattern | + +**Key insight:** The `cryptography` + `PyJWT` combination handles all ECDSA P-256 complexity. There is no case in this phase where custom cryptographic code is appropriate. + +--- + +## Common Pitfalls + +### Pitfall 1: PEM Newlines in Environment Variables +**What goes wrong:** A raw PEM string contains newlines. Shell `.env` files and Docker Compose do not handle multi-line values well — the variable gets truncated at the first newline. +**Why it happens:** PEM format is inherently multi-line. Naive `JWT_PRIVATE_KEY=$(cat private.pem)` breaks. +**How to avoid:** Store as base64-encoded single line (D-01). `base64.b64encode(pem_bytes).decode()` produces a single-line string with no newlines (standard base64 with no padding issues for a ~PKCS8 key). Decode at use with `base64.b64decode(settings.jwt_private_key).decode()`. +**Warning signs:** `binascii.Error: Invalid base64-encoded string` or `jwt.exceptions.DecodeError: Invalid header string` at startup. + +### Pitfall 2: Algorithm Confusion / Downgrade Attack During Transition +**What goes wrong:** Old clients hold HS256 tokens. After ES256 deployment, `decode_access_token` now calls `jwt.decode(token, pub_pem, algorithms=["ES256"])`. An HS256 token presented to the ES256 verifier raises `InvalidAlgorithmError` which maps to 401 — correct behavior. +**Why it happens:** Clients with a valid HS256 access token (up to 15 min old) will get 401 until their token expires. This is expected and correct — they will auto-refresh via the httpOnly cookie, which triggers the rotation. The startup hook bulk-revokes all refresh tokens, forcing re-login. +**How to avoid:** Accept this brief disruption (max 15 minutes for any in-flight session). Document in README.md. No dual-algorithm support is needed since the bulk-revoke forces re-login on first boot. +**Warning signs:** Users report "logged out" immediately after deploy — this is expected. + +### Pitfall 3: SystemSettings Model Incompatibility for jwt_algorithm Row +**What goes wrong:** `SystemSettings.model_name` is `NOT NULL` with `default=""`. `context_chars` is `NOT NULL` with `default=8000`. Inserting a `jwt_algorithm` marker row requires satisfying these constraints without meaningful values. +**Why it happens:** The table was designed for AI provider config, not generic key-value storage. +**How to avoid:** Insert with `model_name="ES256"`, `context_chars=0`, `is_active=False`, `api_key_enc=None`, `base_url=None`. This satisfies all NOT NULL constraints. Use `is_active=False` so the AI provider loader (`load_provider_config`) never returns this row. Verified against the model definition at `db/models.py` line 340. +**Warning signs:** `sqlalchemy.exc.IntegrityError` on NOT NULL violation if `model_name` is omitted. + +### Pitfall 4: _set_refresh_cookie Called in Refresh Endpoint Without remember_me +**What goes wrong:** `_set_refresh_cookie` is called in both the `login` endpoint (new `remember_me` param) and the `refresh_token` endpoint (token rotation, no `remember_me`). If the refresh endpoint passes `remember_me=False` (default), rotating a 30-day session silently downgrades it to 16 hours. +**Why it happens:** Token rotation in `rotate_refresh_token` doesn't know what TTL the original token had — `expires_at` is on the old row, which is being revoked. +**How to avoid:** For the rotation endpoint, preserve the original cookie's `max_age` by reading the remaining TTL from the new token's `expires_at` and computing `max_age = int((row.expires_at - now).total_seconds())` on the newly-created row. Alternatively, accept that rotated sessions default to 16 hours (acceptable since the user can re-login with remember_me). The CONTEXT.md does not require preservation of remember_me state across rotation, so the simpler approach (default TTL on rotation) is acceptable. +**Warning signs:** Users who selected "remember me" get logged out after 16 hours despite the checkbox. + +> **Decision required by planner:** Either preserve the TTL on rotation (complex — needs to pass remember_me through rotate_refresh_token) or accept 16-hour default on rotation (simple and acceptable). The CONTEXT.md is silent on this. The simpler approach is recommended for this phase. + +### Pitfall 5: Existing Test Asserts on Settings Value +**What goes wrong:** `backend/tests/test_task1_models_config.py:31` asserts `settings.refresh_token_expire_days == 30`. Adding `refresh_token_expire_hours` does not break this assertion since the field value is unchanged. +**Why it happens:** Phase 2 locked the TTL value in a test. Phase 7.3 adds a new field but doesn't change the old one. +**How to avoid:** The test continues to pass. A new test should assert `settings.refresh_token_expire_hours == 16` and that `refresh_token_expire_days` remains 30. +**Warning signs:** Test file `test_task1_models_config.py` — check that no assertion fails after adding `refresh_token_expire_hours`. + +### Pitfall 6: Lifespan Startup Order — System Settings Table May Not Exist +**What goes wrong:** The startup rotation check queries `system_settings`. On a fresh container before migrations run, the table doesn't exist and the query raises. +**Why it happens:** Docker healthchecks ensure DB is ready, but the migration step may not have run yet (developer workflow). +**How to avoid:** Wrap the ES256 rotation block in the same `try/except Exception` pattern already used for `seed_system_settings_from_env` in `main.py:lifespan` (lines 172–180). Log a warning and skip if the table is missing. On the next boot (after migrations), the check runs successfully. +**Warning signs:** `sqlalchemy.exc.ProgrammingError: relation "system_settings" does not exist` at startup. + +--- + +## Code Examples + +Verified patterns from official sources: + +### Full ES256 Signing Chain (verified at research time) +```python +# Source: verified via runtime test on this system (PyJWT 2.13.0 + cryptography 41.x) +import base64, jwt, uuid, datetime as dt + +private_pem = base64.b64decode(settings.jwt_private_key).decode() +public_pem = base64.b64decode(settings.jwt_public_key).decode() + +# Sign +now = dt.datetime.now(dt.timezone.utc) +payload = { + "sub": str(user_id), + "role": role, + "typ": "access", + "jti": str(uuid.uuid4()), # Phase 7.2 adds this claim; preserved in ES256 + "iat": now, + "exp": now + timedelta(minutes=settings.access_token_expire_minutes), +} +token = jwt.encode(payload, private_pem, algorithm="ES256") + +# Verify +decoded = jwt.decode(token, public_pem, algorithms=["ES256"]) +# algorithms=["ES256"] is a whitelist — HS256 tokens raise InvalidAlgorithmError (verified) +``` + +### Bulk Revoke + System Settings Upsert (startup) +```python +# Source: D-04/D-05 from CONTEXT.md; SQLAlchemy text() pattern from codebase +from sqlalchemy import text, select +from db.models import SystemSettings + +async with AsyncSessionLocal() as session: + result = await session.execute( + select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") + ) + row = result.scalar_one_or_none() + + if row is None or row.model_name != "ES256": + await session.execute( + text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false") + ) + if row is None: + session.add(SystemSettings( + id=uuid.uuid4(), + provider_id="jwt_algorithm", + model_name="ES256", + context_chars=0, + is_active=False, + )) + else: + row.model_name = "ES256" + await session.commit() +``` + +### LoginRequest with remember_me (backend) +```python +# Source: CONTEXT.md D-11; modeled on existing LoginRequest pattern +class LoginRequest(BaseModel): + email: EmailStr + password: str + totp_code: Optional[str] = None + backup_code: Optional[str] = None + remember_me: bool = False # NEW: opt-in to 30-day session +``` + +### Login form checkbox (Vue 3 Options API → script setup) +```vue + + +
+ + +
+``` + +```javascript +// In script setup, add: +const rememberMe = ref(false) + +// Update submitPassword(): +const result = await authStore.login(email.value, password.value, { + rememberMe: rememberMe.value, +}) +``` + +### Auth store login() update +```javascript +// Source: CONTEXT.md D-12; extends existing stores/auth.js login() action +async function login(email, password, options = {}) { + // ... + const data = await api.login({ + email, + password, + totp_code: options.totpCode ?? null, + backup_code: options.backupCode ?? null, + remember_me: options.rememberMe ?? false, // NEW + }) + // ... rest unchanged +} +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| HS256 (symmetric HMAC) | ES256 (ECDSA P-256) asymmetric | This phase | Public key cannot forge tokens; leaked public key (safe to distribute) cannot impersonate users | +| 30-day default refresh TTL | 16-hour default; 30-day opt-in | This phase | Sessions expire overnight by default; lower risk for unattended sessions | +| `algorithms=["HS256"]` in decode | `algorithms=["ES256"]` whitelist | This phase | PyJWT whitelist prevents algorithm confusion attacks; HS256 tokens immediately rejected | + +**Deprecated/outdated:** +- `settings.secret_key` as JWT signing key: After this phase, `services/auth.py` no longer references `secret_key` for JWT operations. The field stays in config for Phase 7.4 HMAC fingerprinting use. + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | The `model_name` field of `SystemSettings` being repurposed as a value store for `jwt_algorithm` will not trigger unexpected behavior in `load_provider_config()` since `is_active=False` prevents selection | Startup rotation | Low — verified that `load_provider_config` filters by `is_active IS TRUE`; `is_active=False` row is never returned as active provider | +| A2 | `rotate_refresh_token()` (called for normal token rotation, not the startup bulk-revoke) calls `create_refresh_token(session, row.user_id)` without `remember_me` — new default TTL (16h) will apply on every rotation | Pitfall 4 | Low risk for security (shorter is better); risk for UX (remember-me users get 16h after first rotation). Accepted per Pitfall 4 note. | +| A3 | Phase 7.2 adds a `jti` claim to access tokens. Since Phase 7.3 depends on Phase 7.2, the `create_access_token` payload will already contain `jti` when Phase 7.3 is implemented. The ES256 upgrade must preserve the `jti` field. | Code Examples | Low — verified: PyJWT preserves all custom claims through ES256 encode/decode. | + +**If this table is empty:** All claims in this research were verified or cited. — Not applicable; three low-risk assumptions documented above. + +--- + +## Open Questions + +1. **TTL preservation across refresh token rotation** + - What we know: `rotate_refresh_token` calls `create_refresh_token` internally with no `remember_me` param (default=False → 16 hours) + - What's unclear: Should a user who selected "stay signed in 30 days" get a new 30-day token on each rotation, or is 16-hour degradation acceptable? + - Recommendation: Accept 16-hour degradation on rotation for Phase 7.3 (simpler implementation). If users report confusion, Phase 7.4 or a follow-up can add TTL preservation by reading original `expires_at` or adding a `remember_me` column to `RefreshToken`. + +2. **Test `test_settings_has_jwt_config` assertion on `refresh_token_expire_days == 30`** + - What we know: The test at `backend/tests/test_task1_models_config.py:31` asserts the existing field equals 30. This field is NOT changed. + - What's unclear: Nothing — this test continues to pass. A new test should assert `refresh_token_expire_hours == 16`. + - Recommendation: Add assertion for `refresh_token_expire_hours` in the same test function or a new one. + +--- + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| PyJWT | ES256 encoding/decoding | Yes | 2.13.0 | — | +| cryptography | P-256 key generation, PEM serialization | Yes | (>=41.0.0 pinned) | — | +| Python 3.12 | All backend code | Yes | 3.12 | — | +| PostgreSQL | `system_settings` table for algorithm tracking | Yes (Docker Compose) | 17 | — | + +**Missing dependencies with no fallback:** None. +**Missing dependencies with fallback:** None. + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | pytest + pytest-asyncio | +| Config file | `backend/pytest.ini` or `pyproject.toml` | +| Quick run command | `cd backend && pytest tests/test_auth_api.py tests/test_task1_models_config.py -x -v` | +| Full suite command | `cd backend && pytest -v` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| ES256-01 | `create_access_token` uses ES256 algorithm | unit | `pytest tests/test_auth_es256.py::test_access_token_uses_es256 -x` | ❌ Wave 0 | +| ES256-02 | `decode_access_token` rejects HS256 tokens | unit | `pytest tests/test_auth_es256.py::test_hs256_token_rejected -x` | ❌ Wave 0 | +| ES256-03 | `create_password_reset_token` uses ES256 | unit | `pytest tests/test_auth_es256.py::test_reset_token_uses_es256 -x` | ❌ Wave 0 | +| ES256-04 | Startup rotation bulk-revokes on algorithm change | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens -x` | ❌ Wave 0 | +| ES256-05 | Startup rotation is idempotent (second boot skips) | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_idempotent -x` | ❌ Wave 0 | +| RM-01 | Default login issues 16-hour refresh token | integration | `pytest tests/test_auth_es256.py::test_default_ttl_16_hours -x` | ❌ Wave 0 | +| RM-02 | remember_me=True issues 30-day refresh token | integration | `pytest tests/test_auth_es256.py::test_remember_me_ttl_30_days -x` | ❌ Wave 0 | +| RM-03 | remember_me=True sets cookie max_age=30*86400 | integration | `pytest tests/test_auth_es256.py::test_remember_me_cookie_max_age -x` | ❌ Wave 0 | +| CFG-01 | Settings has `jwt_private_key`, `jwt_public_key`, `refresh_token_expire_hours` | unit | `pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x` | ✅ (needs extension) | + +### Sampling Rate + +- **Per task commit:** `cd backend && pytest tests/test_auth_es256.py tests/test_task1_models_config.py -x -v` +- **Per wave merge:** `cd backend && pytest -v` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +- [ ] `backend/tests/test_auth_es256.py` — covers ES256-01 through RM-03 (8 new tests) +- [ ] Extend `backend/tests/test_task1_models_config.py::test_settings_has_jwt_config` to assert `refresh_token_expire_hours == 16` + +--- + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | ES256 asymmetric tokens; `algorithms=["ES256"]` whitelist prevents downgrade | +| V3 Session Management | yes | Refresh token TTL split (16h default / 30d opt-in); bulk-revoke on algorithm change | +| V4 Access Control | no | No new access control logic | +| V5 Input Validation | yes | `remember_me: bool = False` typed Pydantic field; `base64.b64decode` validates key format at startup | +| V6 Cryptography | yes | ECDSA P-256 (256-bit EC key) via `cryptography` library; no hand-rolled crypto | + +### Known Threat Patterns for JWT + ES256 + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Algorithm confusion: HS256 token presented to ES256 verifier | Spoofing | `algorithms=["ES256"]` whitelist in `jwt.decode()` — raises `InvalidAlgorithmError` (verified) | +| None algorithm attack | Spoofing | PyJWT explicitly requires algorithms list; `algorithms=["ES256"]` prevents `alg=none` | +| Leaked public key token forgery | Spoofing | ES256 asymmetric design: public key cannot sign — forgery requires the private key | +| Private key exposure in env | Elevation of Privilege | Base64 single-line in `.env`; `.gitignore` enforces exclusion; no defaults in code (D-07) | +| Stale HS256 tokens after upgrade | Spoofing | Bulk refresh token revocation forces re-login; 15-min access token TTL self-expires | +| remember_me session indefinitely reused | Elevation of Privilege | RFC 9700 family revocation on reuse still applies; 30-day TTL is bounded | + +### Security Gate Requirements + +Before phase advances: +- [ ] `bandit -r backend/` — zero HIGH severity findings +- [ ] `pip audit` — zero critical/high CVEs +- [ ] `npm audit --audit-level=high` — zero high/critical +- [ ] HS256 token presented to ES256 verifier → 401 (negative test `test_hs256_token_rejected`) +- [ ] Startup rotation test: tokens revoked on algorithm change, skipped on same algorithm +- [ ] `services/auth.py` grep confirms zero references to `settings.secret_key` after implementation + +--- + +## Sources + +### Primary (HIGH confidence) + +- PyJWT 2.13.0 installed on system — `ECAlgorithm`, ES256 encode/decode verified via runtime execution +- `cryptography` library installed — `ec.generate_private_key(ec.SECP256R1())`, `serialization.Encoding.PEM` verified via runtime execution +- `backend/services/auth.py` — 4 HS256 sites confirmed at lines 99, 109, 132, 141 (read directly) +- `backend/config.py` — current settings layout confirmed (read directly) +- `backend/main.py` — lifespan function structure confirmed at line 136 (read directly) +- `backend/services/ai_config.py` — `seed_system_settings_from_env` upsert reference pattern (read directly) +- `backend/db/models.py` — `SystemSettings` schema confirmed at lines 340–377; `RefreshToken.revoked` at line 102 (read directly) +- `backend/api/auth.py` — `LoginRequest`, `_set_refresh_cookie`, login handler confirmed (read directly) +- `frontend/src/views/auth/LoginView.vue` — step-based login form, `authStore.login()` at line 228 (read directly) +- `frontend/src/stores/auth.js` — `login()` action confirmed (read directly) +- `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md` — all 12 decisions confirmed (read directly) +- `backend/tests/test_task1_models_config.py:31` — existing assertion on `refresh_token_expire_days == 30` confirmed (read directly) + +### Secondary (MEDIUM confidence) + +None required — all findings verified from code or runtime tests. + +### Tertiary (LOW confidence) + +None — no claims rely on WebSearch-only sources. + +--- + +## Metadata + +**Confidence breakdown:** + +- Standard stack: HIGH — PyJWT ES256 and cryptography P-256 generation both verified via runtime tests on the installed versions +- Architecture: HIGH — all 6 change sites read directly from codebase; data flow confirmed end-to-end +- Pitfalls: HIGH — pitfalls derived from concrete code inspection (existing constraints, test assertions, and the SystemSettings schema) + +**Research date:** 2026-06-05 +**Valid until:** 2026-07-05 (stable libraries; PyJWT API is backward-compatible since 2.0)