diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 1baaf9f679..49ea3c4ad5 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() { diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go index f7cbea8276..e3b426518d 100644 --- a/gateway/gateway-controller/pkg/config/config.go +++ b/gateway/gateway-controller/pkg/config/config.go @@ -305,6 +305,11 @@ type ServerConfig struct { ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` GatewayID string `koanf:"gateway_id"` SkipInvalidDeploymentsOnStartup bool `koanf:"skip_invalid_deployments_on_startup"` + + // XDSTLS switches the main xDS gRPC server (serving Envoy, on XDSPort) + // from plaintext to mutual TLS. Off by default; see XDSServerTLSConfig + // for why xDS has no server-only mode. + XDSTLS XDSServerTLSConfig `koanf:"xds_tls"` } // AdminServerConfig holds controller admin HTTP server configuration. @@ -337,15 +342,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 @@ -568,8 +566,8 @@ type UpstreamTLS struct { // 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 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"` @@ -842,6 +840,16 @@ func defaultConfig() *Config { ShutdownTimeout: 15 * time.Second, GatewayID: constants.PlatformGatewayId, SkipInvalidDeploymentsOnStartup: false, + 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 +866,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{ @@ -1367,6 +1380,16 @@ func (c *Config) Validate() error { return fmt.Errorf("server.gateway_id is required and cannot be empty") } + // 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) 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..e3f483cf20 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/server_tls.go @@ -0,0 +1,95 @@ +/* + * 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" + + "github.com/wso2/api-platform/httpkit/tlsconfig" +) + +// 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. +// +// The name-to-tls.CurveID vocabulary is sourced from httpkit/tlsconfig (the +// shared, direction-neutral implementation); this function keeps its own +// wrapper for the "empty string is an error" behavior, which differs from +// tlsconfig.ParseCurvePreferences's "empty means use Go's defaults" stance — +// an explicit, always-on TLS listener config has no notion of "unset". +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 := tlsconfig.CurvesByName[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 +} + +// ValidateServerTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. Unlike +// tlsconfig.ValidateVersionRange (which treats both-empty as "use Go's +// defaults"), this listener's config always requires both fields to name a +// real version — there is no "unset" state for an always-on TLS listener. +// tls.VersionTLSxx constants are monotonically increasing, so comparing the +// parsed values directly replaces a separate ordering table. +func ValidateServerTLSVersions(minVersion, maxVersion string) error { + minV, ok := tlsconfig.ParseVersion(minVersion) + if !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + maxV, ok := tlsconfig.ParseVersion(maxVersion) + if !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if minV > maxV { + 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) { + return tlsconfig.ParseVersion(name) +} + +// 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) { + return tlsconfig.ParseCipherSuites(raw) +} 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 0b4f745fd5..ca65e01235 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 { @@ -2227,63 +2220,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 @@ -2317,24 +2253,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{}, }, } @@ -2410,6 +2347,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 283de7ac77..4fb9379475 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" @@ -2497,14 +2498,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/policy-engine/internal/config/admin_tls.go b/gateway/gateway-runtime/policy-engine/internal/config/admin_tls.go new file mode 100644 index 0000000000..a3d580e582 --- /dev/null +++ b/gateway/gateway-runtime/policy-engine/internal/config/admin_tls.go @@ -0,0 +1,95 @@ +/* + * 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" + + "github.com/wso2/api-platform/httpkit/tlsconfig" +) + +// 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. +// +// The name-to-tls.CurveID vocabulary is sourced from httpkit/tlsconfig (the +// shared, direction-neutral implementation); this function keeps its own +// wrapper for the "empty string is an error" behavior, which differs from +// tlsconfig.ParseCurvePreferences's "empty means use Go's defaults" stance — +// an explicit, always-on TLS listener config has no notion of "unset". +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 := tlsconfig.CurvesByName[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 +} + +// ValidateAdminTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. Unlike +// tlsconfig.ValidateVersionRange (which treats both-empty as "use Go's +// defaults"), this listener's config always requires both fields to name a +// real version — there is no "unset" state for an always-on TLS listener. +// tls.VersionTLSxx constants are monotonically increasing, so comparing the +// parsed values directly replaces a separate ordering table. +func ValidateAdminTLSVersions(minVersion, maxVersion string) error { + minV, ok := tlsconfig.ParseVersion(minVersion) + if !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + maxV, ok := tlsconfig.ParseVersion(maxVersion) + if !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if minV > maxV { + 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) { + return tlsconfig.ParseVersion(name) +} + +// 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) { + return tlsconfig.ParseCipherSuites(raw) +} 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..3f49049a05 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, resolver.DefaultRegistry()) + 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, resolver.DefaultRegistry()) + 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()