Skip to content

Asymmetric (ES256) internal JWT signing - #1693

Open
omrishiv wants to merge 20 commits into
agentic-community:mainfrom
omrishiv:feat/asymmetric-signing
Open

Asymmetric (ES256) internal JWT signing#1693
omrishiv wants to merge 20 commits into
agentic-community:mainfrom
omrishiv:feat/asymmetric-signing

Conversation

@omrishiv

Copy link
Copy Markdown
Contributor

Asymmetric (ES256) internal JWT signing with kid rotation, JWKS, and dual-verify dispatch

Summary

Moves all auth-server JWT minting (user-vended tokens and internal service-hop tokens) from a single shared HS256 SECRET_KEY to ES256 (P-256) asymmetric signing with a kid header, a published JWKS endpoint, and live key rotation. Verification is kid-dispatched: kid present → ES256 (public key from JWKS), kid absent → HS256 legacy. Zero breaking change — with no signing key configured, behavior is identical to today (HS256 + SECRET_KEY).

Motivation / threat model

Today a single symmetric SECRET_KEY both signs and verifies every token, and it is mounted into every service. Consequences:

  • Anyone who can read SECRET_KEY (any container holding it, a leak, a backup) can forge any token type — user JWTs and internal hop tokens alike.
  • HS256 offers no way for a verifier to check a token without also holding the minting secret, so the secret must be distributed widely.
  • No key rotation without a coordinated outage.

ES256 splits signing (private key, auth-server only) from verification (public key via JWKS), so holding SECRET_KEY no longer grants minting, verifiers never need the private key, and keys rotate online.

What changed

Signing core (auth_server/internal_signing_key.py, new)

  • InternalSigningKeyManager: loads an ES256 P-256 private key from INTERNAL_SIGNING_KEY_PATH (or generates an ephemeral dev key via INTERNAL_SIGNING_KEY_GENERATE=true).
  • Live rotation: polls file mtime (60s, matches kubelet Secret sync), serves new + old keys concurrently, retains old keys 25h (covers the 24h max token TTL). Thread-safe singleton (double-checked locking).
  • JWKS endpoint GET /.well-known/internal-jwks.json — unauthenticated, internal-only, serves all active public keys (F9).

Dual-verify dispatch (auth_server/self_signed_token.py, new)

  • Consolidates 6 duplicated self-signed verify paths (5 IdP providers + the Cognito copy in server.py) into one verify_self_signed_user_token().
  • kid-based dispatch with separate _verify_hs256/_verify_es256 functions (distinct key variables, never mixed in one decode) — structurally prevents algorithm-confusion.
  • Issuer checked before signature verification (rejects external-IdP tokens claiming our issuer); token size-bounded to 8KB before header parse; malformed header → hard reject (never falls through to HS256).

Minters switch to ES256 (server.py /internal/tokens, internal_request_token.py)

  • User-vended JWTs and internal hop tokens (mcp-proxy / registry-ui audiences) sign ES256 with a kid header when a key is configured, HS256 fallback otherwise. Internal hop tokens carry a 30s TTL, so their cutover window is ~30s.

Registry-side verification (registry/auth/internal_jwks.py new, registry/auth/proxied_token.py)

  • The registry verifier was hardcoded to algorithms=["HS256"] and rejected every ES256 hop token → 401 loop on /api/auth/me and all nginx-auth_request-fronted /api/ routes. Added a synchronous, TTL-cached JWKS fetcher (indexed by kid, last-known-good on failure, forced refresh on unknown kid) and routed verify_registry_ui_token / verify_mcp_proxy_token through the same kid dispatch. ([Phase 3] RFC 8707 resource enforcement + gateway token proxy (IdP-signed MCP tokens) #1692 might overlap, future refactor)

Session cookie (registry/auth/dependencies.py, auth-server validate_session_cookie)

  • Drops the cookie signature: the cookie now carries the raw 256-bit hex session_id; security rests on unguessability (2^256) + mandatory server-side store lookup. Format check ^[0-9a-f]{64}$ rejects garbage before the DB roundtrip with no key material. Legacy signed cookies still validate via a fallback during the ≤8h rollover. This removes the registry's dependency on SECRET_KEY for auth. Both verifier processes accept the same format (fixes an OAuth-login infinite-loop where write set raw but read expected signed).

Cutover lever (REJECT_HS256_TOKENS, default false)

  • After all live tokens rotate to ES256 (>24h), operators flip it to hard-reject legacy HS256 — closing the leaked-SECRET_KEY forgery window. A flag (not code deletion) so it's revertible in one config change.

Scoped encryption keys (CSRF_SIGNING_KEY, CREDENTIAL_ENCRYPTION_KEY, SESSION_TOKEN_ENC_KEY)

  • Split the remaining SECRET_KEY uses into purpose-scoped keys (reduced blast radius). All default empty → SECRET_KEY fallback, so existing deployments are unchanged. Weak values are validated (validate_signing_secret) and rejected at first use (fail closed).

Deploy + docs

  • Standardized signing-key mount path /etc/mcp-gateway/signing-key/key.pem across Docker Compose (all 3 variants), Helm (Secret volume), and code auto-detect — so K8s needs no env config, just a Secret + signingKey.enabled.
  • Private key mounted only into auth-server (removed from registry / mcpgw-server containers — F2).
  • Wired INTERNAL_SIGNING_KEY_PATH / _ID, REJECT_HS256_TOKENS, and the scoped keys across Compose/podman/prebuilt, Helm, and Terraform ECS (module vars + ecs-services.tf); build_and_run.sh auto-generates signing-key.pem (chmod 600); .gitignore excludes it.
  • docs/unified-parameter-reference.md + System Config page (config_routes.py) document every new parameter (secrets masked in UI).

Rollout (zero-downtime, staged)

  1. Deploy with INTERNAL_SIGNING_KEY_PATH set → minter starts signing ES256 (kid present); old no-kid tokens keep verifying via HS256.
  2. Wait 24h+ → all live tokens re-issued as ES256.
  3. Set REJECT_HS256_TOKENS=true → legacy HS256 hard-rejected.
  4. After a clean release cycle, scope SECRET_KEY down to session/encryption-only uses.

Testing

  • tests/auth_server/unit/test_algorithm_confusion.py (new): 12 intent-based tests — public-key-as-HMAC-secret (with/without kid), external-IdP token confusion, wrong issuer, oversized/garbage/empty/alg:none, and kid routing (no-kid HS256, valid-kid ES256, unknown-kid reject, wrong token_use).
  • tests/unit/auth/test_internal_jwks.py (new): ES256 verifies via JWKS, works with SECRET_KEY unset, unknown/wrong-key kid rejected, HS256 legacy accepted (and rejected under REJECT_HS256_TOKENS), cache hit / last-known-good / rotation / empty-response.
  • Session-cookie tests: raw-id validates with signer=None (the previously-broken production path), legacy signed cookie still validates, garbage/unknown rejected.

Verified locally under the project uv env: test_internal_jwks.py 6 passed; internal_request_token + algorithm_confusion + internal_jwks selection 39 passed.

New files

auth_server/internal_signing_key.py, auth_server/self_signed_token.py, registry/auth/internal_jwks.py, tests/auth_server/unit/test_algorithm_confusion.py, tests/unit/auth/test_internal_jwks.py.

Backwards compatibility

No signing key configured → identical HS256 + SECRET_KEY behavior. Legacy signed session cookies and no-kid HS256 tokens continue to verify until they expire (≤24h) or REJECT_HS256_TOKENS is set. Scoped encryption keys default to the SECRET_KEY fallback.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Extract verify_self_signed_user_token() into auth_server/self_signed_token.py.
All 5 IdP providers + the Cognito copy in server.py now delegate to this
single implementation. Future asymmetric signing changes land in ONE place.

Reconciled divergences:
- SECRET_KEY: read from os.environ (no fallback default, fail closed)
- method field: uses AUTH_METHOD_SELF_SIGNED constant consistently
- leeway: 30s applied uniformly (was inconsistent across providers)
- logging: masked subject, group/scope counts only (no PII)
InternalSigningKeyManager (auth_server/internal_signing_key.py):
- Loads ES256 (P-256) private key from file (INTERNAL_SIGNING_KEY_PATH)
  or generates ephemeral key (INTERNAL_SIGNING_KEY_GENERATE=true for dev)
- Live key rotation: polls file mtime (60s, matches kubelet Secret sync),
  adds new keys alongside old, retains old for 25h (covers 24h max TTL)
- Kubernetes-native: mount as Secret volume, kubectl apply to rotate
- Thread-safe concurrent sign/verify during rotation
- JWKS endpoint at /.well-known/internal-jwks.json (unauthenticated,
  internal-only, serves all active keys)

Dual-verify dispatch (auth_server/self_signed_token.py):
- kid absent/None → HS256 path (SECRET_KEY, legacy tokens)
- kid present → ES256 path (public key from key manager)
- Algorithm-confusion prevention: separate _verify_hs256/_verify_es256
  functions with distinct key variables (never mixed in one decode call)
- Issuer checked BEFORE signature verification (rejects external IdP
  tokens claiming our issuer before they reach any verify path)
- Token size bounded to 8KB before header parsing
- Malformed header → hard reject (never falls through to HS256)

Zero breaking change: existing tokens (no kid) verify via HS256 as before.
12 intent-based security tests validating the dual-verify dispatch cannot
be exploited via algorithm confusion:

Algorithm confusion attacks (manually-constructed HMAC tokens):
- Public key as HS256 secret (no kid) → rejected (HS256 uses SECRET_KEY)
- Public key as HS256 secret with kid → rejected (ES256 path rejects HS256)

External IdP token confusion:
- External key with our issuer → rejected (unknown kid)
- Wrong issuer → rejected before signature verification

Malformed tokens:
- Oversized (>8KB), garbage, empty, alg:none → all hard-rejected

Kid dispatch routing:
- No kid → HS256 legacy path works
- Valid kid → ES256 asymmetric path works
- Unknown kid → rejected
- Wrong token_use → rejected regardless of algorithm
The /internal/tokens endpoint now signs user-vended JWTs with ES256
(asymmetric) when InternalSigningKeyManager has a key available,
falling back to HS256 (SECRET_KEY) when not configured.

Minted tokens include a kid header matching the key manager's current
kid. Verifiers dispatch on this kid: present → ES256 path, absent →
HS256 legacy path. This enables a zero-downtime transition:

1. Deploy with INTERNAL_SIGNING_KEY_PATH set → minter starts using ES256
2. New tokens carry kid → verify via ES256
3. Old tokens (no kid) continue to verify via HS256 until they expire
4. After max-TTL (24h), all live tokens are ES256-signed

No breaking change: without INTERNAL_SIGNING_KEY_PATH, behavior is
identical to before (HS256 with SECRET_KEY).
After all live tokens have rotated to ES256 (>24h after minter switch),
operators set REJECT_HS256_TOKENS=true to hard-reject legacy HS256 tokens.
This closes the window where a leaked SECRET_KEY could forge tokens.

The lever is a feature flag (not code deletion) so it can be reverted in
one config change if issues arise. Recommended procedure:
1. Deploy with INTERNAL_SIGNING_KEY_PATH (minter switches to ES256)
2. Wait 24h+ (all issued tokens expire and get re-issued as ES256)
3. Set REJECT_HS256_TOKENS=true (legacy tokens hard-rejected)
4. After one release cycle with no issues, SECRET_KEY can be scoped
   down to session/encryption uses only

Default: false (HS256 legacy tokens still accepted).
Docker Compose:
- auth-server env: INTERNAL_SIGNING_KEY_PATH, INTERNAL_SIGNING_KEY_ID,
  REJECT_HS256_TOKENS
- auth-server volume: ./signing-key.pem mounted read-only

build_and_run.sh:
- Auto-generates signing-key.pem (ES256 P-256) if not present
- chmod 600 on the generated key
- Graceful fallback if openssl not available

Helm (charts/auth-server):
- values.yaml: signingKey.enabled, existingSecret, secretKey, kid,
  rejectHs256
- deployment.yaml: volume + volumeMount (conditional on signingKey.enabled)
- secret.yaml: INTERNAL_SIGNING_KEY_PATH, INTERNAL_SIGNING_KEY_ID,
  REJECT_HS256_TOKENS env vars

.env.example:
- Documented all new env vars with generation instructions

.gitignore:
- signing-key.pem excluded (generated private key)

Kubernetes rotation: update the Secret, kubelet syncs, auth-server
detects file change and adds new key to JWKS automatically.
_mint_internal_token and _decode_internal_token now use the ES256 key
manager when available, with HS256 fallback for unconfigured deployments.

This means ALL token minting in the auth-server (user-vended JWTs +
internal service hop tokens) now uses the same ES256 private key when
INTERNAL_SIGNING_KEY_PATH is configured. Holding SECRET_KEY no longer
grants the ability to mint ANY token type.

_decode_internal_token uses kid-based dispatch (same pattern as
verify_self_signed_user_token):
- kid present → ES256 (key manager public key)
- kid absent → HS256 (SECRET_KEY, legacy)
- REJECT_HS256_TOKENS=true → hard-reject legacy tokens

Internal tokens have 30s TTL (vs 24h for user tokens), so the cutover
window after switching is only 30 seconds for this token type.
The session cookie now carries the raw 256-bit hex session_id directly.
Security rests on unguessability (2^256 space) + mandatory DB lookup
(tampered id → no record → 401).

Format check (^[0-9a-f]{64}$) rejects garbage before the DB roundtrip
without needing any key material — no secrets, no state.

Legacy overlap: signed cookies from before this change still work via
a fallback signer.loads() path. After all sessions expire (max 8h
default), the legacy path can be removed.

This removes the registry's dependency on SECRET_KEY for session cookie
verification. The registry no longer needs SECRET_KEY for authentication
(only for CSRF signing and credential encryption, addressed separately).

Test updates: session_id fixtures now use proper 64-char hex strings
matching production format (secrets.token_hex(32)).
Add INTERNAL_SIGNING_KEY_PATH, INTERNAL_SIGNING_KEY_ID, REJECT_HS256_TOKENS
to the remaining deployment surfaces:

docker-compose.podman.yml:
- Auth-server environment block + signing-key.pem volume mount

docker-compose.prebuilt.yml:
- Auth-server environment block + signing-key.pem volume mount

terraform/aws-ecs/variables.tf:
- internal_signing_key_secret_arn (Secrets Manager ARN for PEM key)
- internal_signing_key_id (kid for rotation)
- reject_hs256_tokens (cutover lever)

terraform/aws-ecs/terraform.tfvars.example:
- Documented example values with generation instructions

Still remaining (follow-up before GA):
- terraform/aws-ecs/modules/mcp-gateway/ (module-level vars + ecs-services.tf)
- docs/unified-parameter-reference.md (cross-surface parameter index)
- Scoped encryption keys (CSRF_SIGNING_KEY, CREDENTIAL_ENCRYPTION_KEY,
  SESSION_TOKEN_ENC_KEY) across all surfaces
- registry/api/config_routes.py (System Config page)
terraform/aws-ecs/modules/mcp-gateway/variables.tf:
- internal_signing_key_secret_arn (Secrets Manager ARN for PEM key)
- internal_signing_key_id (kid for rotation)
- reject_hs256_tokens (cutover lever)

terraform/aws-ecs/main.tf:
- Pass all three vars into the module call

terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf:
- Map INTERNAL_SIGNING_KEY_ID and REJECT_HS256_TOKENS to auth-server
  container environment

Note: INTERNAL_SIGNING_KEY_PATH is handled via Secrets Manager volume
mount (not a plain env var) — that wiring requires the secret-rotation.tf
pattern and is tracked separately.
Add CSRF_SIGNING_KEY, CREDENTIAL_ENCRYPTION_KEY, SESSION_TOKEN_ENC_KEY to:
- .env.example (documented with generation instructions + migration warning)
- docker-compose.yml (registry + auth-server for SESSION_TOKEN_ENC_KEY)
- docker-compose.podman.yml (same)
- docker-compose.prebuilt.yml (same)

All default to empty, which triggers the SECRET_KEY fallback in code.
Existing deployments continue to work unchanged. New deployments can set
scoped keys from the start for reduced blast radius.

Migration note in .env.example: changing an encryption key invalidates
data encrypted with the old key. Existing deployments should NOT set
these unless accepting the re-login / re-configure impact.
docs/unified-parameter-reference.md:
- INTERNAL_SIGNING_KEY_PATH (secret), INTERNAL_SIGNING_KEY_ID,
  REJECT_HS256_TOKENS
- CSRF_SIGNING_KEY (secret), CREDENTIAL_ENCRYPTION_KEY (secret),
  SESSION_TOKEN_ENC_KEY (secret)
- Each entry includes Docker env var, Terraform var, Helm values path,
  and purpose description with migration notes

registry/api/config_routes.py:
- Add scoped keys to the System Config page (all marked sensitive,
  values masked in UI via _mask_sensitive_value())
Tighten how the internal signing key, scoped secrets, and session
cookies are handled across the auth-server, registry, and mcpgw-server,
and fix a terraform parse break that blocked deploys.

- Remove a dangling '},' in ecs-services.tf that broke terraform parse.
- Remove the signing-key.pem mount from the registry and mcpgw-server
  containers — the private key is now mounted only in auth-server.
- Add a JWKS endpoint (/.well-known/internal-jwks.json) to auth-server
  so verifiers fetch the public key instead of mounting the private one.
- Make InternalSigningKeyManager a thread-safe singleton (double-checked
  locking with threading.Lock).
- Validate scoped keys (CSRF_SIGNING_KEY, CREDENTIAL_ENCRYPTION_KEY) via
  validate_signing_secret when explicitly set — weak values are rejected
  at first use (fail closed, no silent fallback).
- Set the raw session_id on the cookie write path to match the read path
  that accepts raw hex, removing the signed-write / unsigned-read
  discrepancy.
- Update _last_mtime on key parse failure to avoid a hot-loop of retries
  when the key file is temporarily malformed.
- Validate a minimum length (32 chars) in _get_secret_key() so a weak
  SECRET_KEY cannot be used to verify legacy HS256 tokens.
- Strip exception details from ValueError messages to avoid information
  disclosure — log server-side, return a generic message to the caller.

INTERNAL_SIGNING_KEY_PATH in the Secret is a path string, not a
credential, so it is left as-is.
All surfaces now mount the signing key to the same path:
  /etc/mcp-gateway/signing-key/key.pem

- Docker Compose (all 3 variants): bind mount to standardized path
- Helm: Secret volume mount to /etc/mcp-gateway/signing-key/ with
  item key.pem (results in same final path)
- Code: default constant checks /etc/mcp-gateway/signing-key/key.pem
  when INTERNAL_SIGNING_KEY_PATH env var is not set

This means Kubernetes deployments need NO env var configuration —
just create a Secret with the key and enable signingKey.enabled in
Helm values. The code auto-detects the key at the standard path.

Removed INTERNAL_SIGNING_KEY_PATH from Helm secret.yaml — the path
is not a secret, and with the standardized mount point + auto-detect,
it's not needed as configuration at all.
The session cookie carries a raw 256-bit hex session_id validated by a
server-side store lookup (no signature). registry/auth/dependencies.py was
updated to resolve the raw id, but auth-server's own validate_session_cookie
still called signer.loads() first, raising BadSignature on the unsigned id.
After OAuth login, auth-server's /validate rejected the freshly-set cookie
and redirected back to /login — an infinite login loop.

Both verifier processes (registry and auth-server) must accept the same
cookie format. This mirrors resolve_session_from_cookie: format-check
^[0-9a-f]{64}$ and resolve the raw id directly (no signer required), keeping
signer.loads() only as a legacy fallback for previously-signed cookies during
the rollover window. Unknown/expired sessions fail closed (401).

Tests: raw id validates with signer=None (the production path that was
broken); a legacy signed cookie still validates; garbage and unknown ids are
rejected; the valid_session_cookie fixture now uses the raw-id format.
… JWKS)

auth-server signs the internal hop tokens (mcp-registry-ui / mcp-proxy
audiences) with ES256 and a kid header when a signing key is configured,
falling back to HS256 otherwise. The registry's own verifier in
registry/auth/proxied_token.py was hardcoded to algorithms=["HS256"] with
SECRET_KEY, so it rejected every ES256 token ("The specified alg value is
not allowed"). With signing enabled this broke /api/auth/me and every
nginx-auth_request-fronted /api/ route → 401 → login loop.

- Add registry/auth/internal_jwks.py: a synchronous, TTL-cached fetcher for
  auth-server's /.well-known/internal-jwks.json, indexed by kid, with
  last-known-good on fetch failure (bounded staleness) and forced refresh on
  an unknown kid (key rotation).
- proxied_token.py: extract _decode_internal_jwt with kid-based dispatch
  mirroring auth-server's minter — kid present → ES256 via the fetched public
  key; kid absent → HS256/SECRET_KEY legacy, honoring REJECT_HS256_TOKENS.
  Both verify_registry_ui_token and verify_mcp_proxy_token route through it.
  A malformed header is a hard 401 (never a fall-through to HS256); tokens
  are size-bounded before parsing.
- config.py: internal_jwks_url (default the in-cluster auth-server endpoint)
  + internal_jwks_cache_ttl_seconds.

Tests: ES256 token verifies via the JWKS; verification works with SECRET_KEY
unset; unknown/wrong-key kid rejected; HS256 legacy still works and is
rejected under REJECT_HS256_TOKENS; JWKS cache hit/last-known-good/rotation/
empty-response behavior.
Signed-off-by: omrishiv <327609+omrishiv@users.noreply.github.com>
… key delivery

Signing core (auth_server/internal_signing_key.py):
- Derive kid from the RFC 7638 JWK thumbprint instead of a list-length counter,
  so kids are content-addressed, never reused across keys, and identical on
  every replica. Removes INTERNAL_SIGNING_KEY_ID entirely (a pinned kid breaks
  rotation). Fix _expire_old_keys to compare identity with `is`.
- Add get_signing_material() -> (key, kid) under one lock; mint sites in
  internal_request_token.py and server.py use it (no key/kid TOCTOU on rotation).
- Couple old-key retention default to MCP_TOKEN_MAX_TTL_HOURS (+1h, clamped to
  the 168h ceiling) so raising the token cap keeps still-valid tokens verifiable.

REJECT_HS256 cutover:
- Shared reject_hs256_tokens() helper (1/true/yes/on) reused by the self-signed
  and hop-token verify paths; registry parser aligned to the same set.
- Flag now wired to the registry (verifier) service, not just auth-server, on
  compose (x3), Helm (new registry value + stack-mode render fix), and ECS.

Verify path:
- Validate SESSION_TOKEN_ENC_KEY with the canonical weak-before-length validator
  (fail closed), keeping the SECRET_KEY fallback (registry/auth/session_crypto.py).
- _get_secret_key uses the canonical validator; groups:None returns [] not 500.
- Bounded negative cache for unknown-kid JWKS refreshes to blunt random-kid
  fetch amplification (registry/auth/internal_jwks.py).

Deploy surfaces:
- ECS: deliver the ES256 PEM via a signing-key-init container that writes it to
  a task-scoped shared volume auth-server mounts read-only, sets
  INTERNAL_SIGNING_KEY_PATH, depends on init SUCCESS, and grants the secret ARN.
  Add optional scoped-key secret ARNs (CSRF/CREDENTIAL/SESSION) to both services.
  All gated on the signing-key ARN so an unconfigured deploy is unchanged (HS256).
- Helm: reserved-env names, scoped keys, stack-mode rendering.
- build_and_run.sh: fail hard if ES256 key generation fails.

Docs: new docs/design/asymmetric-signing.md with the verification-flow diagram;
update internal-hop-authentication.md and the unified parameter reference.

Tests: add tests/auth_server/unit/test_internal_signing_key.py covering
thumbprint kid, rotation overlap, retention expiry, and atomic signing material.
…op offload

Deploy correctness (would break ES256 on EKS):
- Helm signing-key secret mount 0400 -> 0440. The pod runs non-root (uid 1000 /
  fsGroup 1000) and k8s secret files are owned root:fsGroup, so 0400 (owner-only)
  gave the process EACCES and it silently fell back to HS256. 0440 is group-readable.
- Wire the registry JWKS endpoint across all surfaces: new INTERNAL_JWKS_URL /
  INTERNAL_JWKS_CACHE_TTL_SECONDS in .env.example, compose x3, Terraform (vars +
  registry container env), Helm registry chart, CONFIG_GROUPS, and the unified
  reference. The Helm default is the in-namespace FQDN (auth-server.<ns>.svc...)
  because the bare service name the app default uses does not reliably resolve
  from the registry pod on EKS, which would 401 every ES256 hop token.

Request-path availability:
- Offload the synchronous JWKS-verify (which may block on a 5s httpx fetch) off
  the asyncio event loop via run_in_threadpool at the two registry callsites
  (dependencies._context_from_internal_token, egress vend_egress_token).
- internal_jwks negative cache: evict-oldest instead of clearing the whole map
  under load, and rate-limit forced (TTL-bypassing) refreshes process-wide so a
  flood of unique/forged kids cannot amplify 1:1 into blocking fetches.

Correctness / hygiene:
- verify_self_signed_user_token chains exceptions (from e).
- credential_encryption attributes a weak CREDENTIAL_ENCRYPTION_KEY to that key
  (not "SECRET_KEY"), still fail-closed.
- JWKS Cache-Control max-age aligned to the registry cache TTL (300s).

Docs:
- theory-of-the-system.md: ES256 hop signing + unsigned session cookie (was
  HS256-only / signed-cookie), with an asymmetric-signing.md index entry.
- asymmetric-signing.md: rotation + HS256->ES256 cutover runbook and deployment
  caveats (ECS init launch dependency, customer-managed KMS kms:Decrypt, k8s
  mount mode); CMK caveat also in tfvars.example + the module variable.
- Umbrella stack chart surfaces the signing/cutover/scoped/JWKS toggles with a
  "flip rejectHs256 on both charts together" note.

Tests: negative-cache dedupe, forced-refresh throttle, and REJECT_HS256 parse
parity (auth-server <-> registry).
# Conflicts:
#	.env.example
#	charts/auth-server/reserved-env-names.txt
#	charts/mcp-gateway-registry-stack/values.yaml
#	charts/registry/reserved-env-names.txt
#	charts/registry/templates/secret.yaml
#	terraform/aws-ecs/modules/mcp-gateway/iam.tf
#	terraform/aws-ecs/modules/mcp-gateway/variables.tf
#	terraform/aws-ecs/variables.tf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants