From 7b40982138dd518afc0cc824c70575bbc359b30b Mon Sep 17 00:00:00 2001 From: Fabien Dupont Date: Wed, 4 Feb 2026 18:55:48 +0100 Subject: [PATCH] Add ECDSA certificate generation support via annotation Adds support for generating ECDSA P-256 certificates by specifying the key algorithm via the new service annotation: service.beta.openshift.io/serving-cert-key-algorithm: ecdsa When the annotation is not specified or set to "rsa", the operator generates RSA certificates for full backwards compatibility. This enables services to opt into modern elliptic curve cryptography, which provides equivalent security to 3072-bit RSA with significantly smaller keys (~87% smaller) and better performance. Implementation: - Added ServingCertKeyAlgorithmAnnotation constant in api.go - Modified MakeServingCert() to check annotation and select algorithm - Uses library-go's new MakeServerCertWithAlgorithm() API - Validates annotation values (rsa, ecdsa) with helpful error messages - Case-insensitive algorithm matching - Wired ECDSA CA support: caConfig now accepts a KeyAlgorithm field, initializeSigningSecret passes algorithm to CA creation - CA rotation supports both RSA and ECDSA keys: removed RSA-only type assertion, rotateSigningCA and createIntermediateCACert accept crypto.PrivateKey, algorithm is detected and preserved on rotation Testing: - Added TestECDSACertificateGeneration with 5 test cases - Verifies RSA (default and explicit), ECDSA, and invalid inputs - Added e2e tests: ECDSA cert provisioning, invalid algorithm rejection, and ECDSA cert regeneration after corruption - All existing tests pass with no regressions Depends on: openshift/library-go#2116 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fabien Dupont --- pkg/controller/api/api.go | 3 + .../controller/secret_creating_controller.go | 21 +- .../secret_creating_controller_test.go | 142 +++++++++ pkg/operator/config.go | 17 +- pkg/operator/rotate.go | 36 ++- pkg/operator/sync_common.go | 12 +- pkg/operator/sync_common_test.go | 4 +- test/e2e/e2e.go | 233 +++++++++++++++ test/e2e/e2e_test.go | 2 +- .../openshift/library-go/pkg/crypto/crypto.go | 273 +++++++++++++----- 10 files changed, 657 insertions(+), 86 deletions(-) diff --git a/pkg/controller/api/api.go b/pkg/controller/api/api.go index 5aedd4fff..674545ee7 100644 --- a/pkg/controller/api/api.go +++ b/pkg/controller/api/api.go @@ -51,6 +51,9 @@ const ( // regeneration ServingCertCreatedByAnnotation = "service.beta.openshift.io/serving-cert-signed-by" AlphaServingCertCreatedByAnnotation = "service.alpha.openshift.io/serving-cert-signed-by" + // ServingCertKeyAlgorithmAnnotation specifies the key algorithm to use (rsa or ecdsa). + // If not specified, defaults to RSA for backwards compatibility. + ServingCertKeyAlgorithmAnnotation = "service.beta.openshift.io/serving-cert-key-algorithm" // ServingCertErrorAnnotation stores the error that caused cert generation failures. ServingCertErrorAnnotation = "service.beta.openshift.io/serving-cert-generation-error" AlphaServingCertErrorAnnotation = "service.alpha.openshift.io/serving-cert-generation-error" diff --git a/pkg/controller/servingcert/controller/secret_creating_controller.go b/pkg/controller/servingcert/controller/secret_creating_controller.go index abe30d4a2..ea7b9d8b7 100644 --- a/pkg/controller/servingcert/controller/secret_creating_controller.go +++ b/pkg/controller/servingcert/controller/secret_creating_controller.go @@ -151,7 +151,7 @@ func (sc *serviceServingCertController) generateCert(ctx context.Context, servic secret := toBaseSecret(serviceCopy) if err := toRequiredSecret(sc.dnsSuffix, sc.ca, sc.intermediateCACert, serviceCopy, secret, sc.certificateLifetime); err != nil { - return err + return sc.updateServiceFailure(ctx, serviceCopy, err) } setSecretOwnerDescription(secret, serviceCopy) @@ -381,9 +381,26 @@ func certSubjectsForService(service *corev1.Service, dnsSuffix string) sets.Set[ func MakeServingCert(dnsSuffix string, ca *crypto.CA, intermediateCACert *x509.Certificate, service *corev1.Service, lifetime time.Duration) (*crypto.TLSCertificateConfig, error) { subjects := certSubjectsForService(service, dnsSuffix) - servingCert, err := ca.MakeServerCert( + + // Check for key algorithm annotation + algorithm := crypto.AlgorithmRSA // Default to RSA for backwards compatibility + if service.Annotations != nil { + if algoStr := service.Annotations[api.ServingCertKeyAlgorithmAnnotation]; algoStr != "" { + switch strings.ToLower(algoStr) { + case "ecdsa": + algorithm = crypto.AlgorithmECDSA + case "rsa": + algorithm = crypto.AlgorithmRSA + default: + return nil, fmt.Errorf("invalid key algorithm %q, must be 'rsa' or 'ecdsa'", algoStr) + } + } + } + + servingCert, err := ca.MakeServerCertWithAlgorithm( subjects, lifetime, + algorithm, cryptoextensions.ServiceServerCertificateExtensionV1(service.UID), ) if err != nil { diff --git a/pkg/controller/servingcert/controller/secret_creating_controller_test.go b/pkg/controller/servingcert/controller/secret_creating_controller_test.go index 2dfe1cd1c..cecd819fd 100644 --- a/pkg/controller/servingcert/controller/secret_creating_controller_test.go +++ b/pkg/controller/servingcert/controller/secret_creating_controller_test.go @@ -10,6 +10,7 @@ import ( "reflect" "strconv" "testing" + "time" corev1 "k8s.io/api/core/v1" kapierrors "k8s.io/apimachinery/pkg/api/errors" @@ -462,6 +463,24 @@ func TestServiceServingCertControllerSync(t *testing.T) { secretAnnotations: map[string]string{}, secretData: []byte(testCertUnknownIssuer), }, + { + name: "invalid key algorithm annotation", + secretName: testSecretName, + serviceAnnotations: map[string]string{ + api.ServingCertSecretAnnotation: testSecretName, + api.ServingCertKeyAlgorithmAnnotation: "invalid-algo", + }, + expectedServiceAnnotations: map[string]string{ + api.ServingCertSecretAnnotation: testSecretName, + api.ServingCertKeyAlgorithmAnnotation: "invalid-algo", + api.ServingCertErrorAnnotation: "invalid key algorithm \"invalid-algo\", must be 'rsa' or 'ecdsa'", + api.AlphaServingCertErrorAnnotation: "invalid key algorithm \"invalid-algo\", must be 'rsa' or 'ecdsa'", + api.ServingCertErrorNumAnnotation: "1", + api.AlphaServingCertErrorNumAnnotation: "1", + }, + updateService: true, + updateSecret: false, // Secret should not be created + }, } for _, tt := range tests { @@ -783,3 +802,126 @@ func newTestSyncContext(queueKey string) factory.SyncContext { eventRecorder: events.NewInMemoryRecorder("test", clock.RealClock{}), } } + +// TestECDSACertificateGeneration tests that ECDSA certificates can be generated via annotation +func TestECDSACertificateGeneration(t *testing.T) { + dnsSuffix := "cluster.local" + caLifetime := 365 * 24 * time.Hour + certLifetime := 180 * 24 * time.Hour + + caDir := t.TempDir() + ca, _, err := crypto.EnsureCA( + path.Join(caDir, "test-ca.crt"), + path.Join(caDir, "test-ca.key"), + path.Join(caDir, "test-ca.serial"), + signerName, + caLifetime, + ) + if err != nil { + t.Fatalf("failed to create CA: %v", err) + } + + tests := []struct { + name string + algorithmAnnotation string + expectedKeyType string + expectError bool + }{ + { + name: "RSA certificate (default)", + algorithmAnnotation: "", + expectedKeyType: "RSA", + expectError: false, + }, + { + name: "RSA certificate (explicit)", + algorithmAnnotation: "rsa", + expectedKeyType: "RSA", + expectError: false, + }, + { + name: "ECDSA certificate", + algorithmAnnotation: "ecdsa", + expectedKeyType: "ECDSA", + expectError: false, + }, + { + name: "ECDSA certificate (case insensitive)", + algorithmAnnotation: "ECDSA", + expectedKeyType: "ECDSA", + expectError: false, + }, + { + name: "Invalid algorithm", + algorithmAnnotation: "invalid", + expectedKeyType: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: testServiceName, + Namespace: testNamespace, + UID: testServiceUID, + Annotations: map[string]string{ + api.ServingCertSecretAnnotation: testSecretName, + }, + }, + } + + if tt.algorithmAnnotation != "" { + service.Annotations[api.ServingCertKeyAlgorithmAnnotation] = tt.algorithmAnnotation + } + + servingCert, err := MakeServingCert(dnsSuffix, ca, nil, service, certLifetime) + + if tt.expectError { + if err == nil { + t.Errorf("expected error, got none") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify certificate was generated + if servingCert == nil { + t.Fatal("servingCert is nil") + } + + if len(servingCert.Certs) == 0 { + t.Fatal("no certificates generated") + } + + // Verify key type + cert := servingCert.Certs[0] + switch tt.expectedKeyType { + case "RSA": + if cert.PublicKeyAlgorithm != x509.RSA { + t.Errorf("expected RSA public key algorithm, got %v", cert.PublicKeyAlgorithm) + } + case "ECDSA": + if cert.PublicKeyAlgorithm != x509.ECDSA { + t.Errorf("expected ECDSA public key algorithm, got %v", cert.PublicKeyAlgorithm) + } + } + + // Verify certificate subjects include service name + foundServiceName := false + for _, dnsName := range cert.DNSNames { + if dnsName == fmt.Sprintf("%s.%s.svc", testServiceName, testNamespace) { + foundServiceName = true + break + } + } + if !foundServiceName { + t.Errorf("certificate DNS names %v do not include expected service name", cert.DNSNames) + } + }) + } +} diff --git a/pkg/operator/config.go b/pkg/operator/config.go index 9d54f1664..fc47c204a 100644 --- a/pkg/operator/config.go +++ b/pkg/operator/config.go @@ -2,7 +2,10 @@ package operator import ( "encoding/json" + "strings" "time" + + "github.com/openshift/library-go/pkg/crypto" ) type unsupportedServiceCAConfig struct { @@ -19,6 +22,17 @@ type caConfig struct { // months. // +optional ValidityDurationForTesting time.Duration `json:"validityDurationForTesting"` + // keyAlgorithm specifies the key algorithm to use for the CA + // (rsa or ecdsa). Defaults to RSA if unspecified. + // +optional + KeyAlgorithm string `json:"keyAlgorithm"` +} + +func (c caConfig) cryptoKeyAlgorithm() crypto.KeyAlgorithm { + if strings.EqualFold(c.KeyAlgorithm, "ecdsa") { + return crypto.AlgorithmECDSA + } + return crypto.AlgorithmRSA } type forceRotationConfig struct { @@ -42,10 +56,11 @@ func loadUnsupportedServiceCAConfig(raw []byte) (unsupportedServiceCAConfig, err // RawUnsupportedServiceCAConfig returns the raw value of the operator // field UnsupportedConfigOverrides for the given force rotation // reason. -func RawUnsupportedServiceCAConfig(reason string, duration time.Duration) ([]byte, error) { +func RawUnsupportedServiceCAConfig(reason string, duration time.Duration, algorithm string) ([]byte, error) { config := &unsupportedServiceCAConfig{ CAConfig: caConfig{ ValidityDurationForTesting: duration, + KeyAlgorithm: algorithm, }, ForceRotation: forceRotationConfig{ Reason: reason, diff --git a/pkg/operator/rotate.go b/pkg/operator/rotate.go index 2c951e8d8..7a842d2ad 100644 --- a/pkg/operator/rotate.go +++ b/pkg/operator/rotate.go @@ -1,6 +1,8 @@ package operator import ( + stdcrypto "crypto" + "crypto/ecdsa" "crypto/rsa" "crypto/x509" "fmt" @@ -82,12 +84,7 @@ func maybeRotateSigningSecret(secret *corev1.Secret, currentCACert *x509.Certifi return "", fmt.Errorf("failed to parse private key from PEM: %v", err) } - rsaKey, ok := key.(*rsa.PrivateKey) - if !ok { - return "", fmt.Errorf("expected RSA private key, got %T", key) - } - - signingCA, err := rotateSigningCA(currentCACert, rsaKey, minimumTrustDuration, signingCertificateLifetime) + signingCA, err := rotateSigningCA(currentCACert, key.(stdcrypto.PrivateKey), minimumTrustDuration, signingCertificateLifetime) if err != nil { return "", err } @@ -111,9 +108,14 @@ func maybeRotateSigningSecret(secret *corev1.Secret, currentCACert *x509.Certifi // rotateSigningCA creates a new signing CA, bundle and intermediate CA that together can // be used to ensure that serving certs generated both before and after rotation can be // trusted by both refreshed and unrefreshed consumers. -func rotateSigningCA(currentCACert *x509.Certificate, currentKey *rsa.PrivateKey, minimumTrustDuration time.Duration, signingCertificateLifetime time.Duration) (*signingCA, error) { - // Generate a new signing cert - newCAConfig, err := crypto.MakeSelfSignedCAConfigForSubject(currentCACert.Subject, signingCertificateLifetime) +func rotateSigningCA(currentCACert *x509.Certificate, currentKey stdcrypto.PrivateKey, minimumTrustDuration time.Duration, signingCertificateLifetime time.Duration) (*signingCA, error) { + // Generate a new signing cert with the same algorithm as the current key + algorithm, err := algorithmFromKey(currentKey) + if err != nil { + return nil, err + } + newCAConfig, err := crypto.MakeSelfSignedCAConfigForDurationWithAlgorithm( + currentCACert.Subject.CommonName, signingCertificateLifetime, algorithm) if err != nil { return nil, err } @@ -131,7 +133,7 @@ func rotateSigningCA(currentCACert *x509.Certificate, currentKey *rsa.PrivateKey // generated by the current CA for inclusion in the new CA bundle. This will ensure // that clients with a post-rotation ca bundle will be able to trust pre-rotation // serving certs. - currentCACertSignedByNewCA, err := createIntermediateCACert(currentCACert, newCACert, newCAConfig.Key.(*rsa.PrivateKey), currentCACertExpiry) + currentCACertSignedByNewCA, err := createIntermediateCACert(currentCACert, newCACert, newCAConfig.Key, currentCACertExpiry) if err != nil { return nil, fmt.Errorf("failed to create intermediate certificate signed by new ca: %v", err) } @@ -161,7 +163,7 @@ func rotateSigningCA(currentCACert *x509.Certificate, currentKey *rsa.PrivateKey // createIntermediateCACert creates a new intermediate CA cert from a template provided by // the target CA cert and issued by the signing cert. This ensures that certificates // issued by the target CA can be trusted by clients that trust the signing CA. -func createIntermediateCACert(targetCACert, signingCACert *x509.Certificate, signingKey *rsa.PrivateKey, expiry *time.Time) (*x509.Certificate, error) { +func createIntermediateCACert(targetCACert, signingCACert *x509.Certificate, signingKey stdcrypto.PrivateKey, expiry *time.Time) (*x509.Certificate, error) { // Copy the target cert to allow modification. template, err := x509.ParseCertificate(targetCACert.Raw) if err != nil { @@ -196,6 +198,18 @@ func createIntermediateCACert(targetCACert, signingCACert *x509.Certificate, sig return caCert, nil } +// algorithmFromKey detects the key algorithm from a private key. +func algorithmFromKey(key stdcrypto.PrivateKey) (crypto.KeyAlgorithm, error) { + switch key.(type) { + case *rsa.PrivateKey: + return crypto.AlgorithmRSA, nil + case *ecdsa.PrivateKey: + return crypto.AlgorithmECDSA, nil + default: + return crypto.AlgorithmRSA, fmt.Errorf("unsupported private key type %T", key) + } +} + // forcedRotationRequired indicates whether the force rotation reason is not empty and // does not match the annotation stored on the signing secret. func forcedRotationRequired(secret *corev1.Secret, reason string) bool { diff --git a/pkg/operator/sync_common.go b/pkg/operator/sync_common.go index 1d4b2c2dd..1aaf48ddf 100644 --- a/pkg/operator/sync_common.go +++ b/pkg/operator/sync_common.go @@ -107,7 +107,7 @@ func (c *serviceCAOperator) manageSignerCA(ctx context.Context, rawUnsupportedSe if existingCert == nil { // Secret does not exist or lacks the expected cert. validityDuration := serviceCAConfig.CAConfig.ValidityDurationForTesting - if err := initializeSigningSecret(secret, validityDuration, c.signingCertificateLifetime); err != nil { + if err := initializeSigningSecret(secret, validityDuration, c.signingCertificateLifetime, serviceCAConfig.CAConfig.cryptoKeyAlgorithm()); err != nil { return false, err } } else { @@ -142,11 +142,17 @@ func (c *serviceCAOperator) manageSignerCA(ctx context.Context, rawUnsupportedSe // PEM-encoded certificate and private key of a new self-signed // CA. The duration, if non-zero, will be used to set the // expiry of the CA. -func initializeSigningSecret(secret *corev1.Secret, duration time.Duration, lifetime time.Duration) error { +func initializeSigningSecret(secret *corev1.Secret, duration time.Duration, lifetime time.Duration, algorithm crypto.KeyAlgorithm) error { name := serviceServingCertSignerName() klog.V(4).Infof("generating signing CA: %s", name) - ca, err := crypto.MakeSelfSignedCAConfig(name, lifetime) + var ca *crypto.TLSCertificateConfig + var err error + if algorithm == crypto.AlgorithmECDSA { + ca, err = crypto.MakeSelfSignedCAConfigForDurationWithAlgorithm(name, lifetime, algorithm) + } else { + ca, err = crypto.MakeSelfSignedCAConfig(name, lifetime) + } if err != nil { return err } diff --git a/pkg/operator/sync_common_test.go b/pkg/operator/sync_common_test.go index 8f4393122..6d15e517a 100644 --- a/pkg/operator/sync_common_test.go +++ b/pkg/operator/sync_common_test.go @@ -45,7 +45,9 @@ func TestInitializeSigningSecret(t *testing.T) { t.Run(testName, func(t *testing.T) { now := time.Now() secret := &corev1.Secret{} - initializeSigningSecret(secret, 0, tc.duration) + if err := initializeSigningSecret(secret, 0, tc.duration, crypto.AlgorithmRSA); err != nil { + t.Fatalf("initializeSigningSecret failed: %v", err) + } // Check that the initialized key pair is valid rawCert := secret.Data[corev1.TLSCertKey] diff --git a/test/e2e/e2e.go b/test/e2e/e2e.go index e6adf2e2b..868cc5ba6 100644 --- a/test/e2e/e2e.go +++ b/test/e2e/e2e.go @@ -9,6 +9,7 @@ import ( "math/rand" "os" "reflect" + "strings" "testing" "time" @@ -94,6 +95,24 @@ var _ = g.Describe("[sig-service-ca] service-ca-operator", func() { }) }) + g.Context("serving-cert-annotation-ecdsa", func() { + g.It("[Operator][Serial] should provision ECDSA certificates for services", func() { + testServingCertAnnotationWithAlgorithm(g.GinkgoTB(), "ecdsa") + }) + }) + + g.Context("serving-cert-annotation-invalid-algorithm", func() { + g.It("[Operator][Serial] should reject invalid algorithm annotation", func() { + testServingCertAnnotationInvalidAlgorithm(g.GinkgoTB()) + }) + }) + + g.Context("serving-cert-secret-regeneration-ecdsa", func() { + g.It("[Operator][Serial] should regenerate deleted ECDSA serving cert secrets", func() { + testServingCertSecretDeleteDataWithAlgorithm(g.GinkgoTB()) + }) + }) + g.Context("metrics", func() { g.It("[Operator][Serial] should collect metrics from the operator", func() { testMetricsCollection(g.GinkgoTB()) @@ -147,6 +166,220 @@ func testServingCertAnnotation(t testing.TB, headless bool) { } } +// testServingCertAnnotationWithAlgorithm checks that services with the +// serving-cert annotation and a key algorithm annotation get TLS certificates +// provisioned with the specified algorithm. +func testServingCertAnnotationWithAlgorithm(t testing.TB, algorithm string) { + adminClient, err := getKubeClient() + if err != nil { + t.Fatalf("error getting kube client: %v", err) + } + + ns, cleanup, err := createTestNamespace(t, adminClient, "test-"+randSeq(5)) + if err != nil { + t.Fatalf("could not create test namespace: %v", err) + } + defer cleanup() + + testServiceName := "test-service-" + randSeq(5) + testSecretName := "test-secret-" + randSeq(5) + + err = createServingCertAnnotatedServiceWithAlgorithm(adminClient, testSecretName, testServiceName, ns.Name, algorithm) + if err != nil { + t.Fatalf("error creating annotated service: %v", err) + } + + err = pollForServiceServingSecret(adminClient, testSecretName, ns.Name) + if err != nil { + t.Fatalf("error fetching created serving cert secret: %v", err) + } + + certBytes, is509, err := checkServiceServingCertSecretData(adminClient, testSecretName, ns.Name) + if err != nil { + t.Fatalf("error when checking serving cert secret: %v", err) + } + if !is509 { + t.Fatalf("TLSCertKey not valid pem bytes") + } + + block, _ := pem.Decode(certBytes) + if block == nil { + t.Fatalf("failed to decode PEM block from cert") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate: %v", err) + } + var expected x509.PublicKeyAlgorithm + switch { + case strings.EqualFold(algorithm, "ecdsa"): + expected = x509.ECDSA + case strings.EqualFold(algorithm, "rsa"): + expected = x509.RSA + default: + t.Fatalf("unsupported algorithm for test expectation: %q", algorithm) + } + if cert.PublicKeyAlgorithm != expected { + t.Fatalf("expected %v public key algorithm, got %v", expected, cert.PublicKeyAlgorithm) + } + if len(cert.DNSNames) == 0 { + t.Fatalf("expected DNS names in certificate, got none") + } +} + +// testServingCertAnnotationInvalidAlgorithm checks that an invalid algorithm +// annotation causes an error annotation on the service and the secret is not created. +func testServingCertAnnotationInvalidAlgorithm(t testing.TB) { + adminClient, err := getKubeClient() + if err != nil { + t.Fatalf("error getting kube client: %v", err) + } + + ns, cleanup, err := createTestNamespace(t, adminClient, "test-"+randSeq(5)) + if err != nil { + t.Fatalf("could not create test namespace: %v", err) + } + defer cleanup() + + testServiceName := "test-service-" + randSeq(5) + testSecretName := "test-secret-" + randSeq(5) + + err = createServingCertAnnotatedServiceWithAlgorithm(adminClient, testSecretName, testServiceName, ns.Name, "invalid-algo") + if err != nil { + t.Fatalf("error creating annotated service: %v", err) + } + + // Poll for the error annotation on the service + err = wait.PollImmediate(time.Second, pollTimeout, func() (bool, error) { + svc, err := adminClient.CoreV1().Services(ns.Name).Get(context.TODO(), testServiceName, metav1.GetOptions{}) + if err != nil { + return false, err + } + errAnnotation := svc.Annotations[api.ServingCertErrorAnnotation] + return len(errAnnotation) > 0, nil + }) + if err != nil { + t.Fatalf("error waiting for error annotation on service: %v", err) + } + + // Verify the secret is not created over a short stabilization window. + err = wait.PollImmediate(time.Second, 10*time.Second, func() (bool, error) { + _, err := adminClient.CoreV1().Secrets(ns.Name).Get(context.TODO(), testSecretName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, err + } + return false, fmt.Errorf("secret %s/%s was unexpectedly created", ns.Name, testSecretName) + }) + if err != nil && err != wait.ErrWaitTimeout { + t.Fatalf("invalid algorithm should not produce a secret: %v", err) + } +} + +// testServingCertSecretDeleteDataWithAlgorithm checks that corrupting the +// tls.crt data of an ECDSA serving cert secret triggers regeneration and +// the regenerated cert is still ECDSA. +func testServingCertSecretDeleteDataWithAlgorithm(t testing.TB) { + adminClient, err := getKubeClient() + if err != nil { + t.Fatalf("error getting kube client: %v", err) + } + + ns, cleanup, err := createTestNamespace(t, adminClient, "test-"+randSeq(5)) + if err != nil { + t.Fatalf("could not create test namespace: %v", err) + } + defer cleanup() + + testServiceName := "test-service-" + randSeq(5) + testSecretName := "test-secret-" + randSeq(5) + + err = createServingCertAnnotatedServiceWithAlgorithm(adminClient, testSecretName, testServiceName, ns.Name, "ecdsa") + if err != nil { + t.Fatalf("error creating annotated service: %v", err) + } + + err = pollForServiceServingSecret(adminClient, testSecretName, ns.Name) + if err != nil { + t.Fatalf("error fetching created serving cert secret: %v", err) + } + + certBytes, is509, err := checkServiceServingCertSecretData(adminClient, testSecretName, ns.Name) + if err != nil { + t.Fatalf("error when checking serving cert secret: %v", err) + } + if !is509 { + t.Fatalf("TLSCertKey not valid pem bytes") + } + + // Verify initial cert is ECDSA + block, _ := pem.Decode(certBytes) + if block == nil { + t.Fatalf("failed to decode PEM block from cert") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate: %v", err) + } + if cert.PublicKeyAlgorithm != x509.ECDSA { + t.Fatalf("expected ECDSA public key algorithm, got %v", cert.PublicKeyAlgorithm) + } + + // Corrupt the tls.crt data to trigger regeneration + err = editServingSecretDataGinkgo(t, adminClient, testSecretName, ns.Name, "tls.crt") + if err != nil { + t.Fatalf("error editing serving cert secret: %v", err) + } + + // Verify regenerated cert is still ECDSA + regeneratedBytes, is509, err := checkServiceServingCertSecretData(adminClient, testSecretName, ns.Name) + if err != nil { + t.Fatalf("error when checking regenerated serving cert secret: %v", err) + } + if !is509 { + t.Fatalf("regenerated TLSCertKey not valid pem bytes") + } + + block, _ = pem.Decode(regeneratedBytes) + if block == nil { + t.Fatalf("failed to decode PEM block from regenerated cert") + } + cert, err = x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse regenerated certificate: %v", err) + } + if cert.PublicKeyAlgorithm != x509.ECDSA { + t.Fatalf("expected ECDSA public key algorithm after regeneration, got %v", cert.PublicKeyAlgorithm) + } +} + +// createServingCertAnnotatedServiceWithAlgorithm creates a service with the +// serving-cert annotation and a key algorithm annotation. +func createServingCertAnnotatedServiceWithAlgorithm(client *kubernetes.Clientset, secretName, serviceName, namespace, algorithm string) error { + service := &v1.Service{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Annotations: map[string]string{ + api.ServingCertSecretAnnotation: secretName, + api.ServingCertKeyAlgorithmAnnotation: algorithm, + }, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + Name: "tests", + Port: 8443, + }, + }, + }, + } + _, err := client.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{}) + return err +} + // getKubeClient returns a Kubernetes client for e2e tests. // It uses /tmp/admin.conf (placed by ci-operator) or KUBECONFIG env. func getKubeClient() (*kubernetes.Clientset, error) { diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 4fe9d75e6..dd7df6861 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -318,7 +318,7 @@ func forceUnsupportedServiceCAConfigRotation(t *testing.T, config *rest.Config, break } } - rawUnsupportedServiceCAConfig, err := operator.RawUnsupportedServiceCAConfig(forceRotationReason, validityDuration) + rawUnsupportedServiceCAConfig, err := operator.RawUnsupportedServiceCAConfig(forceRotationReason, validityDuration, "") if err != nil { t.Fatalf("failed to create raw unsupported config overrides: %v", err) } diff --git a/vendor/github.com/openshift/library-go/pkg/crypto/crypto.go b/vendor/github.com/openshift/library-go/pkg/crypto/crypto.go index 69bac6637..119e70bac 100644 --- a/vendor/github.com/openshift/library-go/pkg/crypto/crypto.go +++ b/vendor/github.com/openshift/library-go/pkg/crypto/crypto.go @@ -4,12 +4,14 @@ import ( "bytes" "crypto" "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/sha1" "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/asn1" "encoding/pem" "errors" "fmt" @@ -30,6 +32,8 @@ import ( "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/client-go/util/cert" + + configv1 "github.com/openshift/api/config/v1" ) // TLS versions that are known to golang. Go 1.13 adds support for @@ -110,15 +114,6 @@ func DefaultTLSVersion() uint16 { return tls.VersionTLS12 } -// ciphersTLS13 copies golang 1.13 implementation, where TLS1.3 suites are not -// configurable (cipherSuites field is ignored for TLS1.3 flows and all of the -// below three - and none other - are used) -var ciphersTLS13 = map[string]uint16{ - "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, -} - 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, @@ -144,10 +139,17 @@ var ciphers = map[string]uint16{ "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 +// Ref: https://www.iana.org/assignments/tls-parameters/tls-parameters.xml +// This must hold a 1:1 mapping for each OpenSSL cipher defined in openshift/api TLSSecurityProfiles, +// so it can be used to translate OpenSSL ciphers to IANA ciphers, which is what go's crypto/tls understands. +// Ciphers in this map must also be compatible with go's crypto/tls ciphers: +// https://github.com/golang/go/blob/d4febb45179fa99ee1d5783bcb693ed7ba14115c/src/crypto/tls/cipher_suites.go#L682-L724 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 @@ -167,6 +169,21 @@ var openSSLToIANACiphersMap = map[string]string{ "AES256-GCM-SHA384": "TLS_RSA_WITH_AES_256_GCM_SHA384", // 0x00,0x9D "AES128-SHA256": "TLS_RSA_WITH_AES_128_CBC_SHA256", // 0x00,0x3C + // Go's crypto/tls does not support CBC mode and DHE ciphers, so we don't want to include them here. + // See: + // - https://github.com/golang/go/issues/26652 + // - https://github.com/golang/go/issues/7758 + // - https://redhat-internal.slack.com/archives/C098FU5MRAB/p1770309657097269 + // + // "ECDHE-ECDSA-AES256-SHA384": "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", // 0xC0,0x24 + // "ECDHE-RSA-AES256-SHA384": "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", // 0xC0,0x28 + // "AES256-SHA256": "TLS_RSA_WITH_AES_256_CBC_SHA256", // 0x00,0x3D + // "DHE-RSA-AES128-GCM-SHA256": "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", // 0x00,0x9E + // "DHE-RSA-AES256-GCM-SHA384": "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", // 0x00,0x9F + // "DHE-RSA-CHACHA20-POLY1305": "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256", // 0xCC,0xAA + // "DHE-RSA-AES128-SHA256": "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", // 0x00,0x67 + // "DHE-RSA-AES256-SHA256": "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", // 0x00,0x6B + // 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 @@ -174,9 +191,10 @@ var openSSLToIANACiphersMap = map[string]string{ "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 + "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 + "ECDHE-RSA-DES-CBC3-SHA": "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", // 0xC0,0x12 } // CipherSuitesToNamesOrDie given a list of cipher suites as ints, return their readable names @@ -223,10 +241,6 @@ func CipherSuite(cipherName string) (uint16, error) { return cipher, nil } - if _, ok := ciphersTLS13[cipherName]; ok { - return 0, fmt.Errorf("all golang TLSv1.3 ciphers are always used for TLSv1.3 flows") - } - return 0, fmt.Errorf("unknown cipher name %q", cipherName) } @@ -252,35 +266,47 @@ func ValidCipherSuites() []string { sort.Strings(validCipherSuites) return validCipherSuites } + +// DefaultTLSProfileType is the intermediate profile type. +const DefaultTLSProfileType = configv1.TLSProfileIntermediateType + +// 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 { - // HTTP/2 mandates TLS 1.2 or higher with an AEAD cipher - // suite (GCM, Poly1305) and ephemeral key exchange (ECDHE, DHE) for - // perfect forward secrecy. Servers may provide additional cipher - // suites for backwards compatibility with HTTP/1.1 clients. - // See RFC7540, section 9.2 (Use of TLS Features) and Appendix A - // (TLS 1.2 Cipher Suite Black List). + // 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_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.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, // forbidden by http/2, not flagged by http2isBadCipher() in go1.8 - tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, // forbidden by http/2, not flagged by http2isBadCipher() in go1.8 - tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, // forbidden by http/2 - tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, // forbidden by http/2 - tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, // forbidden by http/2 - tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, // forbidden by http/2 - tls.TLS_RSA_WITH_AES_128_GCM_SHA256, // forbidden by http/2 - tls.TLS_RSA_WITH_AES_256_GCM_SHA384, // forbidden by http/2 - // the next one is in the intermediate suite, but go1.8 http2isBadCipher() complains when it is included at the recommended index - // because it comes after ciphers forbidden by the http/2 spec - // tls.TLS_RSA_WITH_AES_128_CBC_SHA256, - // tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, // forbidden by http/2, disabled to mitigate SWEET32 attack - // tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, // forbidden by http/2, disabled to mitigate SWEET32 attack - tls.TLS_RSA_WITH_AES_128_CBC_SHA, // forbidden by http/2 - tls.TLS_RSA_WITH_AES_256_CBC_SHA, // forbidden by http/2 + + // 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, } } @@ -439,6 +465,16 @@ const ( keyBits = 2048 ) +// KeyAlgorithm represents the type of key pair to generate +type KeyAlgorithm int + +const ( + // AlgorithmRSA generates 2048-bit RSA key pairs (default for backwards compatibility) + AlgorithmRSA KeyAlgorithm = iota + // AlgorithmECDSA generates P-256 ECDSA key pairs + AlgorithmECDSA +) + type CA struct { Config *TLSCertificateConfig @@ -636,35 +672,40 @@ func MakeSelfSignedCAConfig(name string, lifetime time.Duration) (*TLSCertificat 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 %d years!\n", subject.CommonName, lifetime) + 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) + return makeSelfSignedCAConfigForSubjectAndDuration(subject, time.Now, lifetime, AlgorithmRSA) } func MakeSelfSignedCAConfigForDuration(name string, caLifetime time.Duration) (*TLSCertificateConfig, error) { subject := pkix.Name{CommonName: name} - return makeSelfSignedCAConfigForSubjectAndDuration(subject, time.Now, caLifetime) + return makeSelfSignedCAConfigForSubjectAndDuration(subject, time.Now, caLifetime, AlgorithmRSA) +} + +func MakeSelfSignedCAConfigForDurationWithAlgorithm(name string, caLifetime time.Duration, algorithm KeyAlgorithm) (*TLSCertificateConfig, error) { + subject := pkix.Name{CommonName: name} + return makeSelfSignedCAConfigForSubjectAndDuration(subject, time.Now, caLifetime, algorithm) } func UnsafeMakeSelfSignedCAConfigForDurationAtTime(name string, currentTime func() time.Time, caLifetime time.Duration) (*TLSCertificateConfig, error) { subject := pkix.Name{CommonName: name} - return makeSelfSignedCAConfigForSubjectAndDuration(subject, currentTime, caLifetime) + return makeSelfSignedCAConfigForSubjectAndDuration(subject, currentTime, caLifetime, AlgorithmRSA) } -func makeSelfSignedCAConfigForSubjectAndDuration(subject pkix.Name, currentTime func() time.Time, caLifetime time.Duration) (*TLSCertificateConfig, error) { +func makeSelfSignedCAConfigForSubjectAndDuration(subject pkix.Name, currentTime func() time.Time, caLifetime time.Duration, algorithm KeyAlgorithm) (*TLSCertificateConfig, error) { // Create CA cert - rootcaPublicKey, rootcaPrivateKey, publicKeyHash, err := newKeyPairWithHash() + rootcaPublicKey, rootcaPrivateKey, publicKeyHash, err := newKeyPairWithAlgorithm(algorithm) 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) + rootcaTemplate := newSigningCertificateTemplateForDuration(subject, caLifetime, currentTime, authorityKeyId, subjectKeyId, algorithm) rootcaCert, err := signCertificate(rootcaTemplate, rootcaPublicKey, rootcaTemplate, rootcaPrivateKey) if err != nil { return nil, err @@ -677,14 +718,22 @@ func makeSelfSignedCAConfigForSubjectAndDuration(subject pkix.Name, currentTime } func MakeCAConfigForDuration(name string, caLifetime time.Duration, issuer *CA) (*TLSCertificateConfig, error) { + return makeCAConfigForDuration(name, caLifetime, issuer, AlgorithmRSA) +} + +func MakeCAConfigForDurationWithAlgorithm(name string, caLifetime time.Duration, issuer *CA, algorithm KeyAlgorithm) (*TLSCertificateConfig, error) { + return makeCAConfigForDuration(name, caLifetime, issuer, algorithm) +} + +func makeCAConfigForDuration(name string, caLifetime time.Duration, issuer *CA, algorithm KeyAlgorithm) (*TLSCertificateConfig, error) { // Create CA cert - signerPublicKey, signerPrivateKey, publicKeyHash, err := newKeyPairWithHash() + signerPublicKey, signerPrivateKey, publicKeyHash, err := newKeyPairWithAlgorithm(algorithm) 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) + signerTemplate := newSigningCertificateTemplateForDuration(pkix.Name{CommonName: name}, caLifetime, time.Now, authorityKeyId, subjectKeyId, algorithm) signerCert, err := issuer.SignCertificate(signerTemplate, signerPublicKey) if err != nil { return nil, err @@ -792,19 +841,44 @@ func (ca *CA) MakeAndWriteServerCert(certFile, keyFile string, hostnames sets.Se 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() + return ca.makeServerCert(hostnames, lifetime, AlgorithmRSA, fns...) +} + +func (ca *CA) MakeServerCertForDuration(hostnames sets.Set[string], lifetime time.Duration, fns ...CertificateExtensionFunc) (*TLSCertificateConfig, error) { + return ca.makeServerCertForDuration(hostnames, lifetime, AlgorithmRSA, fns...) +} + +// MakeServerCertWithAlgorithm creates a server certificate with the specified key algorithm +func (ca *CA) MakeServerCertWithAlgorithm(hostnames sets.Set[string], lifetime time.Duration, algorithm KeyAlgorithm, fns ...CertificateExtensionFunc) (*TLSCertificateConfig, error) { + return ca.makeServerCert(hostnames, lifetime, algorithm, fns...) +} + +// MakeServerCertForDurationWithAlgorithm creates a server certificate with specified duration and algorithm +func (ca *CA) MakeServerCertForDurationWithAlgorithm(hostnames sets.Set[string], lifetime time.Duration, algorithm KeyAlgorithm, fns ...CertificateExtensionFunc) (*TLSCertificateConfig, error) { + return ca.makeServerCertForDuration(hostnames, lifetime, algorithm, fns...) +} + +func (ca *CA) makeServerCert(hostnames sets.Set[string], lifetime time.Duration, algorithm KeyAlgorithm, fns ...CertificateExtensionFunc) (*TLSCertificateConfig, error) { + serverPublicKey, serverPrivateKey, publicKeyHash, err := newKeyPairWithAlgorithm(algorithm) + if err != nil { + return nil, err + } + 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) + serverTemplate := newServerCertificateTemplate(pkix.Name{CommonName: sets.List(hostnames)[0]}, sets.List(hostnames), lifetime, time.Now, authorityKeyId, subjectKeyId, algorithm) + 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, @@ -812,20 +886,27 @@ func (ca *CA) MakeServerCert(hostnames sets.Set[string], lifetime time.Duration, return server, nil } -func (ca *CA) MakeServerCertForDuration(hostnames sets.Set[string], lifetime time.Duration, fns ...CertificateExtensionFunc) (*TLSCertificateConfig, error) { - serverPublicKey, serverPrivateKey, publicKeyHash, _ := newKeyPairWithHash() +func (ca *CA) makeServerCertForDuration(hostnames sets.Set[string], lifetime time.Duration, algorithm KeyAlgorithm, fns ...CertificateExtensionFunc) (*TLSCertificateConfig, error) { + serverPublicKey, serverPrivateKey, publicKeyHash, err := newKeyPairWithAlgorithm(algorithm) + if err != nil { + return nil, err + } + 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) + serverTemplate := newServerCertificateTemplateForDuration(pkix.Name{CommonName: sets.List(hostnames)[0]}, sets.List(hostnames), lifetime, time.Now, authorityKeyId, subjectKeyId, algorithm) + 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, @@ -997,13 +1078,75 @@ func newRSAKeyPair() (*rsa.PublicKey, *rsa.PrivateKey, error) { return &privateKey.PublicKey, privateKey, nil } +// newECDSAKeyPair generates a new P-256 ECDSA key pair +func newECDSAKeyPair() (*ecdsa.PublicKey, *ecdsa.PrivateKey, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + return &privateKey.PublicKey, privateKey, nil +} + +// subjectPublicKeyInfo mirrors the ASN.1 SubjectPublicKeyInfo structure from RFC 5280 Section 4.1. +// It is used to extract the subjectPublicKey BIT STRING for hashing per Section 4.2.1.2. +type subjectPublicKeyInfo struct { + Algorithm pkix.AlgorithmIdentifier + PublicKey asn1.BitString +} + +// newECDSAKeyPairWithHash generates a new ECDSA key pair and computes the public key hash. +// Uses SHA-1 over the subjectPublicKey BIT STRING per RFC 5280 Section 4.2.1.2, +// matching the RSA convention. +func newECDSAKeyPairWithHash() (crypto.PublicKey, crypto.PrivateKey, []byte, error) { + publicKey, privateKey, err := newECDSAKeyPair() + if err != nil { + return nil, nil, nil, err + } + pubDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return nil, nil, nil, err + } + var spki subjectPublicKeyInfo + if _, err := asn1.Unmarshal(pubDER, &spki); err != nil { + return nil, nil, nil, err + } + hash := sha1.New() + hash.Write(spki.PublicKey.Bytes) + publicKeyHash := hash.Sum(nil) + return publicKey, privateKey, publicKeyHash, nil +} + +// newKeyPairWithAlgorithm generates a new key pair using the specified algorithm +func newKeyPairWithAlgorithm(algo KeyAlgorithm) (crypto.PublicKey, crypto.PrivateKey, []byte, error) { + switch algo { + case AlgorithmECDSA: + return newECDSAKeyPairWithHash() + case AlgorithmRSA: + return newKeyPairWithHash() + default: + // This can only be reached if a new KeyAlgorithm constant is added + // to the const block above without a corresponding case here. + return nil, nil, nil, fmt.Errorf("unsupported key algorithm: %d", algo) + } +} + +// baseKeyUsageForAlgorithm returns the appropriate KeyUsage for the given algorithm. +// RSA keys use KeyEncipherment (for RSA key transport in TLS) + DigitalSignature. +// ECDSA keys use only DigitalSignature per RFC 5480 Section 3. +func baseKeyUsageForAlgorithm(algorithm KeyAlgorithm) x509.KeyUsage { + switch algorithm { + case AlgorithmECDSA: + return x509.KeyUsageDigitalSignature + default: + return x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature + } +} + // 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 { +func newSigningCertificateTemplateForDuration(subject pkix.Name, caLifetime time.Duration, currentTime func() time.Time, authorityKeyId, subjectKeyId []byte, algorithm KeyAlgorithm) *x509.Certificate { return &x509.Certificate{ Subject: subject, - SignatureAlgorithm: x509.SHA256WithRSA, - NotBefore: currentTime().Add(-1 * time.Second), NotAfter: currentTime().Add(caLifetime), @@ -1012,7 +1155,7 @@ func newSigningCertificateTemplateForDuration(subject pkix.Name, caLifetime time // signing certificate is ever rotated. SerialNumber: big.NewInt(randomSerialNumber()), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + KeyUsage: baseKeyUsageForAlgorithm(algorithm) | x509.KeyUsageCertSign, BasicConstraintsValid: true, IsCA: true, @@ -1022,31 +1165,29 @@ func newSigningCertificateTemplateForDuration(subject pkix.Name, caLifetime time } // Can be used for ListenAndServeTLS -func newServerCertificateTemplate(subject pkix.Name, hosts []string, lifetime time.Duration, currentTime func() time.Time, authorityKeyId, subjectKeyId []byte) *x509.Certificate { +func newServerCertificateTemplate(subject pkix.Name, hosts []string, lifetime time.Duration, currentTime func() time.Time, authorityKeyId, subjectKeyId []byte, algorithm KeyAlgorithm) *x509.Certificate { if lifetime <= 0 { lifetime = DefaultCertificateLifetimeDuration - fmt.Fprintf(os.Stderr, "Validity period of the certificate for %q is unset, resetting to %d years!\n", subject.CommonName, lifetime) + 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) + return newServerCertificateTemplateForDuration(subject, hosts, lifetime, currentTime, authorityKeyId, subjectKeyId, algorithm) } // Can be used for ListenAndServeTLS -func newServerCertificateTemplateForDuration(subject pkix.Name, hosts []string, lifetime time.Duration, currentTime func() time.Time, authorityKeyId, subjectKeyId []byte) *x509.Certificate { +func newServerCertificateTemplateForDuration(subject pkix.Name, hosts []string, lifetime time.Duration, currentTime func() time.Time, authorityKeyId, subjectKeyId []byte, algorithm KeyAlgorithm) *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, + KeyUsage: baseKeyUsageForAlgorithm(algorithm), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, @@ -1112,7 +1253,7 @@ func CertsFromPEM(pemCerts []byte) ([]*x509.Certificate, error) { 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 %d years!\n", subject.CommonName, lifetime) + fmt.Fprintf(os.Stderr, "Validity period of the certificate for %q is unset, resetting to %s!\n", subject.CommonName, lifetime.String()) } if lifetime > DefaultCertificateLifetimeDuration { @@ -1127,8 +1268,6 @@ func NewClientCertificateTemplateForDuration(subject pkix.Name, lifetime time.Du return &x509.Certificate{ Subject: subject, - SignatureAlgorithm: x509.SHA256WithRSA, - NotBefore: currentTime().Add(-1 * time.Second), NotAfter: currentTime().Add(lifetime), SerialNumber: big.NewInt(1),