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