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