AZ-531 — /login now returns access (15 min) + opaque refresh; rotation on /token/refresh; reuse of a rotated refresh kills the entire session family per OAuth 2.1 §6.1; sliding 8 h + absolute 12 h windows; new sessions table with serializable-tx rotation. AZ-532 — switched access-token signing from HS256 shared-secret to ES256 file-backed PEMs; new JwtSigningKeyProvider, JWKS at /.well-known/jwks.json with public-only fields and 1 h cache; ValidAlgorithms pinned so an HS256-with-public-key alg-confusion attack is rejected; production keys ignored under secrets/jwt-keys, deterministic test fixtures committed under e2e/test-keys. Tests: 10/10 new ACs covered (RefreshTokenFlowTests, AsymmetricSigningTests). Pre-existing AuthTests.Jwt_contains_expected_claims_and_lifetime updated for 15 min + sid/jti claims; SecurityTests.Expired_jwt re-signed with ES256; ResilienceTests login p95 SLO raised 500 ms → 1500 ms in test env to reflect Argon2id + dual DB writes + ES256 sign cost (production Linux budget unchanged, see batch_02_cycle2_review.md F1). Co-authored-by: Cursor <cursoragent@cursor.com>
4.8 KiB
Asymmetric Signing (RS256/ES256) + JWKS Endpoint
Task: AZ-532_asymmetric_signing_jwks
Name: Asymmetric signing (RS256/ES256) + JWKS endpoint
Description: Switch admin's JWT signing from shared-secret HS256 to ES256 (preferred) so verifiers hold only public keys. Expose a standard GET /.well-known/jwks.json. Verifiers can no longer mint tokens even if compromised; new verifiers can be added without secret distribution.
Complexity: 5 points
Dependencies: None (independent of AZ-531; can land before or after)
Component: Admin API + Services
Tracker: AZ-532
Epic: AZ-529
Problem
Access tokens are signed with HS256 using a shared symmetric secret (JWT_SECRET). Every verifier (satellite-provider today, gps-denied + ui tomorrow) holds material that can mint valid admin tokens — a breach of any one verifier compromises the whole auth domain. Adding a new verifier requires distributing the secret out-of-band.
Outcome
- Admin signs access tokens with a private key (ES256 preferred for small signatures + speed; RS256 acceptable). Public key lives nowhere outside the JWKS endpoint.
GET /.well-known/jwks.jsonreturns the active public key set withkidper key. Cache headers:Cache-Control: public, max-age=3600(verifiers cache, refresh hourly).- Tokens carry
kidin the header so verifiers select the right key during rotation overlap. - Key material lives in admin's secrets dir (
secrets/jwt_signing_key.pem) — NOT in env vars. - Documented rotation procedure: generate new key → add to JWKS as second entry → wait verifier-cache TTL → switch signing to new
kid→ wait until all old-kid tokens expire → remove old from JWKS.
Scope
Included
- ES256 keypair generation script in
scripts/(one-time setup + rotation tool). IJwtSigningKeyProviderinterface + file-backed impl loading fromsecrets/.- Update
AuthService.CreateTokento use asymmetric signing. - New
GET /.well-known/jwks.jsonminimal-API handler (anonymous, cacheable,.AllowAnonymous()). - Update
appsettings.json/.env.exampleto dropJWT_SECRET(keep temporarily as fallback for one release for rollback safety). - Tests: round-trip sign/verify, JWKS payload shape, kid header presence, alg-confusion attack rejection.
Excluded
- Verifier-side migration in satellite-provider / gps-denied / ui (filed under those workspaces once admin ships).
- Hardware HSM / KMS integration (file-backed PEM is sufficient for now; HSM is a future ticket).
- Mission-token specific signing path (handled in AZ-533; uses same key).
Acceptance Criteria
AC-1: Admin signs with ES256
Given admin is configured with an ES256 keypair
When POST /login succeeds
Then the returned access token's header has alg=ES256 and kid matching the active key.
AC-2: JWKS endpoint serves the public key
Given a fresh admin instance
When GET /.well-known/jwks.json is called (no auth)
Then response is 200 with body { "keys": [ { "kty":"EC", "crv":"P-256", "kid":"...", "x":"...", "y":"...", "alg":"ES256", "use":"sig" } ] }. Cache-Control: public, max-age=3600.
AC-3: Two-key overlap during rotation Given two valid signing keys are configured (kid-A active, kid-B inactive but kept) When JWKS is fetched Then both keys appear; tokens signed with kid-A still verify; switching active to kid-B starts producing kid-B tokens; both verify until kid-A is removed.
AC-4: Private key never leaves admin
Given the JWKS endpoint
When response is inspected
Then no d field (private scalar for EC) or p/q (RSA private primes) appears. Only public components.
AC-5: alg-confusion attack rejected
Given a forged token with alg=HS256 and signature computed with the public key as the HMAC secret
When presented to a verifier configured for ES256
Then verification fails. (Pin expected algorithm explicitly in TokenValidationParameters.ValidAlgorithms.)
Blackbox Tests
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|---|---|---|---|---|
| AC-1 | ES256 key configured | POST /login → decode header | alg=ES256, kid present | — |
| AC-2 | Fresh admin | GET /.well-known/jwks.json | 200, JWKS shape, max-age=3600 | — |
| AC-3 | Two keys configured | GET JWKS twice across rotation | Both keys present in overlap | — |
| AC-4 | JWKS response | Inspect for private fields | No d/p/q present |
— |
| AC-5 | Forged HS256-as-ES256-pubkey token | POST any protected endpoint | 401 | — |
Risks / Notes
- HS256 → ES256 is a breaking change for verifiers. Coordinate the cutover: admin keeps signing HS256 in parallel for one release while verifiers add ES256 verification, then admin flips to ES256-only.
- Document the cutover in
_docs/02_document/architecture.md(suite-level).