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

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

39 KiB
Raw Permalink Blame History

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.pyservices/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:

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 })

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

# 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:

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)

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

# 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

# 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:

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)

# 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.

What goes wrong: _set_refresh_cookie is called in both the login endpoint (new remember_me param) and the refresh_token endpoint (token rotation, no remember_me). If the refresh endpoint passes remember_me=False (default), rotating a 30-day session silently downgrades it to 16 hours. Why it happens: Token rotation in rotate_refresh_token doesn't know what TTL the original token had — expires_at is on the old row, which is being revoked. How to avoid: For the rotation endpoint, preserve the original cookie's max_age by reading the remaining TTL from the new token's expires_at and computing max_age = int((row.expires_at - now).total_seconds()) on the newly-created row. Alternatively, accept that rotated sessions default to 16 hours (acceptable since the user can re-login with remember_me). The CONTEXT.md does not require preservation of remember_me state across rotation, so the simpler approach (default TTL on rotation) is acceptable. Warning signs: Users who selected "remember me" get logged out after 16 hours despite the checkbox.

Decision required by planner: Either preserve the TTL on rotation (complex — needs to pass remember_me through rotate_refresh_token) or accept 16-hour default on rotation (simple and acceptable). The CONTEXT.md is silent on this. The simpler approach is recommended for this phase.

Pitfall 5: Existing Test Asserts on Settings Value

What goes wrong: backend/tests/test_task1_models_config.py:31 asserts settings.refresh_token_expire_days == 30. Adding refresh_token_expire_hours does not break this assertion since the field value is unchanged. Why it happens: Phase 2 locked the TTL value in a test. Phase 7.3 adds a new field but doesn't change the old one. How to avoid: The test continues to pass. A new test should assert settings.refresh_token_expire_hours == 16 and that refresh_token_expire_days remains 30. Warning signs: Test file test_task1_models_config.py — check that no assertion fails after adding refresh_token_expire_hours.

Pitfall 6: Lifespan Startup Order — System Settings Table May Not Exist

What goes wrong: The startup rotation check queries system_settings. On a fresh container before migrations run, the table doesn't exist and the query raises. Why it happens: Docker healthchecks ensure DB is ready, but the migration step may not have run yet (developer workflow). How to avoid: Wrap the ES256 rotation block in the same try/except Exception pattern already used for seed_system_settings_from_env in main.py:lifespan (lines 172180). Log a warning and skip if the table is missing. On the next boot (after migrations), the check runs successfully. Warning signs: sqlalchemy.exc.ProgrammingError: relation "system_settings" does not exist at startup.


Code Examples

Verified patterns from official sources:

Full ES256 Signing Chain (verified at research time)

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

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

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

<!-- 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>
// 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

// 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.pyseed_system_settings_from_env upsert reference pattern (read directly)
  • backend/db/models.pySystemSettings schema confirmed at lines 340377; RefreshToken.revoked at line 102 (read directly)
  • backend/api/auth.pyLoginRequest, _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.jslogin() 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)