[AZ-531] [AZ-532] Refresh-token rotation + ES256 signing with JWKS
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 05:30:03 +03:00
parent 491993f9c1
commit 51a293dbcc
39 changed files with 1326 additions and 57 deletions
@@ -0,0 +1,81 @@
# 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.json` returns the active public key set with `kid` per key. Cache headers: `Cache-Control: public, max-age=3600` (verifiers cache, refresh hourly).
- Tokens carry `kid` in 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).
- `IJwtSigningKeyProvider` interface + file-backed impl loading from `secrets/`.
- Update `AuthService.CreateToken` to use asymmetric signing.
- New `GET /.well-known/jwks.json` minimal-API handler (anonymous, cacheable, `.AllowAnonymous()`).
- Update `appsettings.json` / `.env.example` to drop `JWT_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).