diff --git a/go.mod b/go.mod index ee068633f8..742d89ee91 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/onsi/gomega v1.39.0 github.com/openshift/api v0.0.0-20251111193948-50e2ece149d7 github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235 + github.com/openshift/library-go v0.0.0-20260108135436-db8dbd64c462 github.com/operator-framework/api v0.37.0 github.com/operator-framework/operator-registry v1.61.0 github.com/otiai10/copy v1.14.1 diff --git a/go.sum b/go.sum index 3a28495536..c60ba85135 100644 --- a/go.sum +++ b/go.sum @@ -223,6 +223,7 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru/arc/v2 v2.0.7 h1:QxkVTxwColcduO+LP7eJO56r2hFiG8zEbfAAzRv52KQ= github.com/hashicorp/golang-lru/arc/v2 v2.0.7/go.mod h1:Pe7gBlGdc8clY5LJ0LpJXMt5AmgmWNH1g+oFFVUHOEc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -322,6 +323,8 @@ github.com/openshift/api v0.0.0-20251111193948-50e2ece149d7 h1:MemawsK6SpxEaE5y0 github.com/openshift/api v0.0.0-20251111193948-50e2ece149d7/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY= github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235 h1:9JBeIXmnHlpXTQPi7LPmu1jdxznBhAE7bb1K+3D8gxY= github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235/go.mod h1:L49W6pfrZkfOE5iC1PqEkuLkXG4W0BX4w8b+L2Bv7fM= +github.com/openshift/library-go v0.0.0-20260108135436-db8dbd64c462 h1:zX9Od4Jg8sVmwQLwk6Vd+BX7tcyC/462FVvDdzHEPPk= +github.com/openshift/library-go v0.0.0-20260108135436-db8dbd64c462/go.mod h1:nIzWQQE49XbiKizVnVOip9CEB7HJ0hoJwNi3g3YKnKc= github.com/operator-framework/api v0.37.0 h1:2XCMWitBnumtJTqzip6LQKUwpM2pXVlt3gkpdlkbaCE= github.com/operator-framework/api v0.37.0/go.mod h1:NZs4vB+Jiamyv3pdPDjZtuC4U7KX0eq4z2r5hKY5fUA= github.com/operator-framework/operator-registry v1.61.0 h1:LgX6lP5hUHfpMTMygsnySc7PKxibzqIoqWUm6NPWl2M= diff --git a/pkg/controller/operators/olm/operator.go b/pkg/controller/operators/olm/operator.go index ea240c5470..ad3d61784f 100644 --- a/pkg/controller/operators/olm/operator.go +++ b/pkg/controller/operators/olm/operator.go @@ -57,6 +57,7 @@ import ( "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/event" index "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/index" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/labeler" + "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/openshiftconfig" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorclient" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorlister" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/ownerutil" @@ -852,19 +853,20 @@ func newOperatorWithConfig(ctx context.Context, config *operatorConfig) (*Operat return nil, err } - // setup proxy env var injection policies + // Check if OpenShift config API is available (used by proxy and apiserver controllers) discovery := config.operatorClient.KubernetesInterface().Discovery() - proxyAPIExists, err := proxy.IsAPIAvailable(discovery) + openshiftConfigAPIExists, err := openshiftconfig.IsAPIAvailable(discovery) if err != nil { - op.logger.Errorf("error happened while probing for Proxy API support - %v", err) + op.logger.Errorf("error happened while probing for OpenShift config API support - %v", err) return nil, err } + // setup proxy env var injection policies proxyQuerierInUse := proxy.NoopQuerier() - if proxyAPIExists { + if openshiftConfigAPIExists { op.logger.Info("OpenShift Proxy API available - setting up watch for Proxy type") - proxyInformer, proxySyncer, proxyQuerier, err := proxy.NewSyncer(op.logger, config.configClient, discovery) + proxyInformer, proxySyncer, proxyQuerier, err := proxy.NewSyncer(op.logger, config.configClient) if err != nil { err = fmt.Errorf("failed to initialize syncer for Proxy type - %v", err) return nil, err diff --git a/pkg/lib/apiserver/querier.go b/pkg/lib/apiserver/querier.go new file mode 100644 index 0000000000..94963890e1 --- /dev/null +++ b/pkg/lib/apiserver/querier.go @@ -0,0 +1,35 @@ +package apiserver + +import ( + "crypto/tls" + "fmt" +) + +// NoopQuerier returns an instance of noopQuerier. It's used for upstream where +// we don't have any apiserver.config.openshift.io/cluster resource. +func NoopQuerier() Querier { + return &noopQuerier{} +} + +// Querier is an interface that wraps the QueryTLSConfig method. +// +// QueryTLSConfig updates the provided TLS configuration with cluster-wide +// TLS security profile settings (MinVersion, CipherSuites, PreferServerCipherSuites). +type Querier interface { + QueryTLSConfig(config *tls.Config) error +} + +type noopQuerier struct { +} + +// QueryTLSConfig applies secure default TLS settings to the provided config. +// This is used on non-OpenShift clusters where there is no apiserver.config.openshift.io/cluster resource, +// but we still want to ensure secure TLS configuration. +func (*noopQuerier) QueryTLSConfig(config *tls.Config) error { + if config == nil { + return fmt.Errorf("tls.Config cannot be nil") + } + + // Apply secure defaults for non-OpenShift clusters + return ApplySecureDefaults(config) +} diff --git a/pkg/lib/apiserver/querier_test.go b/pkg/lib/apiserver/querier_test.go new file mode 100644 index 0000000000..7b51fa1a9a --- /dev/null +++ b/pkg/lib/apiserver/querier_test.go @@ -0,0 +1,86 @@ +package apiserver_test + +import ( + "crypto/tls" + "testing" + + "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/apiserver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNoopQuerier_QueryTLSConfig(t *testing.T) { + tests := []struct { + name string + config *tls.Config + expectError bool + errorMsg string + }{ + { + name: "WithNilConfig", + config: nil, + expectError: true, + errorMsg: "tls.Config cannot be nil", + }, + { + name: "WithEmptyConfig", + config: &tls.Config{}, + expectError: false, + }, + { + name: "WithPartialConfig", + config: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + querier := apiserver.NoopQuerier() + err := querier.QueryTLSConfig(tt.config) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + // Verify secure defaults are applied + assert.NotZero(t, tt.config.MinVersion, "MinVersion should be set to a default") + assert.NotEmpty(t, tt.config.CipherSuites, "CipherSuites should be set to defaults") + assert.True(t, tt.config.PreferServerCipherSuites, "PreferServerCipherSuites should be true") + } + }) + } +} + +func TestNoopQuerier_AppliesSecureDefaults(t *testing.T) { + querier := apiserver.NoopQuerier() + config := &tls.Config{} + + err := querier.QueryTLSConfig(config) + require.NoError(t, err) + + // Verify secure defaults + assert.GreaterOrEqual(t, config.MinVersion, uint16(tls.VersionTLS12), "Should use at least TLS 1.2") + assert.NotEmpty(t, config.CipherSuites, "Should have cipher suites configured") + + // Verify cipher suites are valid + for _, cipher := range config.CipherSuites { + assert.NotZero(t, cipher, "Cipher suite should not be zero") + } +} + +func TestNoopQuerier_DoesNotOverwriteNonZeroMinVersion(t *testing.T) { + querier := apiserver.NoopQuerier() + config := &tls.Config{ + MinVersion: tls.VersionTLS13, + } + + err := querier.QueryTLSConfig(config) + require.NoError(t, err) + + // MinVersion should be preserved if already set + assert.Equal(t, uint16(tls.VersionTLS13), config.MinVersion, "Should preserve existing MinVersion") +} diff --git a/pkg/lib/apiserver/syncer.go b/pkg/lib/apiserver/syncer.go new file mode 100644 index 0000000000..4b3793cd62 --- /dev/null +++ b/pkg/lib/apiserver/syncer.go @@ -0,0 +1,212 @@ +package apiserver + +import ( + "crypto/tls" + "fmt" + "sync" + "time" + + "github.com/openshift/client-go/config/informers/externalversions" + + apiconfigv1 "github.com/openshift/api/config/v1" + configv1client "github.com/openshift/client-go/config/clientset/versioned" + configv1 "github.com/openshift/client-go/config/informers/externalversions/config/v1" + "github.com/sirupsen/logrus" + "k8s.io/client-go/tools/cache" +) + +const ( + // This is the cluster level global apiserver.config.openshift.io/cluster object name. + globalAPIServerName = "cluster" + + // default sync interval + defaultSyncInterval = 30 * time.Minute +) + +// NewSyncer returns informer and sync functions to enable watch of the apiserver.config.openshift.io/cluster resource. +func NewSyncer(logger *logrus.Logger, client configv1client.Interface) (apiServerInformer configv1.APIServerInformer, syncer *Syncer, querier Querier, factory externalversions.SharedInformerFactory, err error) { + factory = externalversions.NewSharedInformerFactoryWithOptions(client, defaultSyncInterval) + apiServerInformer = factory.Config().V1().APIServers() + s := &Syncer{ + logger: logger, + currentConfig: newTLSConfigHolder(), + } + + syncer = s + querier = s + return +} + +// RegisterEventHandlers registers event handlers for apiserver.config.openshift.io/cluster resource changes. +// This is a convenience function to set up Add/Update/Delete handlers that call +// the syncer's SyncAPIServer and HandleAPIServerDelete methods. +func RegisterEventHandlers(informer configv1.APIServerInformer, syncer *Syncer) { + informer.Informer().AddEventHandler(&cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + if err := syncer.SyncAPIServer(obj); err != nil { + syncer.logger.WithError(err).Error("error syncing APIServer on add") + } + }, + UpdateFunc: func(_, newObj interface{}) { + if err := syncer.SyncAPIServer(newObj); err != nil { + syncer.logger.WithError(err).Error("error syncing APIServer on update") + } + }, + DeleteFunc: func(obj interface{}) { + syncer.HandleAPIServerDelete(obj) + }, + }) +} + +// Syncer deals with watching APIServer type(s) on the cluster and let the caller +// query for cluster scoped APIServer TLS configuration. +type Syncer struct { + logger *logrus.Logger + currentConfig *tlsConfigHolder +} + +// tlsConfigHolder holds TLS configuration in a thread-safe manner. +// It always contains a valid configuration with secure defaults. +type tlsConfigHolder struct { + mu sync.RWMutex + config tls.Config +} + +// newTLSConfigHolder creates a new holder initialized with secure defaults. +func newTLSConfigHolder() *tlsConfigHolder { + h := &tlsConfigHolder{} + // Initialize with secure defaults + _ = ApplySecureDefaults(&h.config) + return h +} + +// update atomically updates the stored TLS configuration. +func (h *tlsConfigHolder) update(minVersion uint16, cipherSuites []uint16) { + h.mu.Lock() + defer h.mu.Unlock() + + h.config.MinVersion = minVersion + // Make a defensive copy of the slice + h.config.CipherSuites = make([]uint16, len(cipherSuites)) + copy(h.config.CipherSuites, cipherSuites) + h.config.PreferServerCipherSuites = true +} + +// copyTo atomically copies the cached TLS settings to the provided config. +// All reading and copying happens under the read lock, ensuring thread safety. +func (h *tlsConfigHolder) copyTo(config *tls.Config) { + h.mu.RLock() + defer h.mu.RUnlock() + + // Copy all fields while holding the lock + config.MinVersion = h.config.MinVersion + config.CipherSuites = make([]uint16, len(h.config.CipherSuites)) + copy(config.CipherSuites, h.config.CipherSuites) + config.PreferServerCipherSuites = h.config.PreferServerCipherSuites +} + +// QueryTLSConfig queries the global cluster level APIServer object and updates +// the provided TLS configuration with the cluster-wide security profile settings. +func (w *Syncer) QueryTLSConfig(config *tls.Config) error { + if config == nil { + return fmt.Errorf("tls.Config cannot be nil") + } + + // Copy the current cached config atomically + // This always succeeds because currentConfig always has a valid value + w.currentConfig.copyTo(config) + return nil +} + +// SyncAPIServer is invoked when a cluster scoped APIServer object is added or modified. +func (w *Syncer) SyncAPIServer(object interface{}) error { + apiserver, ok := object.(*apiconfigv1.APIServer) + if !ok { + w.logger.Error("wrong type in APIServer syncer") + return nil + } + + // Convert the TLS security profile to get new settings + minVersion, cipherSuites := GetSecurityProfileConfig(apiserver.Spec.TLSSecurityProfile) + + // Check if configuration changed (before updating) + changed := w.hasConfigChanged(minVersion, cipherSuites) + + // Update the stored configuration atomically + w.currentConfig.update(minVersion, cipherSuites) + + // Log if configuration changed + if changed { + profileName := getProfileName(apiserver.Spec.TLSSecurityProfile) + w.logger.Infof("APIServer TLS configuration changed: profile=%s, minVersion=%s, cipherCount=%d", + profileName, + tlsVersionToString(minVersion), + len(cipherSuites)) + } + + return nil +} + +// HandleAPIServerDelete is invoked when a cluster scoped APIServer object is deleted. +func (w *Syncer) HandleAPIServerDelete(object interface{}) { + _, ok := object.(*apiconfigv1.APIServer) + if !ok { + w.logger.Error("wrong type in APIServer delete syncer") + return + } + + // Reset to secure defaults (Intermediate profile) + w.currentConfig.update(GetSecurityProfileConfig(nil)) + + w.logger.Info("APIServer TLS configuration deleted, reverted to secure defaults") + return +} + +// hasConfigChanged checks if the new TLS settings differ from the current cached settings. +func (w *Syncer) hasConfigChanged(minVersion uint16, cipherSuites []uint16) bool { + w.currentConfig.mu.RLock() + defer w.currentConfig.mu.RUnlock() + + if w.currentConfig.config.MinVersion != minVersion { + return true + } + if len(w.currentConfig.config.CipherSuites) != len(cipherSuites) { + return true + } + for i := range cipherSuites { + if w.currentConfig.config.CipherSuites[i] != cipherSuites[i] { + return true + } + } + return false +} + +// getProfileName returns the TLS security profile name for logging. +func getProfileName(profile *apiconfigv1.TLSSecurityProfile) string { + if profile == nil { + return "Intermediate (default)" + } + + profileType := string(profile.Type) + if profileType == "" { + return "Intermediate (default)" + } + + return profileType +} + +// tlsVersionToString converts a TLS version number to a string +func tlsVersionToString(version uint16) string { + switch version { + case tls.VersionTLS10: + return "TLS 1.0" + case tls.VersionTLS11: + return "TLS 1.1" + case tls.VersionTLS12: + return "TLS 1.2" + case tls.VersionTLS13: + return "TLS 1.3" + default: + return "unknown" + } +} diff --git a/pkg/lib/apiserver/syncer_test.go b/pkg/lib/apiserver/syncer_test.go new file mode 100644 index 0000000000..282eaad3e8 --- /dev/null +++ b/pkg/lib/apiserver/syncer_test.go @@ -0,0 +1,232 @@ +package apiserver_test + +import ( + "crypto/tls" + "sync" + "testing" + + apiconfigv1 "github.com/openshift/api/config/v1" + "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/apiserver" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSyncer_QueryTLSConfig_NilConfig(t *testing.T) { + logger := logrus.New() + logger.SetOutput(logrus.StandardLogger().Out) + + syncer := &apiserver.Syncer{} + + err := syncer.QueryTLSConfig(nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "tls.Config cannot be nil") +} + +func TestSyncer_QueryTLSConfig_ReturnsDefaults(t *testing.T) { + logger := logrus.New() + logger.SetOutput(logrus.StandardLogger().Out) + + // Note: We can't easily create a Syncer directly because it requires + // a lister and currentConfig that are internal. Instead, we'll test + // that the Querier interface works as expected with NoopQuerier, + // which has similar behavior for testing purposes. + querier := apiserver.NoopQuerier() + config := &tls.Config{} + + err := querier.QueryTLSConfig(config) + require.NoError(t, err) + + // Verify defaults are applied + assert.NotZero(t, config.MinVersion, "MinVersion should be set") + assert.NotEmpty(t, config.CipherSuites, "CipherSuites should be set") + assert.True(t, config.PreferServerCipherSuites, "PreferServerCipherSuites should be true") +} + +func TestSyncer_SyncAPIServer_IntermediateProfile(t *testing.T) { + // Create a mock APIServer object with Intermediate profile + server := &apiconfigv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + }, + Spec: apiconfigv1.APIServerSpec{ + TLSSecurityProfile: &apiconfigv1.TLSSecurityProfile{ + Type: apiconfigv1.TLSProfileIntermediateType, + }, + }, + } + + // Test that GetSecurityProfileConfig returns expected values for Intermediate + minVersion, cipherSuites := apiserver.GetSecurityProfileConfig(server.Spec.TLSSecurityProfile) + + assert.Equal(t, uint16(tls.VersionTLS12), minVersion, "Intermediate should use TLS 1.2") + assert.NotEmpty(t, cipherSuites, "Should have cipher suites") + assert.Greater(t, len(cipherSuites), 5, "Intermediate should have multiple ciphers") +} + +func TestSyncer_SyncAPIServer_ModernProfile(t *testing.T) { + // Create a mock APIServer object with Modern profile + server := &apiconfigv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + }, + Spec: apiconfigv1.APIServerSpec{ + TLSSecurityProfile: &apiconfigv1.TLSSecurityProfile{ + Type: apiconfigv1.TLSProfileModernType, + }, + }, + } + + // Test that GetSecurityProfileConfig returns expected values for Modern + minVersion, cipherSuites := apiserver.GetSecurityProfileConfig(server.Spec.TLSSecurityProfile) + + assert.Equal(t, uint16(tls.VersionTLS13), minVersion, "Modern should use TLS 1.3") + assert.NotEmpty(t, cipherSuites, "Should have cipher suites") +} + +func TestSyncer_SyncAPIServer_CustomProfile(t *testing.T) { + // Create a mock APIServer object with Custom profile + server := &apiconfigv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + }, + Spec: apiconfigv1.APIServerSpec{ + TLSSecurityProfile: &apiconfigv1.TLSSecurityProfile{ + Type: apiconfigv1.TLSProfileCustomType, + Custom: &apiconfigv1.CustomTLSProfile{ + TLSProfileSpec: apiconfigv1.TLSProfileSpec{ + MinTLSVersion: apiconfigv1.VersionTLS13, + Ciphers: []string{ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + }, + }, + }, + }, + }, + } + + // Test that GetSecurityProfileConfig returns expected values for Custom + minVersion, cipherSuites := apiserver.GetSecurityProfileConfig(server.Spec.TLSSecurityProfile) + + assert.Equal(t, uint16(tls.VersionTLS13), minVersion, "Custom should respect MinTLSVersion") + assert.NotEmpty(t, cipherSuites, "Should have cipher suites") +} + +// TestConcurrentQueryTLSConfig tests thread safety of QueryTLSConfig. +// This simulates multiple goroutines reading the TLS config concurrently. +func TestConcurrentQueryTLSConfig(t *testing.T) { + querier := apiserver.NoopQuerier() + + // Run multiple goroutines concurrently + const numGoroutines = 50 + var wg sync.WaitGroup + wg.Add(numGoroutines) + + // Channel to collect errors + errors := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + defer wg.Done() + + config := &tls.Config{} + if err := querier.QueryTLSConfig(config); err != nil { + errors <- err + return + } + + // Verify the config was populated correctly + if config.MinVersion == 0 { + errors <- assert.AnError + return + } + if len(config.CipherSuites) == 0 { + errors <- assert.AnError + return + } + }() + } + + wg.Wait() + close(errors) + + // Check for any errors + for err := range errors { + t.Errorf("Concurrent query failed: %v", err) + } +} + +// TestConfigIsolation verifies that modifications to a returned config +// don't affect cached values or other callers. +func TestConfigIsolation(t *testing.T) { + querier := apiserver.NoopQuerier() + + // Get first config + config1 := &tls.Config{} + err := querier.QueryTLSConfig(config1) + require.NoError(t, err) + + originalMinVersion := config1.MinVersion + originalCipherCount := len(config1.CipherSuites) + + // Modify the first config + config1.MinVersion = tls.VersionTLS10 + config1.CipherSuites = []uint16{tls.TLS_RSA_WITH_RC4_128_SHA} + + // Get second config + config2 := &tls.Config{} + err = querier.QueryTLSConfig(config2) + require.NoError(t, err) + + // Verify the second config has the original values, not the modified ones + assert.Equal(t, originalMinVersion, config2.MinVersion, "MinVersion should not be affected by modifications to other config") + assert.Equal(t, originalCipherCount, len(config2.CipherSuites), "CipherSuites should not be affected by modifications to other config") + assert.NotEqual(t, config1.MinVersion, config2.MinVersion, "Configs should be isolated") +} + +// TestApplySecureDefaults_PreservesExistingValues tests that +// ApplySecureDefaults only sets values that are zero/empty. +func TestApplySecureDefaults_PreservesExistingValues(t *testing.T) { + config := &tls.Config{ + MinVersion: tls.VersionTLS13, + CipherSuites: []uint16{tls.TLS_AES_256_GCM_SHA384}, + } + + err := apiserver.ApplySecureDefaults(config) + require.NoError(t, err) + + // MinVersion and CipherSuites should be preserved + assert.Equal(t, uint16(tls.VersionTLS13), config.MinVersion, "Should preserve existing MinVersion") + assert.Len(t, config.CipherSuites, 1, "Should preserve existing CipherSuites") + assert.Equal(t, uint16(tls.TLS_AES_256_GCM_SHA384), config.CipherSuites[0]) + + // PreferServerCipherSuites should still be set + assert.True(t, config.PreferServerCipherSuites, "Should set PreferServerCipherSuites") +} + +// TestGetConfigForClient_CreatesFreshConfig tests that the callback +// returns a properly configured TLS config for each connection. +func TestGetConfigForClient_CreatesFreshConfig(t *testing.T) { + querier := apiserver.NoopQuerier() + callback := apiserver.GetConfigForClient(querier) + require.NotNil(t, callback) + + // Call the callback multiple times + config1, err1 := callback(nil) + require.NoError(t, err1) + require.NotNil(t, config1) + + config2, err2 := callback(nil) + require.NoError(t, err2) + require.NotNil(t, config2) + + // Each call should return a different config object + assert.NotSame(t, config1, config2, "Should return fresh config for each connection") + + // But they should have the same values + assert.Equal(t, config1.MinVersion, config2.MinVersion) + assert.Equal(t, config1.CipherSuites, config2.CipherSuites) + assert.Equal(t, config1.PreferServerCipherSuites, config2.PreferServerCipherSuites) +} diff --git a/pkg/lib/apiserver/tlsconfig.go b/pkg/lib/apiserver/tlsconfig.go new file mode 100644 index 0000000000..29a240b179 --- /dev/null +++ b/pkg/lib/apiserver/tlsconfig.go @@ -0,0 +1,105 @@ +package apiserver + +import ( + "crypto/tls" + + apiconfigv1 "github.com/openshift/api/config/v1" + "github.com/openshift/library-go/pkg/crypto" +) + +// GetSecurityProfileConfig extracts the minimum TLS version and cipher suites +// from a TLSSecurityProfile object. Converts OpenSSL cipher names to Go TLS cipher IDs. +// If profile is nil, returns config defined by the Intermediate TLS Profile. +func GetSecurityProfileConfig(profile *apiconfigv1.TLSSecurityProfile) (uint16, []uint16) { + var profileType apiconfigv1.TLSProfileType + if profile == nil { + profileType = apiconfigv1.TLSProfileIntermediateType + } else { + profileType = profile.Type + } + + var profileSpec *apiconfigv1.TLSProfileSpec + if profileType == apiconfigv1.TLSProfileCustomType { + if profile.Custom != nil { + profileSpec = &profile.Custom.TLSProfileSpec + } + } else { + profileSpec = apiconfigv1.TLSProfiles[profileType] + } + + // nothing found / custom type set but no actual custom spec + if profileSpec == nil { + profileSpec = apiconfigv1.TLSProfiles[apiconfigv1.TLSProfileIntermediateType] + } + + // Convert the TLS version string to the Go constant + minTLSVersion, err := crypto.TLSVersion(string(profileSpec.MinTLSVersion)) + if err != nil { + // Fallback to default if conversion fails + minTLSVersion = crypto.DefaultTLSVersion() + } + + // Convert OpenSSL cipher names to IANA names, then to Go cipher suite IDs + ianaCipherNames := crypto.OpenSSLToIANACipherSuites(profileSpec.Ciphers) + cipherSuites := CipherNamesToIDs(ianaCipherNames) + + return minTLSVersion, cipherSuites +} + +// CipherNamesToIDs converts IANA cipher suite names to Go TLS cipher suite IDs +func CipherNamesToIDs(cipherNames []string) []uint16 { + var cipherIDs []uint16 + + for _, name := range cipherNames { + if id, err := crypto.CipherSuite(name); err == nil { + cipherIDs = append(cipherIDs, id) + } + } + + // If no valid ciphers were found, use defaults + if len(cipherIDs) == 0 { + cipherIDs = crypto.DefaultCiphers() + } + + return cipherIDs +} + +// ApplySecureDefaults applies secure default TLS settings to the provided config. +// This ensures a minimum security baseline even when no cluster-wide profile is configured. +func ApplySecureDefaults(config *tls.Config) error { + if config.MinVersion == 0 { + config.MinVersion = crypto.DefaultTLSVersion() + } + if len(config.CipherSuites) == 0 { + config.CipherSuites = crypto.DefaultCiphers() + } + config.PreferServerCipherSuites = true + return nil +} + +// GetConfigForClient returns a GetConfigForClient callback function that can be used +// with tls.Config to provide per-connection dynamic TLS configuration updates. +// This allows the TLS settings to be updated without restarting the server. +// +// Example usage: +// +// server := &http.Server{ +// Addr: ":8443", +// TLSConfig: &tls.Config{ +// GetConfigForClient: apiserver.GetConfigForClient(querier), +// // Other settings like Certificates, ClientCAs, etc. +// }, +// } +func GetConfigForClient(querier Querier) func(*tls.ClientHelloInfo) (*tls.Config, error) { + return func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + // Create a new config for this connection + config := &tls.Config{} + + // Apply cluster-wide TLS profile settings + if err := querier.QueryTLSConfig(config); err != nil { + return nil, err + } + + return config, nil + } +} diff --git a/pkg/lib/apiserver/tlsconfig_test.go b/pkg/lib/apiserver/tlsconfig_test.go new file mode 100644 index 0000000000..1af7cbbf97 --- /dev/null +++ b/pkg/lib/apiserver/tlsconfig_test.go @@ -0,0 +1,190 @@ +package apiserver_test + +import ( + "crypto/tls" + "testing" + + apiconfigv1 "github.com/openshift/api/config/v1" + "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/apiserver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSecurityProfileConfig_NilProfile(t *testing.T) { + // When profile is nil, should use Intermediate defaults + minVersion, cipherSuites := apiserver.GetSecurityProfileConfig(nil) + + assert.Equal(t, uint16(tls.VersionTLS12), minVersion, "Intermediate profile should use TLS 1.2") + assert.NotEmpty(t, cipherSuites, "Should have cipher suites") +} + +func TestGetSecurityProfileConfig_IntermediateProfile(t *testing.T) { + profile := &apiconfigv1.TLSSecurityProfile{ + Type: apiconfigv1.TLSProfileIntermediateType, + } + + minVersion, cipherSuites := apiserver.GetSecurityProfileConfig(profile) + + assert.Equal(t, uint16(tls.VersionTLS12), minVersion, "Intermediate profile should use TLS 1.2") + assert.NotEmpty(t, cipherSuites, "Should have cipher suites") + assert.Greater(t, len(cipherSuites), 5, "Intermediate should have multiple cipher suites") +} + +func TestGetSecurityProfileConfig_ModernProfile(t *testing.T) { + profile := &apiconfigv1.TLSSecurityProfile{ + Type: apiconfigv1.TLSProfileModernType, + } + + minVersion, cipherSuites := apiserver.GetSecurityProfileConfig(profile) + + assert.Equal(t, uint16(tls.VersionTLS13), minVersion, "Modern profile should use TLS 1.3") + assert.NotEmpty(t, cipherSuites, "Should have cipher suites") +} + +func TestGetSecurityProfileConfig_OldProfile(t *testing.T) { + profile := &apiconfigv1.TLSSecurityProfile{ + Type: apiconfigv1.TLSProfileOldType, + } + + minVersion, cipherSuites := apiserver.GetSecurityProfileConfig(profile) + + assert.Equal(t, uint16(tls.VersionTLS10), minVersion, "Old profile should use TLS 1.0") + assert.NotEmpty(t, cipherSuites, "Should have cipher suites") + assert.Greater(t, len(cipherSuites), 10, "Old profile should have many cipher suites for compatibility") +} + +func TestGetSecurityProfileConfig_CustomProfile(t *testing.T) { + profile := &apiconfigv1.TLSSecurityProfile{ + Type: apiconfigv1.TLSProfileCustomType, + Custom: &apiconfigv1.CustomTLSProfile{ + TLSProfileSpec: apiconfigv1.TLSProfileSpec{ + MinTLSVersion: apiconfigv1.VersionTLS13, + Ciphers: []string{ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + }, + }, + }, + } + + minVersion, cipherSuites := apiserver.GetSecurityProfileConfig(profile) + + assert.Equal(t, uint16(tls.VersionTLS13), minVersion, "Custom profile should respect MinTLSVersion") + assert.NotEmpty(t, cipherSuites, "Should have cipher suites") +} + +func TestGetSecurityProfileConfig_CustomProfileWithoutSpec(t *testing.T) { + // Custom type but no actual custom spec should fall back to Intermediate + profile := &apiconfigv1.TLSSecurityProfile{ + Type: apiconfigv1.TLSProfileCustomType, + Custom: nil, + } + + minVersion, cipherSuites := apiserver.GetSecurityProfileConfig(profile) + + assert.Equal(t, uint16(tls.VersionTLS12), minVersion, "Should fall back to Intermediate") + assert.NotEmpty(t, cipherSuites, "Should have cipher suites") +} + +func TestApplySecureDefaults(t *testing.T) { + tests := []struct { + name string + config *tls.Config + }{ + { + name: "EmptyConfig", + config: &tls.Config{}, + }, + { + name: "ConfigWithMinVersionOnly", + config: &tls.Config{ + MinVersion: tls.VersionTLS13, + }, + }, + { + name: "ConfigWithCiphersOnly", + config: &tls.Config{ + CipherSuites: []uint16{tls.TLS_AES_256_GCM_SHA384}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := apiserver.ApplySecureDefaults(tt.config) + require.NoError(t, err) + + // Verify defaults are applied + if tt.name == "EmptyConfig" { + assert.NotZero(t, tt.config.MinVersion, "MinVersion should be set") + assert.NotEmpty(t, tt.config.CipherSuites, "CipherSuites should be set") + } + assert.True(t, tt.config.PreferServerCipherSuites, "PreferServerCipherSuites should be true") + }) + } +} + +func TestGetConfigForClient(t *testing.T) { + // Create a mock querier + querier := apiserver.NoopQuerier() + + // Get the callback function + callback := apiserver.GetConfigForClient(querier) + require.NotNil(t, callback) + + // Call the callback + config, err := callback(nil) + require.NoError(t, err) + require.NotNil(t, config) + + // Verify the config has secure defaults + assert.NotZero(t, config.MinVersion) + assert.NotEmpty(t, config.CipherSuites) + assert.True(t, config.PreferServerCipherSuites) +} + +func TestCipherNamesToIDs(t *testing.T) { + tests := []struct { + name string + cipherNames []string + expectEmpty bool + }{ + { + name: "ValidCiphers", + cipherNames: []string{ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + }, + expectEmpty: false, + }, + { + name: "EmptyCiphers", + cipherNames: []string{}, + expectEmpty: false, // Should fall back to defaults + }, + { + name: "InvalidCiphers", + cipherNames: []string{ + "INVALID_CIPHER_NAME", + }, + expectEmpty: false, // Should fall back to defaults + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cipherIDs := apiserver.CipherNamesToIDs(tt.cipherNames) + + if tt.expectEmpty { + assert.Empty(t, cipherIDs) + } else { + assert.NotEmpty(t, cipherIDs, "Should have cipher IDs (either valid or defaults)") + } + + // Verify all cipher IDs are non-zero + for _, id := range cipherIDs { + assert.NotZero(t, id, "Cipher ID should not be zero") + } + }) + } +} diff --git a/pkg/lib/proxy/available.go b/pkg/lib/openshiftconfig/available.go similarity index 73% rename from pkg/lib/proxy/available.go rename to pkg/lib/openshiftconfig/available.go index 03eff9850a..debf483a26 100644 --- a/pkg/lib/proxy/available.go +++ b/pkg/lib/openshiftconfig/available.go @@ -1,4 +1,4 @@ -package proxy +package openshiftconfig import ( "errors" @@ -14,12 +14,11 @@ const ( notSupportedErrorMessage = "server does not support API version" ) -// IsAPIAvailable return true if OpenShift config API is present on the cluster. +// IsAPIAvailable returns true if OpenShift config API is present on the cluster. // Otherwise, supported is set to false. -func IsAPIAvailable(discovery apidiscovery.DiscoveryInterface) (supported bool, err error) { +func IsAPIAvailable(discovery apidiscovery.DiscoveryInterface) (bool, error) { if discovery == nil { - err = errors.New("discovery interface can not be ") - return + return false, errors.New("discovery interface can not be ") } opStatusGV := schema.GroupVersion{ @@ -28,13 +27,11 @@ func IsAPIAvailable(discovery apidiscovery.DiscoveryInterface) (supported bool, } if discoveryErr := apidiscovery.ServerSupportsVersion(discovery, opStatusGV); discoveryErr != nil { if strings.Contains(discoveryErr.Error(), notSupportedErrorMessage) { - return + return false, nil } - err = discoveryErr - return + return false, discoveryErr } - supported = true - return + return true, nil } diff --git a/pkg/lib/proxy/syncer.go b/pkg/lib/proxy/syncer.go index b31df18bc9..6647481390 100644 --- a/pkg/lib/proxy/syncer.go +++ b/pkg/lib/proxy/syncer.go @@ -12,7 +12,6 @@ import ( "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/client-go/discovery" ) const ( @@ -24,7 +23,7 @@ const ( ) // NewSyncer returns informer and sync functions to enable watch of Proxy type. -func NewSyncer(logger *logrus.Logger, client configv1client.Interface, discovery discovery.DiscoveryInterface) (proxyInformer configv1.ProxyInformer, syncer *Syncer, querier Querier, err error) { +func NewSyncer(logger *logrus.Logger, client configv1client.Interface) (proxyInformer configv1.ProxyInformer, syncer *Syncer, querier Querier, err error) { factory := externalversions.NewSharedInformerFactoryWithOptions(client, defaultSyncInterval) proxyInformer = factory.Config().V1().Proxies() s := &Syncer{ diff --git a/pkg/lib/server/server.go b/pkg/lib/server/server.go index 90608987ea..f56622ec52 100644 --- a/pkg/lib/server/server.go +++ b/pkg/lib/server/server.go @@ -8,6 +8,7 @@ import ( "net/http" "path/filepath" + "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/apiserver" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/filemonitor" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/profile" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -58,13 +59,20 @@ func WithKubeConfig(config *rest.Config) Option { } } +func WithAPIServerTLSQuerier(querier apiserver.Querier) Option { + return func(sc *serverConfig) { + sc.apiServerTLSQuerier = querier + } +} + type serverConfig struct { - logger *logrus.Logger - tlsCertPath *string - tlsKeyPath *string - clientCAPath *string - kubeConfig *rest.Config - debug bool + logger *logrus.Logger + tlsCertPath *string + tlsKeyPath *string + clientCAPath *string + kubeConfig *rest.Config + apiServerTLSQuerier apiserver.Querier + debug bool } func (sc *serverConfig) apply(options []Option) { @@ -75,12 +83,13 @@ func (sc *serverConfig) apply(options []Option) { func defaultServerConfig() serverConfig { return serverConfig{ - tlsCertPath: nil, - tlsKeyPath: nil, - clientCAPath: nil, - kubeConfig: nil, - logger: nil, - debug: false, + tlsCertPath: nil, + tlsKeyPath: nil, + clientCAPath: nil, + kubeConfig: nil, + logger: nil, + apiServerTLSQuerier: nil, + debug: false, } } func (sc *serverConfig) tlsEnabled() (bool, error) { @@ -213,6 +222,14 @@ func (sc serverConfig) getListenAndServeFunc() (func() error, error) { tlsCfg.ClientCAs = certPoolStore.GetCertPool() tlsCfg.ClientAuth = tls.VerifyClientCertIfGiven } + + // Overlay cluster-wide TLS security profile settings if available + if sc.apiServerTLSQuerier != nil { + if err := sc.apiServerTLSQuerier.QueryTLSConfig(tlsCfg); err != nil { + sc.logger.WithError(err).Warn("Failed to query APIServer TLS config, using defaults") + } + } + return tlsCfg, nil }, NextProtos: []string{"http/1.1"}, // Disable HTTP/2 for security diff --git a/vendor/github.com/openshift/library-go/LICENSE b/vendor/github.com/openshift/library-go/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/vendor/github.com/openshift/library-go/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/vendor/github.com/openshift/library-go/pkg/crypto/OWNERS b/vendor/github.com/openshift/library-go/pkg/crypto/OWNERS new file mode 100644 index 0000000000..4d4ce5ab9e --- /dev/null +++ b/vendor/github.com/openshift/library-go/pkg/crypto/OWNERS @@ -0,0 +1,4 @@ +reviewers: + - stlaz +approvers: + - stlaz diff --git a/vendor/github.com/openshift/library-go/pkg/crypto/crypto.go b/vendor/github.com/openshift/library-go/pkg/crypto/crypto.go new file mode 100644 index 0000000000..bff6155c2f --- /dev/null +++ b/vendor/github.com/openshift/library-go/pkg/crypto/crypto.go @@ -0,0 +1,1214 @@ +package crypto + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + mathrand "math/rand" + "net" + "os" + "path/filepath" + "reflect" + "sort" + "strconv" + "sync" + "time" + + "k8s.io/klog/v2" + + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/util/cert" +) + +// TLS versions that are known to golang. Go 1.13 adds support for +// TLS 1.3 that's opt-out with a build flag. +var versions = map[string]uint16{ + "VersionTLS10": tls.VersionTLS10, + "VersionTLS11": tls.VersionTLS11, + "VersionTLS12": tls.VersionTLS12, + "VersionTLS13": tls.VersionTLS13, +} + +// TLS versions that are enabled. +var supportedVersions = map[string]uint16{ + "VersionTLS10": tls.VersionTLS10, + "VersionTLS11": tls.VersionTLS11, + "VersionTLS12": tls.VersionTLS12, + "VersionTLS13": tls.VersionTLS13, +} + +// TLSVersionToNameOrDie given a tls version as an int, return its readable name +func TLSVersionToNameOrDie(intVal uint16) string { + matches := []string{} + for key, version := range versions { + if version == intVal { + matches = append(matches, key) + } + } + + if len(matches) == 0 { + panic(fmt.Sprintf("no name found for %d", intVal)) + } + if len(matches) > 1 { + panic(fmt.Sprintf("multiple names found for %d: %v", intVal, matches)) + } + return matches[0] +} + +func TLSVersion(versionName string) (uint16, error) { + if len(versionName) == 0 { + return DefaultTLSVersion(), nil + } + if version, ok := versions[versionName]; ok { + return version, nil + } + return 0, fmt.Errorf("unknown tls version %q", versionName) +} +func TLSVersionOrDie(versionName string) uint16 { + version, err := TLSVersion(versionName) + if err != nil { + panic(err) + } + return version +} + +// TLS versions that are known to golang, but may not necessarily be enabled. +func GolangTLSVersions() []string { + supported := []string{} + for k := range versions { + supported = append(supported, k) + } + sort.Strings(supported) + return supported +} + +// Returns the build enabled TLS versions. +func ValidTLSVersions() []string { + validVersions := []string{} + for k := range supportedVersions { + validVersions = append(validVersions, k) + } + sort.Strings(validVersions) + return validVersions +} +func DefaultTLSVersion() uint16 { + // Can't use SSLv3 because of POODLE and BEAST + // Can't use TLSv1.0 because of POODLE and BEAST using CBC cipher + // Can't use TLSv1.1 because of RC4 cipher usage + return tls.VersionTLS12 +} + +var ciphers = map[string]uint16{ + "TLS_RSA_WITH_RC4_128_SHA": tls.TLS_RSA_WITH_RC4_128_SHA, + "TLS_RSA_WITH_3DES_EDE_CBC_SHA": tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, + "TLS_RSA_WITH_AES_128_CBC_SHA": tls.TLS_RSA_WITH_AES_128_CBC_SHA, + "TLS_RSA_WITH_AES_256_CBC_SHA": tls.TLS_RSA_WITH_AES_256_CBC_SHA, + "TLS_RSA_WITH_AES_128_CBC_SHA256": tls.TLS_RSA_WITH_AES_128_CBC_SHA256, + "TLS_RSA_WITH_AES_128_GCM_SHA256": tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + "TLS_RSA_WITH_AES_256_GCM_SHA384": tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA": tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, + "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA": tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA": tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + "TLS_ECDHE_RSA_WITH_RC4_128_SHA": tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA, + "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA": tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA": tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA": tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256": tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256": tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256": tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384": tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384": tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305": tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305": tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256": tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + "TLS_AES_128_GCM_SHA256": tls.TLS_AES_128_GCM_SHA256, + "TLS_AES_256_GCM_SHA384": tls.TLS_AES_256_GCM_SHA384, + "TLS_CHACHA20_POLY1305_SHA256": tls.TLS_CHACHA20_POLY1305_SHA256, +} + +// openSSLToIANACiphersMap maps OpenSSL cipher suite names to IANA names +// ref: https://www.iana.org/assignments/tls-parameters/tls-parameters.xml +var openSSLToIANACiphersMap = map[string]string{ + // TLS 1.3 ciphers - not configurable in go 1.13, all of them are used in TLSv1.3 flows + "TLS_AES_128_GCM_SHA256": "TLS_AES_128_GCM_SHA256", // 0x13,0x01 + "TLS_AES_256_GCM_SHA384": "TLS_AES_256_GCM_SHA384", // 0x13,0x02 + "TLS_CHACHA20_POLY1305_SHA256": "TLS_CHACHA20_POLY1305_SHA256", // 0x13,0x03 + + // TLS 1.2 + "ECDHE-ECDSA-AES128-GCM-SHA256": "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", // 0xC0,0x2B + "ECDHE-RSA-AES128-GCM-SHA256": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", // 0xC0,0x2F + "ECDHE-ECDSA-AES256-GCM-SHA384": "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", // 0xC0,0x2C + "ECDHE-RSA-AES256-GCM-SHA384": "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", // 0xC0,0x30 + "ECDHE-ECDSA-CHACHA20-POLY1305": "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", // 0xCC,0xA9 + "ECDHE-RSA-CHACHA20-POLY1305": "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", // 0xCC,0xA8 + "ECDHE-ECDSA-AES128-SHA256": "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", // 0xC0,0x23 + "ECDHE-RSA-AES128-SHA256": "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", // 0xC0,0x27 + "AES128-GCM-SHA256": "TLS_RSA_WITH_AES_128_GCM_SHA256", // 0x00,0x9C + "AES256-GCM-SHA384": "TLS_RSA_WITH_AES_256_GCM_SHA384", // 0x00,0x9D + "AES128-SHA256": "TLS_RSA_WITH_AES_128_CBC_SHA256", // 0x00,0x3C + + // TLS 1 + "ECDHE-ECDSA-AES128-SHA": "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", // 0xC0,0x09 + "ECDHE-RSA-AES128-SHA": "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", // 0xC0,0x13 + "ECDHE-ECDSA-AES256-SHA": "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", // 0xC0,0x0A + "ECDHE-RSA-AES256-SHA": "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", // 0xC0,0x14 + + // SSL 3 + "AES128-SHA": "TLS_RSA_WITH_AES_128_CBC_SHA", // 0x00,0x2F + "AES256-SHA": "TLS_RSA_WITH_AES_256_CBC_SHA", // 0x00,0x35 + "DES-CBC3-SHA": "TLS_RSA_WITH_3DES_EDE_CBC_SHA", // 0x00,0x0A +} + +// CipherSuitesToNamesOrDie given a list of cipher suites as ints, return their readable names +func CipherSuitesToNamesOrDie(intVals []uint16) []string { + ret := []string{} + for _, intVal := range intVals { + ret = append(ret, CipherSuiteToNameOrDie(intVal)) + } + + return ret +} + +// CipherSuiteToNameOrDie given a cipher suite as an int, return its readable name +func CipherSuiteToNameOrDie(intVal uint16) string { + // The following suite ids appear twice in the cipher map (with + // and without the _SHA256 suffix) for the purposes of backwards + // compatibility. Always return the current rather than the legacy + // name. + switch intVal { + case tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: + return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" + case tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: + return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" + } + + matches := []string{} + for key, version := range ciphers { + if version == intVal { + matches = append(matches, key) + } + } + + if len(matches) == 0 { + panic(fmt.Sprintf("no name found for %d", intVal)) + } + if len(matches) > 1 { + panic(fmt.Sprintf("multiple names found for %d: %v", intVal, matches)) + } + return matches[0] +} + +func CipherSuite(cipherName string) (uint16, error) { + if cipher, ok := ciphers[cipherName]; ok { + return cipher, nil + } + + return 0, fmt.Errorf("unknown cipher name %q", cipherName) +} + +func CipherSuitesOrDie(cipherNames []string) []uint16 { + if len(cipherNames) == 0 { + return DefaultCiphers() + } + cipherValues := []uint16{} + for _, cipherName := range cipherNames { + cipher, err := CipherSuite(cipherName) + if err != nil { + panic(err) + } + cipherValues = append(cipherValues, cipher) + } + return cipherValues +} +func ValidCipherSuites() []string { + validCipherSuites := []string{} + for k := range ciphers { + validCipherSuites = append(validCipherSuites, k) + } + sort.Strings(validCipherSuites) + return validCipherSuites +} + +// DefaultCiphers returns the default cipher suites for TLS connections. +// +// RECOMMENDATION: Instead of relying on this function directly, consumers should respect +// TLSSecurityProfile settings from one of the OpenShift API configuration resources: +// - For API servers: Use apiserver.config.openshift.io/cluster Spec.TLSSecurityProfile +// - For ingress controllers: Use operator.openshift.io/v1 IngressController Spec.TLSSecurityProfile +// - For kubelet: Use machineconfiguration.openshift.io/v1 KubeletConfig Spec.TLSSecurityProfile +// +// These API resources allow cluster administrators to choose between Old, Intermediate, +// Modern, or Custom TLS profiles. Components should observe these settings. +func DefaultCiphers() []uint16 { + // Aligned with intermediate profile of the 5.7 version of the Mozilla Server + // Side TLS guidelines found at: https://ssl-config.mozilla.org/guidelines/5.7.json + // + // Latest guidelines: https://ssl-config.mozilla.org/guidelines/latest.json + // + // This profile provides strong security with wide compatibility. + // It requires TLS 1.2+ and uses only AEAD cipher suites (GCM, ChaCha20-Poly1305) + // with ECDHE key exchange for perfect forward secrecy. + // + // All CBC-mode ciphers have been removed due to padding oracle vulnerabilities. + // All RSA key exchange ciphers have been removed due to lack of perfect forward secrecy. + // + // HTTP/2 compliance: All ciphers are compliant with RFC7540, section 9.2. + return []uint16{ + // TLS 1.2 cipher suites with ECDHE + AEAD + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, // required by HTTP/2 + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + + // TLS 1.3 cipher suites (negotiated automatically, not configurable) + tls.TLS_AES_128_GCM_SHA256, + tls.TLS_AES_256_GCM_SHA384, + tls.TLS_CHACHA20_POLY1305_SHA256, + } +} + +// SecureTLSConfig enforces the default minimum security settings for the cluster. +func SecureTLSConfig(config *tls.Config) *tls.Config { + if config.MinVersion == 0 { + config.MinVersion = DefaultTLSVersion() + } + + config.PreferServerCipherSuites = true + if len(config.CipherSuites) == 0 { + config.CipherSuites = DefaultCiphers() + } + return config +} + +// OpenSSLToIANACipherSuites maps input OpenSSL Cipher Suite names to their +// IANA counterparts. +// Unknown ciphers are left out. +func OpenSSLToIANACipherSuites(ciphers []string) []string { + ianaCiphers := make([]string, 0, len(ciphers)) + + for _, c := range ciphers { + ianaCipher, found := openSSLToIANACiphersMap[c] + if found { + ianaCiphers = append(ianaCiphers, ianaCipher) + } + } + + return ianaCiphers +} + +type TLSCertificateConfig struct { + Certs []*x509.Certificate + Key crypto.PrivateKey +} + +type TLSCARoots struct { + Roots []*x509.Certificate +} + +func (c *TLSCertificateConfig) WriteCertConfigFile(certFile, keyFile string) error { + // ensure parent dir + if err := os.MkdirAll(filepath.Dir(certFile), os.FileMode(0755)); err != nil { + return err + } + certFileWriter, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(keyFile), os.FileMode(0755)); err != nil { + return err + } + keyFileWriter, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + + if err := writeCertificates(certFileWriter, c.Certs...); err != nil { + return err + } + if err := writeKeyFile(keyFileWriter, c.Key); err != nil { + return err + } + + if err := certFileWriter.Close(); err != nil { + return err + } + if err := keyFileWriter.Close(); err != nil { + return err + } + + return nil +} + +func (c *TLSCertificateConfig) WriteCertConfig(certFile, keyFile io.Writer) error { + if err := writeCertificates(certFile, c.Certs...); err != nil { + return err + } + if err := writeKeyFile(keyFile, c.Key); err != nil { + return err + } + return nil +} + +func (c *TLSCertificateConfig) GetPEMBytes() ([]byte, []byte, error) { + certBytes, err := EncodeCertificates(c.Certs...) + if err != nil { + return nil, nil, err + } + keyBytes, err := EncodeKey(c.Key) + if err != nil { + return nil, nil, err + } + + return certBytes, keyBytes, nil +} + +func GetTLSCertificateConfig(certFile, keyFile string) (*TLSCertificateConfig, error) { + if len(certFile) == 0 { + return nil, errors.New("certFile missing") + } + if len(keyFile) == 0 { + return nil, errors.New("keyFile missing") + } + + certPEMBlock, err := os.ReadFile(certFile) + if err != nil { + return nil, err + } + certs, err := cert.ParseCertsPEM(certPEMBlock) + if err != nil { + return nil, fmt.Errorf("error reading %s: %s", certFile, err) + } + + keyPEMBlock, err := os.ReadFile(keyFile) + if err != nil { + return nil, err + } + keyPairCert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock) + if err != nil { + return nil, err + } + key := keyPairCert.PrivateKey + + return &TLSCertificateConfig{certs, key}, nil +} + +func GetTLSCertificateConfigFromBytes(certBytes, keyBytes []byte) (*TLSCertificateConfig, error) { + if len(certBytes) == 0 { + return nil, errors.New("certFile missing") + } + if len(keyBytes) == 0 { + return nil, errors.New("keyFile missing") + } + + certs, err := cert.ParseCertsPEM(certBytes) + if err != nil { + return nil, fmt.Errorf("error reading cert: %s", err) + } + + keyPairCert, err := tls.X509KeyPair(certBytes, keyBytes) + if err != nil { + return nil, err + } + key := keyPairCert.PrivateKey + + return &TLSCertificateConfig{certs, key}, nil +} + +const ( + DefaultCertificateLifetimeDuration = time.Hour * 24 * 365 * 2 // 2 years + DefaultCACertificateLifetimeDuration = time.Hour * 24 * 365 * 5 // 5 years + + // Default keys are 2048 bits + keyBits = 2048 +) + +type CA struct { + Config *TLSCertificateConfig + + SerialGenerator SerialGenerator +} + +// SerialGenerator is an interface for getting a serial number for the cert. It MUST be thread-safe. +type SerialGenerator interface { + Next(template *x509.Certificate) (int64, error) +} + +// SerialFileGenerator returns a unique, monotonically increasing serial number and ensures the CA on disk records that value. +type SerialFileGenerator struct { + SerialFile string + + // lock guards access to the Serial field + lock sync.Mutex + Serial int64 +} + +func NewSerialFileGenerator(serialFile string) (*SerialFileGenerator, error) { + // read serial file, it must already exist + serial, err := fileToSerial(serialFile) + if err != nil { + return nil, err + } + + generator := &SerialFileGenerator{ + Serial: serial, + SerialFile: serialFile, + } + + // 0 is unused and 1 is reserved for the CA itself + // Thus we need to guarantee that the first external call to SerialFileGenerator.Next returns 2+ + // meaning that SerialFileGenerator.Serial must not be less than 1 (it is guaranteed to be non-negative) + if generator.Serial < 1 { + // fake a call to Next so the file stays in sync and Serial is incremented + if _, err := generator.Next(&x509.Certificate{}); err != nil { + return nil, err + } + } + + return generator, nil +} + +// Next returns a unique, monotonically increasing serial number and ensures the CA on disk records that value. +func (s *SerialFileGenerator) Next(template *x509.Certificate) (int64, error) { + s.lock.Lock() + defer s.lock.Unlock() + + // do a best effort check to make sure concurrent external writes are not occurring to the underlying serial file + serial, err := fileToSerial(s.SerialFile) + if err != nil { + return 0, err + } + if serial != s.Serial { + return 0, fmt.Errorf("serial file %s out of sync ram=%d disk=%d", s.SerialFile, s.Serial, serial) + } + + next := s.Serial + 1 + s.Serial = next + + // Output in hex, padded to multiples of two characters for OpenSSL's sake + serialText := fmt.Sprintf("%X", next) + if len(serialText)%2 == 1 { + serialText = "0" + serialText + } + // always add a newline at the end to have a valid file + serialText += "\n" + + if err := os.WriteFile(s.SerialFile, []byte(serialText), os.FileMode(0640)); err != nil { + return 0, err + } + return next, nil +} + +func fileToSerial(serialFile string) (int64, error) { + serialData, err := os.ReadFile(serialFile) + if err != nil { + return 0, err + } + + // read the file as a single hex number after stripping any whitespace + serial, err := strconv.ParseInt(string(bytes.TrimSpace(serialData)), 16, 64) + if err != nil { + return 0, err + } + + if serial < 0 { + return 0, fmt.Errorf("invalid negative serial %d in serial file %s", serial, serialFile) + } + + return serial, nil +} + +// RandomSerialGenerator returns a serial based on time.Now and the subject +type RandomSerialGenerator struct { +} + +func (s *RandomSerialGenerator) Next(template *x509.Certificate) (int64, error) { + return randomSerialNumber(), nil +} + +// randomSerialNumber returns a random int64 serial number based on +// time.Now. It is defined separately from the generator interface so +// that the caller doesn't have to worry about an input template or +// error - these are unnecessary when creating a random serial. +func randomSerialNumber() int64 { + r := mathrand.New(mathrand.NewSource(time.Now().UTC().UnixNano())) + return r.Int63() +} + +// EnsureCA returns a CA, whether it was created (as opposed to pre-existing), and any error +// if serialFile is empty, a RandomSerialGenerator will be used +func EnsureCA(certFile, keyFile, serialFile, name string, lifetime time.Duration) (*CA, bool, error) { + if ca, err := GetCA(certFile, keyFile, serialFile); err == nil { + return ca, false, err + } + ca, err := MakeSelfSignedCA(certFile, keyFile, serialFile, name, lifetime) + return ca, true, err +} + +// if serialFile is empty, a RandomSerialGenerator will be used +func GetCA(certFile, keyFile, serialFile string) (*CA, error) { + caConfig, err := GetTLSCertificateConfig(certFile, keyFile) + if err != nil { + return nil, err + } + + var serialGenerator SerialGenerator + if len(serialFile) > 0 { + serialGenerator, err = NewSerialFileGenerator(serialFile) + if err != nil { + return nil, err + } + } else { + serialGenerator = &RandomSerialGenerator{} + } + + return &CA{ + SerialGenerator: serialGenerator, + Config: caConfig, + }, nil +} + +func GetCAFromBytes(certBytes, keyBytes []byte) (*CA, error) { + caConfig, err := GetTLSCertificateConfigFromBytes(certBytes, keyBytes) + if err != nil { + return nil, err + } + + return &CA{ + SerialGenerator: &RandomSerialGenerator{}, + Config: caConfig, + }, nil +} + +// if serialFile is empty, a RandomSerialGenerator will be used +func MakeSelfSignedCA(certFile, keyFile, serialFile, name string, lifetime time.Duration) (*CA, error) { + klog.V(2).Infof("Generating new CA for %s cert, and key in %s, %s", name, certFile, keyFile) + + caConfig, err := MakeSelfSignedCAConfig(name, lifetime) + if err != nil { + return nil, err + } + if err := caConfig.WriteCertConfigFile(certFile, keyFile); err != nil { + return nil, err + } + + var serialGenerator SerialGenerator + if len(serialFile) > 0 { + // create / overwrite the serial file with a zero padded hex value (ending in a newline to have a valid file) + if err := os.WriteFile(serialFile, []byte("00\n"), 0644); err != nil { + return nil, err + } + serialGenerator, err = NewSerialFileGenerator(serialFile) + if err != nil { + return nil, err + } + } else { + serialGenerator = &RandomSerialGenerator{} + } + + return &CA{ + SerialGenerator: serialGenerator, + Config: caConfig, + }, nil +} + +func MakeSelfSignedCAConfig(name string, lifetime time.Duration) (*TLSCertificateConfig, error) { + subject := pkix.Name{CommonName: name} + return MakeSelfSignedCAConfigForSubject(subject, lifetime) +} + +func MakeSelfSignedCAConfigForSubject(subject pkix.Name, lifetime time.Duration) (*TLSCertificateConfig, error) { + if lifetime <= 0 { + lifetime = DefaultCACertificateLifetimeDuration + fmt.Fprintf(os.Stderr, "Validity period of the certificate for %q is unset, resetting to %s!\n", subject.CommonName, lifetime.String()) + } + + if lifetime > DefaultCACertificateLifetimeDuration { + warnAboutCertificateLifeTime(subject.CommonName, DefaultCACertificateLifetimeDuration) + } + return makeSelfSignedCAConfigForSubjectAndDuration(subject, time.Now, lifetime) +} + +func MakeSelfSignedCAConfigForDuration(name string, caLifetime time.Duration) (*TLSCertificateConfig, error) { + subject := pkix.Name{CommonName: name} + return makeSelfSignedCAConfigForSubjectAndDuration(subject, time.Now, caLifetime) +} + +func UnsafeMakeSelfSignedCAConfigForDurationAtTime(name string, currentTime func() time.Time, caLifetime time.Duration) (*TLSCertificateConfig, error) { + subject := pkix.Name{CommonName: name} + return makeSelfSignedCAConfigForSubjectAndDuration(subject, currentTime, caLifetime) +} + +func makeSelfSignedCAConfigForSubjectAndDuration(subject pkix.Name, currentTime func() time.Time, caLifetime time.Duration) (*TLSCertificateConfig, error) { + // Create CA cert + rootcaPublicKey, rootcaPrivateKey, publicKeyHash, err := newKeyPairWithHash() + if err != nil { + return nil, err + } + // AuthorityKeyId and SubjectKeyId should match for a self-signed CA + authorityKeyId := publicKeyHash + subjectKeyId := publicKeyHash + rootcaTemplate := newSigningCertificateTemplateForDuration(subject, caLifetime, currentTime, authorityKeyId, subjectKeyId) + rootcaCert, err := signCertificate(rootcaTemplate, rootcaPublicKey, rootcaTemplate, rootcaPrivateKey) + if err != nil { + return nil, err + } + caConfig := &TLSCertificateConfig{ + Certs: []*x509.Certificate{rootcaCert}, + Key: rootcaPrivateKey, + } + return caConfig, nil +} + +func MakeCAConfigForDuration(name string, caLifetime time.Duration, issuer *CA) (*TLSCertificateConfig, error) { + // Create CA cert + signerPublicKey, signerPrivateKey, publicKeyHash, err := newKeyPairWithHash() + if err != nil { + return nil, err + } + authorityKeyId := issuer.Config.Certs[0].SubjectKeyId + subjectKeyId := publicKeyHash + signerTemplate := newSigningCertificateTemplateForDuration(pkix.Name{CommonName: name}, caLifetime, time.Now, authorityKeyId, subjectKeyId) + signerCert, err := issuer.SignCertificate(signerTemplate, signerPublicKey) + if err != nil { + return nil, err + } + signerConfig := &TLSCertificateConfig{ + Certs: append([]*x509.Certificate{signerCert}, issuer.Config.Certs...), + Key: signerPrivateKey, + } + return signerConfig, nil +} + +// EnsureSubCA returns a subCA signed by the `ca`, whether it was created +// (as opposed to pre-existing), and any error that might occur during the subCA +// creation. +// If serialFile is an empty string, a RandomSerialGenerator will be used. +func (ca *CA) EnsureSubCA(certFile, keyFile, serialFile, name string, lifetime time.Duration) (*CA, bool, error) { + if subCA, err := GetCA(certFile, keyFile, serialFile); err == nil { + return subCA, false, err + } + subCA, err := ca.MakeAndWriteSubCA(certFile, keyFile, serialFile, name, lifetime) + return subCA, true, err +} + +// MakeAndWriteSubCA returns a new sub-CA configuration. New cert/key pair is generated +// while using this function. +// If serialFile is an empty string, a RandomSerialGenerator will be used. +func (ca *CA) MakeAndWriteSubCA(certFile, keyFile, serialFile, name string, lifetime time.Duration) (*CA, error) { + klog.V(4).Infof("Generating sub-CA certificate in %s, key in %s, serial in %s", certFile, keyFile, serialFile) + + subCAConfig, err := MakeCAConfigForDuration(name, lifetime, ca) + if err != nil { + return nil, err + } + + if err := subCAConfig.WriteCertConfigFile(certFile, keyFile); err != nil { + return nil, err + } + + var serialGenerator SerialGenerator + if len(serialFile) > 0 { + // create / overwrite the serial file with a zero padded hex value (ending in a newline to have a valid file) + if err := os.WriteFile(serialFile, []byte("00\n"), 0644); err != nil { + return nil, err + } + + serialGenerator, err = NewSerialFileGenerator(serialFile) + if err != nil { + return nil, err + } + } else { + serialGenerator = &RandomSerialGenerator{} + } + + return &CA{ + Config: subCAConfig, + SerialGenerator: serialGenerator, + }, nil +} + +func (ca *CA) EnsureServerCert(certFile, keyFile string, hostnames sets.Set[string], lifetime time.Duration) (*TLSCertificateConfig, bool, error) { + certConfig, err := GetServerCert(certFile, keyFile, hostnames) + if err != nil { + certConfig, err = ca.MakeAndWriteServerCert(certFile, keyFile, hostnames, lifetime) + return certConfig, true, err + } + + return certConfig, false, nil +} + +func GetServerCert(certFile, keyFile string, hostnames sets.Set[string]) (*TLSCertificateConfig, error) { + server, err := GetTLSCertificateConfig(certFile, keyFile) + if err != nil { + return nil, err + } + + cert := server.Certs[0] + certNames := sets.New[string]() + for _, ip := range cert.IPAddresses { + certNames.Insert(ip.String()) + } + certNames.Insert(cert.DNSNames...) + if hostnames.Equal(certNames) { + klog.V(4).Infof("Found existing server certificate in %s", certFile) + return server, nil + } + + return nil, fmt.Errorf("existing server certificate in %s does not match required hostnames", certFile) +} + +func (ca *CA) MakeAndWriteServerCert(certFile, keyFile string, hostnames sets.Set[string], lifetime time.Duration) (*TLSCertificateConfig, error) { + klog.V(4).Infof("Generating server certificate in %s, key in %s", certFile, keyFile) + + server, err := ca.MakeServerCert(hostnames, lifetime) + if err != nil { + return nil, err + } + if err := server.WriteCertConfigFile(certFile, keyFile); err != nil { + return server, err + } + return server, nil +} + +// CertificateExtensionFunc is passed a certificate that it may extend, or return an error +// if the extension attempt failed. +type CertificateExtensionFunc func(*x509.Certificate) error + +func (ca *CA) MakeServerCert(hostnames sets.Set[string], lifetime time.Duration, fns ...CertificateExtensionFunc) (*TLSCertificateConfig, error) { + serverPublicKey, serverPrivateKey, publicKeyHash, _ := newKeyPairWithHash() + authorityKeyId := ca.Config.Certs[0].SubjectKeyId + subjectKeyId := publicKeyHash + serverTemplate := newServerCertificateTemplate(pkix.Name{CommonName: sets.List(hostnames)[0]}, sets.List(hostnames), lifetime, time.Now, authorityKeyId, subjectKeyId) + for _, fn := range fns { + if err := fn(serverTemplate); err != nil { + return nil, err + } + } + serverCrt, err := ca.SignCertificate(serverTemplate, serverPublicKey) + if err != nil { + return nil, err + } + server := &TLSCertificateConfig{ + Certs: append([]*x509.Certificate{serverCrt}, ca.Config.Certs...), + Key: serverPrivateKey, + } + return server, nil +} + +func (ca *CA) MakeServerCertForDuration(hostnames sets.Set[string], lifetime time.Duration, fns ...CertificateExtensionFunc) (*TLSCertificateConfig, error) { + serverPublicKey, serverPrivateKey, publicKeyHash, _ := newKeyPairWithHash() + authorityKeyId := ca.Config.Certs[0].SubjectKeyId + subjectKeyId := publicKeyHash + serverTemplate := newServerCertificateTemplateForDuration(pkix.Name{CommonName: sets.List(hostnames)[0]}, sets.List(hostnames), lifetime, time.Now, authorityKeyId, subjectKeyId) + for _, fn := range fns { + if err := fn(serverTemplate); err != nil { + return nil, err + } + } + serverCrt, err := ca.SignCertificate(serverTemplate, serverPublicKey) + if err != nil { + return nil, err + } + server := &TLSCertificateConfig{ + Certs: append([]*x509.Certificate{serverCrt}, ca.Config.Certs...), + Key: serverPrivateKey, + } + return server, nil +} + +func (ca *CA) EnsureClientCertificate(certFile, keyFile string, u user.Info, lifetime time.Duration) (*TLSCertificateConfig, bool, error) { + certConfig, err := GetClientCertificate(certFile, keyFile, u) + if err != nil { + certConfig, err = ca.MakeClientCertificate(certFile, keyFile, u, lifetime) + return certConfig, true, err // true indicates we wrote the files. + } + return certConfig, false, nil +} + +func GetClientCertificate(certFile, keyFile string, u user.Info) (*TLSCertificateConfig, error) { + certConfig, err := GetTLSCertificateConfig(certFile, keyFile) + if err != nil { + return nil, err + } + + if subject := certConfig.Certs[0].Subject; subjectChanged(subject, UserToSubject(u)) { + return nil, fmt.Errorf("existing client certificate in %s was issued for a different Subject (%s)", + certFile, subject) + } + + return certConfig, nil +} + +func subjectChanged(existing, expected pkix.Name) bool { + sort.Strings(existing.Organization) + sort.Strings(expected.Organization) + + return existing.CommonName != expected.CommonName || + existing.SerialNumber != expected.SerialNumber || + !reflect.DeepEqual(existing.Organization, expected.Organization) +} + +func (ca *CA) MakeClientCertificate(certFile, keyFile string, u user.Info, lifetime time.Duration) (*TLSCertificateConfig, error) { + klog.V(4).Infof("Generating client cert in %s and key in %s", certFile, keyFile) + // ensure parent dirs + if err := os.MkdirAll(filepath.Dir(certFile), os.FileMode(0755)); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(keyFile), os.FileMode(0755)); err != nil { + return nil, err + } + + clientPublicKey, clientPrivateKey, _ := NewKeyPair() + clientTemplate := NewClientCertificateTemplate(UserToSubject(u), lifetime, time.Now) + clientCrt, err := ca.SignCertificate(clientTemplate, clientPublicKey) + if err != nil { + return nil, err + } + + certData, err := EncodeCertificates(clientCrt) + if err != nil { + return nil, err + } + keyData, err := EncodeKey(clientPrivateKey) + if err != nil { + return nil, err + } + + if err = os.WriteFile(certFile, certData, os.FileMode(0644)); err != nil { + return nil, err + } + if err = os.WriteFile(keyFile, keyData, os.FileMode(0600)); err != nil { + return nil, err + } + + return GetTLSCertificateConfig(certFile, keyFile) +} + +func (ca *CA) MakeClientCertificateForDuration(u user.Info, lifetime time.Duration) (*TLSCertificateConfig, error) { + clientPublicKey, clientPrivateKey, _ := NewKeyPair() + clientTemplate := NewClientCertificateTemplateForDuration(UserToSubject(u), lifetime, time.Now) + clientCrt, err := ca.SignCertificate(clientTemplate, clientPublicKey) + if err != nil { + return nil, err + } + + certData, err := EncodeCertificates(clientCrt) + if err != nil { + return nil, err + } + keyData, err := EncodeKey(clientPrivateKey) + if err != nil { + return nil, err + } + + return GetTLSCertificateConfigFromBytes(certData, keyData) +} + +type sortedForDER []string + +func (s sortedForDER) Len() int { + return len(s) +} +func (s sortedForDER) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} +func (s sortedForDER) Less(i, j int) bool { + l1 := len(s[i]) + l2 := len(s[j]) + if l1 == l2 { + return s[i] < s[j] + } + return l1 < l2 +} + +func UserToSubject(u user.Info) pkix.Name { + // Ok we are going to order groups in a peculiar way here to workaround a + // 2 bugs, 1 in golang (https://github.com/golang/go/issues/24254) which + // incorrectly encodes Multivalued RDNs and another in GNUTLS clients + // which are too picky (https://gitlab.com/gnutls/gnutls/issues/403) + // and try to "correct" this issue when reading client certs. + // + // This workaround should be killed once Golang's pkix module is fixed to + // generate a correct DER encoding. + // + // The workaround relies on the fact that the first octect that differs + // between the encoding of two group RDNs will end up being the encoded + // length which is directly related to the group name's length. So we'll + // sort such that shortest names come first. + ugroups := u.GetGroups() + groups := make([]string, len(ugroups)) + copy(groups, ugroups) + sort.Sort(sortedForDER(groups)) + + return pkix.Name{ + CommonName: u.GetName(), + SerialNumber: u.GetUID(), + Organization: groups, + } +} + +func (ca *CA) SignCertificate(template *x509.Certificate, requestKey crypto.PublicKey) (*x509.Certificate, error) { + // Increment and persist serial + serial, err := ca.SerialGenerator.Next(template) + if err != nil { + return nil, err + } + template.SerialNumber = big.NewInt(serial) + return signCertificate(template, requestKey, ca.Config.Certs[0], ca.Config.Key) +} + +func NewKeyPair() (crypto.PublicKey, crypto.PrivateKey, error) { + return newRSAKeyPair() +} + +func newKeyPairWithHash() (crypto.PublicKey, crypto.PrivateKey, []byte, error) { + publicKey, privateKey, err := newRSAKeyPair() + var publicKeyHash []byte + if err == nil { + hash := sha1.New() + hash.Write(publicKey.N.Bytes()) + publicKeyHash = hash.Sum(nil) + } + return publicKey, privateKey, publicKeyHash, err +} + +func newRSAKeyPair() (*rsa.PublicKey, *rsa.PrivateKey, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, keyBits) + if err != nil { + return nil, nil, err + } + return &privateKey.PublicKey, privateKey, nil +} + +// Can be used for CA or intermediate signing certs +func newSigningCertificateTemplateForDuration(subject pkix.Name, caLifetime time.Duration, currentTime func() time.Time, authorityKeyId, subjectKeyId []byte) *x509.Certificate { + return &x509.Certificate{ + Subject: subject, + + SignatureAlgorithm: x509.SHA256WithRSA, + + NotBefore: currentTime().Add(-1 * time.Second), + NotAfter: currentTime().Add(caLifetime), + + // Specify a random serial number to avoid the same issuer+serial + // number referring to different certs in a chain of trust if the + // signing certificate is ever rotated. + SerialNumber: big.NewInt(randomSerialNumber()), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + + AuthorityKeyId: authorityKeyId, + SubjectKeyId: subjectKeyId, + } +} + +// Can be used for ListenAndServeTLS +func newServerCertificateTemplate(subject pkix.Name, hosts []string, lifetime time.Duration, currentTime func() time.Time, authorityKeyId, subjectKeyId []byte) *x509.Certificate { + if lifetime <= 0 { + lifetime = DefaultCertificateLifetimeDuration + fmt.Fprintf(os.Stderr, "Validity period of the certificate for %q is unset, resetting to %s!\n", subject.CommonName, lifetime.String()) + } + + if lifetime > DefaultCertificateLifetimeDuration { + warnAboutCertificateLifeTime(subject.CommonName, DefaultCertificateLifetimeDuration) + } + + return newServerCertificateTemplateForDuration(subject, hosts, lifetime, currentTime, authorityKeyId, subjectKeyId) +} + +// Can be used for ListenAndServeTLS +func newServerCertificateTemplateForDuration(subject pkix.Name, hosts []string, lifetime time.Duration, currentTime func() time.Time, authorityKeyId, subjectKeyId []byte) *x509.Certificate { + template := &x509.Certificate{ + Subject: subject, + + SignatureAlgorithm: x509.SHA256WithRSA, + + NotBefore: currentTime().Add(-1 * time.Second), + NotAfter: currentTime().Add(lifetime), + SerialNumber: big.NewInt(1), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + AuthorityKeyId: authorityKeyId, + SubjectKeyId: subjectKeyId, + } + + template.IPAddresses, template.DNSNames = IPAddressesDNSNames(hosts) + + return template +} + +func IPAddressesDNSNames(hosts []string) ([]net.IP, []string) { + ips := []net.IP{} + dns := []string{} + for _, host := range hosts { + if ip := net.ParseIP(host); ip != nil { + ips = append(ips, ip) + } else { + dns = append(dns, host) + } + } + + // Include IP addresses as DNS subjectAltNames in the cert as well, for the sake of Python, Windows (< 10), and unnamed other libraries + // Ensure these technically invalid DNS subjectAltNames occur after the valid ones, to avoid triggering cert errors in Firefox + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1148766 + for _, ip := range ips { + dns = append(dns, ip.String()) + } + + return ips, dns +} + +func CertsFromPEM(pemCerts []byte) ([]*x509.Certificate, error) { + ok := false + certs := []*x509.Certificate{} + for len(pemCerts) > 0 { + var block *pem.Block + block, pemCerts = pem.Decode(pemCerts) + if block == nil { + break + } + if block.Type != "CERTIFICATE" || len(block.Headers) != 0 { + continue + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return certs, err + } + + certs = append(certs, cert) + ok = true + } + + if !ok { + return certs, errors.New("could not read any certificates") + } + return certs, nil +} + +// Can be used as a certificate in http.Transport TLSClientConfig +func NewClientCertificateTemplate(subject pkix.Name, lifetime time.Duration, currentTime func() time.Time) *x509.Certificate { + if lifetime <= 0 { + lifetime = DefaultCertificateLifetimeDuration + fmt.Fprintf(os.Stderr, "Validity period of the certificate for %q is unset, resetting to %s!\n", subject.CommonName, lifetime.String()) + } + + if lifetime > DefaultCertificateLifetimeDuration { + warnAboutCertificateLifeTime(subject.CommonName, DefaultCertificateLifetimeDuration) + } + + return NewClientCertificateTemplateForDuration(subject, lifetime, currentTime) +} + +// Can be used as a certificate in http.Transport TLSClientConfig +func NewClientCertificateTemplateForDuration(subject pkix.Name, lifetime time.Duration, currentTime func() time.Time) *x509.Certificate { + return &x509.Certificate{ + Subject: subject, + + SignatureAlgorithm: x509.SHA256WithRSA, + + NotBefore: currentTime().Add(-1 * time.Second), + NotAfter: currentTime().Add(lifetime), + SerialNumber: big.NewInt(1), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } +} + +func warnAboutCertificateLifeTime(name string, defaultLifetimeDuration time.Duration) { + defaultLifetimeInYears := defaultLifetimeDuration / 365 / 24 + fmt.Fprintf(os.Stderr, "WARNING: Validity period of the certificate for %q is greater than %d years!\n", name, defaultLifetimeInYears) + fmt.Fprintln(os.Stderr, "WARNING: By security reasons it is strongly recommended to change this period and make it smaller!") +} + +func signCertificate(template *x509.Certificate, requestKey crypto.PublicKey, issuer *x509.Certificate, issuerKey crypto.PrivateKey) (*x509.Certificate, error) { + derBytes, err := x509.CreateCertificate(rand.Reader, template, issuer, requestKey, issuerKey) + if err != nil { + return nil, err + } + certs, err := x509.ParseCertificates(derBytes) + if err != nil { + return nil, err + } + if len(certs) != 1 { + return nil, errors.New("expected a single certificate") + } + return certs[0], nil +} + +func EncodeCertificates(certs ...*x509.Certificate) ([]byte, error) { + b := bytes.Buffer{} + for _, cert := range certs { + if err := pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}); err != nil { + return []byte{}, err + } + } + return b.Bytes(), nil +} +func EncodeKey(key crypto.PrivateKey) ([]byte, error) { + b := bytes.Buffer{} + switch key := key.(type) { + case *ecdsa.PrivateKey: + keyBytes, err := x509.MarshalECPrivateKey(key) + if err != nil { + return []byte{}, err + } + if err := pem.Encode(&b, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil { + return b.Bytes(), err + } + case *rsa.PrivateKey: + if err := pem.Encode(&b, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}); err != nil { + return []byte{}, err + } + default: + return []byte{}, errors.New("unrecognized key type") + + } + return b.Bytes(), nil +} + +func writeCertificates(f io.Writer, certs ...*x509.Certificate) error { + bytes, err := EncodeCertificates(certs...) + if err != nil { + return err + } + if _, err := f.Write(bytes); err != nil { + return err + } + + return nil +} +func writeKeyFile(f io.Writer, key crypto.PrivateKey) error { + bytes, err := EncodeKey(key) + if err != nil { + return err + } + if _, err := f.Write(bytes); err != nil { + return err + } + + return nil +} diff --git a/vendor/github.com/openshift/library-go/pkg/crypto/rotation.go b/vendor/github.com/openshift/library-go/pkg/crypto/rotation.go new file mode 100644 index 0000000000..0aa127037c --- /dev/null +++ b/vendor/github.com/openshift/library-go/pkg/crypto/rotation.go @@ -0,0 +1,20 @@ +package crypto + +import ( + "crypto/x509" + "time" +) + +// FilterExpiredCerts checks are all certificates in the bundle valid, i.e. they have not expired. +// The function returns new bundle with only valid certificates or error if no valid certificate is found. +func FilterExpiredCerts(certs ...*x509.Certificate) []*x509.Certificate { + currentTime := time.Now() + var validCerts []*x509.Certificate + for _, c := range certs { + if c.NotAfter.After(currentTime) { + validCerts = append(validCerts, c) + } + } + + return validCerts +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 7474a81cf4..91dcd6e0df 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -489,6 +489,9 @@ github.com/openshift/client-go/config/informers/externalversions/internalinterfa github.com/openshift/client-go/config/listers/config/v1 github.com/openshift/client-go/config/listers/config/v1alpha1 github.com/openshift/client-go/config/listers/config/v1alpha2 +# github.com/openshift/library-go v0.0.0-20260108135436-db8dbd64c462 +## explicit; go 1.24.0 +github.com/openshift/library-go/pkg/crypto # github.com/operator-framework/api v0.37.0 ## explicit; go 1.24.6 github.com/operator-framework/api/crds