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