6 tests promoted from xfail to passing (ES256-01..05 + CFG-01); 3 remain xfail for Plan 03
9.0 KiB
phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
| phase | plan | subsystem | tags | dependency_graph | tech_stack | key_files | decisions | metrics | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07.3-security-es256-algorithm-upgrade-inserted | 02 | backend/auth |
|
|
|
|
|
|
Phase 07.3 Plan 02: ES256 Core Upgrade — JWT Sites + Startup Rotation + Operator Wiring Summary
ES256 algorithm upgrade: all 4 JWT signing/decoding sites use ECDSA P-256 with inline base64-decoded PEM keys, with idempotent startup bulk-revocation hook and full operator documentation.
Tasks Completed
| Task | Name | Commit | Files |
|---|---|---|---|
| 1 | Add JWT key + TTL settings; swap all 4 JWT sites to ES256; remove secret_key from JWT code | fd3f611 |
backend/config.py, backend/services/auth.py, backend/tests/test_auth_es256.py |
| 2 | Add lifespan _rotate_tokens_on_algorithm_change hook + promote startup rotation tests | 8d261b0 |
backend/main.py, backend/tests/test_auth_es256.py |
| 3 | Wire JWT key env vars through docker-compose, README key-gen snippet, .env.example, version bump | 0d1ab05 |
docker-compose.yml, README.md, .env.example, backend/main.py, frontend/package.json |
Grep Gate Results (Acceptance Criteria Verified)
| Gate | Expected | Actual | Status |
|---|---|---|---|
settings.secret_key in services/auth.py |
0 | 0 | PASS |
algorithm="ES256" in services/auth.py |
2 | 2 | PASS |
algorithms=["ES256"] in services/auth.py |
2 | 2 | PASS |
"HS256" in services/auth.py |
0 | 0 | PASS |
base64.b64decode(settings.jwt_ in services/auth.py |
4 | 4 | PASS |
import base64 in services/auth.py |
1 | 1 | PASS |
refresh_token_expire_hours: int = 16 in config.py |
1 | 1 | PASS |
jwt_private_key: str = "" in config.py |
1 | 1 | PASS |
jwt_public_key: str = "" in config.py |
1 | 1 | PASS |
async def _rotate_tokens_on_algorithm_change in main.py |
1 | 1 | PASS |
await _rotate_tokens_on_algorithm_change(session) in main.py |
1 | 1 | PASS |
UPDATE refresh_tokens SET revoked = true WHERE revoked = false in main.py |
1 | 1 | PASS |
provider_id == "jwt_algorithm" in main.py |
1 | 1 | PASS |
is_active=False in main.py |
>= 1 | 2 | PASS |
ES256 rotation check skipped in main.py |
1 | 1 | PASS |
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY} in docker-compose.yml |
2 | 2 | PASS |
JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY} in docker-compose.yml |
2 | 2 | PASS |
| Literal base64 PEM in docker-compose.yml | 0 | 0 | PASS |
ec.generate_private_key(ec.SECP256R1()) in README.md |
>= 1 | 1 | PASS |
Rotating these keys invalidates every active session in README.md |
1 | 1 | PASS |
JWT_PRIVATE_KEY= in .env.example (no value) |
present | present | PASS |
JWT_PUBLIC_KEY= in .env.example (no value) |
present | present | PASS |
version="0.1.2" in backend/main.py |
1 | 1 | PASS |
"version": "0.1.2" in frontend/package.json |
1 | 1 | PASS |
docker compose config -q exits 0 |
0 | 0 | PASS |
Promoted Tests (xfail → PASSED)
| Test | Requirement | Before | After |
|---|---|---|---|
| test_access_token_uses_es256 | ES256-01 | XFAIL (strict) | PASSED |
| test_hs256_token_rejected | ES256-02 | XFAIL (strict) | PASSED |
| test_reset_token_uses_es256 | ES256-03 | XFAIL (strict) | PASSED |
| test_startup_rotation_revokes_tokens | ES256-04 | XFAIL (strict) | PASSED |
| test_startup_rotation_idempotent | ES256-05 | XFAIL (strict) | PASSED |
| test_settings_has_jwt_keys | CFG-01 | XFAIL (strict) | PASSED |
Net suite delta: +6 PASSED. Final count: 6 PASSED + 3 XFAILED (RM-01..03 remain for Plan 03).
test_task1_models_config.py::test_settings_has_jwt_config also passes (previously failing due to missing fields).
Deviations from Plan
Auto-fixed Issues
1. [Rule 1 - Bug] SQLAlchemy identity map returns stale values after raw SQL UPDATE with expire_on_commit=False
- Found during: Task 2 test execution
- Issue:
conftest.pycreates the testAsyncTestSessionwithexpire_on_commit=False. After_rotate_tokens_on_algorithm_changeruns a raw SQLUPDATE refresh_tokens SET revoked = true WHERE revoked = falsefollowed bysession.commit(), SQLAlchemy's identity map still holds the pre-commit stale state ofRefreshTokenobjects (revoked=False). Re-querying viaselect()returned the stale cached object instead of issuing a fresh DB query. - Fix: Added
db_session.expire_all()in both startup rotation tests immediately afterawait _rotate_tokens_on_algorithm_change(db_session). This invalidates the identity map cache, forcing SQLAlchemy to re-fetch from DB on the subsequentselect(). Production code is unchanged — the issue is test isolation specific toexpire_on_commit=False. - Files modified:
backend/tests/test_auth_es256.py - Commit:
8d261b0
Remaining XFAIL Tests (Plan 03)
| Test | Requirement | Reason |
|---|---|---|
| test_default_ttl_16_hours | RM-01 | remember_me param not yet added to create_refresh_token |
| test_remember_me_ttl_30_days | RM-02 | same |
| test_remember_me_cookie_max_age | RM-03 | _set_refresh_cookie + LoginView.vue changes deferred to Plan 03 |
Operator Handoff
Operators must generate a P-256 key pair before the backend can sign or verify JWTs:
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}')
"
Paste the two output lines into .env. On next docker compose up, the startup rotation hook will bulk-revoke all existing refresh tokens (forcing all users to re-login) and record the ES256 migration marker in system_settings. Subsequent boots are idempotent.
Self-Check: PASSED
backend/config.pyexists and contains the three new fields: FOUNDbackend/services/auth.pyuses ES256 at all 4 sites, zero HS256/secret_key references: FOUNDbackend/main.pycontains_rotate_tokens_on_algorithm_changeat module scope: FOUNDdocker-compose.ymlhas JWT_PRIVATE_KEY and JWT_PUBLIC_KEY in both services: FOUNDREADME.mdcontains key generation snippet: FOUND.env.examplecontains JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY=: FOUND- Commits
fd3f611,8d261b0,0d1ab05all exist: FOUND - Test suite: 6 PASSED + 3 XFAILED, 0 FAILED: CONFIRMED