diff --git a/assets/components/openshift-router/deployment.yaml b/assets/components/openshift-router/deployment.yaml index af1f48f67f..cb2c7b5f31 100644 --- a/assets/components/openshift-router/deployment.yaml +++ b/assets/components/openshift-router/deployment.yaml @@ -58,6 +58,8 @@ spec: value: '{{ .ThreadCount }}' - name: SSL_MIN_VERSION value: '{{ .RouterSSLMinVersion }}' + - name: ROUTER_CURVES + value: '{{ .RouterTLSCurves }}' - name: ROUTER_USE_PROXY_PROTOCOL value: "false" - name: GRACEFUL_SHUTDOWN_DELAY diff --git a/pkg/components/controllers.go b/pkg/components/controllers.go index e9bdf7af50..fdde5b9c6b 100644 --- a/pkg/components/controllers.go +++ b/pkg/components/controllers.go @@ -27,6 +27,9 @@ const ( haproxyMaxTimeoutMilliseconds = 2147483647 * time.Millisecond ) +// isFIPSEnabled reports whether the cluster has FIPS enabled. +var isFIPSEnabled = detectFIPS() + var ( tlsVersion13Ciphers = sets.NewString( "TLS_AES_128_GCM_SHA256", @@ -35,8 +38,41 @@ var ( "TLS_AES_128_CCM_SHA256", "TLS_AES_128_CCM_8_SHA256", ) + + fipsApprovedTLS13Ciphers = sets.NewString( + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + ) ) +// detectFIPS reports whether the cluster is operating in FIPS +// mode by checking the FIPS_ENABLED environment variable if set or +// the /proc/sys/crypto/fips_enabled file otherwise. +func detectFIPS() bool { + if v, ok := os.LookupEnv("FIPS_ENABLED"); ok { + if result, err := strconv.ParseBool(v); err != nil { + klog.Warningf("Failed to parse FIPS_ENABLED environment variable: %v; falling back to procfs", err) + } else { + klog.Infof("Found FIPS_ENABLED environment variable: value=%s, result=%v", v, result) + return result + } + } + + result := false + data, err := os.ReadFile("/proc/sys/crypto/fips_enabled") + if err != nil { + klog.Warningf("Failed to read /proc/sys/crypto/fips_enabled: %v; assuming FIPS is not enabled", err) + return result + } + if len(data) == 0 { + klog.Warningf("Got empty /proc/sys/crypto/fips_enabled; assuming FIPS is not enabled") + return result + } + result = strings.TrimSpace(string(data)) == "1" + klog.Infof("Read /proc/sys/crypto/fips_enabled: data=%s, result=%v", string(data), result) + return result +} + func startServiceCAController(ctx context.Context, cfg *config.Config, kubeconfigPath string) error { var ( //TODO: fix the rolebinding and sa @@ -230,7 +266,7 @@ func startIngressController(ctx context.Context, cfg *config.Config, kubeconfigP return err } - extraParams, err := generateIngressParams(cfg) + extraParams, err := generateIngressParams(cfg, isFIPSEnabled) if err != nil { return err } @@ -431,7 +467,7 @@ func validateClientTLS(patterns []string) error { return nil } -func generateIngressParams(cfg *config.Config) (assets.RenderParams, error) { +func generateIngressParams(cfg *config.Config, fipsEnabled bool) (assets.RenderParams, error) { routerMode := "v4" if cfg.IsIPv6() { routerMode = "v4v6" @@ -472,12 +508,34 @@ func generateIngressParams(cfg *config.Config) (assets.RenderParams, error) { } } + // On FIPS-enabled clusters, remove non-FIPS-compliant TLS 1.3 cipher + // suites (e.g. TLS_CHACHA20_POLY1305_SHA256). HAProxy would fail TLS + // handshakes when a client offers a non-FIPS cipher first if that cipher + // is listed in ssl-default-bind-ciphersuites but excluded by the OS FIPS policy. + if fipsEnabled { + fipsCiphers := tls13Ciphers[:0] + for _, c := range tls13Ciphers { + if fipsApprovedTLS13Ciphers.Has(c) { + fipsCiphers = append(fipsCiphers, c) + } + } + tls13Ciphers = fipsCiphers + } + RouterCiphers := strings.Join(otherCiphers, ":") RouterCiphersSuites := "" if len(tls13Ciphers) != 0 { RouterCiphersSuites = strings.Join(tls13Ciphers, ":") } + // Default TLS supportedGroups (curves) include X25519MLKEM768 for + // post-quantum readiness. In FIPS mode, ML-KEM and X25519 are not + // supported by OpenSSL FIPS 140-3. + tlsCurves := "X25519MLKEM768:X25519:P-256:P-384:P-521" + if fipsEnabled { + tlsCurves = "P-256:P-384:P-521" + } + var RouterSSLMinVersion string switch tlsProfileSpec.MinTLSVersion { // TLS 1.0 is not supported, convert to TLS 1.1. @@ -569,6 +627,7 @@ func generateIngressParams(cfg *config.Config) (assets.RenderParams, error) { "RouterCiphers": RouterCiphers, "RouterCiphersSuites": RouterCiphersSuites, "RouterSSLMinVersion": RouterSSLMinVersion, + "RouterTLSCurves": tlsCurves, "RouterAllowWildcardRoutes": RouterAllowWildcardRoutes, "ClientCAMapName": clientCAMapName, "ClientAuthPolicy": clientAuthPolicy, diff --git a/pkg/components/controllers_test.go b/pkg/components/controllers_test.go new file mode 100644 index 0000000000..5e0e700c78 --- /dev/null +++ b/pkg/components/controllers_test.go @@ -0,0 +1,139 @@ +package components + +import ( + "strings" + "testing" + "time" + + configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/microshift/pkg/assets" + "github.com/openshift/microshift/pkg/config" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func newTestConfig() *config.Config { + return &config.Config{ + DNS: config.DNS{BaseDomain: "example.com"}, + Network: config.Network{ + ClusterNetwork: []string{"10.42.0.0/16"}, + ServiceNetwork: []string{"10.43.0.0/16"}, + }, + Ingress: config.IngressConfig{ + Ports: config.IngressPortsConfig{ + Http: ptr.To(80), + Https: ptr.To(443), + }, + TuningOptions: config.IngressControllerTuningOptions{ + HeaderBufferBytes: 32768, + HeaderBufferMaxRewriteBytes: 8192, + HealthCheckInterval: &metav1.Duration{Duration: 5 * time.Second}, + ClientTimeout: &metav1.Duration{Duration: 30 * time.Second}, + ClientFinTimeout: &metav1.Duration{Duration: 1 * time.Second}, + ServerTimeout: &metav1.Duration{Duration: 30 * time.Second}, + ServerFinTimeout: &metav1.Duration{Duration: 1 * time.Second}, + TunnelTimeout: &metav1.Duration{Duration: 1 * time.Hour}, + TLSInspectDelay: &metav1.Duration{Duration: 5 * time.Second}, + ThreadCount: 4, + MaxConnections: 50000, + }, + ForwardedHeaderPolicy: "Append", + TLSSecurityProfile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileIntermediateType, + }, + ServingCertificateSecret: "router-certs-default", + DefaultHttpVersionPolicy: 1, + LogEmptyRequests: "Log", + HTTPEmptyRequestsPolicy: "Respond", + AccessLogging: config.AccessLogging{ + Status: config.AccessLoggingDisabled, + }, + }, + } +} + +func requireStringParam(t *testing.T, params assets.RenderParams, key string) string { + t.Helper() + v, ok := params[key] + if !ok { + t.Fatalf("missing param %q", key) + } + s, ok := v.(string) + if !ok { + t.Fatalf("param %q has type %T, want string", key, v) + } + return s +} + +func TestGenerateIngressParamsFIPSCiphers(t *testing.T) { + t.Run("FIPS enabled filters non-FIPS TLS 1.3 ciphers", func(t *testing.T) { + cfg := newTestConfig() + params, err := generateIngressParams(cfg, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cipherSuites := requireStringParam(t, params, "RouterCiphersSuites") + if strings.Contains(cipherSuites, "TLS_CHACHA20_POLY1305_SHA256") { + t.Errorf("FIPS mode should filter out TLS_CHACHA20_POLY1305_SHA256, got: %s", cipherSuites) + } + if !strings.Contains(cipherSuites, "TLS_AES_128_GCM_SHA256") { + t.Errorf("FIPS mode should keep TLS_AES_128_GCM_SHA256, got: %s", cipherSuites) + } + if !strings.Contains(cipherSuites, "TLS_AES_256_GCM_SHA384") { + t.Errorf("FIPS mode should keep TLS_AES_256_GCM_SHA384, got: %s", cipherSuites) + } + }) + + t.Run("non-FIPS keeps all TLS 1.3 ciphers", func(t *testing.T) { + cfg := newTestConfig() + params, err := generateIngressParams(cfg, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cipherSuites := requireStringParam(t, params, "RouterCiphersSuites") + if !strings.Contains(cipherSuites, "TLS_CHACHA20_POLY1305_SHA256") { + t.Errorf("non-FIPS mode should keep TLS_CHACHA20_POLY1305_SHA256, got: %s", cipherSuites) + } + }) +} + +func TestGenerateIngressParamsFIPSCurves(t *testing.T) { + t.Run("FIPS enabled uses only NIST curves", func(t *testing.T) { + cfg := newTestConfig() + params, err := generateIngressParams(cfg, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + curves := requireStringParam(t, params, "RouterTLSCurves") + if strings.Contains(curves, "X25519MLKEM768") { + t.Errorf("FIPS mode should exclude X25519MLKEM768, got: %s", curves) + } + for _, c := range strings.Split(curves, ":") { + if c == "X25519" { + t.Errorf("FIPS mode should exclude X25519, got: %s", curves) + } + } + if curves != "P-256:P-384:P-521" { + t.Errorf("FIPS mode curves should be P-256:P-384:P-521, got: %s", curves) + } + }) + + t.Run("non-FIPS includes PQC hybrid curve", func(t *testing.T) { + cfg := newTestConfig() + params, err := generateIngressParams(cfg, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + curves := requireStringParam(t, params, "RouterTLSCurves") + if !strings.Contains(curves, "X25519MLKEM768") { + t.Errorf("non-FIPS mode should include X25519MLKEM768, got: %s", curves) + } + if curves != "X25519MLKEM768:X25519:P-256:P-384:P-521" { + t.Errorf("non-FIPS mode curves should be X25519MLKEM768:X25519:P-256:P-384:P-521, got: %s", curves) + } + }) +} diff --git a/test/suites/optional/tls-scanner.robot b/test/suites/optional/tls-scanner.robot index 1a26973871..367ca307c8 100644 --- a/test/suites/optional/tls-scanner.robot +++ b/test/suites/optional/tls-scanner.robot @@ -49,6 +49,10 @@ TLS Scanner Host Scan Completes And Produces Artifacts ... Cleanup TLS Scanner Job ... Ensure Cluster Reader Role Deleted +Ingress Router TLS Curves supports ML-KEM Post Quantum Curves + [Documentation] Verify TLS curve negotiation with openssl from inside the router pod. + Verify ML-KEM Post Quantum Curve Negotiation + *** Keywords *** Check Required Scanner Variables @@ -123,3 +127,23 @@ Cleanup TLS Scanner Job IF '${TLS_SCANNER_DIR}' != '' Run Keyword And Ignore Error Remove Directory ${TLS_SCANNER_DIR} recursive=True END + +Verify ML-KEM Post Quantum Curve Negotiation + [Documentation] Verify X25519MLKEM768 post-quantum hybrid key exchange + ... negotiates successfully via oc exec into the router pod, which + ... has OpenSSL 3.5+ (the host OpenSSL may be too old for ML-KEM). + ... Skipped on FIPS clusters where ML-KEM is not configured. + ${curves}= Oc Get JsonPath deployment openshift-ingress router-default + ... .spec.template.spec.containers[0].env[?(@.name=="ROUTER_CURVES")].value + Skip If "X25519MLKEM768" not in """${curves}""" + ... ROUTER_CURVES does not include X25519MLKEM768 (FIPS mode); skipping ML-KEM test + ${router_ip}= Oc Get JsonPath svc openshift-ingress router-default + ... .spec.clusterIP + ${pod_name}= Oc Get JsonPath pod openshift-ingress ${EMPTY} + ... .items[0].metadata.name + ${output}= Oc Exec ${pod_name} + ... echo Q | openssl s_client -connect ${router_ip}:443 -groups X25519MLKEM768 2>&1 || true + ... ns=openshift-ingress + Should Contain ${output} Negotiated TLS1.3 group: X25519MLKEM768 + ... msg=ML-KEM post-quantum curve X25519MLKEM768 negotiation failed + Log Post-quantum ML-KEM negotiation verified: OK