diff --git a/.claude/rules/js-post-quantum-cryptography.md b/.claude/rules/js-post-quantum-cryptography.md index 0d4cc6cf85..3bbe758cac 100644 --- a/.claude/rules/js-post-quantum-cryptography.md +++ b/.claude/rules/js-post-quantum-cryptography.md @@ -4,9 +4,11 @@ Apply this rule whenever writing, refactoring, or reviewing JavaScript (`.js`) code in `portals/developer-portal` that performs key exchange, digital signatures, encryption, or any operation relying on the hardness of integer factorisation or discrete-logarithm problems (RSA, ECDH, ECDSA, `crypto.generateKeyPair` with classic algorithms). Cryptographic primitives must remain secure against an adversary with a cryptographically relevant quantum computer, per NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA). JS counterpart to `post-quantum-cryptography.md` (Go). +**PQC is optional-but-supported, not strictly mandated.** Backends this portal talks to (legacy gateway builds, third-party integrations, older IDPs) do not all negotiate PQC ciphers/curves yet. Configuration must make enabling PQC/hybrid easy — and default to it wherever the peer is known to support it — but code must not hard-fail or drop interoperability when talking to a peer that only speaks classical algorithms. Treat "PQC-capable" as a configurable posture, not an unconditional requirement in every code path. + ## Directives -1. **Prohibited quantum-vulnerable algorithms.** Never use RSA, ECDH (any curve other than the X25519 leg below), ECDSA, Ed25519/Ed448, X448, or classic Diffie-Hellman in new key-exchange or signing paths — this includes `crypto.createECDH(...)`, `crypto.generateKeyPair('rsa', ...)`, and `crypto.sign` with `'RSA-SHA256'` — and never introduce or extend such use with a `// TODO(pqc): migrate`-style comment as cover; a code comment is not a remediation plan. The one narrow exception: X25519 may be used solely as the classical leg of the mandated X25519 + ML-KEM-768 hybrid construction in directive 3 — never standalone, never paired with any KEM other than ML-KEM-768/1024, and never as a substitute for it elsewhere. Existing uses (including standalone X25519) must be filed as a tracked issue (not merely noted inline) with an owner and a migration deadline, and must be migrated the next time that code is touched rather than re-committed as-is. AES-256-GCM, ChaCha20-Poly1305, and SHA-3/BLAKE3 remain quantum-safe exceptions at 256-bit sizes; avoid AES-128/SHA-256 for new long-lived keys. +1. **Quantum-vulnerable algorithms are a configurable fallback, not a ban.** New key-exchange/signing code must offer ML-KEM/ML-DSA-based (hybrid) operation as the default when configuration enables PQC and the peer supports it. RSA, ECDH (any curve), ECDSA, Ed25519/Ed448, X448, or classic Diffie-Hellman — including `crypto.createECDH(...)`, `crypto.generateKeyPair('rsa', ...)`, and `crypto.sign` with `'RSA-SHA256'` — remain acceptable *only* as an explicit, configuration-gated fallback for legacy backends that don't yet support PQC, never as the silent, unconfigured default for new code, and never introduced or extended with a `// TODO(pqc): migrate`-style comment as the only nod to migration; a code comment is not a remediation plan. The one narrow exception for the PQC leg itself: X25519 may be used solely as the classical leg of the mandated X25519 + ML-KEM-768 hybrid construction in directive 3 — never standalone, never paired with any KEM other than ML-KEM-768/1024, and never as a substitute for it elsewhere. Existing classical-only uses that don't yet offer a PQC/hybrid configuration option must be filed as a tracked issue (not merely noted inline) with an owner and a migration deadline, and must gain that configuration option the next time that code is touched rather than being re-committed as classical-only. AES-256-GCM, ChaCha20-Poly1305, and SHA-3/BLAKE3 remain quantum-safe exceptions at 256-bit sizes; avoid AES-128/SHA-256 for new long-lived keys. 2. **Approved algorithm selection:** | Purpose | NIST Standard | Algorithm | npm Package | @@ -18,7 +20,7 @@ Apply this rule whenever writing, refactoring, or reviewing JavaScript (`.js`) c | Hashing | — | SHA3-256 / SHA3-512 | `node:crypto`, `@noble/hashes` | Prefer `@noble/post-quantum` for pure-JS (no native bindings, audited); use `liboqs-node` when FIPS 140-3 or HSM integration is required. Use `-768`/`dilithium3` (NIST Level 3) as the minimum, escalating to `-1024`/`dilithium5` for long-lived or high-assurance keys. -3. **Hybrid classical + PQC during transition.** Combine X25519 + ML-KEM-768 (IETF RFC 9180 pattern) so security degrades gracefully to whichever primitive remains unbroken — never deploy PQC standalone until the library has a stable 1.x release with a public audit. For TLS, Node.js 22+/OpenSSL 3.2+ supports `tls.createServer({ ecdhCurve: 'X25519MLKEM768:X25519' })` — list the hybrid curve first. +3. **Hybrid classical + PQC as the configured default, with a documented classical fallback.** When PQC is enabled in configuration, combine X25519 + ML-KEM-768 (IETF RFC 9180 pattern) so security degrades gracefully to whichever primitive remains unbroken — never deploy PQC standalone until the library has a stable 1.x release with a public audit. For TLS, Node.js 22+/OpenSSL 3.2+ supports `tls.createServer({ ecdhCurve: 'X25519MLKEM768:X25519' })` — list the hybrid curve first, keeping `X25519` (and other configured classical curves) after it so a handshake with a peer that doesn't yet support the hybrid curve still succeeds instead of failing closed. Surface the negotiated/effective curve (config, logs, or a status field) so operators can tell whether a connection actually ran PQC or fell back to classical. 4. **Key and ciphertext size awareness.** ML-KEM-768 public keys are 1184 bytes and ciphertexts 1088 bytes; ML-DSA-65 signatures are 3309 bytes. Never store these in Sequelize `STRING`/`VARCHAR(512)` columns sized for RSA — use `BLOB`/`BYTEA` or `TEXT` (base64). Avoid putting PQC signatures in `Authorization` headers where size limits apply — use the request body instead. Never truncate a PQC key or signature for storage convenience. 5. **Randomness and nonce safety.** Key generation must use `crypto.randomBytes` — never `Math.random()`, `Date.now()`, or a non-CSPRNG. AES-256-GCM nonces (12 bytes) must be freshly generated per encryption via `crypto.randomBytes(12)` and never reused under the same key; rotate the key after 2³² encryptions. `@noble/post-quantum`'s `kyber768.encapsulate(...)` generates its own randomness internally — don't supply external randomness unless the API requires it. 6. **No algorithm negotiation in sensitive paths.** Never accept the algorithm from a JWT header or request payload in auth/key-exchange flows — allowlist exact identifiers and reject deviation with a generic `401`. In `jose` JWS/JWT verification, always pass an explicit `algorithms: ['ML-DSA-65']` (or the IANA codepoint once standardised); never accept `'none'` or legacy `'RS256'`. @@ -26,23 +28,30 @@ Apply this rule whenever writing, refactoring, or reviewing JavaScript (`.js`) c ## Example ```js -// BAD: classical-only key exchange, no PQC migration path, and a standalone -// PQC KEM with no hybrid classical leg. +// BAD: classical-only key exchange with no configuration option to enable PQC at +// all, and (separately) a standalone PQC KEM with no hybrid classical leg. const ecdh = crypto.createECDH('prime256v1'); -const sharedSecret = ecdh.computeSecret(peerPublicKey); // quantum-vulnerable — a TODO(pqc) comment would not excuse this +const sharedSecret = ecdh.computeSecret(peerPublicKey); // quantum-vulnerable, not configurable — a TODO(pqc) comment would not excuse this const { sharedSecret: pqcOnly } = ml_kem768.encapsulate(recipientPub); // no X25519 hybrid leg -// GOOD: hybrid X25519 + ML-KEM-768 (FIPS 203) — security holds if either leg -// is unbroken; all inputs bound into the combiner to prevent downgrade. +// GOOD: hybrid X25519 + ML-KEM-768 (FIPS 203) when config.pqcEnabled and the +// recipient advertises PQC support — security holds if either leg is unbroken; +// all inputs bound into the combiner to prevent downgrade. When PQC isn't +// enabled or the recipient is a legacy peer (recipientPqcPub is undefined), +// falls back to the classical-only leg rather than failing closed. const { x25519 } = require('@noble/curves/ed25519'); const { ml_kem768 } = require('@noble/post-quantum/ml-kem'); const { sha3_256 } = require('@noble/hashes/sha3'); -function encapsulate(recipientClassicalPub, recipientPqcPub) { +function encapsulate(config, recipientClassicalPub, recipientPqcPub) { const ephemeralPriv = x25519.utils.randomPrivateKey(); // crypto.getRandomValues internally const ephemeralPub = x25519.getPublicKey(ephemeralPriv); const classicalShared = x25519.getSharedSecret(ephemeralPriv, recipientClassicalPub); + if (!config.pqcEnabled || !recipientPqcPub) { + return { ciphertext: { classical: ephemeralPub }, sharedSecret: classicalShared }; // documented, config-gated fallback + } + const { cipherText: pqcCT, sharedSecret: pqcShared } = ml_kem768.encapsulate(recipientPqcPub); const combined = sha3_256( @@ -53,9 +62,10 @@ function encapsulate(recipientClassicalPub, recipientPqcPub) { ``` > **Verification Checklist before outputting code:** -> * Any new RSA/ECDH/ECDSA/Ed25519/Ed448/X448/classic-DH use at all, or X25519 used outside its role as the classical leg of the mandated X25519+ML-KEM-768 hybrid (directive 3) — e.g. standalone, or paired with a non-ML-KEM KEM — or any of this "justified" by an inline `// TODO(pqc)`-style comment instead of a tracked issue and actual migration? -> * Is a PQC KEM used standalone instead of hybrid X25519+ML-KEM-768? +> * Does any RSA/ECDH/ECDSA/Ed25519/Ed448/X448/classic-DH use have no configuration option to enable PQC/hybrid at all, or is X25519 used outside its role as the classical leg of the mandated X25519+ML-KEM-768 hybrid (directive 3) — e.g. standalone, or paired with a non-ML-KEM KEM — or is any of this "justified" by an inline `// TODO(pqc)`-style comment instead of a tracked issue and an actual configuration option? +> * When PQC is enabled and the peer supports it, is the PQC KEM used as hybrid X25519+ML-KEM-768 rather than standalone? +> * Does a classical-only code path exist with no way to enable PQC/hybrid, instead of a config-gated fallback for legacy peers? > * Are ML-KEM/ML-DSA key/ciphertext/signature sizes accounted for in Sequelize columns (`BLOB`, never `STRING(512)`) and payload budgets? > * Any nonce/key generation using `Math.random()`/`Date.now()` instead of `crypto.randomBytes`, or a reused GCM nonce? -> * Does TLS config list `X25519MLKEM768` first in `ecdhCurve` for Node.js 22+ services? -> * Does any `jose` JWT/JWS verification omit an explicit `algorithms: ['ML-DSA-65']`-style allowlist? +> * Does TLS config list `X25519MLKEM768` first in `ecdhCurve`, keeping a classical curve after it for legacy peers, for Node.js 22+ services? +> * Does any `jose` JWT/JWS verification omit an explicit `algorithms: ['ML-DSA-65']`-style allowlist? (Enabling a classical fallback via config is fine; accepting an algorithm the peer/token itself claims is not.) diff --git a/gateway/Makefile b/gateway/Makefile index b24434f2bc..003d5f37f6 100644 --- a/gateway/Makefile +++ b/gateway/Makefile @@ -232,7 +232,8 @@ AI_DIST_ZIP := target/$(AI_DIST_NAME).zip dist: clean-dist ## Build standalone gateway distribution zip @echo "Building distribution $(DIST_NAME)..." @mkdir -p $(DIST_DIR)/configs $(DIST_DIR)/resources/certificates \ - $(DIST_DIR)/resources/listener-certs $(DIST_DIR)/resources/secure-backend \ + $(DIST_DIR)/resources/listener-certs $(DIST_DIR)/resources/xds-certs \ + $(DIST_DIR)/resources/secure-backend \ $(DIST_DIR)/resources/gateway-controller/db-scripts @cp build.yaml build-manifest.yaml $(DIST_DIR)/ @cp -R configs/. $(DIST_DIR)/configs/ @@ -240,6 +241,13 @@ dist: clean-dist ## Build standalone gateway distribution zip @cp gateway-controller/certificates/default-listener.crt $(DIST_DIR)/resources/certificates/ @cp gateway-controller/listener-certs/default-listener.crt $(DIST_DIR)/resources/listener-certs/ @cp gateway-controller/listener-certs/default-listener.key $(DIST_DIR)/resources/listener-certs/ + @cp gateway-controller/xds-certs/ca.crt $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/server.crt $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/server.key $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/envoy-client.crt $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/envoy-client.key $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/policy-engine-client.crt $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/policy-engine-client.key $(DIST_DIR)/resources/xds-certs/ @cp -R resources/secure-backend/. $(DIST_DIR)/resources/secure-backend/ @cp gateway-controller/pkg/storage/gateway-controller-db.postgres.sql $(DIST_DIR)/resources/gateway-controller/db-scripts/ @cp gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql $(DIST_DIR)/resources/gateway-controller/db-scripts/ diff --git a/gateway/configs/config-template.toml b/gateway/configs/config-template.toml index 726142183c..8dad5677b4 100644 --- a/gateway/configs/config-template.toml +++ b/gateway/configs/config-template.toml @@ -13,6 +13,66 @@ shutdown_timeout = "15s" # It is recommended to use a uuid_v7 for this to improve db efficiency. gateway_id = '{{ env "APIP_GW_CONTROLLER_SERVER_GATEWAY_ID" "platform-gateway-id" }}' +[controller.server.tls] +# Starts a second, TLS-only REST API listener on `port` below, serving the +# same management API as the plaintext listener on server.api_port above. +# Off by default: no certificate is provisioned by default, and the +# plaintext listener keeps working either way. +enabled = false +port = 9093 +cert_path = "" +key_path = "" +# TLS version bounds, one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". Same +# vocabulary as router.downstream_tls/upstream_tls below for consistency +# within this file, though enforced by a different TLS stack (Go's own +# crypto/tls here, Envoy/BoringSSL there). +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" +# Comma-separated Go crypto/tls cipher suite names (e.g. +# "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which suites this +# listener negotiates. Empty by default -- Go's own secure default set/order +# applies. Only affects TLS 1.2 and below; TLS 1.3 suite selection is fixed +# and not configurable in Go's crypto/tls. Note this is a different naming +# scheme than router.downstream_tls's `ciphers` below (OpenSSL/BoringSSL +# names like "ECDHE-ECDSA-AES128-GCM-SHA256") -- see crypto/tls.CipherSuites +# for the names this listener accepts. +ciphers = "" +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. +# Classical curves only by default. A hybrid post-quantum group (e.g. +# "X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be prepended as an +# explicit opt-in once the clients reaching this listener are confirmed to +# support it. This listener is served by this process's own Go crypto/tls +# (1.23+ implements X25519MLKEM768 natively), not pushed as xDS config to a +# separate Envoy process, so enabling it here does not carry the "already- +# running peer NACKs the update" risk documented on router.downstream_tls's +# ecdh_curves below -- TLS 1.3 negotiation just falls back to a later +# classical entry in this same list for a client that doesn't offer it. +ecdh_curves = "X25519,P-256" + +[controller.server.xds_tls] +# Switches the main xDS gRPC server (serves Envoy on server.xds_port above) +# from plaintext to mutual TLS -- there is no second listener the way +# server.tls above adds one; server.xds_port itself starts speaking mTLS. +# Off by default: Envoy's xds_cluster (router/config/config-override.yaml) +# must be given a matching client cert/CA before this is turned on, or the +# connection will fail closed. +enabled = false +cert_file = "" +key_file = "" +# PEM bundle of CA certificates trusted to sign Envoy's client certificate. +# Required when enabled -- this server offers no server-only TLS mode, +# since it distributes SDS secrets and full route/cluster config. +client_ca_file = "" +# Accepted peer certificate identities: a certificate's first SAN URI (e.g. +# a SPIFFE ID) if present, otherwise its Subject CommonName. A client +# certificate that chains to a trusted CA is not by itself authorization to +# reach this snapshot -- at least one identity is required when enabled. +allowed_client_identities = [] +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" +ciphers = "" +ecdh_curves = "X25519,P-256" + [controller.admin_server] # Dedicated admin/debug HTTP server for config dump and xDS sync endpoints. # Kept enabled by default because it also serves /health, used by Kubernetes @@ -43,12 +103,30 @@ mutex_profile_fraction = 0 port = 18001 [controller.policy_server.tls] -# Enable or disable TLS +# Switches the policy xDS gRPC server (serves the policy-engine on +# policy_server.port above) from plaintext to mutual TLS, on that same +# port. Off by default: policy_engine.xds.tls below must be given a +# matching client cert/CA before this is turned on, or the connection will +# fail closed. enabled = false # Path to TLS certificate file (required if TLS is enabled) cert_file = "./certs/server.crt" # Path to TLS private key file (required if TLS is enabled) key_file = "./certs/server.key" +# PEM bundle of CA certificates trusted to sign the policy-engine's client +# certificate. Required when enabled -- this server offers no server-only +# TLS mode, since it distributes API-key hashes, subscription state, and +# full policy chains for every tenant. +client_ca_file = "./certs/xds-client-ca.crt" +# Accepted peer certificate identities: a certificate's first SAN URI (e.g. +# a SPIFFE ID) if present, otherwise its Subject CommonName. A client +# certificate that chains to a trusted CA is not by itself authorization to +# reach this snapshot -- at least one identity is required when enabled. +allowed_client_identities = [] +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" +ciphers = "" +ecdh_curves = "X25519,P-256" [controller.controlplane] # Control plane websocket endpoint. Environment values reach these keys ONLY through the @@ -265,12 +343,13 @@ minimum_protocol_version = "TLS1_2" maximum_protocol_version = "TLS1_3" ciphers = "ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,AES128-GCM-SHA256,AES128-SHA,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,AES256-GCM-SHA384,AES256-SHA" # Comma-separated ECDH curves for the TLS key exchange, most preferred first. -# Defaults to the hybrid post-quantum group X25519MLKEM768 (FIPS 203 ML-KEM-768 -# + X25519) followed by classical curves X25519 and P-256, so key exchange -# degrades gracefully to classical for peers that don't yet support the -# hybrid group. An unsupported curve name is rejected by Envoy when it -# applies the resulting config, not by this file. -ecdh_curves = "X25519MLKEM768,X25519,P-256" +# Classical curves only by default. A hybrid post-quantum group (e.g. +# "X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be prepended as an +# explicit per-deployment opt-in once the deployed Envoy/BoringSSL build is +# confirmed to support it -- an already-running Envoy that doesn't recognize +# the curve name will NACK this config and keep serving its last-known-good +# state instead of picking up any further changes. +ecdh_curves = "X25519,P-256" trusted_cert_path = "/etc/ssl/certs/ca-certificates.crt" custom_certs_path = "./certificates" verify_host_name = true @@ -350,6 +429,42 @@ allowed_ips = ["*", "127.0.0.1"] # Service port. enabled = false +[policy_engine.admin.tls] +# Starts a second, TLS-only admin listener on `port` below, serving the same +# routes (/health, /xds_sync_status, /config_dump when enabled) as the +# plaintext listener above. Off by default: no certificate is provisioned by +# default, and the plaintext listener keeps working either way. +enabled = false +port = 9004 +cert_path = "./listener-certs/default-listener.crt" +key_path = "./listener-certs/default-listener.key" +# TLS version bounds, one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". Same +# vocabulary as router.downstream_tls/upstream_tls above for consistency +# within this file, though enforced by a different TLS stack (Go's own +# crypto/tls here, Envoy/BoringSSL there). +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" +# Comma-separated Go crypto/tls cipher suite names (e.g. +# "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which suites this +# listener negotiates. Empty by default -- Go's own secure default set/order +# applies. Only affects TLS 1.2 and below; TLS 1.3 suite selection is fixed +# and not configurable in Go's crypto/tls. Note this is a different naming +# scheme than router.downstream_tls's `ciphers` above (OpenSSL/BoringSSL +# names like "ECDHE-ECDSA-AES128-GCM-SHA256") -- see crypto/tls.CipherSuites +# for the names this listener accepts. +ciphers = "" +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. +# Classical curves only by default. A hybrid post-quantum group (e.g. +# "X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be prepended as an +# explicit opt-in once the clients reaching this listener are confirmed to +# support it. This listener is served by this process's own Go crypto/tls +# (1.23+ implements X25519MLKEM768 natively), not pushed as xDS config to a +# separate Envoy process, so enabling it here does not carry the "already- +# running peer NACKs the update" risk documented on router.downstream_tls's +# ecdh_curves above -- TLS 1.3 negotiation just falls back to a later +# classical entry in this same list for a client that doesn't offer it. +ecdh_curves = "X25519,P-256" + [policy_engine.admin.pprof] # Go runtime profiling (net/http/pprof) served on the admin server, off by default. # When profiling, also restrict admin.allowed_ips or reach it via port-forward. @@ -369,10 +484,55 @@ initial_reconnect_delay = "1s" max_reconnect_delay = "60s" [policy_engine.xds.tls] -enabled = false -# cert_path = "/path/to/client-cert.pem" -# key_path = "/path/to/client-key.pem" -# ca_path = "/path/to/ca-cert.pem" +# Mutual TLS for this policy-engine's connection to gateway-controller's +# policy xDS server. Off by default; must be enabled together with +# controller.policy_server.tls above (and this cert's identity added to +# controller.policy_server.tls.allowed_client_identities) -- the server +# offers no server-only TLS mode for xDS, so enabling only one side leaves +# the connection unable to complete its handshake. +# +# The policy-engine binary is a subprocess of gateway-runtime's +# docker-entrypoint.sh, forked into (and inheriting the environment of) the +# same container as Envoy -- so, like Envoy's own XDS_CLIENT_* vars, this +# cert/key/CA material is sourced from env vars local to that container +# rather than hardcoded here. Distinct POLICY_ENGINE_XDS_CLIENT_* names (not +# the plain XDS_CLIENT_* Envoy uses) because this leg presents a different +# client identity (spiffe://.../policy-engine, not .../envoy) -- reusing +# Envoy's cert here would fail controller.policy_server.tls's +# allowed_client_identities check. +# +# Every setting below follows the same three-level precedence: its own +# POLICY_ENGINE_XDS_CLIENT_* var (POLICY_ENGINE_XDS_TLS_ENABLED for the +# enabled flag), if set, always wins; otherwise it inherits Envoy's +# equivalent XDS_CLIENT_* var (the common case -- one container-wide +# xDS-client TLS config, since both legs dial the same gateway-controller +# host); if neither is set, it falls back to the literal default below. CA +# fallback is always safe -- controller.server.xds_tls and +# controller.policy_server.tls share the same server cert/CA by content, +# just mounted at different container paths. cert_path/key_path fallback is +# NOT automatically safe: inheriting Envoy's client cert means this leg +# presents Envoy's identity rather than its own, which +# controller.policy_server.tls's allowed_client_identities rejects unless it +# explicitly allows both -- rely on this fallback only in a deployment that +# deliberately shares one client identity across both legs; otherwise set +# POLICY_ENGINE_XDS_CLIENT_* explicitly, as this repo's own docker-compose +# files do. +enabled = '{{ env "POLICY_ENGINE_XDS_TLS_ENABLED" (env "XDS_TLS_ENABLED" "false") }}' +cert_path = '{{ env "POLICY_ENGINE_XDS_CLIENT_CERT_PATH" (env "XDS_CLIENT_CERT_PATH" "") }}' +key_path = '{{ env "POLICY_ENGINE_XDS_CLIENT_KEY_PATH" (env "XDS_CLIENT_KEY_PATH" "") }}' +ca_path = '{{ env "POLICY_ENGINE_XDS_CLIENT_CA_PATH" (env "XDS_CLIENT_CA_PATH" "") }}' +# Comma-separated Go crypto/tls cipher suite names, restricting which +# suites this client offers. Empty by default -- Go's own secure default +# set/order applies. Only affects TLS 1.2 and below. +ciphers = '{{ env "POLICY_ENGINE_XDS_CLIENT_TLS_CIPHERS" (env "XDS_CLIENT_TLS_CIPHERS" "") }}' +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. +# Classical curves only by default. A hybrid post-quantum group (e.g. +# "X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be prepended as an +# explicit opt-in once controller.policy_server.tls above is confirmed to +# support it -- this client is Go's own crypto/tls (1.23+ implements +# X25519MLKEM768 natively), so a server that doesn't offer the hybrid group +# simply falls back to a later classical entry in this same list. +ecdh_curves = '{{ env "POLICY_ENGINE_XDS_CLIENT_TLS_ECDH_CURVES" (env "XDS_CLIENT_TLS_ECDH_CURVES" "X25519,P-256") }}' [policy_engine.file_config] path = "" diff --git a/gateway/configs/config.toml b/gateway/configs/config.toml index e81a042d7a..e18cf47e47 100644 --- a/gateway/configs/config.toml +++ b/gateway/configs/config.toml @@ -14,6 +14,27 @@ enabled = true [controller.server] gateway_id = '{{ env "APIP_GW_CONTROLLER_SERVER_GATEWAY_ID" "platform-gateway-id" }}' + +[controller.server.tls] +enabled = true +port = 9093 +cert_path = "./listener-certs/default-listener.crt" +key_path = "./listener-certs/default-listener.key" +minimum_protocol_version = "TLS1_3" +maximum_protocol_version = "TLS1_3" +ciphers = "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. +# Classical curves only by default. A hybrid post-quantum group (e.g. +# "X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be prepended as an +# explicit opt-in once the clients reaching this listener are confirmed to +# support it. This listener is served by this process's own Go crypto/tls +# (1.23+ implements X25519MLKEM768 natively), not pushed as xDS config to a +# separate Envoy process, so enabling it here does not carry the "already- +# running peer NACKs the update" risk documented on router.downstream_tls's +# ecdh_curves below -- TLS 1.3 negotiation just falls back to a later +# classical entry in this same list for a client that doesn't offer it. +ecdh_curves = "X25519,P-256,X25519MLKEM768" + [controller.storage] type = '{{ env "APIP_GW_CONTROLLER_STORAGE_TYPE" "sqlite" }}' diff --git a/gateway/distribution/docker-compose.yaml b/gateway/distribution/docker-compose.yaml index a1ac1f7288..0db9dccf63 100644 --- a/gateway/distribution/docker-compose.yaml +++ b/gateway/distribution/docker-compose.yaml @@ -36,6 +36,7 @@ services: - ./configs/config.toml:/etc/gateway-controller/config.toml:ro - ./resources/certificates:/app/certificates - ./resources/listener-certs:/app/listener-certs:ro + - ./resources/xds-certs:/app/xds-certs:ro # Read-only mTLS dev CA/server cert for the two xDS servers (server.xds_tls, policy_server.tls) - ./resources/aesgcm-keys/default-aesgcm256-v1.bin:/app/data/aesgcm-keys/default-aesgcm256-v1.bin:ro extra_hosts: - "host.docker.internal:host-gateway" @@ -66,9 +67,43 @@ services: # Envoy admin is disabled by default in the image; enabled here for local # dev convenience since the port is already mapped to the host above. - ROUTER_ADMIN_ENABLED=true + # Mutual TLS for the Router's (Envoy's) connection to gateway-controller's + # main xDS server -- off by default (plaintext), matching + # controller.server.xds_tls.enabled=false in configs/config.toml. + - XDS_TLS_ENABLED=false + - XDS_CLIENT_CERT_PATH=/etc/xds-certs/envoy-client.crt + - XDS_CLIENT_KEY_PATH=/etc/xds-certs/envoy-client.key + - XDS_CLIENT_CA_PATH=/etc/xds-certs/ca.crt + # Classical curves only by default -- prepend "X25519MLKEM768" (FIPS 203 + # ML-KEM-768 + X25519) once gateway-controller's server.xds_tls.ecdh_curves + # is updated to match, confirming the deployed Envoy/BoringSSL build + # supports the group. + - XDS_CLIENT_TLS_MIN_VERSION=TLS1_2 + - XDS_CLIENT_TLS_MAX_VERSION=TLS1_3 + - XDS_CLIENT_TLS_CIPHERS= + - XDS_CLIENT_TLS_ECDH_CURVES=X25519,P-256 + # Mutual TLS for the Policy Engine's (a subprocess of this same + # container's entrypoint) connection to gateway-controller's policy + # xDS server -- matches controller.policy_server.tls.enabled in + # configs/config.toml. Read by that file's [policy_engine.xds.tls] via + # {{ env }} interpolation, not by docker-entrypoint.sh -- distinct + # POLICY_ENGINE_XDS_CLIENT_* names because this leg presents a + # different client identity than Envoy's XDS_CLIENT_* cert above. + # Left unset, this would inherit XDS_TLS_ENABLED (=false above) -- + # explicit here because, unlike the other two compose files, this + # profile wants the two legs to diverge: Envoy plaintext, policy-engine + # mTLS (matching controller.policy_server.tls.enabled=true, which is + # unconditional in configs/config.toml regardless of profile). + - POLICY_ENGINE_XDS_TLS_ENABLED=true + - POLICY_ENGINE_XDS_CLIENT_CERT_PATH=/etc/policy-engine/xds-certs/policy-engine-client.crt + - POLICY_ENGINE_XDS_CLIENT_KEY_PATH=/etc/policy-engine/xds-certs/policy-engine-client.key + - POLICY_ENGINE_XDS_CLIENT_CA_PATH=/etc/policy-engine/xds-certs/ca.crt + - POLICY_ENGINE_XDS_CLIENT_TLS_ECDH_CURVES=X25519MLKEM768 volumes: - ./configs/config.toml:/etc/policy-engine/config.toml:ro - ./configs/llm-pricing/model_prices.json:/etc/policy-engine/llm-pricing/model_prices.json:ro + - ./resources/xds-certs:/etc/xds-certs:ro # Envoy's xDS mTLS client cert/key + CA (env vars above) + - ./resources/xds-certs:/etc/policy-engine/xds-certs:ro # Policy-engine's xDS mTLS client cert/key + CA (POLICY_ENGINE_XDS_CLIENT_* env vars above) networks: - gateway-network diff --git a/gateway/docker-compose.debug.yaml b/gateway/docker-compose.debug.yaml index 55bfaa1877..6d6e397063 100644 --- a/gateway/docker-compose.debug.yaml +++ b/gateway/docker-compose.debug.yaml @@ -38,6 +38,7 @@ services: - ./configs/config.toml:/etc/gateway-controller/config.toml:ro - ./gateway-controller/certificates:/app/certificates - ./gateway-controller/listener-certs:/app/listener-certs:ro + - ./gateway-controller/xds-certs:/app/xds-certs:ro # Read-only mTLS dev CA/server cert for the two xDS servers (server.xds_tls, policy_server.tls) - ./gateway-controller/aesgcm-keys/default-aesgcm256-v1.bin:/app/data/aesgcm-keys/default-aesgcm256-v1.bin:ro # AES-256 at-rest encryption key (generated by scripts/setup.sh) extra_hosts: - "host.docker.internal:host-gateway" @@ -73,8 +74,46 @@ services: # Envoy admin is disabled by default in the image; enabled here for local # dev convenience since the port is already mapped to the host above. - ROUTER_ADMIN_ENABLED=true + # Mutual TLS for the Router's (Envoy's) connection to gateway-controller's + # main xDS server -- must match controller.server.xds_tls.enabled in + # configs/config.toml (shared with gateway-controller above). Entirely + # self-contained to this container: gateway-controller never needs to + # know these paths -- SDS/secret delivery rides the same ADS stream + # this bootstrap cluster already opens, so there's no second, + # controller-side copy of this TLS material to keep in sync. + - XDS_TLS_ENABLED=true + - XDS_CLIENT_CERT_PATH=/etc/xds-certs/envoy-client.crt + - XDS_CLIENT_KEY_PATH=/etc/xds-certs/envoy-client.key + - XDS_CLIENT_CA_PATH=/etc/xds-certs/ca.crt + - XDS_CLIENT_TLS_MIN_VERSION=TLS1_2 + - XDS_CLIENT_TLS_MAX_VERSION=TLS1_3 + # TLS 1.2 fallback suites only (BoringSSL/Envoy naming) -- forward-secret + # (ECDHE) + AEAD (AES-GCM/ChaCha20-Poly1305) only, 256-bit and + # ChaCha20 ordered ahead of AES-128 per post-quantum-cryptography.md + # directive 1. X25519MLKEM768 itself is a TLS 1.3 key-share group + # negotiated via XDS_CLIENT_TLS_ECDH_CURVES below regardless of this + # list -- TLS 1.3's own cipher suites are fixed and not configurable. + - XDS_CLIENT_TLS_CIPHERS=ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-CHACHA20-POLY1305,ECDHE-RSA-CHACHA20-POLY1305,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256 + - XDS_CLIENT_TLS_ECDH_CURVES=X25519,P-256,X25519MLKEM768 + # Mutual TLS for the Policy Engine's (a subprocess of this same + # container's entrypoint) connection to gateway-controller's policy + # xDS server -- matches controller.policy_server.tls.enabled in + # configs/config.toml. Read by that file's [policy_engine.xds.tls] via + # {{ env }} interpolation, not by docker-entrypoint.sh -- distinct + # POLICY_ENGINE_XDS_CLIENT_* names because this leg presents a + # different client identity than Envoy's XDS_CLIENT_* cert above. + # No POLICY_ENGINE_XDS_TLS_ENABLED here: unset, it inherits + # XDS_TLS_ENABLED above (=true), which is what we want since both legs + # run mTLS in this profile -- set it explicitly only to diverge from + # Envoy's setting (see distribution/docker-compose.yaml). + - POLICY_ENGINE_XDS_CLIENT_CERT_PATH=/etc/policy-engine/xds-certs/policy-engine-client.crt + - POLICY_ENGINE_XDS_CLIENT_KEY_PATH=/etc/policy-engine/xds-certs/policy-engine-client.key + - POLICY_ENGINE_XDS_CLIENT_CA_PATH=/etc/policy-engine/xds-certs/ca.crt + - POLICY_ENGINE_XDS_CLIENT_TLS_ECDH_CURVES=X25519MLKEM768 volumes: - ./configs/config.toml:/etc/policy-engine/config.toml:ro + - ./gateway-controller/xds-certs:/etc/xds-certs:ro # Envoy's xDS mTLS client cert/key + CA (env vars above) + - ./gateway-controller/xds-certs:/etc/policy-engine/xds-certs:ro # Policy-engine's xDS mTLS client cert/key + CA (POLICY_ENGINE_XDS_CLIENT_* env vars above) networks: - gateway-network cap_add: diff --git a/gateway/docker-compose.yaml b/gateway/docker-compose.yaml index f9d117bff0..3774f5b35a 100644 --- a/gateway/docker-compose.yaml +++ b/gateway/docker-compose.yaml @@ -32,6 +32,7 @@ services: - "9090:9090" # REST API - "9094:9092" # Admin API - "9011:9091" # Metrics + - "9093:9093" # REST API TLS env_file: - path: api-platform.env required: true @@ -62,6 +63,7 @@ services: # Policy Engine - "9002:9002" # Admin API - "9003:9003" # Metrics + - "9004:9004" # Health env_file: - path: api-platform.env required: true @@ -69,6 +71,7 @@ services: volumes: - ./configs/config.toml:/etc/policy-engine/config.toml:ro - ./configs/llm-pricing/model_prices.json:/etc/policy-engine/llm-pricing/model_prices.json:ro + - ./gateway-controller/listener-certs:/etc/policy-engine/listener-certs:ro networks: - gateway-network diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index ec2549f673..bcef92a7d5 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -389,7 +389,16 @@ func main() { policyEngineConnected := make(chan struct{}) // Start xDS gRPC server with SDS support - xdsServer := xds.NewServer(snapshotManager, sdsSecretManager, cfg.Controller.Server.XDSPort, log, routerConnected) + var xdsServerOpts []xds.ServerOption + if cfg.Controller.Server.XDSTLS.Enabled { + xdsTLSConfig, err := config.BuildXDSServerTLSConfig(cfg.Controller.Server.XDSTLS) + if err != nil { + log.Error("invalid server.xds_tls config, refusing to start main xDS server in plaintext", slog.Any("error", err)) + os.Exit(1) + } + xdsServerOpts = append(xdsServerOpts, xds.WithMTLS(xdsTLSConfig, cfg.Controller.Server.XDSTLS.AllowedClientIdentities)) + } + xdsServer := xds.NewServer(snapshotManager, sdsSecretManager, cfg.Controller.Server.XDSPort, log, routerConnected, xdsServerOpts...) go func() { if err := xdsServer.Start(); err != nil { log.Error("xDS server failed", slog.Any("error", err)) @@ -490,10 +499,12 @@ func main() { policyxds.WithOnFirstConnect(policyEngineConnected), } if cfg.Controller.PolicyServer.TLS.Enabled { - serverOpts = append(serverOpts, policyxds.WithTLS( - cfg.Controller.PolicyServer.TLS.CertFile, - cfg.Controller.PolicyServer.TLS.KeyFile, - )) + policyXDSTLSConfig, err := config.BuildXDSServerTLSConfig(cfg.Controller.PolicyServer.TLS) + if err != nil { + log.Error("invalid policy_server.tls config, refusing to start policy xDS server in plaintext", slog.Any("error", err)) + os.Exit(1) + } + serverOpts = append(serverOpts, policyxds.WithMTLS(policyXDSTLSConfig, cfg.Controller.PolicyServer.TLS.AllowedClientIdentities)) } policyXDSServer := policyxds.NewServer(policySnapshotManager, apiKeySnapshotManager, lazyResourceSnapshotManager, subscriptionSnapshotManager, nil, cfg.Controller.PolicyServer.Port, log, serverOpts...) go func() { @@ -752,6 +763,31 @@ func main() { } }() + // Optional TLS listener for the REST API, additive to the plaintext one + // above — never instead of it. A misconfigured/missing certificate here + // disables just this listener rather than exiting the process, since the + // plaintext listener remains the required one. + var tlsSrv *http.Server + if cfg.Controller.Server.TLS.Enabled { + tlsConfig, err := buildRESTAPITLSConfig(&cfg.Controller.Server.TLS) + if err != nil { + log.Error("invalid server.tls config, REST API TLS listener disabled", slog.Any("error", err)) + } else { + tlsSrv = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Controller.Server.TLS.Port), + Handler: handler, + ReadHeaderTimeout: 30 * time.Second, + TLSConfig: tlsConfig, + } + go func() { + log.Info("Starting REST API TLS server", slog.Int("port", cfg.Controller.Server.TLS.Port)) + if err := tlsSrv.ListenAndServeTLS(cfg.Controller.Server.TLS.CertPath, cfg.Controller.Server.TLS.KeyPath); err != nil && err != http.ErrServerClosed { + log.Error("REST API TLS server error", slog.Any("error", err)) + } + }() + } + } + log.Info("Gateway Controller started successfully") // Print banner when both router and policy engine have sent their first ACK, @@ -803,6 +839,12 @@ func main() { log.Error("Server forced to shutdown", slog.Any("error", err)) } + if tlsSrv != nil { + if err := tlsSrv.Shutdown(ctx); err != nil { + log.Error("REST API TLS server forced to shutdown", slog.Any("error", err)) + } + } + xdsServer.Stop() // Stop policy xDS server if it was started diff --git a/gateway/gateway-controller/cmd/controller/server_tls.go b/gateway/gateway-controller/cmd/controller/server_tls.go new file mode 100644 index 0000000000..e5b10b2571 --- /dev/null +++ b/gateway/gateway-controller/cmd/controller/server_tls.go @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package main + +import ( + "crypto/tls" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" +) + +// buildRESTAPITLSConfig translates a config.ServerTLSConfig into a tls.Config: +// bounded protocol version range, an optional cipher-suite restriction +// (TLS 1.2 and below only — TLS 1.3 suite selection isn't configurable in +// Go's crypto/tls), and the ECDH/group preference list, PQC hybrid group +// included when the operator has opted in. Config.Validate already rejects a +// bad version/cipher/curve value before this ever runs in production, so an +// error here can only come from a caller that bypassed validation. +func buildRESTAPITLSConfig(cfg *config.ServerTLSConfig) (*tls.Config, error) { + if err := config.ValidateServerTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := config.ParseServerTLSVersion(cfg.MinimumProtocolVersion) + maxVersion, _ := config.ParseServerTLSVersion(cfg.MaximumProtocolVersion) + + cipherSuites, err := config.ParseServerCiphers(cfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := config.ParseServerEcdhCurves(cfg.EcdhCurves) + if err != nil { + return nil, err + } + + return &tls.Config{ + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, + }, nil +} diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go index f7cbea8276..7c96d34803 100644 --- a/gateway/gateway-controller/pkg/config/config.go +++ b/gateway/gateway-controller/pkg/config/config.go @@ -305,6 +305,77 @@ type ServerConfig struct { ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` GatewayID string `koanf:"gateway_id"` SkipInvalidDeploymentsOnStartup bool `koanf:"skip_invalid_deployments_on_startup"` + + // TLS starts a second, TLS-only listener on TLS.Port serving the same + // REST management API as the plaintext listener on APIPort. Off by + // default. + TLS ServerTLSConfig `koanf:"tls"` + + // XDSTLS switches the main xDS gRPC server (serving Envoy, on XDSPort) + // from plaintext to mutual TLS. Unlike TLS above, this does not add a + // second listener -- XDSPort itself starts speaking mTLS. Off by + // default; see XDSServerTLSConfig for why xDS has no server-only mode. + XDSTLS XDSServerTLSConfig `koanf:"xds_tls"` +} + +// ServerTLSConfig holds configuration for an additional TLS listener for the +// REST management API. It is served alongside — not instead of — the +// plaintext listener on ServerConfig.APIPort, so enabling it never breaks an +// existing plaintext deployment. Same shape and naming conventions as +// policy-engine's AdminTLSConfig (gateway-runtime/policy-engine/internal/config) — +// keep the two in sync if either changes, they are independent implementations +// (different Go modules) of the same pattern. +type ServerTLSConfig struct { + // Enabled starts the TLS listener on Port. Off by default: no + // certificate is provisioned by default, and the plaintext listener + // keeps working either way. + Enabled bool `koanf:"enabled"` + + // Port is the port for the TLS REST API listener. Must differ from every + // other configured controller port (server.api_port, server.xds_port, + // admin_server.port, metrics.port). + Port int `koanf:"port"` + + // CertPath and KeyPath are the PEM-encoded server certificate and + // private key for the TLS listener. Required when Enabled. + CertPath string `koanf:"cert_path"` + KeyPath string `koanf:"key_path"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the negotiated + // TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". Same + // vocabulary as router.downstream_tls/upstream_tls for consistency + // within the shared config file. + MinimumProtocolVersion string `koanf:"minimum_protocol_version"` + MaximumProtocolVersion string `koanf:"maximum_protocol_version"` + + // Ciphers is a comma-separated list of Go crypto/tls cipher suite names + // (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which + // suites this listener will negotiate. Empty by default, meaning Go's + // own secure default set/order applies. Only affects TLS 1.2 and below — + // TLS 1.3 suite selection is not configurable in Go's crypto/tls. + // + // Note this is a different naming scheme than router.downstream_tls's + // ciphers field (OpenSSL/BoringSSL names like + // "ECDHE-ECDSA-AES128-GCM-SHA256"): this listener is served by Go's own + // crypto/tls, not Envoy, so it uses Go's canonical cipher suite names — + // see crypto/tls.CipherSuites for the supported list. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups, + // most preferred first (e.g. "X25519,P-256"). Classical curves only by + // default. A hybrid post-quantum group ("X25519MLKEM768", FIPS 203 + // ML-KEM-768 + X25519) can be prepended as an explicit opt-in once the + // clients that reach this listener are confirmed to support it. + // + // Unlike router.downstream_tls/upstream_tls's EcdhCurves, this listener + // is served directly by this process's own Go crypto/tls (1.23+ + // implements X25519MLKEM768 natively) rather than pushed as xDS config + // to a separate Envoy process, so enabling the hybrid group here carries + // none of the "already-running peer NACKs the update" risk documented on + // those fields — TLS 1.3 negotiation simply falls back to a later + // classical entry in this same list for a client that doesn't offer the + // hybrid group. + EcdhCurves string `koanf:"ecdh_curves"` } // AdminServerConfig holds controller admin HTTP server configuration. @@ -337,15 +408,8 @@ type PprofConfig struct { // PolicyServerConfig holds policy xDS server-related configuration type PolicyServerConfig struct { - Port int `koanf:"port"` - TLS PolicyServerTLS `koanf:"tls"` -} - -// PolicyServerTLS holds TLS configuration for the policy xDS server -type PolicyServerTLS struct { - Enabled bool `koanf:"enabled"` - CertFile string `koanf:"cert_file"` - KeyFile string `koanf:"key_file"` + Port int `koanf:"port"` + TLS XDSServerTLSConfig `koanf:"tls"` } // PoliciesConfig holds policy-related configuration @@ -564,12 +628,15 @@ type UpstreamTLS struct { MinimumProtocolVersion string `koanf:"minimum_protocol_version"` MaximumProtocolVersion string `koanf:"maximum_protocol_version"` Ciphers string `koanf:"ciphers"` - // EcdhCurves is a comma-separated list of ECDH curves, most preferred first. Defaults to a - // hybrid post-quantum group ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) followed by - // classical curves, so key exchange degrades gracefully to classical for peers that don't yet - // support the hybrid group. - EcdhCurves string `koanf:"ecdh_curves"` - TrustedCertPath string `koanf:"trusted_cert_path"` + // EcdhCurves is a comma-separated list of ECDH curves (e.g. "X25519,P-256"), most preferred + // first. Defaults to classical curves only — a hybrid post-quantum group (e.g. + // "X25519MLKEM768") can be added as the first preference, but only as an explicit opt-in per + // deployment: an already-running Envoy instance that doesn't recognize the curve name will + // NACK the xDS update and keep serving its last-known-good config, silently freezing that + // instance out of any further config changes until the operator fixes it. Confirm the + // deployed Envoy/BoringSSL build supports the group before enabling it. + EcdhCurves string `koanf:"ecdh_curves"` + TrustedCertPath string `koanf:"trusted_cert_path"` CustomCertsPath string `koanf:"custom_certs_path"` // Directory containing custom trusted certificates VerifyHostName bool `koanf:"verify_host_name"` DisableSslVerification bool `koanf:"disable_ssl_verification"` @@ -599,10 +666,13 @@ type DownstreamTLS struct { MinimumProtocolVersion string `koanf:"minimum_protocol_version"` MaximumProtocolVersion string `koanf:"maximum_protocol_version"` Ciphers string `koanf:"ciphers"` - // EcdhCurves is a comma-separated list of ECDH curves, most preferred first. Defaults to a - // hybrid post-quantum group ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) followed by - // classical curves, so key exchange degrades gracefully to classical for peers that don't yet - // support the hybrid group. + // EcdhCurves is a comma-separated list of ECDH curves (e.g. "X25519,P-256"), most preferred + // first. Defaults to classical curves only — a hybrid post-quantum group (e.g. + // "X25519MLKEM768") can be added as the first preference, but only as an explicit opt-in per + // deployment: an already-running Envoy instance that doesn't recognize the curve name will + // NACK the xDS update and keep serving its last-known-good config, silently freezing that + // instance out of any further config changes until the operator fixes it. Confirm the + // deployed Envoy/BoringSSL build supports the group before enabling it. EcdhCurves string `koanf:"ecdh_curves"` } @@ -842,6 +912,24 @@ func defaultConfig() *Config { ShutdownTimeout: 15 * time.Second, GatewayID: constants.PlatformGatewayId, SkipInvalidDeploymentsOnStartup: false, + TLS: ServerTLSConfig{ + Enabled: false, + Port: 9093, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", + }, + XDSTLS: XDSServerTLSConfig{ + Enabled: false, + CertFile: "./xds-certs/server.crt", + KeyFile: "./xds-certs/server.key", + ClientCAFile: "./xds-certs/ca.crt", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", + }, }, AdminServer: AdminServerConfig{ Enabled: true, @@ -858,10 +946,15 @@ func defaultConfig() *Config { }, PolicyServer: PolicyServerConfig{ Port: 18001, - TLS: PolicyServerTLS{ - Enabled: false, - CertFile: "./certs/server.crt", - KeyFile: "./certs/server.key", + TLS: XDSServerTLSConfig{ + Enabled: false, + CertFile: "./xds-certs/server.crt", + KeyFile: "./xds-certs/server.key", + ClientCAFile: "./xds-certs/ca.crt", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", }, }, Policies: PoliciesConfig{ @@ -999,7 +1092,7 @@ func defaultConfig() *Config { MinimumProtocolVersion: "TLS1_2", MaximumProtocolVersion: "TLS1_3", Ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,AES128-GCM-SHA256,AES128-SHA,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,AES256-GCM-SHA384,AES256-SHA", - EcdhCurves: "X25519MLKEM768,X25519,P-256", + EcdhCurves: "X25519,P-256", }, GatewayHost: "*", Upstream: RouterUpstream{ @@ -1007,7 +1100,7 @@ func defaultConfig() *Config { MinimumProtocolVersion: "TLS1_2", MaximumProtocolVersion: "TLS1_3", Ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,AES128-GCM-SHA256,AES128-SHA,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,AES256-GCM-SHA384,AES256-SHA", - EcdhCurves: "X25519MLKEM768,X25519,P-256", + EcdhCurves: "X25519,P-256", TrustedCertPath: "/etc/ssl/certs/ca-certificates.crt", CustomCertsPath: "./certificates", VerifyHostName: true, @@ -1367,6 +1460,44 @@ func (c *Config) Validate() error { return fmt.Errorf("server.gateway_id is required and cannot be empty") } + // Validate REST API TLS config + if c.Controller.Server.TLS.Enabled { + if c.Controller.Server.TLS.Port < 1 || c.Controller.Server.TLS.Port > 65535 { + return fmt.Errorf("server.tls.port must be between 1 and 65535, got: %d", c.Controller.Server.TLS.Port) + } + if c.Controller.Server.TLS.Port == c.Controller.Server.APIPort { + return fmt.Errorf("server.tls.port cannot be same as server.api_port") + } + if c.Controller.Server.TLS.Port == c.Controller.Server.XDSPort { + return fmt.Errorf("server.tls.port cannot be same as server.xds_port") + } + if c.Controller.Server.TLS.CertPath == "" { + return fmt.Errorf("server.tls.cert_path is required when server.tls.enabled") + } + if c.Controller.Server.TLS.KeyPath == "" { + return fmt.Errorf("server.tls.key_path is required when server.tls.enabled") + } + if err := ValidateServerTLSVersions(c.Controller.Server.TLS.MinimumProtocolVersion, c.Controller.Server.TLS.MaximumProtocolVersion); err != nil { + return fmt.Errorf("server.tls: %w", err) + } + if _, err := ParseServerCiphers(c.Controller.Server.TLS.Ciphers); err != nil { + return fmt.Errorf("server.tls.ciphers: %w", err) + } + if _, err := ParseServerEcdhCurves(c.Controller.Server.TLS.EcdhCurves); err != nil { + return fmt.Errorf("server.tls.ecdh_curves: %w", err) + } + } + + // Validate main xDS server mTLS config (serves Envoy on server.xds_port) + if err := ValidateXDSServerTLS("server.xds_tls", c.Controller.Server.XDSTLS); err != nil { + return err + } + + // Validate policy xDS server mTLS config (serves the policy-engine on policy_server.port) + if err := ValidateXDSServerTLS("policy_server.tls", c.Controller.PolicyServer.TLS); err != nil { + return err + } + if c.Controller.AdminServer.Enabled { if c.Controller.AdminServer.Port < 1 || c.Controller.AdminServer.Port > 65535 { return fmt.Errorf("admin_server.port must be between 1 and 65535, got: %d", c.Controller.AdminServer.Port) @@ -1377,6 +1508,9 @@ func (c *Config) Validate() error { if c.Controller.AdminServer.Port == c.Controller.Server.XDSPort { return fmt.Errorf("admin_server.port cannot be same as server.xds_port") } + if c.Controller.Server.TLS.Enabled && c.Controller.AdminServer.Port == c.Controller.Server.TLS.Port { + return fmt.Errorf("admin_server.port cannot be same as server.tls.port") + } } // Validate metrics config @@ -1393,6 +1527,9 @@ func (c *Config) Validate() error { if c.Controller.AdminServer.Enabled && c.Controller.Metrics.Port == c.Controller.AdminServer.Port { return fmt.Errorf("metrics.port cannot be same as admin_server.port") } + if c.Controller.Server.TLS.Enabled && c.Controller.Metrics.Port == c.Controller.Server.TLS.Port { + return fmt.Errorf("metrics.port cannot be same as server.tls.port") + } } if c.Router.ListenerPort < 1 || c.Router.ListenerPort > 65535 { diff --git a/gateway/gateway-controller/pkg/config/config_test.go b/gateway/gateway-controller/pkg/config/config_test.go index 2fbebb5417..29b9630f28 100644 --- a/gateway/gateway-controller/pkg/config/config_test.go +++ b/gateway/gateway-controller/pkg/config/config_test.go @@ -19,6 +19,7 @@ package config import ( + "crypto/tls" "os" "path/filepath" "strings" @@ -664,6 +665,330 @@ func TestConfig_Validate_Ports(t *testing.T) { } } +func TestConfig_Validate_ServerTLS(t *testing.T) { + validTLS := func() ServerTLSConfig { + return ServerTLSConfig{ + Enabled: true, + Port: 9093, + CertPath: "./certs/rest-api.crt", + KeyPath: "./certs/rest-api.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + } + + tests := []struct { + name string + mutate func(*ServerTLSConfig) + wantErr bool + errContains string + }{ + {name: "valid config", mutate: func(tls *ServerTLSConfig) {}, wantErr: false}, + { + name: "PQC hybrid group opt-in", + mutate: func(tls *ServerTLSConfig) { tls.EcdhCurves = "X25519MLKEM768,X25519,P-256" }, + wantErr: false, + }, + { + name: "restricted cipher suite list", + mutate: func(tls *ServerTLSConfig) { + tls.Ciphers = "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + }, + wantErr: false, + }, + { + name: "invalid port zero", + mutate: func(tls *ServerTLSConfig) { tls.Port = 0 }, + wantErr: true, + errContains: "server.tls.port must be between", + }, + { + name: "port conflicts with api_port", + mutate: func(tls *ServerTLSConfig) { tls.Port = 8080 }, + wantErr: true, + errContains: "server.tls.port cannot be same as server.api_port", + }, + { + name: "port conflicts with xds_port", + mutate: func(tls *ServerTLSConfig) { tls.Port = 18000 }, + wantErr: true, + errContains: "server.tls.port cannot be same as server.xds_port", + }, + { + name: "missing cert path", + mutate: func(tls *ServerTLSConfig) { tls.CertPath = "" }, + wantErr: true, + errContains: "server.tls.cert_path is required", + }, + { + name: "missing key path", + mutate: func(tls *ServerTLSConfig) { tls.KeyPath = "" }, + wantErr: true, + errContains: "server.tls.key_path is required", + }, + { + name: "missing minimum protocol version", + mutate: func(tls *ServerTLSConfig) { tls.MinimumProtocolVersion = "" }, + wantErr: true, + errContains: "minimum_protocol_version", + }, + { + name: "minimum protocol version greater than maximum", + mutate: func(tls *ServerTLSConfig) { + tls.MinimumProtocolVersion = "TLS1_3" + tls.MaximumProtocolVersion = "TLS1_2" + }, + wantErr: true, + errContains: "cannot be greater than maximum_protocol_version", + }, + { + name: "unsupported cipher suite", + mutate: func(tls *ServerTLSConfig) { tls.Ciphers = "TLS_RSA_WITH_RC4_128_SHA" }, + wantErr: true, + errContains: "server.tls.ciphers", + }, + { + name: "unsupported ecdh curve", + mutate: func(tls *ServerTLSConfig) { tls.EcdhCurves = "not-a-curve" }, + wantErr: true, + errContains: "server.tls.ecdh_curves", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.APIPort = 8080 + cfg.Controller.Server.XDSPort = 18000 + tlsCfg := validTLS() + tt.mutate(&tlsCfg) + cfg.Controller.Server.TLS = tlsCfg + + err := cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } + + t.Run("disabled - no validation", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.TLS = ServerTLSConfig{Enabled: false, Port: 0} // invalid but should pass since disabled + assert.NoError(t, cfg.Validate()) + }) + + t.Run("conflicts with admin_server.port", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.APIPort = 8080 + cfg.Controller.Server.XDSPort = 18000 + cfg.Controller.AdminServer.Enabled = true + cfg.Controller.AdminServer.Port = 9092 + tlsCfg := validTLS() + tlsCfg.Port = 9092 + cfg.Controller.Server.TLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "admin_server.port cannot be same as server.tls.port") + }) + + t.Run("conflicts with metrics.port", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.APIPort = 8080 + cfg.Controller.Server.XDSPort = 18000 + cfg.Controller.Metrics.Enabled = true + cfg.Controller.Metrics.Port = 9091 + tlsCfg := validTLS() + tlsCfg.Port = 9091 + cfg.Controller.Server.TLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "metrics.port cannot be same as server.tls.port") + }) +} + +// TestConfig_Validate_XDSServerTLS verifies Config.Validate() routes +// server.xds_tls and policy_server.tls through ValidateXDSServerTLS. +func TestConfig_Validate_XDSServerTLS(t *testing.T) { + validXDSTLS := func() XDSServerTLSConfig { + return XDSServerTLSConfig{ + Enabled: true, + CertFile: "./xds-certs/server.crt", + KeyFile: "./xds-certs/server.key", + ClientCAFile: "./xds-certs/ca.crt", + AllowedClientIdentities: []string{"spiffe://api-platform/gateway-runtime/envoy"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + } + + t.Run("valid server.xds_tls passes", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.XDSTLS = validXDSTLS() + assert.NoError(t, cfg.Validate()) + }) + + t.Run("invalid server.xds_tls is rejected with a prefixed error", func(t *testing.T) { + cfg := validConfig() + tlsCfg := validXDSTLS() + tlsCfg.AllowedClientIdentities = nil + cfg.Controller.Server.XDSTLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "server.xds_tls.allowed_client_identities") + }) + + t.Run("valid policy_server.tls passes", func(t *testing.T) { + cfg := validConfig() + tlsCfg := validXDSTLS() + tlsCfg.AllowedClientIdentities = []string{"spiffe://api-platform/gateway-runtime/policy-engine"} + cfg.Controller.PolicyServer.TLS = tlsCfg + assert.NoError(t, cfg.Validate()) + }) + + t.Run("invalid policy_server.tls is rejected with a prefixed error", func(t *testing.T) { + cfg := validConfig() + tlsCfg := validXDSTLS() + tlsCfg.ClientCAFile = "" + cfg.Controller.PolicyServer.TLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "policy_server.tls.client_ca_file") + }) + + t.Run("disabled by default -- no validation", func(t *testing.T) { + cfg := validConfig() + assert.NoError(t, cfg.Validate()) + }) +} + +// TestParseServerEcdhCurves tests the ECDH curve preference parser used by +// ServerTLSConfig.EcdhCurves. +func TestParseServerEcdhCurves(t *testing.T) { + t.Run("classical curves only", func(t *testing.T) { + curves, err := ParseServerEcdhCurves("X25519,P-256") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("PQC hybrid group prepended", func(t *testing.T) { + curves, err := ParseServerEcdhCurves("X25519MLKEM768,X25519,P-256") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("whitespace tolerated", func(t *testing.T) { + curves, err := ParseServerEcdhCurves(" X25519 , P-256 ") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("unsupported curve name rejected", func(t *testing.T) { + _, err := ParseServerEcdhCurves("X25519,not-a-curve") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported ecdh curve") + }) + + t.Run("empty string rejected", func(t *testing.T) { + _, err := ParseServerEcdhCurves("") + assert.Error(t, err) + }) +} + +// TestValidateServerTLSVersions tests the min/max protocol version +// validation used by ServerTLSConfig. +func TestValidateServerTLSVersions(t *testing.T) { + t.Run("valid TLS1_2 to TLS1_3 range", func(t *testing.T) { + assert.NoError(t, ValidateServerTLSVersions("TLS1_2", "TLS1_3")) + }) + + t.Run("equal min and max", func(t *testing.T) { + assert.NoError(t, ValidateServerTLSVersions("TLS1_2", "TLS1_2")) + }) + + t.Run("unrecognized minimum version", func(t *testing.T) { + err := ValidateServerTLSVersions("bogus", "TLS1_3") + assert.Error(t, err) + assert.Contains(t, err.Error(), "minimum_protocol_version") + }) + + t.Run("unrecognized maximum version", func(t *testing.T) { + err := ValidateServerTLSVersions("TLS1_2", "bogus") + assert.Error(t, err) + assert.Contains(t, err.Error(), "maximum_protocol_version") + }) + + t.Run("minimum greater than maximum", func(t *testing.T) { + err := ValidateServerTLSVersions("TLS1_3", "TLS1_2") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be greater than maximum_protocol_version") + }) +} + +// TestParseServerTLSVersion tests the version-name to crypto/tls-identifier +// conversion used by ServerTLSConfig. +func TestParseServerTLSVersion(t *testing.T) { + tests := []struct { + name string + version string + want uint16 + }{ + {"TLS1_0", "TLS1_0", tls.VersionTLS10}, + {"TLS1_1", "TLS1_1", tls.VersionTLS11}, + {"TLS1_2", "TLS1_2", tls.VersionTLS12}, + {"TLS1_3", "TLS1_3", tls.VersionTLS13}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ParseServerTLSVersion(tt.version) + require.True(t, ok) + assert.Equal(t, tt.want, got) + }) + } + + t.Run("unrecognized version", func(t *testing.T) { + _, ok := ParseServerTLSVersion("bogus") + assert.False(t, ok) + }) +} + +// TestParseServerCiphers tests the cipher-suite-name parser used by +// ServerTLSConfig.Ciphers. +func TestParseServerCiphers(t *testing.T) { + t.Run("empty string is valid and means Go's defaults", func(t *testing.T) { + suites, err := ParseServerCiphers("") + require.NoError(t, err) + assert.Nil(t, suites) + }) + + t.Run("restricts to the named secure suites", func(t *testing.T) { + suites, err := ParseServerCiphers("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + require.NoError(t, err) + assert.Equal(t, []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, suites) + }) + + t.Run("whitespace tolerated", func(t *testing.T) { + suites, err := ParseServerCiphers(" TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 ") + require.NoError(t, err) + assert.Equal(t, []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, suites) + }) + + t.Run("insecure cipher suite rejected", func(t *testing.T) { + _, err := ParseServerCiphers("TLS_RSA_WITH_RC4_128_SHA") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported or insecure cipher suite") + }) + + t.Run("unrecognized cipher suite name rejected", func(t *testing.T) { + _, err := ParseServerCiphers("NOT_A_REAL_SUITE") + assert.Error(t, err) + }) +} + func TestConfig_Validate_MetricsConfig(t *testing.T) { tests := []struct { name string @@ -1812,14 +2137,18 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, 5*time.Minute, hcm.StreamIdleTimeout, "default stream_idle_timeout should be 5m") assert.Equal(t, time.Hour, hcm.IdleTimeout, "default idle_timeout should be 1h") - // TLS 1.2-1.3 with a hybrid post-quantum + classical ECDH curve preference - // list must be available by default on both upstream and downstream. + // TLS 1.2-1.3 must be available by default on both upstream and downstream. + // ECDH curves default to classical only (X25519, P-256): prepending a hybrid + // post-quantum group is an explicit per-deployment opt-in (see the EcdhCurves + // field doc), not a default, because an already-running Envoy instance that + // doesn't recognize the curve name would NACK the xDS update rather than + // picking up the change. assert.Equal(t, "TLS1_2", cfg.Router.DownstreamTLS.MinimumProtocolVersion) assert.Equal(t, "TLS1_3", cfg.Router.DownstreamTLS.MaximumProtocolVersion) - assert.Equal(t, "X25519MLKEM768,X25519,P-256", cfg.Router.DownstreamTLS.EcdhCurves) + assert.Equal(t, "X25519,P-256", cfg.Router.DownstreamTLS.EcdhCurves) assert.Equal(t, "TLS1_2", cfg.Router.Upstream.TLS.MinimumProtocolVersion) assert.Equal(t, "TLS1_3", cfg.Router.Upstream.TLS.MaximumProtocolVersion) - assert.Equal(t, "X25519MLKEM768,X25519,P-256", cfg.Router.Upstream.TLS.EcdhCurves) + assert.Equal(t, "X25519,P-256", cfg.Router.Upstream.TLS.EcdhCurves) } func TestLoadConfig_HCMTimeouts(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/config/server_tls.go b/gateway/gateway-controller/pkg/config/server_tls.go new file mode 100644 index 0000000000..610551a298 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/server_tls.go @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "crypto/tls" + "fmt" + "strings" +) + +// serverEcdhCurvesByName maps the names accepted in ServerTLSConfig.EcdhCurves +// to Go's crypto/tls group identifiers. X25519MLKEM768 is the FIPS 203 +// ML-KEM-768 + X25519 hybrid group, implemented natively by Go 1.23+. +var serverEcdhCurvesByName = map[string]tls.CurveID{ + "X25519": tls.X25519, + "P-256": tls.CurveP256, + "P-384": tls.CurveP384, + "P-521": tls.CurveP521, + "X25519MLKEM768": tls.X25519MLKEM768, +} + +// ParseServerEcdhCurves parses a comma-separated EcdhCurves preference list +// (e.g. "X25519MLKEM768,X25519,P-256") into the tls.CurveID slice consumed by +// tls.Config.CurvePreferences. Used both to fail config validation closed on +// an unrecognized curve name and to build the REST API TLS listener's config. +func ParseServerEcdhCurves(raw string) ([]tls.CurveID, error) { + parts := strings.Split(raw, ",") + curves := make([]tls.CurveID, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + curve, ok := serverEcdhCurvesByName[name] + if !ok { + return nil, fmt.Errorf("unsupported ecdh curve %q (supported: X25519, P-256, P-384, P-521, X25519MLKEM768)", name) + } + curves = append(curves, curve) + } + if len(curves) == 0 { + return nil, fmt.Errorf("must specify at least one ecdh curve") + } + return curves, nil +} + +// serverTLSVersionByName maps the version strings accepted in +// ServerTLSConfig.MinimumProtocolVersion/MaximumProtocolVersion to Go's +// crypto/tls version identifiers. Same vocabulary ("TLS1_2", etc.) as +// router.downstream_tls/upstream_tls for consistency within this shared +// config file, even though the two are enforced by different TLS stacks +// (Envoy/BoringSSL for the router, Go's own crypto/tls here). +var serverTLSVersionByName = map[string]uint16{ + "TLS1_0": tls.VersionTLS10, + "TLS1_1": tls.VersionTLS11, + "TLS1_2": tls.VersionTLS12, + "TLS1_3": tls.VersionTLS13, +} + +// serverTLSVersionOrder ranks the version names above so a min > max +// combination can be rejected at validation time. +var serverTLSVersionOrder = map[string]int{ + "TLS1_0": 0, + "TLS1_1": 1, + "TLS1_2": 2, + "TLS1_3": 3, +} + +// ValidateServerTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. +func ValidateServerTLSVersions(minVersion, maxVersion string) error { + if _, ok := serverTLSVersionByName[minVersion]; !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + if _, ok := serverTLSVersionByName[maxVersion]; !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if serverTLSVersionOrder[minVersion] > serverTLSVersionOrder[maxVersion] { + return fmt.Errorf("minimum_protocol_version (%s) cannot be greater than maximum_protocol_version (%s)", minVersion, maxVersion) + } + return nil +} + +// ParseServerTLSVersion converts a validated version name to its crypto/tls +// identifier. Callers should run ValidateServerTLSVersions first; an +// unrecognized name here returns ok=false rather than panicking. +func ParseServerTLSVersion(name string) (version uint16, ok bool) { + version, ok = serverTLSVersionByName[name] + return version, ok +} + +// serverCipherSuiteByName is built from Go's own list of secure cipher +// suites (tls.CipherSuites — deliberately excludes tls.InsecureCipherSuites) +// so an operator can only ever restrict to suites Go itself considers safe, +// never re-enable a weak one. Includes the three TLS 1.3 suite names for +// completeness, though Go does not apply CipherSuites to TLS 1.3 — TLS 1.3 +// suite selection is not configurable and always uses Go's own safe set. +var serverCipherSuiteByName = func() map[string]uint16 { + m := make(map[string]uint16) + for _, cs := range tls.CipherSuites() { + m[cs.Name] = cs.ID + } + return m +}() + +// ParseServerCiphers parses a comma-separated list of Go crypto/tls cipher +// suite names (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") into the +// []uint16 consumed by tls.Config.CipherSuites. An empty string is valid and +// returns (nil, nil) — Go's own default suite set/order applies. Only +// affects TLS 1.2 (and below) connections; TLS 1.3 ignores this field. +func ParseServerCiphers(raw string) ([]uint16, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + suites := make([]uint16, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + id, ok := serverCipherSuiteByName[name] + if !ok { + return nil, fmt.Errorf("unsupported or insecure cipher suite %q (see crypto/tls.CipherSuites for the supported list)", name) + } + suites = append(suites, id) + } + if len(suites) == 0 { + return nil, fmt.Errorf("must specify at least one cipher suite, or omit ciphers entirely to use Go's default set") + } + return suites, nil +} diff --git a/gateway/gateway-controller/pkg/config/xds_tls.go b/gateway/gateway-controller/pkg/config/xds_tls.go new file mode 100644 index 0000000000..041b61943e --- /dev/null +++ b/gateway/gateway-controller/pkg/config/xds_tls.go @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" +) + +// XDSServerTLSConfig holds mutual-TLS configuration for an xDS gRPC server: +// the main Envoy-facing ADS/SDS server on server.xds_port, and the +// policy-engine-facing server on policy_server.port. Off by default -- both +// servers keep working in plaintext either way, consistent with this +// repo's "PQC/TLS is optional-but-supported, not mandatory" posture (see +// post-quantum-cryptography.md), since not every deployment's Envoy or +// policy-engine build is configured for mTLS yet. +// +// Unlike ServerTLSConfig (the REST management API's TLS listener, which is +// server-only TLS), this type has no server-only mode: xDS is a +// control-plane channel that carries per-tenant API-key hashes, +// subscription state, and full policy chains, so authenticating only the +// server side is not sufficient (go-control-plane-xds-security.md +// directive 2). Whenever Enabled is true, ClientCAFile and +// AllowedClientIdentities are both required -- see ValidateXDSServerTLS. +type XDSServerTLSConfig struct { + // Enabled switches the xDS server from plaintext to mutual TLS on its + // existing port (server.xds_port or policy_server.port) -- there is no + // second listener the way ServerTLSConfig adds one for the REST API, + // since a gRPC server serves one credential type per port. + Enabled bool `koanf:"enabled"` + + // CertFile and KeyFile are the PEM-encoded server certificate and + // private key this xDS server presents to connecting clients. Required + // when Enabled. + CertFile string `koanf:"cert_file"` + KeyFile string `koanf:"key_file"` + + // ClientCAFile is a PEM bundle of CA certificates trusted to sign a + // connecting client's certificate (Envoy's or the policy-engine's). + // Required when Enabled -- this is what makes the handshake mutual + // rather than server-only. + ClientCAFile string `koanf:"client_ca_file"` + + // AllowedClientIdentities is an explicit allowlist of accepted peer + // certificate identities: a certificate's first SAN URI (e.g. a SPIFFE + // ID) if present, otherwise its Subject CommonName -- see + // pkg/tlsauth.PeerIdentity. A client certificate that chains to a + // trusted CA is not by itself authorization to reach this snapshot; at + // least one identity is required when Enabled, so this can't be + // silently left as a no-op allowlist (go-control-plane-xds-security.md + // directive 2). + AllowedClientIdentities []string `koanf:"allowed_client_identities"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the + // negotiated TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". + // Same vocabulary as ServerTLSConfig/router.downstream_tls for + // consistency within this file. + MinimumProtocolVersion string `koanf:"minimum_protocol_version"` + MaximumProtocolVersion string `koanf:"maximum_protocol_version"` + + // Ciphers is a comma-separated list of Go crypto/tls cipher suite names + // restricting which suites this server negotiates. Empty by default -- + // Go's own secure default set/order applies. Only affects TLS 1.2 and + // below; TLS 1.3 suite selection is not configurable in Go's crypto/tls. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups, + // most preferred first (e.g. "X25519,P-256"). A hybrid post-quantum + // group ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be + // prepended once both the Envoy/policy-engine peers reaching this + // server are confirmed to support it -- this server is Go's own + // crypto/tls (1.23+ implements X25519MLKEM768 natively), so an + // unsupporting peer simply falls back to a later classical entry in + // this same list. + EcdhCurves string `koanf:"ecdh_curves"` +} + +// ValidateXDSServerTLS validates an XDSServerTLSConfig block, a no-op when +// Enabled is false. fieldPrefix is the dotted config path used in error +// messages (e.g. "server.xds_tls" or "policy_server.tls"). +func ValidateXDSServerTLS(fieldPrefix string, cfg XDSServerTLSConfig) error { + if !cfg.Enabled { + return nil + } + if cfg.CertFile == "" { + return fmt.Errorf("%s.cert_file is required when %s.enabled", fieldPrefix, fieldPrefix) + } + if cfg.KeyFile == "" { + return fmt.Errorf("%s.key_file is required when %s.enabled", fieldPrefix, fieldPrefix) + } + if cfg.ClientCAFile == "" { + return fmt.Errorf("%s.client_ca_file is required when %s.enabled -- xDS requires mutual TLS, server-only TLS is not offered for this server", fieldPrefix, fieldPrefix) + } + if len(cfg.AllowedClientIdentities) == 0 { + return fmt.Errorf("%s.allowed_client_identities must list at least one accepted peer identity when %s.enabled", fieldPrefix, fieldPrefix) + } + if err := ValidateServerTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return fmt.Errorf("%s: %w", fieldPrefix, err) + } + if _, err := ParseServerCiphers(cfg.Ciphers); err != nil { + return fmt.Errorf("%s.ciphers: %w", fieldPrefix, err) + } + if _, err := ParseServerEcdhCurves(cfg.EcdhCurves); err != nil { + return fmt.Errorf("%s.ecdh_curves: %w", fieldPrefix, err) + } + return nil +} + +// BuildXDSServerTLSConfig turns a validated XDSServerTLSConfig into a +// *tls.Config enforcing mutual TLS: the server's own certificate, plus a +// client CA pool used to require and verify a client certificate +// (tls.RequireAndVerifyClientCert). This only performs the TLS handshake -- +// checking the verified peer's identity against AllowedClientIdentities is +// a separate authorization step the xDS server's stream callbacks must +// still perform (see pkg/tlsauth.VerifyStreamPeer); a certificate chaining +// to a trusted CA is not by itself authorization to reach this snapshot. +// +// Callers should run ValidateXDSServerTLS first; this function re-validates +// version/cipher/curve fields defensively but does not check +// AllowedClientIdentities, which it never reads. +func BuildXDSServerTLSConfig(cfg XDSServerTLSConfig) (*tls.Config, error) { + if err := ValidateServerTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := ParseServerTLSVersion(cfg.MinimumProtocolVersion) + maxVersion, _ := ParseServerTLSVersion(cfg.MaximumProtocolVersion) + + cipherSuites, err := ParseServerCiphers(cfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := ParseServerEcdhCurves(cfg.EcdhCurves) + if err != nil { + return nil, err + } + + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("loading xDS server certificate: %w", err) + } + + caPEM, err := os.ReadFile(cfg.ClientCAFile) + if err != nil { + return nil, fmt.Errorf("reading xDS client CA file: %w", err) + } + clientCAs := x509.NewCertPool() + if !clientCAs.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("no valid certificates found in xDS client CA file %q", cfg.ClientCAFile) + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientCAs: clientCAs, + ClientAuth: tls.RequireAndVerifyClientCert, + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, + }, nil +} diff --git a/gateway/gateway-controller/pkg/config/xds_tls_test.go b/gateway/gateway-controller/pkg/config/xds_tls_test.go new file mode 100644 index 0000000000..7c8429fa45 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/xds_tls_test.go @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generateXDSTestCA generates a self-signed CA and returns its cert/key +// (PEM-encoded) plus a helper that mints a leaf certificate signed by it. +func generateXDSTestCA(t *testing.T) (caCertPEM []byte, caKey *ecdsa.PrivateKey, caCert *x509.Certificate) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test xDS CA"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + return pemBytes, priv, cert +} + +// writeXDSLeafCert mints a leaf certificate signed by the given CA and +// writes its cert+key as PEM files under dir, returning their paths. +func writeXDSLeafCert(t *testing.T, dir, name string, caCert *x509.Certificate, caKey *ecdsa.PrivateKey, isServer bool) (certPath, keyPath string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: name}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + if isServer { + template.DNSNames = []string{"localhost"} + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + } else { + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + } + + der, err := x509.CreateCertificate(rand.Reader, template, caCert, &priv.PublicKey, caKey) + require.NoError(t, err) + + certPath = filepath.Join(dir, name+".crt") + keyPath = filepath.Join(dir, name+".key") + + certOut, err := os.Create(certPath) + require.NoError(t, err) + defer certOut.Close() + require.NoError(t, pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der})) + + keyBytes, err := x509.MarshalECPrivateKey(priv) + require.NoError(t, err) + keyOut, err := os.Create(keyPath) + require.NoError(t, err) + defer keyOut.Close() + require.NoError(t, pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})) + + return certPath, keyPath +} + +func TestValidateXDSServerTLS(t *testing.T) { + validCfg := func() XDSServerTLSConfig { + return XDSServerTLSConfig{ + Enabled: true, + CertFile: "./certs/server.crt", + KeyFile: "./certs/server.key", + ClientCAFile: "./certs/ca.crt", + AllowedClientIdentities: []string{"spiffe://cluster.local/ns/gw/sa/envoy"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + } + + tests := []struct { + name string + mutate func(*XDSServerTLSConfig) + wantErr bool + errContains string + }{ + {name: "valid config", mutate: func(*XDSServerTLSConfig) {}, wantErr: false}, + { + name: "disabled skips all checks", + mutate: func(c *XDSServerTLSConfig) { *c = XDSServerTLSConfig{Enabled: false} }, + wantErr: false, + }, + { + name: "missing cert file", + mutate: func(c *XDSServerTLSConfig) { c.CertFile = "" }, + wantErr: true, + errContains: "cert_file is required", + }, + { + name: "missing key file", + mutate: func(c *XDSServerTLSConfig) { c.KeyFile = "" }, + wantErr: true, + errContains: "key_file is required", + }, + { + name: "missing client CA file -- xDS has no server-only TLS mode", + mutate: func(c *XDSServerTLSConfig) { c.ClientCAFile = "" }, + wantErr: true, + errContains: "client_ca_file is required", + }, + { + name: "empty allowed client identities is fail-closed, not a no-op allowlist", + mutate: func(c *XDSServerTLSConfig) { c.AllowedClientIdentities = nil }, + wantErr: true, + errContains: "allowed_client_identities must list at least one", + }, + { + name: "bad protocol version", + mutate: func(c *XDSServerTLSConfig) { c.MinimumProtocolVersion = "TLS9_9" }, + wantErr: true, + errContains: "minimum_protocol_version", + }, + { + name: "unsupported cipher", + mutate: func(c *XDSServerTLSConfig) { c.Ciphers = "TLS_RSA_WITH_RC4_128_SHA" }, + wantErr: true, + errContains: "ciphers", + }, + { + name: "unsupported curve", + mutate: func(c *XDSServerTLSConfig) { c.EcdhCurves = "not-a-curve" }, + wantErr: true, + errContains: "ecdh_curves", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validCfg() + tt.mutate(&cfg) + err := ValidateXDSServerTLS("policy_server.tls", cfg) + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestBuildXDSServerTLSConfig(t *testing.T) { + dir := t.TempDir() + _, caKey, caCert := generateXDSTestCA(t) + caCertPath := filepath.Join(dir, "ca.crt") + require.NoError(t, os.WriteFile(caCertPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}), 0o600)) + serverCertPath, serverKeyPath := writeXDSLeafCert(t, dir, "server", caCert, caKey, true) + + t.Run("builds a working mTLS config", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: caCertPath, + AllowedClientIdentities: []string{"anything"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + tlsConfig, err := BuildXDSServerTLSConfig(cfg) + require.NoError(t, err) + require.NotNil(t, tlsConfig) + + assert.Len(t, tlsConfig.Certificates, 1) + assert.NotNil(t, tlsConfig.ClientCAs) + assert.Equal(t, tls.RequireAndVerifyClientCert, tlsConfig.ClientAuth) + assert.Equal(t, uint16(tls.VersionTLS12), tlsConfig.MinVersion) + assert.Equal(t, uint16(tls.VersionTLS13), tlsConfig.MaxVersion) + }) + + t.Run("PQC hybrid group opt-in is reflected in CurvePreferences", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: caCertPath, + AllowedClientIdentities: []string{"anything"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + } + tlsConfig, err := BuildXDSServerTLSConfig(cfg) + require.NoError(t, err) + require.NotEmpty(t, tlsConfig.CurvePreferences) + assert.Equal(t, tls.X25519MLKEM768, tlsConfig.CurvePreferences[0]) + }) + + t.Run("missing cert file surfaces a clear error", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: "/nonexistent/cert.pem", + KeyFile: "/nonexistent/key.pem", + ClientCAFile: caCertPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519", + } + _, err := BuildXDSServerTLSConfig(cfg) + assert.Error(t, err) + }) + + t.Run("missing CA file surfaces a clear error", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: "/nonexistent/ca.pem", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519", + } + _, err := BuildXDSServerTLSConfig(cfg) + assert.Error(t, err) + }) + + t.Run("garbage CA file content is rejected", func(t *testing.T) { + badCAPath := filepath.Join(dir, "bad-ca.crt") + require.NoError(t, os.WriteFile(badCAPath, []byte("not a certificate"), 0o600)) + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: badCAPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519", + } + _, err := BuildXDSServerTLSConfig(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no valid certificates") + }) +} + +// TestBuildXDSServerTLSConfig_PQCHandshakeNegotiation is a live TLS +// handshake (not just a config-shape assertion) proving BuildXDSServerTLSConfig's +// CurvePreferences is actually honored by crypto/tls's negotiation, and that a +// peer without PQC support still completes the handshake via classical +// fallback -- the exact interoperability guarantee post-quantum-cryptography.md +// requires ("must not hard-fail... when talking to a peer that only speaks +// classical algorithms"). +func TestBuildXDSServerTLSConfig_PQCHandshakeNegotiation(t *testing.T) { + dir := t.TempDir() + _, caKey, caCert := generateXDSTestCA(t) + caCertPath := filepath.Join(dir, "ca.crt") + require.NoError(t, os.WriteFile(caCertPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}), 0o600)) + serverCertPath, serverKeyPath := writeXDSLeafCert(t, dir, "server", caCert, caKey, true) + clientCertPath, clientKeyPath := writeXDSLeafCert(t, dir, "client", caCert, caKey, false) + + serverCfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: caCertPath, + AllowedClientIdentities: []string{"client"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + } + serverTLSConfig, err := BuildXDSServerTLSConfig(serverCfg) + require.NoError(t, err) + + clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + require.NoError(t, err) + clientCAPool := x509.NewCertPool() + clientCAPool.AddCert(caCert) + + dial := func(t *testing.T, clientCurves []tls.CurveID) tls.CurveID { + t.Helper() + ln, err := tls.Listen("tcp", "127.0.0.1:0", serverTLSConfig) + require.NoError(t, err) + defer ln.Close() + + negotiated := make(chan tls.CurveID, 1) + errCh := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + errCh <- err + return + } + defer conn.Close() + tlsConn := conn.(*tls.Conn) + if err := tlsConn.HandshakeContext(context.Background()); err != nil { + errCh <- err + return + } + negotiated <- tlsConn.ConnectionState().CurveID + errCh <- nil + }() + + clientTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{clientCert}, + RootCAs: clientCAPool, + ServerName: "localhost", + CurvePreferences: clientCurves, + } + conn, err := tls.Dial("tcp", ln.Addr().String(), clientTLSConfig) + require.NoError(t, err) + defer conn.Close() + + require.NoError(t, <-errCh) + return <-negotiated + } + + t.Run("PQC-capable peer negotiates the hybrid group", func(t *testing.T) { + curve := dial(t, []tls.CurveID{tls.X25519MLKEM768, tls.X25519}) + assert.Equal(t, tls.X25519MLKEM768, curve) + }) + + t.Run("classical-only peer still completes the handshake", func(t *testing.T) { + curve := dial(t, []tls.CurveID{tls.CurveP256}) + assert.Equal(t, tls.CurveP256, curve) + }) +} diff --git a/gateway/gateway-controller/pkg/policyxds/server.go b/gateway/gateway-controller/pkg/policyxds/server.go index 36e05842ec..f13c0ca00e 100644 --- a/gateway/gateway-controller/pkg/policyxds/server.go +++ b/gateway/gateway-controller/pkg/policyxds/server.go @@ -20,6 +20,7 @@ package policyxds import ( "context" + "crypto/tls" "fmt" "log/slog" "net" @@ -30,6 +31,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/subscriptionxds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/tlsauth" core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" discoverygrpc "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" @@ -39,6 +41,15 @@ import ( "google.golang.org/grpc/keepalive" ) +// Bounds on the policy xDS gRPC server (go-network-service-hardening.md +// directive 2 / go-control-plane-xds-security.md directive 5) -- unbounded +// defaults let one client (a misbehaving/compromised policy-engine) exhaust +// memory or the stream-slot budget every other connection depends on. +const ( + policyXDSMaxMessageSize = 16 * 1024 * 1024 + policyXDSMaxConcurrentStreams = 1000 +) + // WebhookSecretCacheProvider is the extension point through which an external // event-gateway-controller binary supplies the xDS cache backing webhook-secret // (HMAC) resources. Core never implements this interface itself; it is only @@ -58,28 +69,34 @@ type Server struct { subscriptionSnapshotMgr *subscriptionxds.SnapshotManager webhookSecretSnapshotMgr WebhookSecretCacheProvider port int - tlsConfig *TLSConfig + mtls *serverMTLS onFirstConnect chan struct{} logger *slog.Logger } -// TLSConfig holds TLS configuration for the server -type TLSConfig struct { - Enabled bool - CertFile string - KeyFile string +// serverMTLS holds the resolved mutual-TLS state for the policy xDS server. +type serverMTLS struct { + tlsConfig *tls.Config + allowedIdentities map[string]bool } // ServerOption is a functional option for configuring the Server type ServerOption func(*Server) -// WithTLS enables TLS with the provided certificate and key files -func WithTLS(certFile, keyFile string) ServerOption { +// WithMTLS enables mutual TLS on the policy xDS gRPC server (serving the +// policy-engine). tlsConfig must come from config.BuildXDSServerTLSConfig, +// which already sets ClientAuth: tls.RequireAndVerifyClientCert -- +// server-only TLS is not offered here because this channel carries +// per-tenant API-key hashes, subscription state, and full policy chains, +// so authenticating only the server side is not enough +// (go-control-plane-xds-security.md directive 2). allowedIdentities +// restricts accepted streams to peers whose certificate identity +// (tlsauth.PeerIdentity) is in the list. +func WithMTLS(tlsConfig *tls.Config, allowedIdentities []string) ServerOption { return func(s *Server) { - s.tlsConfig = &TLSConfig{ - Enabled: true, - CertFile: certFile, - KeyFile: keyFile, + s.mtls = &serverMTLS{ + tlsConfig: tlsConfig, + allowedIdentities: tlsauth.AllowedSet(allowedIdentities), } } } @@ -101,7 +118,6 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP webhookSecretSnapshotMgr: webhookSecretSnapshotMgr, port: port, logger: logger, - tlsConfig: &TLSConfig{Enabled: false}, } // Apply options @@ -119,19 +135,17 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP MinTime: 5 * time.Second, PermitWithoutStream: true, }), + grpc.MaxRecvMsgSize(policyXDSMaxMessageSize), + grpc.MaxSendMsgSize(policyXDSMaxMessageSize), + grpc.MaxConcurrentStreams(policyXDSMaxConcurrentStreams), } - // Add TLS credentials if enabled - if s.tlsConfig.Enabled { - creds, err := credentials.NewServerTLSFromFile(s.tlsConfig.CertFile, s.tlsConfig.KeyFile) - if err != nil { - logger.Error("Failed to load TLS credentials", slog.Any("error", err)) - panic(err) - } - grpcOpts = append(grpcOpts, grpc.Creds(creds)) - logger.Info("TLS enabled for Policy xDS server", - slog.String("cert_file", s.tlsConfig.CertFile), - slog.String("key_file", s.tlsConfig.KeyFile)) + // Add mTLS credentials if enabled + var allowedIdentities map[string]bool + if s.mtls != nil { + grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(s.mtls.tlsConfig))) + allowedIdentities = s.mtls.allowedIdentities + logger.Info("mTLS enabled for Policy xDS server") } grpcServer := grpc.NewServer(grpcOpts...) @@ -150,10 +164,11 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP combinedCache := NewCombinedCache(policyCache, apiKeyCache, lazyResourceCache, subscriptionCache, routeConfigCache, eventChannelCache, webhookSecretCache, logger) callbacks := &serverCallbacks{ - logger: logger, - activeStreams: make(map[int64]bool), - onFirstConnect: s.onFirstConnect, - pendingNonces: make(map[int64]string), + logger: logger, + activeStreams: make(map[int64]bool), + onFirstConnect: s.onFirstConnect, + pendingNonces: make(map[int64]string), + allowedIdentities: allowedIdentities, } xdsServer := server.NewServer(context.Background(), combinedCache, callbacks) @@ -174,8 +189,8 @@ func (s *Server) Start() error { } protocol := "insecure" - if s.tlsConfig.Enabled { - protocol = "TLS" + if s.mtls != nil { + protocol = "mTLS" } s.logger.Info("Starting Policy xDS server", slog.Int("port", s.port), @@ -196,16 +211,24 @@ func (s *Server) Stop() { // serverCallbacks implements xDS server callbacks for logging and debugging type serverCallbacks struct { - logger *slog.Logger - activeStreams map[int64]bool - activeStreamsMu sync.Mutex - onFirstConnect chan struct{} - firstConnectOnce sync.Once - pendingNonces map[int64]string // stream_id -> last sent nonce + logger *slog.Logger + activeStreams map[int64]bool + activeStreamsMu sync.Mutex + onFirstConnect chan struct{} + firstConnectOnce sync.Once + pendingNonces map[int64]string // stream_id -> last sent nonce + allowedIdentities map[string]bool // nil when mTLS is not configured -- no identity check performed } // OnStreamOpen is called when a new stream is opened func (cb *serverCallbacks) OnStreamOpen(ctx context.Context, streamID int64, typeURL string) error { + if cb.allowedIdentities != nil { + if err := tlsauth.VerifyStreamPeer(ctx, cb.allowedIdentities); err != nil { + cb.logger.Warn("Policy xDS stream rejected: peer identity not authorized", + slog.Int64("stream_id", streamID), slog.Any("error", err)) + return err + } + } cb.logger.Info("Policy xDS stream opened", slog.Int64("stream_id", streamID), slog.String("type_url", typeURL)) diff --git a/gateway/gateway-controller/pkg/policyxds/server_test.go b/gateway/gateway-controller/pkg/policyxds/server_test.go index fc129805f4..ad6aad2dd9 100644 --- a/gateway/gateway-controller/pkg/policyxds/server_test.go +++ b/gateway/gateway-controller/pkg/policyxds/server_test.go @@ -20,6 +20,7 @@ package policyxds import ( "context" + "crypto/tls" "io" "log/slog" "testing" @@ -27,6 +28,7 @@ import ( core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" discoverygrpc "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/anypb" ) @@ -164,35 +166,28 @@ func TestServerCallbacks_OnStreamDeltaResponse(t *testing.T) { cb.OnStreamDeltaResponse(789, req, resp) } -func TestWithTLS(t *testing.T) { - t.Run("enables TLS configuration", func(t *testing.T) { +func TestWithMTLS(t *testing.T) { + t.Run("enables mTLS configuration", func(t *testing.T) { s := &Server{} - opt := WithTLS("/path/to/cert.pem", "/path/to/key.pem") + tlsConfig := &tls.Config{} + opt := WithMTLS(tlsConfig, []string{"spiffe://cluster.local/ns/gw/sa/policy-engine"}) opt(s) - assert.NotNil(t, s.tlsConfig) - assert.True(t, s.tlsConfig.Enabled) - assert.Equal(t, "/path/to/cert.pem", s.tlsConfig.CertFile) - assert.Equal(t, "/path/to/key.pem", s.tlsConfig.KeyFile) + require.NotNil(t, s.mtls) + assert.Same(t, tlsConfig, s.mtls.tlsConfig) + assert.True(t, s.mtls.allowedIdentities["spiffe://cluster.local/ns/gw/sa/policy-engine"]) }) } -func TestTLSConfig(t *testing.T) { - t.Run("default values", func(t *testing.T) { - config := &TLSConfig{} - assert.False(t, config.Enabled) - assert.Empty(t, config.CertFile) - assert.Empty(t, config.KeyFile) - }) +func TestServerCallbacks_OnStreamOpen_RejectsUnauthorizedPeer(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + cb := &serverCallbacks{ + logger: logger, + allowedIdentities: map[string]bool{"spiffe://cluster.local/ns/gw/sa/policy-engine": true}, + } - t.Run("with values", func(t *testing.T) { - config := &TLSConfig{ - Enabled: true, - CertFile: "cert.pem", - KeyFile: "key.pem", - } - assert.True(t, config.Enabled) - assert.Equal(t, "cert.pem", config.CertFile) - assert.Equal(t, "key.pem", config.KeyFile) - }) + // No peer/TLS info in a bare background context -- must be rejected, + // not silently allowed through, once an identity allowlist is configured. + err := cb.OnStreamOpen(context.Background(), 999, "test-type-url") + assert.Error(t, err) } diff --git a/gateway/gateway-controller/pkg/tlsauth/peer_identity.go b/gateway/gateway-controller/pkg/tlsauth/peer_identity.go new file mode 100644 index 0000000000..b5fcd18820 --- /dev/null +++ b/gateway/gateway-controller/pkg/tlsauth/peer_identity.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package tlsauth provides the peer-identity authorization check shared by +// every xDS gRPC server in gateway-controller (pkg/xds, pkg/policyxds). +// Mutual TLS alone proves a connecting client's certificate chains to a +// trusted CA; it does not prove that client is entitled to this particular +// snapshot. go-control-plane-xds-security.md directive 2 requires an +// explicit accept/reject decision against a known-identity allowlist on +// every stream, in addition to the TLS handshake itself. +package tlsauth + +import ( + "context" + "crypto/x509" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +// PeerIdentity returns the identity a verified client certificate presents: +// the first SAN URI (e.g. a SPIFFE ID) if present, otherwise the +// certificate's Subject CommonName. +func PeerIdentity(cert *x509.Certificate) string { + if len(cert.URIs) > 0 { + return cert.URIs[0].String() + } + return cert.Subject.CommonName +} + +// AllowedSet converts a config allowlist slice into the map VerifyStreamPeer +// expects. +func AllowedSet(identities []string) map[string]bool { + set := make(map[string]bool, len(identities)) + for _, id := range identities { + set[id] = true + } + return set +} + +// VerifyStreamPeer checks that a streaming RPC's authenticated context +// carries a client certificate whose identity (see PeerIdentity) is in +// allowed. Returns a gRPC status error suitable for returning directly from +// an xDS server.Callbacks.OnStreamOpen implementation; any client that +// clears the mTLS handshake but isn't in allowed is rejected here, not +// merely logged. +func VerifyStreamPeer(ctx context.Context, allowed map[string]bool) error { + p, ok := peer.FromContext(ctx) + if !ok { + return status.Error(codes.Unauthenticated, "no peer information") + } + tlsInfo, isTLS := p.AuthInfo.(credentials.TLSInfo) + if !isTLS || len(tlsInfo.State.PeerCertificates) == 0 { + return status.Error(codes.Unauthenticated, "no client certificate presented") + } + identity := PeerIdentity(tlsInfo.State.PeerCertificates[0]) + if !allowed[identity] { + return status.Error(codes.PermissionDenied, "peer identity not authorized for this xDS snapshot") + } + return nil +} diff --git a/gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go b/gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go new file mode 100644 index 0000000000..9f37fd2014 --- /dev/null +++ b/gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package tlsauth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" +) + +func generateTestCert(t *testing.T, cn string, uris []*url.URL) *x509.Certificate { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + URIs: uris, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +func TestPeerIdentity(t *testing.T) { + t.Run("prefers the first SAN URI over CommonName", func(t *testing.T) { + spiffeID, err := url.Parse("spiffe://cluster.local/ns/gw/sa/envoy") + require.NoError(t, err) + cert := generateTestCert(t, "envoy-router", []*url.URL{spiffeID}) + assert.Equal(t, "spiffe://cluster.local/ns/gw/sa/envoy", PeerIdentity(cert)) + }) + + t.Run("falls back to CommonName when there is no SAN URI", func(t *testing.T) { + cert := generateTestCert(t, "policy-engine", nil) + assert.Equal(t, "policy-engine", PeerIdentity(cert)) + }) +} + +func TestAllowedSet(t *testing.T) { + set := AllowedSet([]string{"a", "b", "a"}) + assert.True(t, set["a"]) + assert.True(t, set["b"]) + assert.False(t, set["c"]) + assert.Len(t, set, 2) + + assert.Empty(t, AllowedSet(nil)) +} + +func TestVerifyStreamPeer(t *testing.T) { + t.Run("no peer info in context is unauthenticated", func(t *testing.T) { + err := VerifyStreamPeer(context.Background(), AllowedSet([]string{"x"})) + assert.Error(t, err) + }) + + t.Run("peer info without TLS auth info is unauthenticated", func(t *testing.T) { + p := &peer.Peer{Addr: &net.TCPAddr{}} + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"x"})) + assert.Error(t, err) + }) + + t.Run("TLS peer with no client certificate is unauthenticated", func(t *testing.T) { + p := &peer.Peer{ + Addr: &net.TCPAddr{}, + AuthInfo: credentials.TLSInfo{}, + } + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"x"})) + assert.Error(t, err) + }) + + t.Run("allowed identity passes", func(t *testing.T) { + cert := generateTestCert(t, "envoy-router", nil) + p := makeTLSPeer(cert) + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"envoy-router"})) + assert.NoError(t, err) + }) + + t.Run("unlisted identity is rejected even with a valid client cert", func(t *testing.T) { + cert := generateTestCert(t, "unknown-caller", nil) + p := makeTLSPeer(cert) + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"envoy-router"})) + assert.Error(t, err) + }) +} + +func makeTLSPeer(cert *x509.Certificate) *peer.Peer { + return &peer.Peer{ + Addr: &net.TCPAddr{}, + AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{PeerCertificates: []*x509.Certificate{cert}}, + }, + } +} diff --git a/gateway/gateway-controller/pkg/xds/server.go b/gateway/gateway-controller/pkg/xds/server.go index 83f0a3704a..d25e959dd1 100644 --- a/gateway/gateway-controller/pkg/xds/server.go +++ b/gateway/gateway-controller/pkg/xds/server.go @@ -20,6 +20,7 @@ package xds import ( "context" + "crypto/tls" "fmt" "net" "sync" @@ -36,22 +37,71 @@ import ( secretservice "github.com/envoyproxy/go-control-plane/envoy/service/secret/v3" "github.com/envoyproxy/go-control-plane/pkg/server/v3" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/metrics" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/tlsauth" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/keepalive" ) +// Bounds on the main xDS gRPC server (go-network-service-hardening.md +// directive 2 / go-control-plane-xds-security.md directive 5) -- unbounded +// defaults let one client (a misbehaving/compromised Envoy) exhaust memory +// or the stream-slot budget every other connection depends on. xDS +// snapshots can carry many routes/clusters, so the message ceiling is set +// well above gRPC's 4MB default. +const ( + xdsMaxMessageSize = 16 * 1024 * 1024 + xdsMaxConcurrentStreams = 1000 +) + // Server is the xDS gRPC server type Server struct { grpcServer *grpc.Server xdsServer server.Server snapshotManager *SnapshotManager port int + mtls *serverMTLS logger *slog.Logger } +// serverMTLS holds the resolved mutual-TLS state for the main xDS server. +type serverMTLS struct { + tlsConfig *tls.Config + allowedIdentities map[string]bool +} + +// ServerOption is a functional option for configuring the xDS Server. +type ServerOption func(*serverOptions) + +type serverOptions struct { + mtls *serverMTLS +} + +// WithMTLS enables mutual TLS on the main xDS gRPC server (serving Envoy). +// tlsConfig must come from config.BuildXDSServerTLSConfig, which already +// sets ClientAuth: tls.RequireAndVerifyClientCert -- server-only TLS is not +// offered here because this server distributes SDS secrets and full +// route/cluster config, so authenticating only the server side is not +// enough (go-control-plane-xds-security.md directive 2). allowedIdentities +// restricts accepted streams to peers whose certificate identity +// (tlsauth.PeerIdentity) is in the list. +func WithMTLS(tlsConfig *tls.Config, allowedIdentities []string) ServerOption { + return func(o *serverOptions) { + o.mtls = &serverMTLS{ + tlsConfig: tlsConfig, + allowedIdentities: tlsauth.AllowedSet(allowedIdentities), + } + } +} + // NewServer creates a new xDS server -func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretManager, port int, logger *slog.Logger, onFirstConnect chan struct{}) *Server { - grpcServer := grpc.NewServer( +func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretManager, port int, logger *slog.Logger, onFirstConnect chan struct{}, opts ...ServerOption) *Server { + var o serverOptions + for _, opt := range opts { + opt(&o) + } + + grpcOpts := []grpc.ServerOption{ grpc.KeepaliveParams(keepalive.ServerParameters{ Time: 30 * time.Second, Timeout: 5 * time.Second, @@ -60,11 +110,23 @@ func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretMana MinTime: 5 * time.Second, PermitWithoutStream: true, }), - ) + grpc.MaxRecvMsgSize(xdsMaxMessageSize), + grpc.MaxSendMsgSize(xdsMaxMessageSize), + grpc.MaxConcurrentStreams(xdsMaxConcurrentStreams), + } + + var allowedIdentities map[string]bool + if o.mtls != nil { + grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(o.mtls.tlsConfig))) + allowedIdentities = o.mtls.allowedIdentities + logger.Info("mTLS enabled for main xDS server") + } + + grpcServer := grpc.NewServer(grpcOpts...) // Create xDS server with the snapshot cache (shared with SDS) cache := snapshotManager.GetCache() - callbacks := NewServerCallbacks(logger, onFirstConnect) + callbacks := NewServerCallbacks(logger, onFirstConnect, allowedIdentities) xdsServer := server.NewServer(context.Background(), cache, callbacks) // Register xDS services @@ -85,6 +147,7 @@ func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretMana xdsServer: xdsServer, snapshotManager: snapshotManager, port: port, + mtls: o.mtls, logger: logger, } } @@ -96,7 +159,11 @@ func (s *Server) Start() error { return fmt.Errorf("failed to listen on port %d: %w", s.port, err) } - s.logger.Info("Starting xDS server", slog.Int("port", s.port)) + protocol := "insecure" + if s.mtls != nil { + protocol = "mTLS" + } + s.logger.Info("Starting xDS server", slog.Int("port", s.port), slog.String("protocol", protocol)) if err := s.grpcServer.Serve(listener); err != nil { return fmt.Errorf("failed to serve: %w", err) @@ -113,24 +180,33 @@ func (s *Server) Stop() { // serverCallbacks implements server.Callbacks type serverCallbacks struct { - logger *slog.Logger - activeStreams map[int64]string // stream_id -> node_id - activeStreamsMu sync.Mutex - onFirstConnect chan struct{} - firstConnectOnce sync.Once - pendingNonces map[int64]string // stream_id -> last sent nonce + logger *slog.Logger + activeStreams map[int64]string // stream_id -> node_id + activeStreamsMu sync.Mutex + onFirstConnect chan struct{} + firstConnectOnce sync.Once + pendingNonces map[int64]string // stream_id -> last sent nonce + allowedIdentities map[string]bool // nil when mTLS is not configured -- no identity check performed } -func NewServerCallbacks(logger *slog.Logger, onFirstConnect chan struct{}) *serverCallbacks { +func NewServerCallbacks(logger *slog.Logger, onFirstConnect chan struct{}, allowedIdentities map[string]bool) *serverCallbacks { return &serverCallbacks{ - logger: logger, - activeStreams: make(map[int64]string), - onFirstConnect: onFirstConnect, - pendingNonces: make(map[int64]string), + logger: logger, + activeStreams: make(map[int64]string), + onFirstConnect: onFirstConnect, + pendingNonces: make(map[int64]string), + allowedIdentities: allowedIdentities, } } func (cb *serverCallbacks) OnStreamOpen(ctx context.Context, id int64, typ string) error { + if cb.allowedIdentities != nil { + if err := tlsauth.VerifyStreamPeer(ctx, cb.allowedIdentities); err != nil { + cb.logger.Warn("xDS stream rejected: peer identity not authorized", + slog.Int64("stream_id", id), slog.Any("error", err)) + return err + } + } cb.logger.Info("xDS stream opened", slog.Int64("stream_id", id), slog.String("type", typ)) return nil } diff --git a/gateway/gateway-controller/pkg/xds/snapshot.go b/gateway/gateway-controller/pkg/xds/snapshot.go index bfd14c872d..4d58677707 100644 --- a/gateway/gateway-controller/pkg/xds/snapshot.go +++ b/gateway/gateway-controller/pkg/xds/snapshot.go @@ -137,8 +137,12 @@ func (sm *SnapshotManager) UpdateSnapshot(ctx context.Context, correlationID str return fmt.Errorf("failed to translate configurations: %w", err) } - // Add SDS secrets if SDS secret manager is configured - if sm.sdsSecretManager != nil { + // Add the SDS secret only when this snapshot's clusters actually reference it. + // Envoy never issues a watch for the Secret type URL unless a Cluster it accepted + // points at that secret name via SDS, so pushing it unconditionally just produces + // an "Ignoring unwatched type URL ... Secret" warning whenever no HTTPS-scheme + // upstream is configured. + if sm.sdsSecretManager != nil && ClusterResourcesReferenceUpstreamCASecret(resources[resource.ClusterType]) { secret, err := sm.sdsSecretManager.GetSecret() if err != nil { log.Warn("Failed to get SDS secret, continuing without it", slog.Any("error", err)) diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index 4a0960e1f1..2a8889e29a 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -930,13 +930,6 @@ func (t *Translator) TranslateConfigs( } } - // Add SDS cluster if cert store is enabled - // This cluster allows Envoy to fetch certificates from the SDS service - if t.certStore != nil { - sdsCluster := t.createSDSCluster() - clusters = append(clusters, sdsCluster) - } - // Add OTEL collector cluster if tracing is enabled // This cluster allows Envoy to send traces to OpenTelemetry collector if t.config.TracingConfig.Enabled { @@ -2223,63 +2216,6 @@ func (t *Translator) createOTELCollectorCluster() *cluster.Cluster { return c } -// createSDSCluster creates an Envoy cluster for the SDS (Secret Discovery Service) -// This cluster allows Envoy to fetch TLS certificates dynamically via xDS -func (t *Translator) createSDSCluster() *cluster.Cluster { - // SDS uses the same xDS server - // In containerized environments, Envoy connects to the gateway-controller container - // Use the same host/port configuration as the main xDS connection - xdsHost := "gateway-controller" // Default for Docker Compose - if envHost := os.Getenv("GATEWAY_CONTROLLER_HOST"); envHost != "" { - xdsHost = envHost - } - - xdsPort := t.config.Controller.Server.XDSPort - if xdsPort == 0 { - xdsPort = 18000 // Default xDS port - } - - address := &core.Address{ - Address: &core.Address_SocketAddress{ - SocketAddress: &core.SocketAddress{ - Protocol: core.SocketAddress_TCP, - Address: xdsHost, - PortSpecifier: &core.SocketAddress_PortValue{ - PortValue: uint32(xdsPort), - }, - }, - }, - } - - lbEndpoint := &endpoint.LbEndpoint{ - HostIdentifier: &endpoint.LbEndpoint_Endpoint{ - Endpoint: &endpoint.Endpoint{ - Address: address, - }, - }, - } - - localityLbEndpoints := &endpoint.LocalityLbEndpoints{ - LbEndpoints: []*endpoint.LbEndpoint{lbEndpoint}, - } - - // Create the SDS cluster - // Note: SDS must use HTTP/2 for gRPC communication - return &cluster.Cluster{ - Name: "sds_cluster", - ConnectTimeout: durationpb.New(5 * time.Second), - ClusterDiscoveryType: &cluster.Cluster_Type{Type: cluster.Cluster_STRICT_DNS}, - DnsLookupFamily: cluster.Cluster_V4_PREFERRED, - LbPolicy: cluster.Cluster_ROUND_ROBIN, - LoadAssignment: &endpoint.ClusterLoadAssignment{ - ClusterName: "sds_cluster", - Endpoints: []*endpoint.LocalityLbEndpoints{localityLbEndpoints}, - }, - // Enable HTTP/2 for gRPC - Http2ProtocolOptions: &core.Http2ProtocolOptions{}, - } -} - // createUpstreamTLSContext creates an upstream TLS context for secure connections func (t *Translator) createUpstreamTLSContext(certificate []byte, address string) *tlsv3.UpstreamTlsContext { // Create TLS context with base configuration @@ -2313,24 +2249,25 @@ func (t *Translator) createUpstreamTLSContext(certificate []byte, address string // 4. If none provided, Envoy falls back to system default trust store if t.certStore != nil { - // Use SDS to dynamically fetch certificates - // This is more efficient than inlining certificates in every cluster config + // Use SDS to dynamically fetch certificates, riding the same ADS + // stream Envoy already has open for LDS/CDS/RDS (bootstrap + // xds_cluster, see envoy-bootstrap.yaml's dynamic_resources). + // The SDS service is registered on the same gRPC server/cache as + // the main xDS server (see xds/server.go), so no dedicated + // cluster or connection is needed -- this also means + // gateway-controller never needs to know any TLS client cert/key + // paths that live on gateway-runtime's filesystem: the ADS + // connection's own TLS is entirely gateway-runtime's concern, + // configured in its own bootstrap (docker-entrypoint.sh + + // config-override.yaml's xds_cluster), independent of this + // process. A prior version of this pushed a second CDS cluster + // ("sds_cluster") that duplicated xds_cluster's host:port and + // required this process to embed gateway-runtime-local file + // paths -- removed in favor of this ADS-based reference. sdsConfig := &core.ConfigSource{ ResourceApiVersion: core.ApiVersion_V3, - ConfigSourceSpecifier: &core.ConfigSource_ApiConfigSource{ - ApiConfigSource: &core.ApiConfigSource{ - ApiType: core.ApiConfigSource_GRPC, - TransportApiVersion: core.ApiVersion_V3, - GrpcServices: []*core.GrpcService{ - { - TargetSpecifier: &core.GrpcService_EnvoyGrpc_{ - EnvoyGrpc: &core.GrpcService_EnvoyGrpc{ - ClusterName: "sds_cluster", - }, - }, - }, - }, - }, + ConfigSourceSpecifier: &core.ConfigSource_Ads{ + Ads: &core.AggregatedConfigSource{}, }, } @@ -2406,6 +2343,41 @@ func (t *Translator) createUpstreamTLSContext(certificate []byte, address string return upstreamTLSContext } +// ClusterResourcesReferenceUpstreamCASecret reports whether any cluster in +// clusters attaches the upstream CA bundle via SDS (ValidationContextSdsSecretConfig +// named SecretNameUpstreamCA). Envoy only issues a watch for the Secret type URL +// once a Cluster it has actually accepted references that secret name, so the +// snapshot manager uses this to decide whether including the Secret resource in +// a given snapshot version is warranted, rather than pushing it unconditionally +// and having Envoy log "Ignoring unwatched type URL ... Secret" when no +// HTTPS-scheme upstream is configured. +func ClusterResourcesReferenceUpstreamCASecret(clusters []types.Resource) bool { + for _, res := range clusters { + c, ok := res.(*cluster.Cluster) + if !ok { + continue + } + for _, tsm := range c.GetTransportSocketMatches() { + typedConfig := tsm.GetTransportSocket().GetTypedConfig() + if typedConfig == nil { + continue + } + var tlsCtx tlsv3.UpstreamTlsContext + if err := typedConfig.UnmarshalTo(&tlsCtx); err != nil { + continue + } + combined, ok := tlsCtx.GetCommonTlsContext().GetValidationContextType().(*tlsv3.CommonTlsContext_CombinedValidationContext) + if !ok { + continue + } + if combined.CombinedValidationContext.GetValidationContextSdsSecretConfig().GetName() == SecretNameUpstreamCA { + return true + } + } + } + return false +} + // createDownstreamTLSContext creates a downstream TLS context for HTTPS listeners func (t *Translator) createDownstreamTLSContext() (*tlsv3.DownstreamTlsContext, error) { // Read certificate and key files diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 3d0f6d1d02..a6d8596b46 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -43,6 +43,7 @@ import ( "github.com/stretchr/testify/require" commonconstants "github.com/wso2/api-platform/common/constants" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/certstore" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" @@ -2339,14 +2340,35 @@ func TestNotEffectivelyMatchesPrefix(t *testing.T) { } } -func TestTranslator_CreateSDSCluster(t *testing.T) { +// TestTranslator_CreateUpstreamTLSContext_SDSViaADS verifies that, when a +// cert store is configured, the upstream validation context's SDS reference +// rides the existing ADS stream (ConfigSource_Ads) rather than naming a +// dedicated cluster. This means gateway-controller never needs to construct +// a TLS transport socket pointing at cert/key/CA file paths that only exist +// on gateway-runtime's filesystem -- that connection's TLS is entirely +// gateway-runtime's own concern (its bootstrap xds_cluster). +func TestTranslator_CreateUpstreamTLSContext_SDSViaADS(t *testing.T) { logger := createTestLogger() routerCfg := testRouterConfig() + routerCfg.Upstream.TLS.DisableSslVerification = false cfg := testConfig() translator := NewTranslator(logger, routerCfg, nil, cfg) + // Only t.certStore != nil matters for this code path -- construct one + // directly rather than routing through NewTranslator's CustomCertsPath + // init, which calls LoadCertificates against a real db.Storage. + translator.certStore = certstore.NewCertStore(logger, nil, "", "") - cluster := translator.createSDSCluster() - assert.NotNil(t, cluster) + tlsContext := translator.createUpstreamTLSContext(nil, "example.com") + require.NotNil(t, tlsContext) + + combinedCtx := tlsContext.CommonTlsContext.GetCombinedValidationContext() + require.NotNil(t, combinedCtx) + sdsConfig := combinedCtx.GetValidationContextSdsSecretConfig().GetSdsConfig() + require.NotNil(t, sdsConfig) + + ads := sdsConfig.GetAds() + assert.NotNil(t, ads, "SDS config should ride the ADS stream rather than naming a dedicated cluster") + assert.Nil(t, sdsConfig.GetApiConfigSource(), "SDS config should not name a dedicated grpc cluster") } func TestTranslator_CreateUpstreamTLSContext(t *testing.T) { diff --git a/gateway/gateway-runtime/docker-entrypoint.sh b/gateway/gateway-runtime/docker-entrypoint.sh index 5804c335dc..2a90c29d4c 100644 --- a/gateway/gateway-runtime/docker-entrypoint.sh +++ b/gateway/gateway-runtime/docker-entrypoint.sh @@ -146,6 +146,31 @@ export ROUTER_DRAIN_TIME_SECONDS="${ROUTER_DRAIN_TIME_SECONDS:-15}" export XDS_SERVER_HOST="${GATEWAY_CONTROLLER_HOST}" export XDS_SERVER_PORT="${ROUTER_XDS_PORT}" +# Mutual TLS for the Router's (Envoy's) connection to gateway-controller's main +# xDS server, off by default (plaintext) so this stays interoperable with a +# gateway-controller build/config where server.xds_tls.enabled=false. This +# cert/key/CA material is entirely local to this container -- gateway- +# controller never needs to know these paths. SDS/secret delivery rides the +# same ADS stream this bootstrap xds_cluster already opens (see +# pkg/xds/translator.go's createUpstreamTLSContext, which references the SDS +# config source via ConfigSource_Ads rather than a second, TLS-duplicating +# cluster), so there is no separate controller-side copy of this TLS +# material to keep in sync. +export XDS_TLS_ENABLED="${XDS_TLS_ENABLED:-false}" +export XDS_CLIENT_CERT_PATH="${XDS_CLIENT_CERT_PATH:-}" +export XDS_CLIENT_KEY_PATH="${XDS_CLIENT_KEY_PATH:-}" +export XDS_CLIENT_CA_PATH="${XDS_CLIENT_CA_PATH:-}" + +# TLS parameters for the same xDS connection, kept in their own vars so the +# PQC hybrid group can be opted into independently of turning TLS on at all. +# Classical curves only by default -- prepend "X25519MLKEM768" (FIPS 203 +# ML-KEM-768 + X25519) once the deployed Envoy/BoringSSL build is confirmed +# to support it. +export XDS_CLIENT_TLS_MIN_VERSION="${XDS_CLIENT_TLS_MIN_VERSION:-TLS1_2}" +export XDS_CLIENT_TLS_MAX_VERSION="${XDS_CLIENT_TLS_MAX_VERSION:-TLS1_3}" +export XDS_CLIENT_TLS_CIPHERS="${XDS_CLIENT_TLS_CIPHERS:-}" +export XDS_CLIENT_TLS_ECDH_CURVES="${XDS_CLIENT_TLS_ECDH_CURVES:-X25519,P-256}" + # Policy Engine xDS address PE_XDS_SERVER="${GATEWAY_CONTROLLER_HOST}:${POLICY_ENGINE_XDS_PORT}" @@ -178,6 +203,67 @@ log " Python Timeout: ${PYTHON_POLICY_TIMEOUT}s" rm -f "${POLICY_ENGINE_SOCKET}" rm -f "${PYTHON_EXECUTOR_SOCKET}" +# csv_to_json_array converts "a,b,c" into a JSON array ["a","b","c"], +# trimming whitespace around each element; empty/blank input yields []. +# Used to render XDS_CLIENT_TLS_CIPHERS/ECDH_CURVES (this repo's comma- +# separated convention) into Envoy's cipher_suites/ecdh_curves list fields. +csv_to_json_array() { + local input="$1" out="[" first=true part + IFS=',' read -ra parts <<< "$input" + for part in "${parts[@]}"; do + part="$(echo "${part}" | xargs)" + [ -z "$part" ] && continue + if [ "$first" = true ]; then first=false; else out+=", "; fi + out+="\"${part}\"" + done + out+="]" + echo "$out" +} + +# envoy_tls_version maps this repo's internal TLS version vocabulary +# ("TLS1_2", shared with gateway-controller's Go-side TLS config for +# consistency) to Envoy's own enum names ("TLSv1_2"). +envoy_tls_version() { + case "$1" in + TLS1_0) echo "TLSv1_0" ;; + TLS1_1) echo "TLSv1_1" ;; + TLS1_2) echo "TLSv1_2" ;; + TLS1_3) echo "TLSv1_3" ;; + *) + log "FATAL: unrecognized TLS version '$1' (expected one of TLS1_0, TLS1_1, TLS1_2, TLS1_3)" + exit 1 + ;; + esac +} + +# Build the xds_cluster transport_socket value config-override.yaml +# substitutes in. `null` (== field not present, once Envoy parses the YAML) +# keeps xds_cluster plaintext by default; a flow-style (JSON-like) +# UpstreamTlsContext mapping is used instead when XDS_TLS_ENABLED=true, kept +# on one line so config-override.yaml stays valid YAML both before and after +# envsubst. Cert/key/CA are referenced by filename, read directly from disk +# by Envoy -- never embedded inline in this config. tls_params carries the +# same PQC-capable ecdh_curves preference (X25519MLKEM768 opt-in) as +# gateway-controller's Go-side xDS TLS config -- see XDS_CLIENT_TLS_ECDH_CURVES +# above. cipher_suites is only emitted when XDS_CLIENT_TLS_CIPHERS is +# non-empty; omitted, Envoy/BoringSSL's own default suite set/order applies +# (and only affects TLS 1.2 and below -- TLS 1.3 suite selection is fixed). +if [ "${XDS_TLS_ENABLED}" = "true" ]; then + if [ -z "${XDS_CLIENT_CERT_PATH}" ] || [ -z "${XDS_CLIENT_KEY_PATH}" ] || [ -z "${XDS_CLIENT_CA_PATH}" ]; then + log "FATAL: XDS_TLS_ENABLED=true requires XDS_CLIENT_CERT_PATH, XDS_CLIENT_KEY_PATH, and XDS_CLIENT_CA_PATH to all be set" + exit 1 + fi + XDS_TLS_PARAMS="tls_params: {tls_minimum_protocol_version: $(envoy_tls_version "${XDS_CLIENT_TLS_MIN_VERSION}"), tls_maximum_protocol_version: $(envoy_tls_version "${XDS_CLIENT_TLS_MAX_VERSION}"), ecdh_curves: $(csv_to_json_array "${XDS_CLIENT_TLS_ECDH_CURVES}")" + if [ -n "${XDS_CLIENT_TLS_CIPHERS}" ]; then + XDS_TLS_PARAMS="${XDS_TLS_PARAMS}, cipher_suites: $(csv_to_json_array "${XDS_CLIENT_TLS_CIPHERS}")" + fi + XDS_TLS_PARAMS="${XDS_TLS_PARAMS}}" + + export XDS_CLUSTER_TRANSPORT_SOCKET="{name: envoy.transport_sockets.tls, typed_config: {\"@type\": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext, common_tls_context: {${XDS_TLS_PARAMS}, tls_certificates: [{certificate_chain: {filename: \"${XDS_CLIENT_CERT_PATH}\"}, private_key: {filename: \"${XDS_CLIENT_KEY_PATH}\"}}], validation_context: {trusted_ca: {filename: \"${XDS_CLIENT_CA_PATH}\"}}}}}" +else + export XDS_CLUSTER_TRANSPORT_SOCKET="null" +fi + # Generate Envoy config override by substituting environment variables CONFIG_OVERRIDE=$(envsubst < /etc/envoy/config-override.yaml) diff --git a/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go b/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go index 08f729ee28..d705ddcc72 100644 --- a/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go +++ b/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go @@ -442,6 +442,8 @@ func initializeXDSClient(ctx context.Context, cfg *config.Config, serverAddr str TLSCertPath: cfg.PolicyEngine.XDS.TLS.CertPath, TLSKeyPath: cfg.PolicyEngine.XDS.TLS.KeyPath, TLSCAPath: cfg.PolicyEngine.XDS.TLS.CAPath, + TLSCiphers: cfg.PolicyEngine.XDS.TLS.Ciphers, + TLSEcdhCurves: cfg.PolicyEngine.XDS.TLS.EcdhCurves, } client, err := xdsclient.NewClient(xdsConfig, k, reg, resolvers) diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/server.go b/gateway/gateway-runtime/policy-engine/internal/admin/server.go index ef377f6359..88abf3fa6b 100644 --- a/gateway/gateway-runtime/policy-engine/internal/admin/server.go +++ b/gateway/gateway-runtime/policy-engine/internal/admin/server.go @@ -20,6 +20,7 @@ package admin import ( "context" + "crypto/tls" "fmt" "log/slog" "net" @@ -37,6 +38,7 @@ import ( type Server struct { cfg *config.AdminConfig httpServer *http.Server + tlsServer *http.Server // nil unless cfg.TLS.Enabled } // NewServer creates a new admin server @@ -69,14 +71,80 @@ func NewServer(cfg *config.AdminConfig, k *kernel.Kernel, reg *registry.PolicyRe ReadHeaderTimeout: 30 * time.Second, } + // TLS listener is additive: served alongside, not instead of, the + // plaintext listener above, on the same mux — every route keeps the same + // IP-allowlist/config_dump gating regardless of which listener it's + // reached through. Config validation (Config.Validate) already rejects a + // bad EcdhCurves/Ciphers/protocol-version value before this ever runs in + // production, so a parse failure here can only come from a caller that + // bypassed validation — fail safe by leaving the TLS listener disabled + // rather than panicking. + var tlsServer *http.Server + if cfg.TLS.Enabled { + tlsConfig, err := buildAdminTLSConfig(&cfg.TLS) + if err != nil { + slog.Error("invalid admin.tls config, admin TLS listener disabled", "error", err) + } else { + tlsServer = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.TLS.Port), + Handler: mux, + ReadHeaderTimeout: 30 * time.Second, + TLSConfig: tlsConfig, + } + } + } + return &Server{ cfg: cfg, httpServer: httpServer, + tlsServer: tlsServer, } } -// Start starts the admin HTTP server +// buildAdminTLSConfig translates an AdminTLSConfig into a tls.Config: bounded +// protocol version range, an optional cipher-suite restriction (TLS 1.2 and +// below only — TLS 1.3 suite selection isn't configurable in Go's +// crypto/tls), and the ECDH/group preference list, PQC hybrid group included +// when the operator has opted in. +func buildAdminTLSConfig(cfg *config.AdminTLSConfig) (*tls.Config, error) { + if err := config.ValidateAdminTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := config.ParseAdminTLSVersion(cfg.MinimumProtocolVersion) + maxVersion, _ := config.ParseAdminTLSVersion(cfg.MaximumProtocolVersion) + + cipherSuites, err := config.ParseAdminCiphers(cfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := config.ParseAdminEcdhCurves(cfg.EcdhCurves) + if err != nil { + return nil, err + } + + return &tls.Config{ + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, + }, nil +} + +// Start starts the admin HTTP server(s): the plaintext listener always, and — +// when configured — the TLS listener in the background alongside it. Blocks +// on the plaintext listener, matching the previous single-listener behavior +// callers already depend on. func (s *Server) Start(ctx context.Context) error { + if s.tlsServer != nil { + go func() { + slog.InfoContext(ctx, "Starting admin TLS HTTP server", "port", s.cfg.TLS.Port) + if err := s.tlsServer.ListenAndServeTLS(s.cfg.TLS.CertPath, s.cfg.TLS.KeyPath); err != nil && err != http.ErrServerClosed { + slog.ErrorContext(ctx, "Admin TLS server error", "error", err) + } + }() + } + slog.InfoContext(ctx, "Starting admin HTTP server", "port", s.cfg.Port, "allowed_ips", s.cfg.AllowedIPs) @@ -88,10 +156,16 @@ func (s *Server) Start(ctx context.Context) error { return nil } -// Stop gracefully stops the admin HTTP server +// Stop gracefully stops the admin HTTP server(s) func (s *Server) Stop(ctx context.Context) error { slog.InfoContext(ctx, "Stopping admin HTTP server") - return s.httpServer.Shutdown(ctx) + err := s.httpServer.Shutdown(ctx) + if s.tlsServer != nil { + if tlsErr := s.tlsServer.Shutdown(ctx); tlsErr != nil && err == nil { + err = tlsErr + } + } + return err } // configDumpEnabledMiddleware gates /config_dump behind an explicit enable flag diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/server_test.go b/gateway/gateway-runtime/policy-engine/internal/admin/server_test.go index 2ae838c629..21c5494e86 100644 --- a/gateway/gateway-runtime/policy-engine/internal/admin/server_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/admin/server_test.go @@ -20,10 +20,20 @@ package admin import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "fmt" + "math/big" "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "time" @@ -35,6 +45,42 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" ) +// generateSelfSignedCert writes a self-signed ECDSA cert/key pair for +// "localhost" to certPath/keyPath, for exercising the admin TLS listener in +// tests without depending on any repo-committed certificate material. +func generateSelfSignedCert(t *testing.T, certPath, keyPath string) { + t.Helper() + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + DNSNames: []string{"localhost"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + + certOut, err := os.Create(certPath) + require.NoError(t, err) + defer certOut.Close() + require.NoError(t, pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes})) + + keyBytes, err := x509.MarshalECPrivateKey(priv) + require.NoError(t, err) + + keyOut, err := os.Create(keyPath) + require.NoError(t, err) + defer keyOut.Close() + require.NoError(t, pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})) +} + // ============================================================================= // NewServer Tests // ============================================================================= @@ -356,3 +402,292 @@ func TestIPWhitelistMiddleware_PreservesRequestPath(t *testing.T) { assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, "/config_dump", capturedPath) } + +// ============================================================================= +// TLS Listener Tests +// ============================================================================= + +// TestServer_TLSListener verifies the admin API is reachable over the +// additional TLS listener, using the PQC hybrid group first in the +// preference list, while the plaintext listener keeps serving unchanged. +func TestServer_TLSListener(t *testing.T) { + plainPort := getFreePort(t) + tlsPort := getFreePort(t) + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "admin.crt") + keyPath := filepath.Join(tmpDir, "admin.key") + generateSelfSignedCert(t, certPath, keyPath) + + cfg := &config.AdminConfig{ + Port: plainPort, + AllowedIPs: []string{"127.0.0.1", "*"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: tlsPort, + CertPath: certPath, + KeyPath: keyPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + require.NotNil(t, server.tlsServer) + + ctx := context.Background() + errChan := make(chan error, 1) + go func() { errChan <- server.Start(ctx) }() + time.Sleep(100 * time.Millisecond) + + // The plaintext listener is unaffected by enabling TLS. + plainResp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health", plainPort)) + require.NoError(t, err) + plainResp.Body.Close() + assert.Equal(t, http.StatusOK, plainResp.StatusCode) + + // The TLS listener serves the same routes, negotiating the hybrid + // PQC group when the client offers it. + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test-only self-signed cert + CurvePreferences: []tls.CurveID{tls.X25519MLKEM768, tls.X25519}, + }, + }, + } + tlsResp, err := httpsClient.Get(fmt.Sprintf("https://127.0.0.1:%d/health", tlsPort)) + require.NoError(t, err) + defer tlsResp.Body.Close() + assert.Equal(t, http.StatusOK, tlsResp.StatusCode) + require.NotNil(t, tlsResp.TLS) + assert.Equal(t, tls.X25519MLKEM768, tlsResp.TLS.CurveID) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, server.Stop(stopCtx)) + + select { + case startErr := <-errChan: + assert.NoError(t, startErr) + case <-time.After(2 * time.Second): + t.Fatal("Server did not stop within timeout") + } +} + +// TestServer_TLSListener_ClassicalFallback verifies a client that doesn't +// offer the PQC hybrid group still completes the handshake against the same +// listener, falling back to the classical curve later in the preference list. +func TestServer_TLSListener_ClassicalFallback(t *testing.T) { + plainPort := getFreePort(t) + tlsPort := getFreePort(t) + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "admin.crt") + keyPath := filepath.Join(tmpDir, "admin.key") + generateSelfSignedCert(t, certPath, keyPath) + + cfg := &config.AdminConfig{ + Port: plainPort, + AllowedIPs: []string{"127.0.0.1", "*"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: tlsPort, + CertPath: certPath, + KeyPath: keyPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + + ctx := context.Background() + errChan := make(chan error, 1) + go func() { errChan <- server.Start(ctx) }() + time.Sleep(100 * time.Millisecond) + + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test-only self-signed cert + CurvePreferences: []tls.CurveID{tls.CurveP256}, // no PQC support offered + }, + }, + } + resp, err := httpsClient.Get(fmt.Sprintf("https://127.0.0.1:%d/health", tlsPort)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, resp.TLS) + assert.Equal(t, tls.CurveP256, resp.TLS.CurveID) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, server.Stop(stopCtx)) + + select { + case startErr := <-errChan: + assert.NoError(t, startErr) + case <-time.After(2 * time.Second): + t.Fatal("Server did not stop within timeout") + } +} + +// TestServer_TLSListener_InvalidEcdhCurves verifies an invalid curve name +// disables the TLS listener rather than panicking — config validation +// (Config.Validate) is the real gate and already rejects this in production. +func TestServer_TLSListener_InvalidEcdhCurves(t *testing.T) { + cfg := &config.AdminConfig{ + Port: getFreePort(t), + AllowedIPs: []string{"127.0.0.1"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: getFreePort(t), + CertPath: "/nonexistent/cert.pem", + KeyPath: "/nonexistent/key.pem", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "not-a-curve", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + assert.Nil(t, server.tlsServer) +} + +// TestServer_TLSListener_MinimumVersionEnforced verifies a client offering +// only a protocol version below MinimumProtocolVersion is rejected by the +// handshake rather than silently downgrading. +func TestServer_TLSListener_MinimumVersionEnforced(t *testing.T) { + plainPort := getFreePort(t) + tlsPort := getFreePort(t) + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "admin.crt") + keyPath := filepath.Join(tmpDir, "admin.key") + generateSelfSignedCert(t, certPath, keyPath) + + cfg := &config.AdminConfig{ + Port: plainPort, + AllowedIPs: []string{"127.0.0.1", "*"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: tlsPort, + CertPath: certPath, + KeyPath: keyPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + + ctx := context.Background() + errChan := make(chan error, 1) + go func() { errChan <- server.Start(ctx) }() + time.Sleep(100 * time.Millisecond) + + // A client capped at TLS 1.1 cannot complete the handshake against a + // listener whose floor is TLS 1.2. + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test-only self-signed cert + MinVersion: tls.VersionTLS10, + MaxVersion: tls.VersionTLS11, + }, + }, + } + _, err := httpsClient.Get(fmt.Sprintf("https://127.0.0.1:%d/health", tlsPort)) + assert.Error(t, err) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, server.Stop(stopCtx)) + + select { + case startErr := <-errChan: + assert.NoError(t, startErr) + case <-time.After(2 * time.Second): + t.Fatal("Server did not stop within timeout") + } +} + +// TestServer_TLSListener_CipherRestriction verifies a configured Ciphers +// list actually constrains which TLS 1.2 suite gets negotiated. +func TestServer_TLSListener_CipherRestriction(t *testing.T) { + plainPort := getFreePort(t) + tlsPort := getFreePort(t) + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "admin.crt") + keyPath := filepath.Join(tmpDir, "admin.key") + generateSelfSignedCert(t, certPath, keyPath) + + cfg := &config.AdminConfig{ + Port: plainPort, + AllowedIPs: []string{"127.0.0.1", "*"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: tlsPort, + CertPath: certPath, + KeyPath: keyPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_2", // pin to 1.2 so CipherSuites governs selection + Ciphers: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + EcdhCurves: "X25519,P-256", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + require.NotNil(t, server.tlsServer) + + ctx := context.Background() + errChan := make(chan error, 1) + go func() { errChan <- server.Start(ctx) }() + time.Sleep(100 * time.Millisecond) + + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test-only self-signed cert + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, // offered but not configured server-side + }, + }, + }, + } + resp, err := httpsClient.Get(fmt.Sprintf("https://127.0.0.1:%d/health", tlsPort)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, resp.TLS) + assert.Equal(t, uint16(tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), resp.TLS.CipherSuite) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, server.Stop(stopCtx)) + + select { + case startErr := <-errChan: + assert.NoError(t, startErr) + case <-time.After(2 * time.Second): + t.Fatal("Server did not stop within timeout") + } +} diff --git a/gateway/gateway-runtime/policy-engine/internal/config/admin_tls.go b/gateway/gateway-runtime/policy-engine/internal/config/admin_tls.go new file mode 100644 index 0000000000..5f94e5ae65 --- /dev/null +++ b/gateway/gateway-runtime/policy-engine/internal/config/admin_tls.go @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "crypto/tls" + "fmt" + "strings" +) + +// adminEcdhCurvesByName maps the names accepted in AdminTLSConfig.EcdhCurves +// to Go's crypto/tls group identifiers. X25519MLKEM768 is the FIPS 203 +// ML-KEM-768 + X25519 hybrid group, implemented natively by Go 1.23+. +var adminEcdhCurvesByName = map[string]tls.CurveID{ + "X25519": tls.X25519, + "P-256": tls.CurveP256, + "P-384": tls.CurveP384, + "P-521": tls.CurveP521, + "X25519MLKEM768": tls.X25519MLKEM768, +} + +// ParseAdminEcdhCurves parses a comma-separated EcdhCurves preference list +// (e.g. "X25519MLKEM768,X25519,P-256") into the tls.CurveID slice consumed by +// tls.Config.CurvePreferences. Used both to fail config validation closed on +// an unrecognized curve name and to build the admin TLS listener's config. +func ParseAdminEcdhCurves(raw string) ([]tls.CurveID, error) { + parts := strings.Split(raw, ",") + curves := make([]tls.CurveID, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + curve, ok := adminEcdhCurvesByName[name] + if !ok { + return nil, fmt.Errorf("unsupported ecdh curve %q (supported: X25519, P-256, P-384, P-521, X25519MLKEM768)", name) + } + curves = append(curves, curve) + } + if len(curves) == 0 { + return nil, fmt.Errorf("must specify at least one ecdh curve") + } + return curves, nil +} + +// adminTLSVersionByName maps the version strings accepted in +// AdminTLSConfig.MinimumProtocolVersion/MaximumProtocolVersion to Go's +// crypto/tls version identifiers. Same vocabulary ("TLS1_2", etc.) as the +// router's downstream_tls/upstream_tls for consistency within this shared +// config file, even though the two are enforced by different TLS stacks +// (Envoy/BoringSSL for the router, Go's own crypto/tls here). +var adminTLSVersionByName = map[string]uint16{ + "TLS1_0": tls.VersionTLS10, + "TLS1_1": tls.VersionTLS11, + "TLS1_2": tls.VersionTLS12, + "TLS1_3": tls.VersionTLS13, +} + +// adminTLSVersionOrder ranks the version names above so a min > max +// combination can be rejected at validation time. +var adminTLSVersionOrder = map[string]int{ + "TLS1_0": 0, + "TLS1_1": 1, + "TLS1_2": 2, + "TLS1_3": 3, +} + +// ValidateAdminTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. +func ValidateAdminTLSVersions(minVersion, maxVersion string) error { + if _, ok := adminTLSVersionByName[minVersion]; !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + if _, ok := adminTLSVersionByName[maxVersion]; !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if adminTLSVersionOrder[minVersion] > adminTLSVersionOrder[maxVersion] { + return fmt.Errorf("minimum_protocol_version (%s) cannot be greater than maximum_protocol_version (%s)", minVersion, maxVersion) + } + return nil +} + +// ParseAdminTLSVersion converts a validated version name to its crypto/tls +// identifier. Callers should run ValidateAdminTLSVersions first; an +// unrecognized name here returns ok=false rather than panicking. +func ParseAdminTLSVersion(name string) (version uint16, ok bool) { + version, ok = adminTLSVersionByName[name] + return version, ok +} + +// adminCipherSuiteByName is built from Go's own list of secure cipher suites +// (tls.CipherSuites — deliberately excludes tls.InsecureCipherSuites) so an +// operator can only ever restrict to suites Go itself considers safe, never +// re-enable a weak one. Includes the three TLS 1.3 suite names for +// completeness, though Go does not apply CipherSuites to TLS 1.3 — TLS 1.3 +// suite selection is not configurable and always uses Go's own safe set. +var adminCipherSuiteByName = func() map[string]uint16 { + m := make(map[string]uint16) + for _, cs := range tls.CipherSuites() { + m[cs.Name] = cs.ID + } + return m +}() + +// ParseAdminCiphers parses a comma-separated list of Go crypto/tls cipher +// suite names (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") into the +// []uint16 consumed by tls.Config.CipherSuites. An empty string is valid and +// returns (nil, nil) — Go's own default suite set/order applies. Only +// affects TLS 1.2 (and below) connections; TLS 1.3 ignores this field. +func ParseAdminCiphers(raw string) ([]uint16, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + suites := make([]uint16, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + id, ok := adminCipherSuiteByName[name] + if !ok { + return nil, fmt.Errorf("unsupported or insecure cipher suite %q (see crypto/tls.CipherSuites for the supported list)", name) + } + suites = append(suites, id) + } + if len(suites) == 0 { + return nil, fmt.Errorf("must specify at least one cipher suite, or omit ciphers entirely to use Go's default set") + } + return suites, nil +} diff --git a/gateway/gateway-runtime/policy-engine/internal/config/config.go b/gateway/gateway-runtime/policy-engine/internal/config/config.go index 0d378f3541..5767ba5fb1 100644 --- a/gateway/gateway-runtime/policy-engine/internal/config/config.go +++ b/gateway/gateway-runtime/policy-engine/internal/config/config.go @@ -565,6 +565,67 @@ type AdminConfig struct { // ConfigDump gates the /config_dump endpoint served on this admin server. ConfigDump ConfigDumpConfig `koanf:"config_dump"` + + // TLS starts a second, TLS-only listener on TLS.Port serving the same + // routes as the plaintext listener on Port. Off by default. + TLS AdminTLSConfig `koanf:"tls"` +} + +// AdminTLSConfig holds configuration for an additional TLS listener for the +// admin HTTP server. It is served alongside — not instead of — the plaintext +// listener on AdminConfig.Port, so enabling it never breaks an existing +// plaintext deployment. +type AdminTLSConfig struct { + // Enabled starts the TLS listener on Port. Off by default: no certificate + // is provisioned by default, and the plaintext listener keeps working + // either way. + Enabled bool `koanf:"enabled"` + + // Port is the port for the TLS admin listener. Must differ from every + // other configured policy-engine port (admin.port, server.extproc_port, + // metrics.port). + Port int `koanf:"port"` + + // CertPath and KeyPath are the PEM-encoded server certificate and private + // key for the TLS listener. Required when Enabled. + CertPath string `koanf:"cert_path"` + KeyPath string `koanf:"key_path"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the negotiated + // TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". Same + // vocabulary as the router's downstream_tls/upstream_tls for consistency + // within this shared config file. + MinimumProtocolVersion string `koanf:"minimum_protocol_version"` + MaximumProtocolVersion string `koanf:"maximum_protocol_version"` + + // Ciphers is a comma-separated list of Go crypto/tls cipher suite names + // (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which + // suites this listener will negotiate. Empty by default, meaning Go's own + // secure default set/order applies. Only affects TLS 1.2 and below — + // TLS 1.3 suite selection is not configurable in Go's crypto/tls. + // + // Note this is a different naming scheme than the router's ciphers field + // (OpenSSL/BoringSSL names like "ECDHE-ECDSA-AES128-GCM-SHA256"): this + // listener is served by Go's own crypto/tls, not Envoy, so it uses Go's + // canonical cipher suite names — see crypto/tls.CipherSuites for the + // supported list. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups, + // most preferred first (e.g. "X25519,P-256"). Classical curves only by + // default. A hybrid post-quantum group ("X25519MLKEM768", FIPS 203 + // ML-KEM-768 + X25519) can be prepended as an explicit opt-in once the + // clients that reach this listener are confirmed to support it. + // + // Unlike the router's EcdhCurves (gateway-controller/pkg/config), this + // listener is served directly by this process's own Go crypto/tls + // (1.23+ implements X25519MLKEM768 natively) rather than pushed as xDS + // config to a separate Envoy process, so enabling the hybrid group here + // carries none of the "already-running peer NACKs the update" risk + // documented on the router's EcdhCurves field — TLS 1.3 negotiation + // simply falls back to a later classical entry in this same list for a + // client that doesn't offer the hybrid group. + EcdhCurves string `koanf:"ecdh_curves"` } // ConfigDumpConfig gates the /config_dump endpoint served on the admin HTTP @@ -625,6 +686,25 @@ type XDSTLSConfig struct { // CAPath is the path to the CA certificate for server verification CAPath string `koanf:"ca_path"` + + // Ciphers is a comma-separated list of Go crypto/tls cipher suite names + // (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which + // suites this client offers. Empty by default -- Go's own secure default + // set/order applies. Only affects TLS 1.2 and below; TLS 1.3 suite + // selection is not configurable in Go's crypto/tls. Parsed with the same + // ParseAdminCiphers helper the admin TLS listener uses (config.go), + // reused here rather than duplicated. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups + // this client offers, most preferred first (e.g. "X25519,P-256"). + // Classical curves only by default. A hybrid post-quantum group + // ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be prepended once + // gateway-controller's policy_server.tls is confirmed to support it -- + // this is Go's own crypto/tls (1.23+ implements X25519MLKEM768 + // natively), so an unsupporting peer simply falls back to a later + // classical entry in this same list rather than failing the handshake. + EcdhCurves string `koanf:"ecdh_curves"` } // FileConfigConfig holds file-based configuration settings @@ -867,6 +947,14 @@ func defaultConfig() *Config { ConfigDump: ConfigDumpConfig{ Enabled: false, }, + TLS: AdminTLSConfig{ + Enabled: false, + Port: 9004, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", + }, }, Metrics: MetricsConfig{ Enabled: false, @@ -881,7 +969,8 @@ func defaultConfig() *Config { InitialReconnectDelay: 1 * time.Second, MaxReconnectDelay: 60 * time.Second, TLS: XDSTLSConfig{ - Enabled: false, + Enabled: false, + EcdhCurves: "X25519,P-256", }, }, FileConfig: FileConfigConfig{ @@ -1009,6 +1098,34 @@ func (c *Config) Validate() error { if len(c.PolicyEngine.Admin.AllowedIPs) == 0 { return fmt.Errorf("admin.allowed_ips cannot be empty when admin is enabled") } + + // Validate admin TLS config + if c.PolicyEngine.Admin.TLS.Enabled { + if c.PolicyEngine.Admin.TLS.Port <= 0 || c.PolicyEngine.Admin.TLS.Port > 65535 { + return fmt.Errorf("invalid admin.tls.port: %d (must be 1-65535)", c.PolicyEngine.Admin.TLS.Port) + } + if c.PolicyEngine.Admin.TLS.Port == c.PolicyEngine.Admin.Port { + return fmt.Errorf("admin.tls.port cannot be same as admin.port") + } + if c.PolicyEngine.Server.Mode == "tcp" && c.PolicyEngine.Admin.TLS.Port == c.PolicyEngine.Server.ExtProcPort { + return fmt.Errorf("admin.tls.port cannot be same as server.extproc_port") + } + if c.PolicyEngine.Admin.TLS.CertPath == "" { + return fmt.Errorf("admin.tls.cert_path is required when admin.tls.enabled") + } + if c.PolicyEngine.Admin.TLS.KeyPath == "" { + return fmt.Errorf("admin.tls.key_path is required when admin.tls.enabled") + } + if err := ValidateAdminTLSVersions(c.PolicyEngine.Admin.TLS.MinimumProtocolVersion, c.PolicyEngine.Admin.TLS.MaximumProtocolVersion); err != nil { + return fmt.Errorf("admin.tls: %w", err) + } + if _, err := ParseAdminCiphers(c.PolicyEngine.Admin.TLS.Ciphers); err != nil { + return fmt.Errorf("admin.tls.ciphers: %w", err) + } + if _, err := ParseAdminEcdhCurves(c.PolicyEngine.Admin.TLS.EcdhCurves); err != nil { + return fmt.Errorf("admin.tls.ecdh_curves: %w", err) + } + } } // Validate metrics config @@ -1023,6 +1140,9 @@ func (c *Config) Validate() error { if c.PolicyEngine.Metrics.Port == c.PolicyEngine.Admin.Port { return fmt.Errorf("metrics.port cannot be same as admin.port") } + if c.PolicyEngine.Admin.TLS.Enabled && c.PolicyEngine.Metrics.Port == c.PolicyEngine.Admin.TLS.Port { + return fmt.Errorf("metrics.port cannot be same as admin.tls.port") + } } if c.PolicyEngine.RequestBody.MaxDecompressedBytes <= 0 { @@ -1183,6 +1303,12 @@ func (c *Config) validateXDSConfig() error { if c.PolicyEngine.XDS.TLS.CAPath == "" { return fmt.Errorf("xds.tls.ca_path is required when TLS is enabled") } + if _, err := ParseAdminCiphers(c.PolicyEngine.XDS.TLS.Ciphers); err != nil { + return fmt.Errorf("xds.tls.ciphers: %w", err) + } + if _, err := ParseAdminEcdhCurves(c.PolicyEngine.XDS.TLS.EcdhCurves); err != nil { + return fmt.Errorf("xds.tls.ecdh_curves: %w", err) + } } return nil diff --git a/gateway/gateway-runtime/policy-engine/internal/config/config_test.go b/gateway/gateway-runtime/policy-engine/internal/config/config_test.go index 49eccc433c..e790f7da9e 100644 --- a/gateway/gateway-runtime/policy-engine/internal/config/config_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/config/config_test.go @@ -19,6 +19,7 @@ package config import ( + "crypto/tls" "math" "os" "path/filepath" @@ -537,6 +538,232 @@ func TestValidate_AdminConfig(t *testing.T) { expectErr: true, errMsg: "admin.allowed_ips cannot be empty", }, + { + name: "admin TLS enabled - valid config", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: false, + }, + { + name: "admin TLS enabled - PQC hybrid group opt-in", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + } + }, + expectErr: false, + }, + { + name: "admin TLS enabled - restricted cipher suite list", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: false, + }, + { + name: "admin TLS enabled - invalid port zero", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 0, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "invalid admin.tls.port", + }, + { + name: "admin TLS enabled - port conflicts with admin.port", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9002, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.port cannot be same as admin.port", + }, + { + name: "admin TLS enabled - port conflicts with extproc port (TCP mode)", + setup: func(cfg *Config) { + cfg.PolicyEngine.Server.Mode = "tcp" + cfg.PolicyEngine.Server.ExtProcPort = 9001 + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9001, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.port cannot be same as server.extproc_port", + }, + { + name: "admin TLS enabled - missing cert path", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.cert_path is required", + }, + { + name: "admin TLS enabled - missing key path", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.key_path is required", + }, + { + name: "admin TLS enabled - missing minimum protocol version", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "minimum_protocol_version", + }, + { + name: "admin TLS enabled - minimum protocol version greater than maximum", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_3", + MaximumProtocolVersion: "TLS1_2", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "cannot be greater than maximum_protocol_version", + }, + { + name: "admin TLS enabled - unsupported cipher suite", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "TLS_RSA_WITH_RC4_128_SHA", // insecure, deliberately excluded + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.ciphers", + }, + { + name: "admin TLS enabled - unsupported ecdh curve", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "not-a-curve", + } + }, + expectErr: true, + errMsg: "admin.tls.ecdh_curves", + }, } for _, tt := range tests { @@ -555,6 +782,129 @@ func TestValidate_AdminConfig(t *testing.T) { } } +// TestParseAdminEcdhCurves tests the ECDH curve preference parser used by +// AdminTLSConfig.EcdhCurves. +func TestParseAdminEcdhCurves(t *testing.T) { + t.Run("classical curves only", func(t *testing.T) { + curves, err := ParseAdminEcdhCurves("X25519,P-256") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("PQC hybrid group prepended", func(t *testing.T) { + curves, err := ParseAdminEcdhCurves("X25519MLKEM768,X25519,P-256") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("whitespace tolerated", func(t *testing.T) { + curves, err := ParseAdminEcdhCurves(" X25519 , P-256 ") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("unsupported curve name rejected", func(t *testing.T) { + _, err := ParseAdminEcdhCurves("X25519,not-a-curve") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported ecdh curve") + }) + + t.Run("empty string rejected", func(t *testing.T) { + _, err := ParseAdminEcdhCurves("") + assert.Error(t, err) + }) +} + +// TestValidateAdminTLSVersions tests the min/max protocol version validation +// used by AdminTLSConfig. +func TestValidateAdminTLSVersions(t *testing.T) { + t.Run("valid TLS1_2 to TLS1_3 range", func(t *testing.T) { + assert.NoError(t, ValidateAdminTLSVersions("TLS1_2", "TLS1_3")) + }) + + t.Run("equal min and max", func(t *testing.T) { + assert.NoError(t, ValidateAdminTLSVersions("TLS1_2", "TLS1_2")) + }) + + t.Run("unrecognized minimum version", func(t *testing.T) { + err := ValidateAdminTLSVersions("bogus", "TLS1_3") + assert.Error(t, err) + assert.Contains(t, err.Error(), "minimum_protocol_version") + }) + + t.Run("unrecognized maximum version", func(t *testing.T) { + err := ValidateAdminTLSVersions("TLS1_2", "bogus") + assert.Error(t, err) + assert.Contains(t, err.Error(), "maximum_protocol_version") + }) + + t.Run("minimum greater than maximum", func(t *testing.T) { + err := ValidateAdminTLSVersions("TLS1_3", "TLS1_2") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be greater than maximum_protocol_version") + }) +} + +// TestParseAdminTLSVersion tests the version-name to crypto/tls-identifier +// conversion used by AdminTLSConfig. +func TestParseAdminTLSVersion(t *testing.T) { + tests := []struct { + name string + version string + want uint16 + }{ + {"TLS1_0", "TLS1_0", tls.VersionTLS10}, + {"TLS1_1", "TLS1_1", tls.VersionTLS11}, + {"TLS1_2", "TLS1_2", tls.VersionTLS12}, + {"TLS1_3", "TLS1_3", tls.VersionTLS13}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ParseAdminTLSVersion(tt.version) + require.True(t, ok) + assert.Equal(t, tt.want, got) + }) + } + + t.Run("unrecognized version", func(t *testing.T) { + _, ok := ParseAdminTLSVersion("bogus") + assert.False(t, ok) + }) +} + +// TestParseAdminCiphers tests the cipher-suite-name parser used by +// AdminTLSConfig.Ciphers. +func TestParseAdminCiphers(t *testing.T) { + t.Run("empty string is valid and means Go's defaults", func(t *testing.T) { + suites, err := ParseAdminCiphers("") + require.NoError(t, err) + assert.Nil(t, suites) + }) + + t.Run("restricts to the named secure suites", func(t *testing.T) { + suites, err := ParseAdminCiphers("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + require.NoError(t, err) + assert.Equal(t, []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, suites) + }) + + t.Run("whitespace tolerated", func(t *testing.T) { + suites, err := ParseAdminCiphers(" TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 ") + require.NoError(t, err) + assert.Equal(t, []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, suites) + }) + + t.Run("insecure cipher suite rejected", func(t *testing.T) { + _, err := ParseAdminCiphers("TLS_RSA_WITH_RC4_128_SHA") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported or insecure cipher suite") + }) + + t.Run("unrecognized cipher suite name rejected", func(t *testing.T) { + _, err := ParseAdminCiphers("NOT_A_REAL_SUITE") + assert.Error(t, err) + }) +} + // TestValidate_MetricsConfig tests metrics configuration validation func TestValidate_MetricsConfig(t *testing.T) { tests := []struct { @@ -848,9 +1198,61 @@ func TestValidate_XDSTLSConfig(t *testing.T) { cfg.PolicyEngine.XDS.TLS.CertPath = "/path/to/cert" cfg.PolicyEngine.XDS.TLS.KeyPath = "/path/to/key" cfg.PolicyEngine.XDS.TLS.CAPath = "/path/to/ca" + cfg.PolicyEngine.XDS.TLS.EcdhCurves = "X25519,P-256" }, expectErr: false, }, + { + name: "TLS enabled - PQC hybrid group opt-in", + setup: func(cfg *Config) { + cfg.PolicyEngine.ConfigMode.Mode = "xds" + cfg.PolicyEngine.XDS.ConnectTimeout = 10 * time.Second + cfg.PolicyEngine.XDS.RequestTimeout = 5 * time.Second + cfg.PolicyEngine.XDS.InitialReconnectDelay = 1 * time.Second + cfg.PolicyEngine.XDS.MaxReconnectDelay = 60 * time.Second + cfg.PolicyEngine.XDS.TLS.Enabled = true + cfg.PolicyEngine.XDS.TLS.CertPath = "/path/to/cert" + cfg.PolicyEngine.XDS.TLS.KeyPath = "/path/to/key" + cfg.PolicyEngine.XDS.TLS.CAPath = "/path/to/ca" + cfg.PolicyEngine.XDS.TLS.EcdhCurves = "X25519MLKEM768,X25519,P-256" + }, + expectErr: false, + }, + { + name: "TLS enabled - unsupported ecdh curve", + setup: func(cfg *Config) { + cfg.PolicyEngine.ConfigMode.Mode = "xds" + cfg.PolicyEngine.XDS.ConnectTimeout = 10 * time.Second + cfg.PolicyEngine.XDS.RequestTimeout = 5 * time.Second + cfg.PolicyEngine.XDS.InitialReconnectDelay = 1 * time.Second + cfg.PolicyEngine.XDS.MaxReconnectDelay = 60 * time.Second + cfg.PolicyEngine.XDS.TLS.Enabled = true + cfg.PolicyEngine.XDS.TLS.CertPath = "/path/to/cert" + cfg.PolicyEngine.XDS.TLS.KeyPath = "/path/to/key" + cfg.PolicyEngine.XDS.TLS.CAPath = "/path/to/ca" + cfg.PolicyEngine.XDS.TLS.EcdhCurves = "not-a-curve" + }, + expectErr: true, + errMsg: "xds.tls.ecdh_curves", + }, + { + name: "TLS enabled - unsupported cipher suite", + setup: func(cfg *Config) { + cfg.PolicyEngine.ConfigMode.Mode = "xds" + cfg.PolicyEngine.XDS.ConnectTimeout = 10 * time.Second + cfg.PolicyEngine.XDS.RequestTimeout = 5 * time.Second + cfg.PolicyEngine.XDS.InitialReconnectDelay = 1 * time.Second + cfg.PolicyEngine.XDS.MaxReconnectDelay = 60 * time.Second + cfg.PolicyEngine.XDS.TLS.Enabled = true + cfg.PolicyEngine.XDS.TLS.CertPath = "/path/to/cert" + cfg.PolicyEngine.XDS.TLS.KeyPath = "/path/to/key" + cfg.PolicyEngine.XDS.TLS.CAPath = "/path/to/ca" + cfg.PolicyEngine.XDS.TLS.EcdhCurves = "X25519,P-256" + cfg.PolicyEngine.XDS.TLS.Ciphers = "TLS_RSA_WITH_RC4_128_SHA" + }, + expectErr: true, + errMsg: "xds.tls.ciphers", + }, } for _, tt := range tests { diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go index 20a376ebe3..347aa491c7 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go @@ -38,6 +38,7 @@ import ( "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/config" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" @@ -336,10 +337,28 @@ func (c *Client) loadTLSConfig() (*tls.Config, error) { return nil, fmt.Errorf("failed to parse CA certificate") } + // CipherSuites/CurvePreferences reuse the admin TLS listener's own + // parsers (policy-engine/internal/config) rather than duplicating the + // curve-name-to-tls.CurveID map here. CurvePreferences is what lets this + // client offer the FIPS 203 X25519MLKEM768 hybrid group ahead of + // classical curves when the operator opts in via xds.tls.ecdh_curves; + // gateway-controller's policy xDS server falls back to a later classical + // entry if it doesn't support the hybrid group. + cipherSuites, err := config.ParseAdminCiphers(c.config.TLSCiphers) + if err != nil { + return nil, fmt.Errorf("invalid xds.tls.ciphers: %w", err) + } + curves, err := config.ParseAdminEcdhCurves(c.config.TLSEcdhCurves) + if err != nil { + return nil, fmt.Errorf("invalid xds.tls.ecdh_curves: %w", err) + } + return &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + CipherSuites: cipherSuites, + CurvePreferences: curves, }, nil } diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go index b3cdee4984..eb37b5c24a 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go @@ -21,6 +21,7 @@ package xdsclient import ( "crypto/rand" "crypto/rsa" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" @@ -47,6 +48,7 @@ func createValidTestConfig() *Config { InitialReconnectDelay: 1 * time.Second, MaxReconnectDelay: 60 * time.Second, TLSEnabled: false, + TLSEcdhCurves: "X25519,P-256", } } @@ -330,6 +332,73 @@ func TestLoadTLSConfig_ValidCerts(t *testing.T) { assert.Equal(t, uint16(0x0303), tlsConfig.MinVersion) // TLS 1.2 } +// TestLoadTLSConfig_PQCHybridCurveOptIn verifies that opting into the FIPS +// 203 X25519MLKEM768 hybrid group via TLSEcdhCurves is actually reflected in +// the tls.Config this client dials with -- not just accepted by config +// validation. +func TestLoadTLSConfig_PQCHybridCurveOptIn(t *testing.T) { + tmpDir := t.TempDir() + ca, caPrivKey := generateTestCA(t) + cert, certPrivKey := generateTestCert(t, ca, caPrivKey) + + certPath := filepath.Join(tmpDir, "cert.pem") + keyPath := filepath.Join(tmpDir, "key.pem") + caPath := filepath.Join(tmpDir, "ca.pem") + writeCertToFile(t, cert, certPath) + writeKeyToFile(t, certPrivKey, keyPath) + writeCertToFile(t, ca, caPath) + + k, reg := createTestKernelAndRegistry(t) + config := createValidTestConfig() + config.TLSEnabled = true + config.TLSCertPath = certPath + config.TLSKeyPath = keyPath + config.TLSCAPath = caPath + config.TLSEcdhCurves = "X25519MLKEM768,X25519,P-256" + + client, err := NewClient(config, k, reg) + require.NoError(t, err) + + tlsConfig, err := client.loadTLSConfig() + require.NoError(t, err) + require.NotNil(t, tlsConfig) + + require.NotEmpty(t, tlsConfig.CurvePreferences) + assert.Equal(t, tls.X25519MLKEM768, tlsConfig.CurvePreferences[0]) +} + +// TestLoadTLSConfig_InvalidEcdhCurve verifies an unrecognized curve name +// surfaces as an error from loadTLSConfig rather than silently falling back +// to Go's default curve preferences. +func TestLoadTLSConfig_InvalidEcdhCurve(t *testing.T) { + tmpDir := t.TempDir() + ca, caPrivKey := generateTestCA(t) + cert, certPrivKey := generateTestCert(t, ca, caPrivKey) + + certPath := filepath.Join(tmpDir, "cert.pem") + keyPath := filepath.Join(tmpDir, "key.pem") + caPath := filepath.Join(tmpDir, "ca.pem") + writeCertToFile(t, cert, certPath) + writeKeyToFile(t, certPrivKey, keyPath) + writeCertToFile(t, ca, caPath) + + k, reg := createTestKernelAndRegistry(t) + config := createValidTestConfig() + config.TLSEnabled = true + config.TLSCertPath = certPath + config.TLSKeyPath = keyPath + config.TLSCAPath = caPath + config.TLSEcdhCurves = "not-a-curve" + + client, err := NewClient(config, k, reg) + require.NoError(t, err) + + tlsConfig, err := client.loadTLSConfig() + assert.Error(t, err) + assert.Nil(t, tlsConfig) + assert.Contains(t, err.Error(), "ecdh_curves") +} + // TestLoadTLSConfig_InvalidCertPath tests error when cert file doesn't exist func TestLoadTLSConfig_InvalidCertPath(t *testing.T) { tmpDir := t.TempDir() diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/config.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/config.go index a96aadeca0..784cd6436f 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/config.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/config.go @@ -51,6 +51,16 @@ type Config struct { // TLSCAPath is the path to the CA certificate for server verification (if TLSEnabled) TLSCAPath string + + // TLSCiphers is a comma-separated list of Go crypto/tls cipher suite + // names restricting which suites this client offers. Empty means Go's + // own secure default set/order applies. Only affects TLS 1.2 and below. + TLSCiphers string + + // TLSEcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups + // this client offers, most preferred first (e.g. "X25519MLKEM768,X25519,P-256"). + // Required whenever TLSEnabled -- see config.ParseAdminEcdhCurves. + TLSEcdhCurves string } // Validate validates the xDS client configuration @@ -85,6 +95,9 @@ func (c *Config) Validate() error { if c.TLSCAPath == "" { return fmt.Errorf("TLS CA path is required when TLS is enabled") } + if c.TLSEcdhCurves == "" { + return fmt.Errorf("TLS ECDH curves are required when TLS is enabled") + } } return nil diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/config_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/config_test.go index 1e77949b3b..8177249ac9 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/config_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/config_test.go @@ -261,6 +261,7 @@ func TestValidate_TLSEnabledWithAllPaths(t *testing.T) { TLSCertPath: "/path/to/cert.pem", TLSKeyPath: "/path/to/key.pem", TLSCAPath: "/path/to/ca.pem", + TLSEcdhCurves: "X25519,P-256", } err := config.Validate() diff --git a/gateway/gateway-runtime/router/config/config-override.yaml b/gateway/gateway-runtime/router/config/config-override.yaml index 88ed0e3653..bf24318273 100644 --- a/gateway/gateway-runtime/router/config/config-override.yaml +++ b/gateway/gateway-runtime/router/config/config-override.yaml @@ -39,6 +39,14 @@ static_resources: "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions explicit_http_config: http2_protocol_options: {} + # docker-entrypoint.sh sets this to a flow-style (JSON-like) + # UpstreamTlsContext mapping when XDS_TLS_ENABLED=true, or to `null` + # (== field not present, once parsed) otherwise -- xds_cluster stays + # plaintext by default, matching gateway-controller's server.xds_tls + # being off by default too. A flow-style single-line value keeps this + # file valid YAML both before and after envsubst, unlike a multi-line + # block substitution would. + transport_socket: ${XDS_CLUSTER_TRANSPORT_SOCKET} load_assignment: cluster_name: xds_cluster endpoints: diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index c957db82f0..95cd66e515 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -298,6 +298,27 @@ port = 9243 cert_file = "/app/data/certs/cert.pem" # default: ./data/certs/cert.pem key_file = "/app/data/certs/key.pem" # default: ./data/certs/key.pem +# minimum_protocol_version / maximum_protocol_version bound the negotiated TLS +# version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" + +# ciphers is a comma-separated list of Go crypto/tls cipher suite names +# (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") restricting which suites +# this listener will negotiate. Empty means Go's own secure default set/order +# applies. Only affects TLS 1.2 and below — TLS 1.3 suite selection is not +# configurable in Go's crypto/tls. +ciphers = "" + +# ecdh_curves is a comma-separated list of TLS 1.3 key-exchange groups, most +# preferred first. Classical curves only by default. Prepend the hybrid +# post-quantum group "X25519MLKEM768" (FIPS 203 ML-KEM-768 + X25519) as an +# explicit opt-in once clients reaching this listener are confirmed to +# support it, e.g. "X25519MLKEM768,X25519,P-256" — a client that doesn't +# offer the hybrid group simply falls back to a later classical entry in this +# same list, so enabling it never breaks a legacy peer. +ecdh_curves = "X25519,P-256" + # --------------------------------------------------------------------------- # Listener timeouts # --------------------------------------------------------------------------- diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 5957a59b87..37b887f7f3 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -272,6 +272,28 @@ type HTTPSListener struct { Port int `koanf:"port"` CertFile string `koanf:"cert_file"` KeyFile string `koanf:"key_file"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the negotiated + // TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". + MinimumProtocolVersion string `koanf:"minimum_protocol_version"` + MaximumProtocolVersion string `koanf:"maximum_protocol_version"` + + // Ciphers is a comma-separated list of Go crypto/tls cipher suite names + // (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which + // suites this listener will negotiate. Empty by default, meaning Go's own + // secure default set/order applies. Only affects TLS 1.2 and below — TLS + // 1.3 suite selection is not configurable in Go's crypto/tls. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups, + // most preferred first (e.g. "X25519,P-256"). Classical curves only by + // default. A hybrid post-quantum group ("X25519MLKEM768", FIPS 203 + // ML-KEM-768 + X25519) can be prepended as an explicit opt-in once the + // clients that reach this listener are confirmed to support it — TLS 1.3 + // negotiation simply falls back to a later classical entry in this same + // list for a client that doesn't offer the hybrid group, so enabling it + // never breaks a legacy peer. See post-quantum-cryptography.md. + EcdhCurves string `koanf:"ecdh_curves"` } // Timeouts bounds the lifetime of a connection on both listeners, so a slow or @@ -894,6 +916,17 @@ func validateListenersConfig(l *ServerListeners) error { if l.HTTP.Enabled && l.HTTPS.Enabled && l.HTTP.Port == l.HTTPS.Port { return fmt.Errorf("server.http.port and server.https.port must differ when both listeners are enabled (both are %d)", l.HTTP.Port) } + if l.HTTPS.Enabled { + if err := ValidateHTTPSTLSVersions(l.HTTPS.MinimumProtocolVersion, l.HTTPS.MaximumProtocolVersion); err != nil { + return fmt.Errorf("server.https: %w", err) + } + if _, err := ParseHTTPSCiphers(l.HTTPS.Ciphers); err != nil { + return fmt.Errorf("server.https.ciphers: %w", err) + } + if _, err := ParseHTTPSEcdhCurves(l.HTTPS.EcdhCurves); err != nil { + return fmt.Errorf("server.https.ecdh_curves: %w", err) + } + } return nil } diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index 44a9bfaf52..eac5064e5f 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -123,10 +123,14 @@ func defaultConfig() *Server { Port: 9080, }, HTTPS: HTTPSListener{ - Enabled: true, - Port: 9243, - CertFile: "./data/certs/cert.pem", - KeyFile: "./data/certs/key.pem", + Enabled: true, + Port: 9243, + CertFile: "./data/certs/cert.pem", + KeyFile: "./data/certs/key.pem", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", }, // Finite by default so a slow or idle peer cannot hold a connection open // indefinitely. Write is the loosest of the four because some handlers diff --git a/platform-api/config/server_tls.go b/platform-api/config/server_tls.go new file mode 100644 index 0000000000..84023545a0 --- /dev/null +++ b/platform-api/config/server_tls.go @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package config + +import ( + "crypto/tls" + "fmt" + "strings" +) + +// httpsEcdhCurvesByName maps the names accepted in HTTPSListener.EcdhCurves to +// Go's crypto/tls group identifiers. X25519MLKEM768 is the FIPS 203 +// ML-KEM-768 + X25519 hybrid group, implemented natively by Go 1.23+. Same +// vocabulary as gateway-controller's ServerTLSConfig.EcdhCurves — keep the two +// in sync if either changes. +var httpsEcdhCurvesByName = map[string]tls.CurveID{ + "X25519": tls.X25519, + "P-256": tls.CurveP256, + "P-384": tls.CurveP384, + "P-521": tls.CurveP521, + "X25519MLKEM768": tls.X25519MLKEM768, +} + +// ParseHTTPSEcdhCurves parses a comma-separated EcdhCurves preference list +// (e.g. "X25519MLKEM768,X25519,P-256") into the tls.CurveID slice consumed by +// tls.Config.CurvePreferences. Used both to fail config validation closed on +// an unrecognized curve name and to build the HTTPS listener's TLS config. +func ParseHTTPSEcdhCurves(raw string) ([]tls.CurveID, error) { + parts := strings.Split(raw, ",") + curves := make([]tls.CurveID, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + curve, ok := httpsEcdhCurvesByName[name] + if !ok { + return nil, fmt.Errorf("unsupported ecdh curve %q (supported: X25519, P-256, P-384, P-521, X25519MLKEM768)", name) + } + curves = append(curves, curve) + } + if len(curves) == 0 { + return nil, fmt.Errorf("must specify at least one ecdh curve") + } + return curves, nil +} + +// httpsTLSVersionByName maps the version strings accepted in +// HTTPSListener.MinimumProtocolVersion/MaximumProtocolVersion to Go's +// crypto/tls version identifiers. +var httpsTLSVersionByName = map[string]uint16{ + "TLS1_0": tls.VersionTLS10, + "TLS1_1": tls.VersionTLS11, + "TLS1_2": tls.VersionTLS12, + "TLS1_3": tls.VersionTLS13, +} + +// httpsTLSVersionOrder ranks the version names above so a min > max +// combination can be rejected at validation time. +var httpsTLSVersionOrder = map[string]int{ + "TLS1_0": 0, + "TLS1_1": 1, + "TLS1_2": 2, + "TLS1_3": 3, +} + +// ValidateHTTPSTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. +func ValidateHTTPSTLSVersions(minVersion, maxVersion string) error { + if _, ok := httpsTLSVersionByName[minVersion]; !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + if _, ok := httpsTLSVersionByName[maxVersion]; !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if httpsTLSVersionOrder[minVersion] > httpsTLSVersionOrder[maxVersion] { + return fmt.Errorf("minimum_protocol_version (%s) cannot be greater than maximum_protocol_version (%s)", minVersion, maxVersion) + } + return nil +} + +// ParseHTTPSTLSVersion converts a validated version name to its crypto/tls +// identifier. Callers should run ValidateHTTPSTLSVersions first; an +// unrecognized name here returns ok=false rather than panicking. +func ParseHTTPSTLSVersion(name string) (version uint16, ok bool) { + version, ok = httpsTLSVersionByName[name] + return version, ok +} + +// httpsCipherSuiteByName is built from Go's own list of secure cipher suites +// (tls.CipherSuites — deliberately excludes tls.InsecureCipherSuites) so an +// operator can only ever restrict to suites Go itself considers safe, never +// re-enable a weak one. Includes the three TLS 1.3 suite names for +// completeness, though Go does not apply CipherSuites to TLS 1.3 — TLS 1.3 +// suite selection is not configurable and always uses Go's own safe set. +var httpsCipherSuiteByName = func() map[string]uint16 { + m := make(map[string]uint16) + for _, cs := range tls.CipherSuites() { + m[cs.Name] = cs.ID + } + return m +}() + +// ParseHTTPSCiphers parses a comma-separated list of Go crypto/tls cipher +// suite names (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") into the +// []uint16 consumed by tls.Config.CipherSuites. An empty string is valid and +// returns (nil, nil) — Go's own default suite set/order applies. Only +// affects TLS 1.2 (and below) connections; TLS 1.3 ignores this field. +func ParseHTTPSCiphers(raw string) ([]uint16, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + suites := make([]uint16, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + id, ok := httpsCipherSuiteByName[name] + if !ok { + return nil, fmt.Errorf("unsupported or insecure cipher suite %q (see crypto/tls.CipherSuites for the supported list)", name) + } + suites = append(suites, id) + } + if len(suites) == 0 { + return nil, fmt.Errorf("must specify at least one cipher suite, or omit ciphers entirely to use Go's default set") + } + return suites, nil +} diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 5c7c5a2d1c..3b7eb06f48 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -807,9 +807,31 @@ func (s *Server) buildTLSConfig(httpsCfg config.HTTPSListener) (*tls.Config, err } s.logger.Info("Using mounted certificates", "certFile", certFile, "keyFile", keyFile) + // Config.Validate (validateListenersConfig) already rejects a bad + // version/cipher/curve value before this ever runs in production, so an + // error here can only come from a caller that bypassed validation. + if err := config.ValidateHTTPSTLSVersions(httpsCfg.MinimumProtocolVersion, httpsCfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := config.ParseHTTPSTLSVersion(httpsCfg.MinimumProtocolVersion) + maxVersion, _ := config.ParseHTTPSTLSVersion(httpsCfg.MaximumProtocolVersion) + + cipherSuites, err := config.ParseHTTPSCiphers(httpsCfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := config.ParseHTTPSEcdhCurves(httpsCfg.EcdhCurves) + if err != nil { + return nil, err + } + return &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, }, nil } diff --git a/platform-api/internal/server/server_tls_test.go b/platform-api/internal/server/server_tls_test.go index 82b9492a2a..3dd5f48f20 100644 --- a/platform-api/internal/server/server_tls_test.go +++ b/platform-api/internal/server/server_tls_test.go @@ -20,6 +20,7 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" @@ -66,10 +67,13 @@ func TestBuildTLSConfig_MountedCert_Loads(t *testing.T) { writeTestCertPair(t, certDir) tlsConfig, err := testServer().buildTLSConfig(config.HTTPSListener{ - Enabled: true, - Port: 9243, - CertFile: filepath.Join(certDir, "cert.pem"), - KeyFile: filepath.Join(certDir, "key.pem"), + Enabled: true, + Port: 9243, + CertFile: filepath.Join(certDir, "cert.pem"), + KeyFile: filepath.Join(certDir, "key.pem"), + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", }) if err != nil { t.Fatalf("expected mounted certificates to load, got %v", err) @@ -77,6 +81,68 @@ func TestBuildTLSConfig_MountedCert_Loads(t *testing.T) { if tlsConfig == nil || len(tlsConfig.Certificates) != 1 { t.Fatal("expected exactly one loaded certificate") } + if tlsConfig.MinVersion != tls.VersionTLS12 || tlsConfig.MaxVersion != tls.VersionTLS13 { + t.Fatalf("expected TLS1_2-TLS1_3 bounds, got min=%x max=%x", tlsConfig.MinVersion, tlsConfig.MaxVersion) + } + wantCurves := []tls.CurveID{tls.X25519, tls.CurveP256} + if len(tlsConfig.CurvePreferences) != len(wantCurves) { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, tlsConfig.CurvePreferences) + } + for i, c := range wantCurves { + if tlsConfig.CurvePreferences[i] != c { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, tlsConfig.CurvePreferences) + } + } +} + +// HTTPS listener with the hybrid post-quantum curve opted in: X25519MLKEM768 +// is accepted and placed first, with classical curves retained after it so a +// peer that doesn't support the hybrid group still negotiates successfully. +func TestBuildTLSConfig_PQCHybridCurveOptIn_Loads(t *testing.T) { + certDir := t.TempDir() + writeTestCertPair(t, certDir) + + tlsConfig, err := testServer().buildTLSConfig(config.HTTPSListener{ + Enabled: true, + Port: 9243, + CertFile: filepath.Join(certDir, "cert.pem"), + KeyFile: filepath.Join(certDir, "key.pem"), + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + }) + if err != nil { + t.Fatalf("expected PQC hybrid opt-in to build successfully, got %v", err) + } + wantCurves := []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256} + if len(tlsConfig.CurvePreferences) != len(wantCurves) { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, tlsConfig.CurvePreferences) + } + for i, c := range wantCurves { + if tlsConfig.CurvePreferences[i] != c { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, tlsConfig.CurvePreferences) + } + } +} + +// HTTPS listener with an invalid ecdh_curves value: rejected rather than +// silently falling back to Go's default curve list. +func TestBuildTLSConfig_InvalidEcdhCurve_Errors(t *testing.T) { + certDir := t.TempDir() + writeTestCertPair(t, certDir) + + _, err := testServer().buildTLSConfig(config.HTTPSListener{ + Enabled: true, + Port: 9243, + CertFile: filepath.Join(certDir, "cert.pem"), + KeyFile: filepath.Join(certDir, "key.pem"), + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "not-a-curve", + }) + if err == nil { + t.Fatal("expected an error for an unrecognized ecdh curve name") + } } // writeTestCertPair writes a throwaway self-signed cert.pem / key.pem into dir. diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 98f69d89cd..e7739104ce 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -90,6 +90,28 @@ type HTTPSListener struct { Port int `koanf:"port"` CertFile string `koanf:"cert_file"` KeyFile string `koanf:"key_file"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the negotiated + // TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". + MinimumProtocolVersion string `koanf:"minimum_protocol_version"` + MaximumProtocolVersion string `koanf:"maximum_protocol_version"` + + // Ciphers is a comma-separated list of Go crypto/tls cipher suite names + // (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which + // suites this listener will negotiate. Empty by default, meaning Go's own + // secure default set/order applies. Only affects TLS 1.2 and below — TLS + // 1.3 suite selection is not configurable in Go's crypto/tls. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups, + // most preferred first (e.g. "X25519,P-256"). Classical curves only by + // default. A hybrid post-quantum group ("X25519MLKEM768", FIPS 203 + // ML-KEM-768 + X25519) can be prepended as an explicit opt-in once the + // clients that reach this listener are confirmed to support it — TLS 1.3 + // negotiation simply falls back to a later classical entry in this same + // list for a client that doesn't offer the hybrid group, so enabling it + // never breaks a legacy peer. See post-quantum-cryptography.md. + EcdhCurves string `koanf:"ecdh_curves"` } // LoggingConfig is [ai_workspace.logging]. Level/Format are this process's own logs; @@ -350,6 +372,17 @@ func (c *Config) validate() error { if c.Server.HTTP.Enabled && c.Server.HTTPS.Enabled && c.Server.HTTP.Port == c.Server.HTTPS.Port { return fmt.Errorf("[server.http] port and [server.https] port must differ, both are %d", c.Server.HTTP.Port) } + if c.Server.HTTPS.Enabled { + if err := ValidateHTTPSTLSVersions(c.Server.HTTPS.MinimumProtocolVersion, c.Server.HTTPS.MaximumProtocolVersion); err != nil { + return fmt.Errorf("[server.https]: %w", err) + } + if _, err := ParseHTTPSCiphers(c.Server.HTTPS.Ciphers); err != nil { + return fmt.Errorf("[server.https] ciphers: %w", err) + } + if _, err := ParseHTTPSEcdhCurves(c.Server.HTTPS.EcdhCurves); err != nil { + return fmt.Errorf("[server.https] ecdh_curves: %w", err) + } + } // Every session duration is a lifetime, where <= 0 is never meaningful. if c.Session.IdleTimeout <= 0 { return fmt.Errorf("[session] idle_timeout must be positive, got %s", c.Session.IdleTimeout) diff --git a/portals/ai-workspace/bff/internal/config/default_config.go b/portals/ai-workspace/bff/internal/config/default_config.go index bb57d9fb4f..8eaa2b537c 100644 --- a/portals/ai-workspace/bff/internal/config/default_config.go +++ b/portals/ai-workspace/bff/internal/config/default_config.go @@ -35,8 +35,12 @@ func defaultConfig() *Config { Port: 9643, // Convention matches the container's mount path. A certificate pair is // required there whenever the listener terminates TLS. - CertFile: "/etc/ai-workspace/tls/cert.pem", - KeyFile: "/etc/ai-workspace/tls/key.pem", + CertFile: "/etc/ai-workspace/tls/cert.pem", + KeyFile: "/etc/ai-workspace/tls/key.pem", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", }, }, Logging: LoggingConfig{ diff --git a/portals/ai-workspace/bff/internal/config/server_tls.go b/portals/ai-workspace/bff/internal/config/server_tls.go new file mode 100644 index 0000000000..474a6c74bb --- /dev/null +++ b/portals/ai-workspace/bff/internal/config/server_tls.go @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "crypto/tls" + "fmt" + "strings" +) + +// httpsEcdhCurvesByName maps the names accepted in HTTPSListener.EcdhCurves to +// Go's crypto/tls group identifiers. X25519MLKEM768 is the FIPS 203 +// ML-KEM-768 + X25519 hybrid group, implemented natively by Go 1.23+. Same +// vocabulary as platform-api's HTTPSListener.EcdhCurves — keep the two in +// sync if either changes. +var httpsEcdhCurvesByName = map[string]tls.CurveID{ + "X25519": tls.X25519, + "P-256": tls.CurveP256, + "P-384": tls.CurveP384, + "P-521": tls.CurveP521, + "X25519MLKEM768": tls.X25519MLKEM768, +} + +// ParseHTTPSEcdhCurves parses a comma-separated EcdhCurves preference list +// (e.g. "X25519MLKEM768,X25519,P-256") into the tls.CurveID slice consumed by +// tls.Config.CurvePreferences. Used both to fail config validation closed on +// an unrecognized curve name and to build the HTTPS listener's TLS config. +func ParseHTTPSEcdhCurves(raw string) ([]tls.CurveID, error) { + parts := strings.Split(raw, ",") + curves := make([]tls.CurveID, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + curve, ok := httpsEcdhCurvesByName[name] + if !ok { + return nil, fmt.Errorf("unsupported ecdh curve %q (supported: X25519, P-256, P-384, P-521, X25519MLKEM768)", name) + } + curves = append(curves, curve) + } + if len(curves) == 0 { + return nil, fmt.Errorf("must specify at least one ecdh curve") + } + return curves, nil +} + +// httpsTLSVersionByName maps the version strings accepted in +// HTTPSListener.MinimumProtocolVersion/MaximumProtocolVersion to Go's +// crypto/tls version identifiers. +var httpsTLSVersionByName = map[string]uint16{ + "TLS1_0": tls.VersionTLS10, + "TLS1_1": tls.VersionTLS11, + "TLS1_2": tls.VersionTLS12, + "TLS1_3": tls.VersionTLS13, +} + +// httpsTLSVersionOrder ranks the version names above so a min > max +// combination can be rejected at validation time. +var httpsTLSVersionOrder = map[string]int{ + "TLS1_0": 0, + "TLS1_1": 1, + "TLS1_2": 2, + "TLS1_3": 3, +} + +// ValidateHTTPSTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. +func ValidateHTTPSTLSVersions(minVersion, maxVersion string) error { + if _, ok := httpsTLSVersionByName[minVersion]; !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + if _, ok := httpsTLSVersionByName[maxVersion]; !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if httpsTLSVersionOrder[minVersion] > httpsTLSVersionOrder[maxVersion] { + return fmt.Errorf("minimum_protocol_version (%s) cannot be greater than maximum_protocol_version (%s)", minVersion, maxVersion) + } + return nil +} + +// ParseHTTPSTLSVersion converts a validated version name to its crypto/tls +// identifier. Callers should run ValidateHTTPSTLSVersions first; an +// unrecognized name here returns ok=false rather than panicking. +func ParseHTTPSTLSVersion(name string) (version uint16, ok bool) { + version, ok = httpsTLSVersionByName[name] + return version, ok +} + +// httpsCipherSuiteByName is built from Go's own list of secure cipher suites +// (tls.CipherSuites — deliberately excludes tls.InsecureCipherSuites) so an +// operator can only ever restrict to suites Go itself considers safe, never +// re-enable a weak one. Includes the three TLS 1.3 suite names for +// completeness, though Go does not apply CipherSuites to TLS 1.3 — TLS 1.3 +// suite selection is not configurable and always uses Go's own safe set. +var httpsCipherSuiteByName = func() map[string]uint16 { + m := make(map[string]uint16) + for _, cs := range tls.CipherSuites() { + m[cs.Name] = cs.ID + } + return m +}() + +// ParseHTTPSCiphers parses a comma-separated list of Go crypto/tls cipher +// suite names (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") into the +// []uint16 consumed by tls.Config.CipherSuites. An empty string is valid and +// returns (nil, nil) — Go's own default suite set/order applies. Only +// affects TLS 1.2 (and below) connections; TLS 1.3 ignores this field. +func ParseHTTPSCiphers(raw string) ([]uint16, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + suites := make([]uint16, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + id, ok := httpsCipherSuiteByName[name] + if !ok { + return nil, fmt.Errorf("unsupported or insecure cipher suite %q (see crypto/tls.CipherSuites for the supported list)", name) + } + suites = append(suites, id) + } + if len(suites) == 0 { + return nil, fmt.Errorf("must specify at least one cipher suite, or omit ciphers entirely to use Go's default set") + } + return suites, nil +} diff --git a/portals/ai-workspace/bff/main.go b/portals/ai-workspace/bff/main.go index 0d2e40c582..13fe637b1d 100644 --- a/portals/ai-workspace/bff/main.go +++ b/portals/ai-workspace/bff/main.go @@ -273,7 +273,33 @@ func buildTLS(c config.HTTPSListener) (*tls.Config, error) { return nil, err } slog.Info("TLS: using mounted certificate", "cert", c.CertFile) - return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil + + // Config.validate already rejects a bad version/cipher/curve value before + // this ever runs in production, so an error here can only come from a + // caller that bypassed validation. + if err := config.ValidateHTTPSTLSVersions(c.MinimumProtocolVersion, c.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := config.ParseHTTPSTLSVersion(c.MinimumProtocolVersion) + maxVersion, _ := config.ParseHTTPSTLSVersion(c.MaximumProtocolVersion) + + cipherSuites, err := config.ParseHTTPSCiphers(c.Ciphers) + if err != nil { + return nil, err + } + + curves, err := config.ParseHTTPSEcdhCurves(c.EcdhCurves) + if err != nil { + return nil, err + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, + }, nil } func fileExists(p string) bool { diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index 80a744dea5..f7da6d4648 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -118,6 +118,27 @@ port = 9643 cert_file = "/etc/ai-workspace/tls/cert.pem" key_file = "/etc/ai-workspace/tls/key.pem" +# minimum_protocol_version / maximum_protocol_version bound the negotiated TLS +# version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" + +# ciphers is a comma-separated list of Go crypto/tls cipher suite names +# (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") restricting which suites +# this listener will negotiate. Empty means Go's own secure default set/order +# applies. Only affects TLS 1.2 and below — TLS 1.3 suite selection is not +# configurable in Go's crypto/tls. +ciphers = "" + +# ecdh_curves is a comma-separated list of TLS 1.3 key-exchange groups, most +# preferred first. Classical curves only by default. Prepend the hybrid +# post-quantum group "X25519MLKEM768" (FIPS 203 ML-KEM-768 + X25519) as an +# explicit opt-in once clients reaching this listener are confirmed to +# support it, e.g. "X25519MLKEM768,X25519,P-256" — a client that doesn't +# offer the hybrid group simply falls back to a later classical entry in this +# same list, so enabling it never breaks a legacy peer. +ecdh_curves = "X25519,P-256" + # --------------------------------------------------------------------------- # Logging. level/format are this process's own logs; browser_debug is diff --git a/portals/api-portal/configs/config-template.toml b/portals/api-portal/configs/config-template.toml index bf6441f580..a7f60a372f 100644 --- a/portals/api-portal/configs/config-template.toml +++ b/portals/api-portal/configs/config-template.toml @@ -54,6 +54,31 @@ enabled = false cert_file = "./resources/security/client-truststore.pem" key_file = "./resources/security/private-key.pem" +# Same field names/shape as platform-api's and ai-workspace's [server.https] +# (Go) for cross-component consistency, even though this listener runs on +# Node's own tls stack (converted to Node's option formats in tlsOptions.js). + +# minimum_protocol_version / maximum_protocol_version bound the negotiated TLS +# version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" + +# ciphers is a comma-separated list of cipher suite names (OpenSSL names, e.g. +# "ECDHE-RSA-AES128-GCM-SHA256" -- see Node's tls.getCiphers() for the +# supported list), restricting which suites this listener will negotiate. +# Empty means Node's own default cipher set/order applies. Only affects TLS +# 1.2 and below -- TLS 1.3 suite selection is not configurable. +ciphers = "" + +# ecdh_curves is a comma-separated list of TLS 1.3 key-exchange groups, most +# preferred first. Classical curves only by default. Prepend the hybrid +# post-quantum group "X25519MLKEM768" (FIPS 203 ML-KEM-768 + X25519, needs +# Node 22+/OpenSSL 3.2+) as an explicit opt-in once clients reaching this +# listener are confirmed to support it, e.g. "X25519MLKEM768,X25519,P-256" -- +# a client that doesn't offer the hybrid group simply falls back to a later +# classical entry in this same list, so enabling it never breaks a legacy peer. +ecdh_curves = "X25519,P-256" + # ============================================================================= # LOGGING CONFIGURATION # ============================================================================= diff --git a/portals/api-portal/src/config/configDefaults.js b/portals/api-portal/src/config/configDefaults.js index 26ad8d6de0..e4959c1263 100644 --- a/portals/api-portal/src/config/configDefaults.js +++ b/portals/api-portal/src/config/configDefaults.js @@ -45,6 +45,27 @@ const DEFAULTS = { enabled: false, certFile: './resources/security/client-truststore.pem', keyFile: './resources/security/private-key.pem', + // Same field names/shape as platform-api's and ai-workspace's + // HTTPSListener (Go) for cross-component consistency, even though + // this listener is served by Node's own tls stack. minimumProtocolVersion + // / maximumProtocolVersion use the "TLS1_2".."TLS1_3" vocabulary; ciphers + // and ecdhCurves are comma-separated (converted to Node's colon-delimited + // `ciphers`/`ecdhCurve` options in tlsOptions.js) rather than Node-native + // colon-delimited strings directly in config. + minimumProtocolVersion: 'TLS1_2', + maximumProtocolVersion: 'TLS1_3', + // Empty means Node's own default cipher set/order applies. Only affects + // TLS 1.2 and below — TLS 1.3 suite selection is not configurable. + ciphers: '', + // Classical curves only by default. Prepend the hybrid post-quantum + // group "X25519MLKEM768" (FIPS 203 ML-KEM-768 + X25519, Node 22+/ + // OpenSSL 3.2+) as an explicit opt-in once clients reaching this + // listener are confirmed to support it, e.g. + // "X25519MLKEM768,X25519,P-256" — a client that doesn't offer the + // hybrid group falls back to a later classical entry in this same + // list, so opting in never breaks a legacy peer. See + // js-post-quantum-cryptography.md. + ecdhCurves: 'X25519,P-256', }, }, logging: { diff --git a/portals/api-portal/src/config/tlsOptions.js b/portals/api-portal/src/config/tlsOptions.js new file mode 100644 index 0000000000..97aa80c59b --- /dev/null +++ b/portals/api-portal/src/config/tlsOptions.js @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +'use strict'; + +/** + * Translates the platform-wide HTTPSListener TLS fields (minimumProtocolVersion / + * maximumProtocolVersion / ciphers / ecdhCurves — same names, same comma-separated + * shape as platform-api's and ai-workspace's Go HTTPSListener struct) into the + * options https.createServer()/tls.createServer() actually accept. Kept as a + * separate module so server.js's happy path stays readable and so the mapping + * tables here are the one place that needs updating if Node's option names change. + */ + +const tls = require('tls'); + +// TLS version name -> Node's tls minVersion/maxVersion string. Same vocabulary +// ("TLS1_2", etc.) as platform-api/ai-workspace's HTTPSListener fields, even +// though each is enforced by a different TLS stack (Go crypto/tls there, Node's +// own tls module here). +const TLS_VERSION_BY_NAME = { + TLS1_0: 'TLSv1', + TLS1_1: 'TLSv1.1', + TLS1_2: 'TLSv1.2', + TLS1_3: 'TLSv1.3', +}; + +const TLS_VERSION_ORDER = { TLS1_0: 0, TLS1_1: 1, TLS1_2: 2, TLS1_3: 3 }; + +/** + * Validates that minVersion/maxVersion are recognized names and that minVersion + * does not come after maxVersion. Throws on failure so an invalid config fails + * closed at startup rather than silently falling back to Node's default range. + */ +function validateTLSVersions(minVersion, maxVersion) { + if (!Object.prototype.hasOwnProperty.call(TLS_VERSION_BY_NAME, minVersion)) { + throw new Error(`server.https.minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: "${minVersion}"`); + } + if (!Object.prototype.hasOwnProperty.call(TLS_VERSION_BY_NAME, maxVersion)) { + throw new Error(`server.https.maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: "${maxVersion}"`); + } + if (TLS_VERSION_ORDER[minVersion] > TLS_VERSION_ORDER[maxVersion]) { + throw new Error(`server.https.minimum_protocol_version (${minVersion}) cannot be greater than maximum_protocol_version (${maxVersion})`); + } +} + +/** Converts a validated version name to Node's minVersion/maxVersion string. */ +function parseTLSVersion(name) { + return TLS_VERSION_BY_NAME[name]; +} + +// ECDH/group name -> the OpenSSL curve name Node's `ecdhCurve` option expects. +// X25519MLKEM768 is the FIPS 203 ML-KEM-768 + X25519 hybrid group (Node 22+ / +// OpenSSL 3.2+). Same curve vocabulary as platform-api/ai-workspace's EcdhCurves. +const ECDH_CURVE_BY_NAME = { + X25519: 'X25519', + 'P-256': 'prime256v1', + 'P-384': 'secp384r1', + 'P-521': 'secp521r1', + X25519MLKEM768: 'X25519MLKEM768', +}; + +/** + * Parses a comma-separated EcdhCurves preference list (e.g. + * "X25519MLKEM768,X25519,P-256") into the colon-delimited string Node's + * `ecdhCurve` option expects. Throws on an unrecognized name or an empty list — + * fail closed rather than silently falling back to Node's default curve list. + */ +function parseEcdhCurves(raw) { + const names = String(raw || '').split(',').map((s) => s.trim()).filter(Boolean); + if (names.length === 0) { + throw new Error('server.https.ecdh_curves must specify at least one curve'); + } + const curves = names.map((name) => { + const curve = ECDH_CURVE_BY_NAME[name]; + if (!curve) { + throw new Error(`server.https.ecdh_curves: unsupported curve "${name}" (supported: X25519, P-256, P-384, P-521, X25519MLKEM768)`); + } + return curve; + }); + return curves.join(':'); +} + +/** + * Parses a comma-separated list of cipher suite names into the colon-delimited + * string Node's `ciphers` option expects, validated against tls.getCiphers() so + * a typo fails closed at startup rather than silently negotiating Node's default + * set. An empty string is valid and returns undefined, meaning Node's own + * default cipher set/order applies. + */ +function parseCiphers(raw) { + const trimmed = String(raw || '').trim(); + if (trimmed === '') { + return undefined; + } + const names = trimmed.split(',').map((s) => s.trim()).filter(Boolean); + if (names.length === 0) { + throw new Error('server.https.ciphers must specify at least one cipher suite, or be left empty to use Node\'s default set'); + } + const supported = new Set(tls.getCiphers()); + for (const name of names) { + if (!supported.has(name.toLowerCase())) { + throw new Error(`server.https.ciphers: unsupported cipher suite "${name}" (see tls.getCiphers() for the supported list)`); + } + } + return names.join(':'); +} + +/** + * Builds the { minVersion, maxVersion, ecdhCurve, ciphers? } options object for + * https.createServer() from an HTTPSListener-shaped config object. Throws on any + * invalid field — callers should let that abort startup rather than serve a + * listener with a silently-degraded TLS posture. + */ +function buildTLSOptions(httpsCfg) { + validateTLSVersions(httpsCfg.minimumProtocolVersion, httpsCfg.maximumProtocolVersion); + const options = { + minVersion: parseTLSVersion(httpsCfg.minimumProtocolVersion), + maxVersion: parseTLSVersion(httpsCfg.maximumProtocolVersion), + ecdhCurve: parseEcdhCurves(httpsCfg.ecdhCurves), + }; + const ciphers = parseCiphers(httpsCfg.ciphers); + if (ciphers) { + options.ciphers = ciphers; + } + return options; +} + +module.exports = { + validateTLSVersions, + parseTLSVersion, + parseEcdhCurves, + parseCiphers, + buildTLSOptions, +}; diff --git a/portals/api-portal/src/server.js b/portals/api-portal/src/server.js index 62d35d3f19..7f627c0437 100644 --- a/portals/api-portal/src/server.js +++ b/portals/api-portal/src/server.js @@ -21,6 +21,7 @@ const fs = require('fs'); const path = require('path'); const logger = require('./config/logger'); const { config } = require('./config/configLoader'); +const { buildTLSOptions } = require('./config/tlsOptions'); const webhookDispatcher = require('./services/webhooks/dispatcher'); const webhookDeliveryWorker = require('./services/webhooks/deliveryWorker'); const db = require('./db/driver'); @@ -150,9 +151,15 @@ async function startServer() { const serverCert = fs.readFileSync(certPath); const serverKey = fs.readFileSync(keyPath); + // TLS version bounds + ECDH/group preference list, config-gated — see + // js-post-quantum-cryptography.md. Throws (caught below) on an invalid + // value, so a bad config fails startup rather than silently degrading. + const tlsOptions = buildTLSOptions(config.server.https); + server = https.createServer({ key: serverKey, cert: serverCert, + ...tlsOptions, }, app).listen(PORT, onListening); } catch (err) {