Files
kite/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
T
curo1305andClaude Sonnet 4.6 b7994efd06 docs(07.3): create phase plan — ES256 algorithm upgrade
3 plans (3 waves): Wave 0 xfail stubs, Wave 1 ES256 core + startup
rotation, Wave 2 remember-me TTL split + LoginView checkbox.
Verification passed (0 blockers, 1 minor doc warning fixed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:07:25 +02:00

700 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>
## 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.
</user_constraints>
---
## 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 172180). 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
<!-- Source: CONTEXT.md D-12; follows existing LoginView.vue pattern -->
<!-- Add inside the password step form, before the submit button: -->
<div class="flex items-center gap-2">
<input
v-model="rememberMe"
id="remember-me"
type="checkbox"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<label for="remember-me" class="text-sm text-gray-600">
Stay signed in for 30 days
</label>
</div>
```
```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 (RESOLVED)
1. **TTL preservation across refresh token rotation** (RESOLVED)
- What we know: `rotate_refresh_token` calls `create_refresh_token` internally with no `remember_me` param (default=False → 16 hours)
- RESOLVED: Accept 16-hour degradation on rotation for Phase 7.3 (simpler implementation). `rotate_refresh_token` is intentionally NOT updated. Documented in Plan 07.3-03 objective and acceptance criteria.
2. **Test `test_settings_has_jwt_config` assertion on `refresh_token_expire_days == 30`** (RESOLVED)
- RESOLVED: Nothing — this test continues to pass. Plan 07.3-01-T2 adds assertion for `refresh_token_expire_hours == 16` to the same test function.
---
## 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 340377; `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)