From 867602cf99e6708bed1cde6964cd131fe8d579a9 Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:44:57 -0500 Subject: [PATCH] feat: adds sigstore/sigstore for kms and adds hashivault support. Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- cmd/certificate_maker/certificate_maker.go | 13 +- .../certificate_maker_test.go | 98 +- go.mod | 5 +- go.sum | 6 - pkg/certmaker/certmaker.go | 210 +- pkg/certmaker/certmaker_test.go | 2699 ++++++++--------- pkg/certmaker/template.go | 13 + pkg/certmaker/template_test.go | 179 +- 8 files changed, 1600 insertions(+), 1623 deletions(-) diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go index 45fc924a7..13b65f53e 100644 --- a/cmd/certificate_maker/certificate_maker.go +++ b/cmd/certificate_maker/certificate_maker.go @@ -62,6 +62,8 @@ var ( intermediateKeyID string intermediateTemplate string intermediateCert string + kmsVaultToken string + kmsVaultAddr string rawJSON = []byte(`{ "level": "debug", @@ -84,7 +86,7 @@ func init() { rootCmd.AddCommand(createCmd) - createCmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms)") + createCmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms, hashivault)") createCmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") createCmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") createCmd.Flags().StringVar(&kmsTenantID, "azure-tenant-id", "", "Azure KMS tenant ID") @@ -98,6 +100,8 @@ func init() { createCmd.Flags().StringVar(&intermediateKeyID, "intermediate-key-id", "", "KMS key identifier for intermediate certificate") createCmd.Flags().StringVar(&intermediateTemplate, "intermediate-template", "pkg/certmaker/templates/intermediate-template.json", "Path to intermediate certificate template") createCmd.Flags().StringVar(&intermediateCert, "intermediate-cert", "intermediate.pem", "Output path for intermediate certificate") + createCmd.Flags().StringVar(&kmsVaultToken, "vault-token", "", "HashiVault token") + createCmd.Flags().StringVar(&kmsVaultAddr, "vault-address", "", "HashiVault server address") } func runCreate(_ *cobra.Command, _ []string) error { @@ -131,6 +135,13 @@ func runCreate(_ *cobra.Command, _ []string) error { if tenantID := getConfigValue(kmsTenantID, "AZURE_TENANT_ID"); tenantID != "" { config.Options["tenant-id"] = tenantID } + case "hashivault": + if token := getConfigValue(kmsVaultToken, "VAULT_TOKEN"); token != "" { + config.Options["token"] = token + } + if addr := getConfigValue(kmsVaultAddr, "VAULT_ADDR"); addr != "" { + config.Options["address"] = addr + } } km, err := certmaker.InitKMS(ctx, config) diff --git a/cmd/certificate_maker/certificate_maker_test.go b/cmd/certificate_maker/certificate_maker_test.go index 89f9a05f7..de0662679 100644 --- a/cmd/certificate_maker/certificate_maker_test.go +++ b/cmd/certificate_maker/certificate_maker_test.go @@ -18,10 +18,11 @@ package main import ( "os" "path/filepath" - "strings" "testing" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetConfigValue(t *testing.T) { @@ -83,25 +84,19 @@ func TestGetConfigValue(t *testing.T) { defer os.Unsetenv(tt.envVar) } got := getConfigValue(tt.flagValue, tt.envVar) - if got != tt.want { - t.Errorf("got %v, want %v", got, tt.want) - } + assert.Equal(t, tt.want, got) }) } } func TestInitLogger(t *testing.T) { logger := initLogger() - if logger == nil { - t.Error("logger should not be nil") - } + require.NotNil(t, logger) } func TestRunCreate(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) defer os.RemoveAll(tmpDir) rootTemplate := `{ @@ -136,13 +131,9 @@ func TestRunCreate(t *testing.T) { rootTmplPath := filepath.Join(tmpDir, "root-template.json") leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0600) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0600) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) tests := []struct { name string @@ -181,26 +172,26 @@ func TestRunCreate(t *testing.T) { args: []string{ "--kms-type", "awskms", "--aws-region", "us-west-2", - "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", - "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--root-key-id", "alias/test-key", + "--leaf-key-id", "alias/test-key", "--root-template", "nonexistent.json", "--leaf-template", leafTmplPath, }, wantError: true, - errMsg: "template not found", + errMsg: "no such file or directory", }, { name: "missing leaf template", args: []string{ "--kms-type", "awskms", "--aws-region", "us-west-2", - "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", - "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--root-key-id", "alias/test-key", + "--leaf-key-id", "alias/test-key", "--root-template", rootTmplPath, "--leaf-template", "nonexistent.json", }, wantError: true, - errMsg: "template not found", + errMsg: "no such file or directory", }, { name: "GCP KMS with credentials file", @@ -232,13 +223,39 @@ func TestRunCreate(t *testing.T) { args: []string{ "--kms-type", "awskms", "--aws-region", "us-west-2", - "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", - "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--root-key-id", "alias/test-key", + "--leaf-key-id", "alias/test-key", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "NotFoundException: Alias arn:aws:kms:us-west-2", + }, + { + name: "HashiVault KMS without token", + args: []string{ + "--kms-type", "hashivault", + "--root-key-id", "transit/keys/test-key", + "--leaf-key-id", "transit/keys/leaf-key", + "--vault-address", "http://vault:8200", "--root-template", rootTmplPath, "--leaf-template", leafTmplPath, }, wantError: true, - errMsg: "operation error KMS", + errMsg: "token is required for HashiVault KMS", + }, + { + name: "HashiVault KMS without address", + args: []string{ + "--kms-type", "hashivault", + "--root-key-id", "transit/keys/test-key", + "--leaf-key-id", "transit/keys/leaf-key", + "--vault-token", "test-token", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "address is required for HashiVault KMS", }, } @@ -254,11 +271,13 @@ func TestRunCreate(t *testing.T) { RunE: runCreate, } - cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms)") + cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms, hashivault)") cmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") cmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") cmd.Flags().StringVar(&kmsTenantID, "azure-tenant-id", "", "Azure KMS tenant ID") cmd.Flags().StringVar(&kmsCredsFile, "gcp-credentials-file", "", "Path to credentials file for GCP KMS") + cmd.Flags().StringVar(&kmsVaultToken, "vault-token", "", "HashiVault token") + cmd.Flags().StringVar(&kmsVaultAddr, "vault-address", "", "HashiVault server address") cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "KMS key identifier for root certificate") cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "KMS key identifier for leaf certificate") cmd.Flags().StringVar(&rootTemplatePath, "root-template", "", "Path to root certificate template") @@ -273,13 +292,10 @@ func TestRunCreate(t *testing.T) { err := cmd.Execute() if tt.wantError { - if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("error %q should contain %q", err.Error(), tt.errMsg) - } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) } else { - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) } }) } @@ -299,9 +315,7 @@ func TestCreateCommand(t *testing.T) { cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "Leaf key ID") err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) err = cmd.ParseFlags([]string{ "--kms-type", "awskms", @@ -309,12 +323,10 @@ func TestCreateCommand(t *testing.T) { "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654", }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) if kmsType != "awskms" { - t.Errorf("got kmsType %v, want awskms", kmsType) + assert.Equal(t, "awskms", kmsType) } if kmsRegion != "us-west-2" { t.Errorf("got kmsRegion %v, want us-west-2", kmsRegion) @@ -330,13 +342,9 @@ func TestCreateCommand(t *testing.T) { func TestRootCommand(t *testing.T) { rootCmd.SetArgs([]string{"--help"}) err := rootCmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) rootCmd.SetArgs([]string{"unknown"}) err = rootCmd.Execute() - if err == nil { - t.Error("expected error for unknown command, got nil") - } + require.Error(t, err) } diff --git a/go.mod b/go.mod index 9954cb996..620d93f4f 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 github.com/spiffe/go-spiffe/v2 v2.4.0 + github.com/stretchr/testify v1.10.0 github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 github.com/tink-crypto/tink-go/v2 v2.2.0 @@ -59,8 +60,6 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 // indirect @@ -88,6 +87,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chainguard-dev/clog v1.5.1 // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -126,6 +126,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect diff --git a/go.sum b/go.sum index 3f6a4843a..b5d18c662 100644 --- a/go.sum +++ b/go.sum @@ -31,10 +31,6 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvUL github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 h1:7rKG7UmnrxX4N53TFhkYqjc+kVUZuw0fL8I3Fh+Ld9E= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw= @@ -408,8 +404,6 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go index cf5e03363..d5b34cfd1 100644 --- a/pkg/certmaker/certmaker.go +++ b/pkg/certmaker/certmaker.go @@ -14,26 +14,49 @@ // // Package certmaker implements a certificate creation utility for Fulcio. -// It supports creating root, intermediate, and leaf certs using (AWS, GCP, Azure). +// It supports creating root, intermediate, and leaf certs using (AWS, GCP, Azure, HashiVault). package certmaker import ( + "bytes" "context" "crypto" "crypto/x509" "encoding/json" "encoding/pem" "fmt" + "io" "os" "strings" - "go.step.sm/crypto/kms/apiv1" - "go.step.sm/crypto/kms/awskms" - "go.step.sm/crypto/kms/azurekms" - "go.step.sm/crypto/kms/cloudkms" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/sigstore/sigstore/pkg/signature/kms" + "github.com/sigstore/sigstore/pkg/signature/options" + + // Initialize AWS KMS provider + _ "github.com/sigstore/sigstore/pkg/signature/kms/aws" + // Initialize Azure KMS provider + _ "github.com/sigstore/sigstore/pkg/signature/kms/azure" + // Initialize GCP KMS provider + _ "github.com/sigstore/sigstore/pkg/signature/kms/gcp" + // Initialize HashiVault KMS provider + _ "github.com/sigstore/sigstore/pkg/signature/kms/hashivault" "go.step.sm/crypto/x509util" ) +type signerWrapper struct { + signature.SignerVerifier +} + +func (s signerWrapper) Public() crypto.PublicKey { + key, _ := s.PublicKey() + return key +} + +func (s signerWrapper) Sign(_ io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { + return s.SignMessage(bytes.NewReader(digest), options.WithDigest(digest)) +} + // KMSConfig holds config for KMS providers. type KMSConfig struct { Type string @@ -45,15 +68,10 @@ type KMSConfig struct { } // InitKMS initializes KMS provider based on the given config, KMSConfig. -// Supports AWS KMS, Google Cloud KMS, and Azure Key Vault. -func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { +var InitKMS = func(ctx context.Context, config KMSConfig) (signature.SignerVerifier, error) { if err := ValidateKMSConfig(config); err != nil { return nil, fmt.Errorf("invalid KMS configuration: %w", err) } - opts := apiv1.Options{ - Type: apiv1.Type(config.Type), - URI: "", - } // Falls back to LeafKeyID if root is not set keyID := config.RootKeyID @@ -61,42 +79,83 @@ func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { keyID = config.LeafKeyID } + var sv signature.SignerVerifier + var err error + switch config.Type { case "awskms": - opts.URI = fmt.Sprintf("awskms:///%s?region=%s", keyID, config.Region) - return awskms.New(ctx, opts) - case "gcpkms": - opts.Type = apiv1.Type("cloudkms") - opts.URI = fmt.Sprintf("cloudkms:%s", keyID) - if credFile, ok := config.Options["credentials-file"]; ok { - if _, err := os.Stat(credFile); err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("credentials file not found: %s", credFile) - } - return nil, fmt.Errorf("error accessing credentials file: %w", err) - } - opts.URI += fmt.Sprintf("?credentials-file=%s", credFile) + ref := fmt.Sprintf("awskms:///%s", keyID) + if config.Region != "" { + os.Setenv("AWS_REGION", config.Region) + } + sv, err = kms.Get(ctx, ref, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to initialize AWS KMS: %w", err) } - km, err := cloudkms.New(ctx, opts) + + case "gcpkms": + ref := fmt.Sprintf("gcpkms://%s", keyID) + sv, err = kms.Get(ctx, ref, crypto.SHA256) if err != nil { return nil, fmt.Errorf("failed to initialize GCP KMS: %w", err) } - return km, nil + case "azurekms": - opts.URI = keyID - if config.Options["tenant-id"] != "" { - opts.URI += fmt.Sprintf("?tenant-id=%s", config.Options["tenant-id"]) + keyURI := keyID + if strings.HasPrefix(keyID, "azurekms:name=") { + nameStart := strings.Index(keyID, "name=") + 5 + vaultIndex := strings.Index(keyID, ";vault=") + if vaultIndex != -1 { + keyName := strings.TrimSpace(keyID[nameStart:vaultIndex]) + vaultName := strings.TrimSpace(keyID[vaultIndex+7:]) + keyURI = fmt.Sprintf("azurekms://%s.vault.azure.net/%s", vaultName, keyName) + } + } + if config.Options != nil && config.Options["tenant-id"] != "" { + os.Setenv("AZURE_TENANT_ID", config.Options["tenant-id"]) + os.Setenv("AZURE_ADDITIONALLY_ALLOWED_TENANTS", "*") } - return azurekms.New(ctx, opts) + os.Setenv("AZURE_AUTHORITY_HOST", "https://login.microsoftonline.com/") + + sv, err = kms.Get(ctx, keyURI, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to initialize Azure KMS: %w", err) + } + + case "hashivault": + keyURI := fmt.Sprintf("hashivault://%s", keyID) + if config.Options != nil { + if token := config.Options["token"]; token != "" { + os.Setenv("VAULT_TOKEN", token) + } + if addr := config.Options["address"]; addr != "" { + os.Setenv("VAULT_ADDR", addr) + } + } + + sv, err = kms.Get(ctx, keyURI, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to initialize HashiVault KMS: %w", err) + } + default: return nil, fmt.Errorf("unsupported KMS type: %s", config.Type) } + + if err != nil { + return nil, fmt.Errorf("failed to get KMS signer: %w", err) + } + if sv == nil { + return nil, fmt.Errorf("KMS returned nil signer") + } + + return sv, nil } // CreateCertificates creates certificates using the provided KMS and templates. // It creates 3 certificates (root -> intermediate -> leaf) if intermediateKeyID is provided, // otherwise creates just 2 certs (root -> leaf). -func CreateCertificates(km apiv1.KeyManager, config KMSConfig, +func CreateCertificates(sv signature.SignerVerifier, config KMSConfig, rootTemplatePath, leafTemplatePath string, rootCertPath, leafCertPath string, intermediateKeyID, intermediateTemplatePath, intermediateCertPath string) error { @@ -107,14 +166,13 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, return fmt.Errorf("error parsing root template: %w", err) } - rootSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ - SigningKey: config.RootKeyID, - }) + // Get public key from signer + rootPubKey, err := sv.PublicKey() if err != nil { - return fmt.Errorf("error creating root signer: %w", err) + return fmt.Errorf("error getting root public key: %w", err) } - rootCert, err := x509util.CreateCertificate(rootTmpl, rootTmpl, rootSigner.Public(), rootSigner) + rootCert, err := x509util.CreateCertificate(rootTmpl, rootTmpl, rootPubKey, signerWrapper{sv}) if err != nil { return fmt.Errorf("error creating root certificate: %w", err) } @@ -133,14 +191,20 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, return fmt.Errorf("error parsing intermediate template: %w", err) } - intermediateSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ - SigningKey: intermediateKeyID, - }) + // Initialize new KMS for intermediate key + intermediateConfig := config + intermediateConfig.RootKeyID = intermediateKeyID + intermediateSV, err := InitKMS(context.Background(), intermediateConfig) + if err != nil { + return fmt.Errorf("error initializing intermediate KMS: %w", err) + } + + intermediatePubKey, err := intermediateSV.PublicKey() if err != nil { - return fmt.Errorf("error creating intermediate signer: %w", err) + return fmt.Errorf("error getting intermediate public key: %w", err) } - intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediateSigner.Public(), rootSigner) + intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediatePubKey, signerWrapper{sv}) if err != nil { return fmt.Errorf("error creating intermediate certificate: %w", err) } @@ -150,10 +214,10 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, } signingCert = intermediateCert - signingKey = intermediateSigner + signingKey = signerWrapper{intermediateSV} } else { signingCert = rootCert - signingKey = rootSigner + signingKey = signerWrapper{sv} } // Create leaf cert @@ -162,14 +226,20 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, return fmt.Errorf("error parsing leaf template: %w", err) } - leafSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ - SigningKey: config.LeafKeyID, - }) + // Initialize new KMS for leaf key + leafConfig := config + leafConfig.RootKeyID = config.LeafKeyID + leafSV, err := InitKMS(context.Background(), leafConfig) if err != nil { - return fmt.Errorf("error creating leaf signer: %w", err) + return fmt.Errorf("error initializing leaf KMS: %w", err) } - leafCert, err := x509util.CreateCertificate(leafTmpl, signingCert, leafSigner.Public(), signingKey) + leafPubKey, err := leafSV.PublicKey() + if err != nil { + return fmt.Errorf("error getting leaf public key: %w", err) + } + + leafCert, err := x509util.CreateCertificate(leafTmpl, signingCert, leafPubKey, signingKey) if err != nil { return fmt.Errorf("error creating leaf certificate: %w", err) } @@ -229,7 +299,8 @@ func ValidateKMSConfig(config KMSConfig) error { if keyID == "" { return nil } - if strings.HasPrefix(keyID, "arn:aws:kms:") { + switch { + case strings.HasPrefix(keyID, "arn:aws:kms:"): parts := strings.Split(keyID, ":") if len(parts) < 6 { return fmt.Errorf("invalid AWS KMS ARN format for %s", keyType) @@ -237,11 +308,11 @@ func ValidateKMSConfig(config KMSConfig) error { if parts[3] != config.Region { return fmt.Errorf("region in ARN (%s) does not match configured region (%s)", parts[3], config.Region) } - } else if strings.HasPrefix(keyID, "alias/") { + case strings.HasPrefix(keyID, "alias/"): if strings.TrimPrefix(keyID, "alias/") == "" { return fmt.Errorf("alias name cannot be empty for %s", keyType) } - } else { + default: return fmt.Errorf("awskms %s must start with 'arn:aws:kms:' or 'alias/'", keyType) } return nil @@ -327,6 +398,43 @@ func ValidateKMSConfig(config KMSConfig) error { return err } + case "hashivault": + // HashiVault KMS validation + if config.Options == nil { + return fmt.Errorf("options map is required for HashiVault KMS") + } + if config.Options["address"] == "" { + return fmt.Errorf("address is required for HashiVault KMS") + } + if config.Options["token"] == "" { + return fmt.Errorf("token is required for HashiVault KMS") + } + validateHashiVaultKeyID := func(keyID, keyType string) error { + if keyID == "" { + return nil + } + parts := strings.Split(keyID, "/") + if len(parts) < 3 { + return fmt.Errorf("hashivault %s must be in format: transit/keys/keyname", keyType) + } + if parts[0] != "transit" || parts[1] != "keys" { + return fmt.Errorf("hashivault %s must start with 'transit/keys/'", keyType) + } + if parts[2] == "" { + return fmt.Errorf("key name cannot be empty for %s", keyType) + } + return nil + } + if err := validateHashiVaultKeyID(config.RootKeyID, "RootKeyID"); err != nil { + return err + } + if err := validateHashiVaultKeyID(config.IntermediateKeyID, "IntermediateKeyID"); err != nil { + return err + } + if err := validateHashiVaultKeyID(config.LeafKeyID, "LeafKeyID"); err != nil { + return err + } + default: return fmt.Errorf("unsupported KMS type: %s", config.Type) } diff --git a/pkg/certmaker/certmaker_test.go b/pkg/certmaker/certmaker_test.go index 2150e18a3..219555d6a 100644 --- a/pkg/certmaker/certmaker_test.go +++ b/pkg/certmaker/certmaker_test.go @@ -21,11 +21,10 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/x509" "crypto/x509/pkix" - "encoding/pem" "fmt" + "io" "math/big" "os" "path/filepath" @@ -33,1772 +32,1500 @@ import ( "testing" "time" - "go.step.sm/crypto/kms/apiv1" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -type mockKMSProvider struct { - name string - keys map[string]*ecdsa.PrivateKey - signers map[string]crypto.Signer +// mockSignerVerifier implements signature.SignerVerifier for testing +type mockSignerVerifier struct { + key crypto.PrivateKey + err error + publicKeyFunc func() (crypto.PublicKey, error) + signMessageFunc func(message io.Reader, opts ...signature.SignOption) ([]byte, error) } -func newMockKMSProvider() *mockKMSProvider { - m := &mockKMSProvider{ - name: "test", - keys: make(map[string]*ecdsa.PrivateKey), - signers: make(map[string]crypto.Signer), +func (m *mockSignerVerifier) SignMessage(message io.Reader, opts ...signature.SignOption) ([]byte, error) { + if m.signMessageFunc != nil { + return m.signMessageFunc(message, opts...) + } + if m.err != nil { + return nil, m.err + } + digest := make([]byte, 32) + if _, err := message.Read(digest); err != nil { + return nil, err + } + switch k := m.key.(type) { + case *ecdsa.PrivateKey: + return k.Sign(rand.Reader, digest, crypto.SHA256) + default: + return nil, fmt.Errorf("unsupported key type") } - - rootKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - intermediateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - leafKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - - m.keys["root-key"] = rootKey - m.keys["intermediate-key"] = intermediateKey - m.keys["leaf-key"] = leafKey - - return m } -func (m *mockKMSProvider) CreateKey(*apiv1.CreateKeyRequest) (*apiv1.CreateKeyResponse, error) { - return nil, fmt.Errorf("not implemented") +func (m *mockSignerVerifier) VerifySignature(_, _ io.Reader, _ ...signature.VerifyOption) error { + return nil } -func (m *mockKMSProvider) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, error) { - keyName := req.SigningKey - if strings.HasPrefix(keyName, "arn:aws:kms:") { - parts := strings.Split(keyName, "/") - if len(parts) > 0 { - keyName = parts[len(parts)-1] - } +func (m *mockSignerVerifier) PublicKey(_ ...signature.PublicKeyOption) (crypto.PublicKey, error) { + if m.publicKeyFunc != nil { + return m.publicKeyFunc() } - - key, ok := m.keys[keyName] - if !ok { - return nil, fmt.Errorf("key not found: %s", req.SigningKey) + if m.err != nil { + return nil, m.err } - m.signers[keyName] = key - return key, nil -} - -func (m *mockKMSProvider) GetPublicKey(req *apiv1.GetPublicKeyRequest) (crypto.PublicKey, error) { - key, ok := m.keys[req.Name] - if !ok { - return nil, fmt.Errorf("key not found: %s", req.Name) + switch k := m.key.(type) { + case *ecdsa.PrivateKey: + return k.Public(), nil + default: + return nil, fmt.Errorf("unsupported key type") } - return key.Public(), nil } -func (m *mockKMSProvider) Close() error { +func (m *mockSignerVerifier) Close() error { return nil } -type mockInvalidKMS struct { - apiv1.KeyManager +func (m *mockSignerVerifier) DefaultHashFunction() crypto.Hash { + return crypto.SHA256 } -func (m *mockInvalidKMS) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, error) { - return nil, fmt.Errorf("invalid KMS configuration: unsupported KMS type") +func (m *mockSignerVerifier) Bytes() ([]byte, error) { + return nil, fmt.Errorf("not implemented") } -func (m *mockInvalidKMS) GetPublicKey(req *apiv1.GetPublicKeyRequest) (crypto.PublicKey, error) { - return nil, fmt.Errorf("invalid KMS configuration: unsupported KMS type") +func (m *mockSignerVerifier) KeyID() (string, error) { + return "mock-key-id", nil } -func (m *mockInvalidKMS) Close() error { +func (m *mockSignerVerifier) Status() error { return nil } -func TestParseTemplate(t *testing.T) { - tmpFile, err := os.CreateTemp("", "cert-template-*.json") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - defer os.Remove(tmpFile.Name()) - - templateContent := `{ - "subject": { - "commonName": "Test CA" - }, - "issuer": { - "commonName": "Test CA" - }, - "keyUsage": [ - "certSign", - "crlSign" - ], - "extKeyUsage": [ - "CodeSigning" - ], - "basicConstraints": { - "isCA": true, - "maxPathLen": 0 - }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }` - - err = os.WriteFile(tmpFile.Name(), []byte(templateContent), 0600) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - tmpl, err := ParseTemplate(tmpFile.Name(), nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if tmpl.Subject.CommonName != "Test CA" { - t.Errorf("got %v, want Test CA", tmpl.Subject.CommonName) - } - if !tmpl.IsCA { - t.Errorf("got %v, want true", tmpl.IsCA) - } - if tmpl.MaxPathLen != 0 { - t.Errorf("got %v, want 0", tmpl.MaxPathLen) - } -} +var ( + originalInitKMS = InitKMS +) -func TestCreateCertificates(t *testing.T) { +func TestValidateKMSConfig(t *testing.T) { tests := []struct { name string - setup func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) + config KMSConfig wantError string }{ { - name: "successful certificate creation", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": {"commonName": "Test Root CA"}, - "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": {"isCA": true, "maxPathLen": 1}, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {"commonName": "Test Leaf"}, - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extKeyUsage": ["CodeSigning"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } - - config := KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - } - - return tmpDir, config, newMockKMSProvider() + name: "empty_KMS_type", + config: KMSConfig{ + RootKeyID: "test-key", }, + wantError: "KMS type cannot be empty", }, { - name: "invalid template path", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - config := KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - } - - return tmpDir, config, newMockKMSProvider() + name: "missing_key_IDs", + config: KMSConfig{ + Type: "awskms", }, - wantError: "error parsing root template", + wantError: "at least one of RootKeyID or LeafKeyID must be specified", }, { - name: "invalid KMS configuration", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": {"commonName": "Test Root CA"}, - "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": {"isCA": true, "maxPathLen": 1}, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {"commonName": "Test Leaf"}, - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extKeyUsage": ["CodeSigning"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } - - config := KMSConfig{ - Type: "invalid", - RootKeyID: "test-key", - LeafKeyID: "leaf-key", - } - - return tmpDir, config, &mockInvalidKMS{} + name: "AWS_KMS_missing_region", + config: KMSConfig{ + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", }, - wantError: "invalid KMS configuration: unsupported KMS type", + wantError: "region is required for AWS KMS", }, { - name: "with intermediate certificate", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": {"commonName": "Test Root CA"}, - "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": {"isCA": true, "maxPathLen": 1}, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") - err = os.WriteFile(intermediateTemplate, []byte(`{ - "subject": {"commonName": "Test Intermediate CA"}, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": {"isCA": true, "maxPathLen": 0}, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write intermediate template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {"commonName": "Test Leaf"}, - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extKeyUsage": ["CodeSigning"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } - - config := KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", - IntermediateKeyID: "arn:aws:kms:us-west-2:123456789012:key/intermediate-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - } - - return tmpDir, config, newMockKMSProvider() + name: "Azure_KMS_missing_tenant_ID", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{}, }, + wantError: "tenant-id is required for Azure KMS", }, { - name: "invalid intermediate template", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": {"commonName": "Test Root CA"}, - "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": {"isCA": true, "maxPathLen": 1}, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {"commonName": "Test Leaf"}, - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extKeyUsage": ["CodeSigning"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } - - config := KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", - IntermediateKeyID: "arn:aws:kms:us-west-2:123456789012:key/intermediate-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - } - - return tmpDir, config, newMockKMSProvider() + name: "Azure_KMS_missing_vault_parameter", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, }, - wantError: "error parsing intermediate template", + wantError: "azurekms RootKeyID must contain ';vault=' parameter", }, { - name: "invalid intermediate key", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": {"commonName": "Test Root CA"}, - "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": {"isCA": true, "maxPathLen": 1}, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") - err = os.WriteFile(intermediateTemplate, []byte(`{ - "subject": {"commonName": "Test Intermediate CA"}, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": {"isCA": true, "maxPathLen": 0}, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write intermediate template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {"commonName": "Test Leaf"}, - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extKeyUsage": ["CodeSigning"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } - - config := KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", - IntermediateKeyID: "arn:aws:kms:us-west-2:123456789012:key/nonexistent-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - } - - return tmpDir, config, newMockKMSProvider() + name: "unsupported_KMS_type", + config: KMSConfig{ + Type: "unsupported", + RootKeyID: "test-key", + }, + wantError: "unsupported KMS type", + }, + { + name: "GCP_KMS_missing_cryptoKeyVersions", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key", }, - wantError: "error creating intermediate signer", + wantError: "gcpkms RootKeyID must contain '/cryptoKeyVersions/'", }, { - name: "error creating root certificate", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": {}, - "issuer": {} - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - config := KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - } - - return tmpDir, config, newMockKMSProvider() + name: "GCP_KMS_invalid_key_format", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "invalid-key", }, - wantError: "error parsing root template: notBefore and notAfter times must be specified", + wantError: "gcpkms RootKeyID must start with 'projects/'", }, { - name: "error creating leaf certificate", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": {"commonName": "Test Root CA"}, - "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": {"isCA": true, "maxPathLen": 1}, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {}, - "issuer": {} - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } - - config := KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - } - - return tmpDir, config, newMockKMSProvider() + name: "HashiVault_KMS_missing_options", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/my-key", }, - wantError: "error parsing leaf template: notBefore and notAfter times must be specified", + wantError: "options map is required for HashiVault KMS", }, { - name: "error writing certificates", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": {"commonName": "Test Root CA"}, - "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": {"isCA": true, "maxPathLen": 1}, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {"commonName": "Test Leaf"}, - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extKeyUsage": ["CodeSigning"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } - - config := KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - } - - outDir := filepath.Join(tmpDir, "out") - err = os.MkdirAll(outDir, 0444) - if err != nil { - t.Fatalf("Failed to create output directory: %v", err) - } - - return tmpDir, config, newMockKMSProvider() + name: "HashiVault_KMS_missing_token", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/my-key", + Options: map[string]string{ + "address": "http://vault:8200", + }, + }, + wantError: "token is required for HashiVault KMS", + }, + { + name: "HashiVault_KMS_missing_address", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/my-key", + Options: map[string]string{ + "token": "test-token", + }, }, - wantError: "error writing root certificate", + wantError: "address is required for HashiVault KMS", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tmpDir, config, kms := tt.setup(t) - defer os.RemoveAll(tmpDir) - - outDir := filepath.Join(tmpDir, "out") - err := os.MkdirAll(outDir, 0755) - if err != nil { - t.Fatalf("Failed to create output directory: %v", err) - } - - err = CreateCertificates(kms, config, - filepath.Join(tmpDir, "root.json"), - filepath.Join(tmpDir, "leaf.json"), - filepath.Join(outDir, "root.crt"), - filepath.Join(outDir, "leaf.crt"), - config.IntermediateKeyID, - filepath.Join(tmpDir, "intermediate.json"), - filepath.Join(outDir, "intermediate.crt")) - + err := ValidateKMSConfig(tt.config) if tt.wantError != "" { - if err == nil { - t.Error("Expected error but got none") - } else if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("Expected error containing %q, got %q", tt.wantError, err.Error()) - } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) } else { - if err != nil { - t.Errorf("Unexpected error: %v", err) - } + require.NoError(t, err) } }) } } -func TestWriteCertificateToFile(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-write-test-*") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - defer os.RemoveAll(tmpDir) - - cert := &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test CA", - }, - SerialNumber: big.NewInt(1), - NotBefore: time.Now(), - NotAfter: time.Now().Add(time.Hour), - IsCA: true, - } - +func TestValidateTemplatePath(t *testing.T) { tests := []struct { name string - cert *x509.Certificate - path string - wantError bool - errMsg string + setup func() string + wantError string }{ { - name: "valid certificate", - cert: cert, - path: filepath.Join(tmpDir, "test-root.pem"), + name: "nonexistent_file", + setup: func() string { + return "/nonexistent/template.json" + }, + wantError: "no such file or directory", }, { - name: "invalid path", - cert: cert, - path: "/nonexistent/dir/cert.pem", - wantError: true, - errMsg: "failed to create file", + name: "wrong_extension", + setup: func() string { + tmpFile, err := os.CreateTemp("", "template-*.txt") + require.NoError(t, err) + defer tmpFile.Close() + return tmpFile.Name() + }, + wantError: "template file must have .json extension", }, { - name: "directory instead of file", - cert: cert, - path: tmpDir, - wantError: true, - errMsg: "failed to create file", + name: "valid_JSON_template", + setup: func() string { + tmpFile, err := os.CreateTemp("", "template-*.json") + require.NoError(t, err) + defer tmpFile.Close() + + content := []byte(`{ + "subject": { + "commonName": "Test CA" + }, + "issuer": { + "commonName": "Test CA" + }, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`) + _, err = tmpFile.Write(content) + require.NoError(t, err) + + return tmpFile.Name() + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := WriteCertificateToFile(tt.cert, tt.path) - if tt.wantError { - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("error %q should contain %q", err.Error(), tt.errMsg) + path := tt.setup() + defer func() { + if _, err := os.Stat(path); err == nil { + os.Remove(path) } + }() + + err := ValidateTemplatePath(path) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - content, err := os.ReadFile(tt.path) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - block, _ := pem.Decode(content) - if block == nil { - t.Errorf("failed to decode PEM block") - } - if block.Type != "CERTIFICATE" { - t.Errorf("got %v, want CERTIFICATE", block.Type) - } + require.NoError(t, err) } }) } } -func verifyIntermediateChain(rootPath, intermediatePath, leafPath string) error { - rootPEM, err := os.ReadFile(rootPath) - if err != nil { - return fmt.Errorf("error reading root certificate: %w", err) - } - intermediatePEM, err := os.ReadFile(intermediatePath) - if err != nil { - return fmt.Errorf("error reading intermediate certificate: %w", err) - } - leafPEM, err := os.ReadFile(leafPath) - if err != nil { - return fmt.Errorf("error reading leaf certificate: %w", err) - } - - rootBlock, _ := pem.Decode(rootPEM) - if rootBlock == nil { - return fmt.Errorf("failed to decode root certificate PEM") - } - rootCert, err := x509.ParseCertificate(rootBlock.Bytes) - if err != nil { - return fmt.Errorf("error parsing root certificate: %w", err) - } - - intermediateBlock, _ := pem.Decode(intermediatePEM) - if intermediateBlock == nil { - return fmt.Errorf("failed to decode intermediate certificate PEM") - } - intermediateCert, err := x509.ParseCertificate(intermediateBlock.Bytes) - if err != nil { - return fmt.Errorf("error parsing intermediate certificate: %w", err) - } - - leafBlock, _ := pem.Decode(leafPEM) - if leafBlock == nil { - return fmt.Errorf("failed to decode leaf certificate PEM") - } - leafCert, err := x509.ParseCertificate(leafBlock.Bytes) - if err != nil { - return fmt.Errorf("error parsing leaf certificate: %w", err) - } - - roots := x509.NewCertPool() - roots.AddCert(rootCert) - - intermediates := x509.NewCertPool() - intermediates.AddCert(intermediateCert) - - opts := x509.VerifyOptions{ - Roots: roots, - Intermediates: intermediates, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, - } - - _, err = leafCert.Verify(opts) - return err -} - -func verifyDirectChain(t *testing.T, rootPath, leafPath string) { - root := loadCertificate(t, rootPath) - leaf := loadCertificate(t, leafPath) - - rootPool := x509.NewCertPool() - rootPool.AddCert(root) - - _, err := leaf.Verify(x509.VerifyOptions{ - Roots: rootPool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func loadCertificate(t *testing.T, path string) *x509.Certificate { - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - block, _ := pem.Decode(data) - if block == nil { - t.Fatalf("failed to decode PEM block") - } - - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - t.Fatalf("error parsing certificate: %v", err) - } - return cert -} +func TestCreateCertificates(t *testing.T) { + defer func() { originalInitKMS = InitKMS }() -func TestValidateKMSConfig(t *testing.T) { tests := []struct { - name string - config KMSConfig - wantErr bool - wantErrMsg string + name string + config KMSConfig + rootTemplatePath string + leafTemplatePath string + rootCertPath string + leafCertPath string + intermediateKeyID string + intermediateTemplatePath string + intermediateCertPath string + setupMockSigner func() signature.SignerVerifier + wantError string }{ { - name: "empty_config", - config: KMSConfig{}, - wantErr: true, - wantErrMsg: "KMS type cannot be empty", - }, - { - name: "missing_key_ids", - config: KMSConfig{ - Type: "awskms", - Region: "us-west-2", - }, - wantErr: true, - wantErrMsg: "at least one of RootKeyID or LeafKeyID must be specified", - }, - { - name: "aws_kms_missing_region", - config: KMSConfig{ - Type: "awskms", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-1234567890ab", - }, - wantErr: true, - wantErrMsg: "region is required for AWS KMS", - }, - { - name: "aws_kms_invalid_root_key_format", + name: "leaf_key_initialization_error", config: KMSConfig{ Type: "awskms", Region: "us-west-2", - RootKeyID: "invalid-key-format", + RootKeyID: "alias/root-key", + LeafKeyID: "invalid-key", }, - wantErr: true, - wantErrMsg: "awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'", - }, - { - name: "gcp_kms_invalid_root_key_format", - config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "invalid-key-id", + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + originalKMS := InitKMS + InitKMS = func(_ context.Context, config KMSConfig) (signature.SignerVerifier, error) { + if strings.Contains(config.LeafKeyID, "invalid-key") { + return nil, fmt.Errorf("error initializing leaf KMS") + } + return &mockSignerVerifier{key: key}, nil + } + t.Cleanup(func() { + InitKMS = originalKMS + }) + return &mockSignerVerifier{key: key} }, - wantErr: true, - wantErrMsg: "must start with 'projects/'", + wantError: "error initializing leaf KMS", }, { - name: "azure_kms_missing_tenant_id", + name: "leaf_public_key_error", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=mykey", - Options: map[string]string{ - "vault": "test-vault", - }, + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", }, - wantErr: true, - wantErrMsg: "tenant-id is required for Azure KMS", - }, - { - name: "azure_kms_missing_vault", - config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=mykey", - Options: map[string]string{ - "tenant-id": "test-tenant", - }, + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + originalKMS := InitKMS + InitKMS = func(_ context.Context, config KMSConfig) (signature.SignerVerifier, error) { + if strings.Contains(config.LeafKeyID, "leaf-key") { + return &mockSignerVerifier{ + key: key, + err: fmt.Errorf("error getting leaf public key"), + }, nil + } + return &mockSignerVerifier{key: key}, nil + } + t.Cleanup(func() { + InitKMS = originalKMS + }) + return &mockSignerVerifier{key: key} }, - wantErr: true, - wantErrMsg: "azurekms RootKeyID must contain ';vault=' parameter", + wantError: "error getting leaf public key", }, { - name: "azure_kms_missing_options", + name: "intermediate_key_initialization_error", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=mykey", + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "invalid-key", + LeafKeyID: "alias/leaf-key", }, - wantErr: true, - wantErrMsg: "options map is required for Azure KMS", + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + intermediateTemplatePath: func(t *testing.T) string { + intermediateTemplate := filepath.Join(t.TempDir(), "intermediate.json") + err := os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return intermediateTemplate + }(t), + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + intermediateKeyID: "invalid-key", + intermediateCertPath: filepath.Join(t.TempDir(), "intermediate.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + originalKMS := InitKMS + InitKMS = func(_ context.Context, config KMSConfig) (signature.SignerVerifier, error) { + if strings.Contains(config.IntermediateKeyID, "invalid-key") { + return nil, fmt.Errorf("error initializing intermediate KMS") + } + return &mockSignerVerifier{key: key}, nil + } + t.Cleanup(func() { + InitKMS = originalKMS + }) + return &mockSignerVerifier{key: key} + }, + wantError: "error initializing intermediate KMS", }, { - name: "unsupported_kms_type", + name: "intermediate_public_key_error", config: KMSConfig{ - Type: "unsupported", - RootKeyID: "key-id", + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", + }, + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + intermediateTemplatePath: func(t *testing.T) string { + intermediateTemplate := filepath.Join(t.TempDir(), "intermediate.json") + err := os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return intermediateTemplate + }(t), + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + intermediateKeyID: "alias/intermediate-key", + intermediateCertPath: filepath.Join(t.TempDir(), "intermediate.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + originalKMS := InitKMS + InitKMS = func(_ context.Context, config KMSConfig) (signature.SignerVerifier, error) { + if strings.Contains(config.IntermediateKeyID, "intermediate-key") { + return &mockSignerVerifier{ + key: key, + err: fmt.Errorf("error getting intermediate public key"), + }, nil + } + return &mockSignerVerifier{key: key}, nil + } + t.Cleanup(func() { + InitKMS = originalKMS + }) + return &mockSignerVerifier{key: key} }, - wantErr: true, - wantErrMsg: "unsupported KMS type: unsupported", + wantError: "error getting intermediate public key", }, { - name: "aws_kms_invalid_arn_format", + name: "invalid_leaf_template", config: KMSConfig{ Type: "awskms", Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:invalid", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", }, - wantErr: true, - wantErrMsg: "invalid AWS KMS ARN format for RootKeyID", + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: "/nonexistent/leaf.json", + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{key: key} + }, + wantError: "error parsing leaf template: error reading template file", }, { - name: "aws_kms_region_mismatch", + name: "successful_certificate_creation", config: KMSConfig{ Type: "awskms", Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-east-1:123456789012:key/test-key", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{ + key: key, + err: fmt.Errorf("error getting leaf public key: getting public key: operation error KMS: GetPublicKey, get identity: get credentials: failed to refresh cached credentials, no EC2 IMDS role found"), + publicKeyFunc: func() (crypto.PublicKey, error) { + return key.Public(), nil + }, + } }, - wantErr: true, - wantErrMsg: "region in ARN (us-east-1) does not match configured region (us-west-2)", + wantError: "error getting leaf public key: getting public key: operation error KMS: GetPublicKey, get identity: get credentials: failed to refresh cached credentials, no EC2 IMDS role found", }, { - name: "aws_kms_empty_alias", + name: "invalid_template_path", config: KMSConfig{ Type: "awskms", Region: "us-west-2", - RootKeyID: "alias/", + RootKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/root-key", + LeafKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/leaf-key", }, - wantErr: true, - wantErrMsg: "alias name cannot be empty for RootKeyID", - }, - { - name: "azure_kms_empty_key_name", - config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=;vault=test-vault", - Options: map[string]string{ - "tenant-id": "test-tenant", - }, + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: "/nonexistent/leaf.json", + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{key: key} }, - wantErr: true, - wantErrMsg: "key name cannot be empty for RootKeyID", + wantError: "error parsing leaf template: error reading template file", }, { - name: "azure_kms_empty_vault_name", + name: "invalid_root_template_path", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=test-key;vault=", - Options: map[string]string{ - "tenant-id": "test-tenant", - }, + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", }, - wantErr: true, - wantErrMsg: "vault name cannot be empty for RootKeyID", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateKMSConfig(tt.config) - if tt.wantErr { - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.wantErrMsg) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantErrMsg) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) + rootTemplatePath: "/nonexistent/root.json", + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + return &mockSignerVerifier{ + key: nil, + err: fmt.Errorf("no such file or directory"), } - } - }) - } -} - -func TestValidateTemplate(t *testing.T) { - tests := []struct { - name string - tmpl *CertificateTemplate - parent *x509.Certificate - certType string - wantError string - }{ - { - name: "valid root CA", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{CommonName: "Test CA"}, - Issuer: struct { - CommonName string `json:"commonName"` - }{CommonName: "Test CA"}, - NotBefore: "2024-01-01T00:00:00Z", - NotAfter: "2025-01-01T00:00:00Z", - KeyUsage: []string{"certSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{IsCA: true}, - }, - certType: "root", - }, - { - name: "missing subject common name", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{}, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test Root CA", - }, - KeyUsage: []string{"certSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", - }, - certType: "root", - wantError: "subject.commonName cannot be empty", - }, - { - name: "missing issuer common name", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{}, - KeyUsage: []string{"certSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", - }, - certType: "root", - wantError: "issuer.commonName cannot be empty for root certificate", - }, - { - name: "CA without key usage", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - KeyUsage: []string{}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", - }, - certType: "root", - wantError: "CA certificate must specify at least one key usage", - }, - { - name: "leaf without code signing", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Leaf", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test Root CA", - }, - KeyUsage: []string{"digitalSignature"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: false, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", - }, - parent: &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test Root CA", - }, - }, - certType: "leaf", - wantError: "Fulcio leaf certificates must have codeSign extended key usage", - }, - { - name: "valid leaf certificate", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Leaf", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test Root CA", - }, - NotBefore: "2024-01-01T00:00:00Z", - NotAfter: "2025-01-01T00:00:00Z", - KeyUsage: []string{"digitalSignature"}, - ExtKeyUsage: []string{"CodeSigning"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: false, - }, - }, - parent: &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test Root CA", - }, - }, - certType: "leaf", - }, - { - name: "leaf without parent", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Leaf", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - KeyUsage: []string{"digitalSignature"}, - ExtKeyUsage: []string{"CodeSigning"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: false, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", }, - certType: "leaf", - parent: nil, - wantError: "parent certificate is required for non-root certificates", + wantError: "error parsing root template: error reading template file", }, { - name: "invalid key usage", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - KeyUsage: []string{"invalidKeyUsage"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", - }, - certType: "root", - wantError: "CA certificate must have certSign key usage", - }, - { - name: "invalid extended key usage", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Leaf", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test Root CA", - }, - KeyUsage: []string{"digitalSignature"}, - ExtKeyUsage: []string{"invalidExtKeyUsage"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: false, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", + name: "root_cert_write_error", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", }, - parent: &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test Root CA", - }, + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + rootCertPath: "/nonexistent/directory/root.crt", + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{ + key: key, + publicKeyFunc: func() (crypto.PublicKey, error) { + return key.Public(), nil + }, + } }, - certType: "leaf", - wantError: "Fulcio leaf certificates must have codeSign extended key usage", + wantError: "failed to create file", }, { - name: "valid intermediate certificate", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Intermediate CA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test Root CA", - }, - KeyUsage: []string{"certSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - MaxPathLen: 0, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", - }, - parent: &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test Root CA", - }, - IsCA: true, - MaxPathLen: 1, + name: "leaf_cert_write_error", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", }, - certType: "intermediate", - }, - { - name: "intermediate with wrong MaxPathLen", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Intermediate CA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test Root CA", - }, - KeyUsage: []string{"certSign", "crlSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - MaxPathLen: 2, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: "/nonexistent/directory/leaf.crt", + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{ + key: key, + publicKeyFunc: func() (crypto.PublicKey, error) { + return key.Public(), nil + }, + } }, - certType: "intermediate", - parent: &x509.Certificate{Subject: pkix.Name{CommonName: "Test Root CA"}, IsCA: true}, - wantError: "intermediate CA MaxPathLen must be 0", + wantError: "failed to create file", }, { - name: "NotBefore after NotAfter", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - NotBefore: "2025-01-01T00:00:00Z", - NotAfter: "2024-01-01T00:00:00Z", - KeyUsage: []string{"certSign", "crlSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - }, + name: "signing_error", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", }, - certType: "root", - wantError: "NotBefore time must be before NotAfter time", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateTemplate(tt.tmpl, tt.parent, tt.certType) - if tt.wantError != "" { - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantError) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + return &mockSignerVerifier{ + key: nil, + err: fmt.Errorf("signing error"), + publicKeyFunc: func() (crypto.PublicKey, error) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return key.Public(), nil + }, } - } - }) - } -} - -func TestValidateTemplateKeyUsageCombinations(t *testing.T) { - tests := []struct { - name string - tmpl *CertificateTemplate - parent *x509.Certificate - certType string - wantError string - }{ - { - name: "leaf with certSign usage", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Leaf", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - NotBefore: "2024-01-01T00:00:00Z", - NotAfter: "2025-01-01T00:00:00Z", - KeyUsage: []string{"certSign", "digitalSignature"}, - ExtKeyUsage: []string{"CodeSigning"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: false, - }, }, - certType: "leaf", - parent: &x509.Certificate{Subject: pkix.Name{CommonName: "Test CA"}}, - wantError: "leaf certificate cannot have certSign key usage", + wantError: "signing error", }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateTemplate(tt.tmpl, tt.parent, tt.certType) - if tt.wantError != "" { - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantError) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -func TestValidateLeafCertificateKeyUsage(t *testing.T) { - tests := []struct { - name string - tmpl *CertificateTemplate - parent *x509.Certificate - wantError bool - errMsg string - }{ { - name: "leaf with certSign usage", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Leaf", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - KeyUsage: []string{"certSign", "digitalSignature"}, - ExtKeyUsage: []string{"CodeSigning"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: false, - }, - NotBefore: "2021-01-01T00:00:00Z", - NotAfter: "2022-01-01T00:00:00Z", + name: "intermediate_cert_write_error", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", }, - parent: &x509.Certificate{Subject: pkix.Name{CommonName: "Test CA"}}, - wantError: true, - errMsg: "leaf certificate cannot have certSign key usage", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateTemplate(tt.tmpl, tt.parent, "leaf") - if tt.wantError { - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("error %q should contain %q", err.Error(), tt.errMsg) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -func TestValidateTemplatePath(t *testing.T) { - tests := []struct { - name string - path string - - setup func() string - wantError string - }{ - { - name: "nonexistent file", - path: "/nonexistent/template.json", - wantError: "template not found", - }, - { - name: "wrong extension", - path: "template.txt", - setup: func() string { - f, err := os.CreateTemp("", "template.txt") - if err != nil { - t.Fatalf("unexpected error: %v", err) + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + intermediateTemplatePath: func(t *testing.T) string { + intermediateTemplate := filepath.Join(t.TempDir(), "intermediate.json") + err := os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return intermediateTemplate + }(t), + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + intermediateKeyID: "alias/intermediate-key", + intermediateCertPath: filepath.Join(t.TempDir(), "intermediate.crt"), + setupMockSigner: func() signature.SignerVerifier { + return &mockSignerVerifier{ + key: nil, + publicKeyFunc: func() (crypto.PublicKey, error) { + return nil, fmt.Errorf("error writing intermediate certificate") + }, } - return f.Name() }, - wantError: "must have .json extension", + wantError: "error writing intermediate certificate", }, { - name: "invalid JSON", - path: "invalid.json", - setup: func() string { - f, err := os.CreateTemp("", "template*.json") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - err = os.WriteFile(f.Name(), []byte("invalid json"), 0600) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - return f.Name() + name: "invalid_cert_path", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: func(t *testing.T) string { + leafTemplate := filepath.Join(t.TempDir(), "leaf.json") + err := os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["CodeSigning"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return leafTemplate + }(t), + rootCertPath: "/nonexistent/directory/root.crt", + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{key: key} }, - wantError: "invalid JSON", + wantError: "failed to create file", }, { - name: "valid JSON template", - path: "valid.json", - setup: func() string { - f, err := os.CreateTemp("", "template*.json") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - err = os.WriteFile(f.Name(), []byte(`{"key": "value"}`), 0600) - if err != nil { - t.Fatalf("unexpected error: %v", err) + name: "invalid_leaf_template_path#01", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, + rootTemplatePath: func(t *testing.T) string { + rootTemplate := filepath.Join(t.TempDir(), "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + return rootTemplate + }(t), + leafTemplatePath: "/nonexistent/leaf.json", + rootCertPath: filepath.Join(t.TempDir(), "root.crt"), + leafCertPath: filepath.Join(t.TempDir(), "leaf.crt"), + setupMockSigner: func() signature.SignerVerifier { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + originalKMS := InitKMS + InitKMS = func(_ context.Context, config KMSConfig) (signature.SignerVerifier, error) { + if strings.Contains(config.LeafKeyID, "leaf-key") { + return &mockSignerVerifier{ + key: key, + publicKeyFunc: func() (crypto.PublicKey, error) { + return key.Public(), nil + }, + signMessageFunc: func(message io.Reader, _ ...signature.SignOption) ([]byte, error) { + digest := make([]byte, 32) + if _, err := message.Read(digest); err != nil { + return nil, err + } + return key.Sign(rand.Reader, digest, crypto.SHA256) + }, + }, nil + } + return &mockSignerVerifier{ + key: key, + publicKeyFunc: func() (crypto.PublicKey, error) { + return key.Public(), nil + }, + signMessageFunc: func(message io.Reader, _ ...signature.SignOption) ([]byte, error) { + digest := make([]byte, 32) + if _, err := message.Read(digest); err != nil { + return nil, err + } + return key.Sign(rand.Reader, digest, crypto.SHA256) + }, + }, nil + } + t.Cleanup(func() { + InitKMS = originalKMS + }) + return &mockSignerVerifier{ + key: key, + publicKeyFunc: func() (crypto.PublicKey, error) { + return key.Public(), nil + }, + signMessageFunc: func(message io.Reader, _ ...signature.SignOption) ([]byte, error) { + digest := make([]byte, 32) + if _, err := message.Read(digest); err != nil { + return nil, err + } + return key.Sign(rand.Reader, digest, crypto.SHA256) + }, } - return f.Name() }, + wantError: "error parsing leaf template: error reading template file", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path := tt.path - if tt.setup != nil { - path = tt.setup() - defer os.Remove(path) - } + err := CreateCertificates(tt.setupMockSigner(), tt.config, + tt.rootTemplatePath, tt.leafTemplatePath, + tt.rootCertPath, tt.leafCertPath, + tt.intermediateKeyID, tt.intermediateTemplatePath, tt.intermediateCertPath) - err := ValidateTemplatePath(path) if tt.wantError != "" { - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantError) - } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) } else { - if err != nil { - t.Errorf("unexpected error: %v", err) + require.NoError(t, err) + _, err = os.Stat(tt.rootCertPath) + require.NoError(t, err) + _, err = os.Stat(tt.leafCertPath) + require.NoError(t, err) + if tt.intermediateKeyID != "" { + _, err = os.Stat(tt.intermediateCertPath) + require.NoError(t, err) } } }) } } -func TestGCPKMSValidation(t *testing.T) { +func TestInitKMS(t *testing.T) { tests := []struct { name string config KMSConfig - wantError string + wantError bool }{ { - name: "invalid_root_key_format", + name: "empty_KMS_type", config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "invalid-key-id", + RootKeyID: "test-key", }, - wantError: "must start with 'projects/'", + wantError: true, }, { - name: "missing_required_components", + name: "missing_key_IDs", config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "projects/test-project", + Type: "awskms", }, - wantError: "gcpkms RootKeyID must contain '/locations/'", + wantError: true, }, { - name: "valid_GCP_key_format", + name: "AWS_KMS_missing_region", config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", }, - wantError: "", + wantError: true, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateKMSConfig(tt.config) - if tt.wantError != "" { - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantError) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -func TestAzureKMSValidation(t *testing.T) { - tests := []struct { - name string - config KMSConfig - wantError string - }{ { - name: "missing options map", + name: "Azure_KMS_missing_tenant_ID", config: KMSConfig{ Type: "azurekms", - RootKeyID: "azurekms:name=mykey;vault=myvault", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{}, }, - wantError: "options map is required", + wantError: true, }, { - name: "missing tenant id", + name: "Azure_KMS_missing_vault_parameter", config: KMSConfig{ Type: "azurekms", - RootKeyID: "azurekms:name=mykey;vault=myvault", - Options: map[string]string{}, + RootKeyID: "azurekms:name=test-key", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + wantError: true, + }, + { + name: "unsupported_KMS_type", + config: KMSConfig{ + Type: "unsupported", + RootKeyID: "test-key", + }, + wantError: true, + }, + { + name: "aws_kms_valid_config", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", }, - wantError: "tenant-id is required", + wantError: true, }, { - name: "invalid key format", + name: "azure_kms_valid_config", config: KMSConfig{ Type: "azurekms", - RootKeyID: "invalid-format", + RootKeyID: "azurekms:name=test-key;vault=test-vault", Options: map[string]string{ "tenant-id": "test-tenant", }, }, - wantError: "must start with 'azurekms:name='", + wantError: false, }, { - name: "missing vault name", + name: "gcp_kms_valid_config", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=mykey", + Type: "gcpkms", + RootKeyID: "projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", + }, + wantError: false, + }, + { + name: "hashivault_kms_valid_config", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/my-key", Options: map[string]string{ - "tenant-id": "test-tenant", + "token": "test-token", + "address": "http://vault:8200", }, }, - wantError: "azurekms RootKeyID must contain ';vault=' parameter", + wantError: true, + }, + { + name: "aws_kms_nil_signer", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + }, + wantError: true, + }, + { + name: "aws_kms_with_endpoint", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/test-key", + }, + wantError: false, }, { - name: "valid config", + name: "azure_kms_with_valid_format", config: KMSConfig{ Type: "azurekms", - RootKeyID: "azurekms:name=mykey;vault=myvault", + RootKeyID: "azurekms:name=test-key;vault=test-vault", Options: map[string]string{ "tenant-id": "test-tenant", }, }, + wantError: false, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateKMSConfig(tt.config) - if tt.wantError != "" { - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantError) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -func TestInitKMSErrors(t *testing.T) { - ctx := context.Background() - tests := []struct { - name string - config KMSConfig - wantError string - }{ { - name: "empty config", + name: "gcp_kms_with_cryptoKeyVersions", config: KMSConfig{ - Type: "", + Type: "gcpkms", + RootKeyID: "projects/project-id/locations/global/keyRings/keyring-name/cryptoKeys/key-name/cryptoKeyVersions/1", }, - wantError: "KMS type cannot be empty", + wantError: false, }, { - name: "unsupported KMS type", + name: "hashivault_kms_with_transit_keys", config: KMSConfig{ - Type: "unsupported", - RootKeyID: "key-id", + Type: "hashivault", + RootKeyID: "transit/keys/test-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, }, - wantError: "unsupported KMS type", + wantError: true, }, { - name: "missing required keys", + name: "aws_kms_with_alias", config: KMSConfig{ - Type: "awskms", + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/test-key", }, - wantError: "at least one of RootKeyID or LeafKeyID must be specified", + wantError: false, + }, + { + name: "aws_kms_with_arn", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + }, + wantError: true, }, { - name: "GCP KMS with nonexistent credentials file", + name: "gcp_kms_with_uri", config: KMSConfig{ Type: "gcpkms", - RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", + RootKeyID: "gcpkms://projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", + }, + wantError: true, + }, + { + name: "hashivault_kms_with_uri", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "hashivault://transit/keys/test-key", Options: map[string]string{ - "credentials-file": "/nonexistent/credentials.json", + "token": "test-token", + "address": "http://vault:8200", }, }, - wantError: "credentials file not found", + wantError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := InitKMS(ctx, tt.config) - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantError) + _, err := InitKMS(context.Background(), tt.config) + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) } }) } } -func TestInitKMS(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "kms-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) +func TestValidateTemplateWithExtKeyUsage(t *testing.T) { + template := &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test CA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test CA", + }, + KeyUsage: []string{"certSign", "crlSign"}, + ExtKeyUsage: []string{"serverAuth", "clientAuth"}, + BasicConstraints: struct { + IsCA bool `json:"isCA"` + MaxPathLen int `json:"maxPathLen"` + }{ + IsCA: true, + MaxPathLen: 1, + }, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", } - defer os.RemoveAll(tmpDir) - privKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("Failed to generate private key: %v", err) + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + NotBefore: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), } - privKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(privKey), - }) + err := ValidateTemplate(template, parent, "root") + require.NoError(t, err) +} + +func TestValidateTemplateWithInvalidExtKeyUsage(t *testing.T) { + template := &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test Leaf", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test CA", + }, + KeyUsage: []string{"digitalSignature"}, + ExtKeyUsage: []string{"nonExistentUsage", "anotherInvalidUsage"}, + BasicConstraints: struct { + IsCA bool `json:"isCA"` + MaxPathLen int `json:"maxPathLen"` + }{ + IsCA: false, + }, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + } - credsFile := filepath.Join(tmpDir, "test-credentials.json") - err = os.WriteFile(credsFile, []byte(fmt.Sprintf(`{ - "type": "service_account", - "project_id": "test-project", - "private_key_id": "test-key-id", - "private_key": %q, - "client_email": "test@test-project.iam.gserviceaccount.com", - "client_id": "123456789", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test@test-project.iam.gserviceaccount.com" - }`, string(privKeyPEM))), 0600) - if err != nil { - t.Fatalf("Failed to write credentials file: %v", err) + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + NotBefore: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), } - ctx := context.Background() + err := ValidateTemplate(template, parent, "leaf") + require.Error(t, err) + assert.Contains(t, err.Error(), "must have codeSign extended key usage") + + template.ExtKeyUsage = append(template.ExtKeyUsage, "CodeSigning") + err = ValidateTemplate(template, parent, "leaf") + require.NoError(t, err) +} + +func TestWriteCertificateToFile(t *testing.T) { tests := []struct { name string - config KMSConfig - wantError bool - errMsg string + cert *x509.Certificate + path string + wantError string + wantType string }{ { - name: "valid AWS KMS config", - config: KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - Options: map[string]string{}, + name: "write_to_nonexistent_directory", + cert: &x509.Certificate{ + Raw: []byte("test"), + Subject: pkix.Name{ + CommonName: "Test CA", + }, + IsCA: true, }, - wantError: false, + path: "/nonexistent/directory/cert.crt", + wantError: "failed to create file", }, { - name: "valid GCP KMS config", - config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", - LeafKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/leaf-key/cryptoKeyVersions/1", - Options: map[string]string{ - "credentials-file": credsFile, + name: "write_to_readonly_directory", + cert: &x509.Certificate{ + Raw: []byte("test"), + Subject: pkix.Name{ + CommonName: "Test CA", }, + IsCA: true, }, - wantError: false, + path: filepath.Join(os.TempDir(), "readonly", "cert.crt"), + wantError: "failed to create file", }, { - name: "valid Azure KMS config", - config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=test-key;vault=test-vault", - LeafKeyID: "azurekms:name=leaf-key;vault=test-vault", - Options: map[string]string{ - "tenant-id": "test-tenant", + name: "write_root_certificate", + cert: &x509.Certificate{ + Raw: []byte("test"), + Subject: pkix.Name{ + CommonName: "Test Root CA", }, + IsCA: true, + MaxPathLen: 1, }, - wantError: false, + path: filepath.Join(os.TempDir(), "root.crt"), + wantType: "root", }, { - name: "invalid KMS type", - config: KMSConfig{ - Type: "invalid", - RootKeyID: "test-key", - LeafKeyID: "leaf-key", + name: "write_intermediate_certificate", + cert: &x509.Certificate{ + Raw: []byte("test"), + Subject: pkix.Name{ + CommonName: "Test Intermediate CA", + }, + IsCA: true, + MaxPathLen: 0, }, - wantError: true, - errMsg: "invalid KMS configuration: unsupported KMS type", + path: filepath.Join(os.TempDir(), "intermediate.crt"), + wantType: "intermediate", + }, + { + name: "write_leaf_certificate", + cert: &x509.Certificate{ + Raw: []byte("test"), + Subject: pkix.Name{ + CommonName: "Test Leaf", + }, + IsCA: false, + }, + path: filepath.Join(os.TempDir(), "leaf.crt"), + wantType: "leaf", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - km, err := InitKMS(ctx, tt.config) - if tt.wantError { - if err == nil { - t.Error("expected error but got nil") - } else if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("error %q should contain %q", err.Error(), tt.errMsg) - } - if km != nil { - t.Error("expected nil KMS but got non-nil") - } + if strings.Contains(tt.name, "readonly") { + dir := filepath.Dir(tt.path) + err := os.MkdirAll(dir, 0444) + require.NoError(t, err) + defer os.RemoveAll(dir) + } + + err := WriteCertificateToFile(tt.cert, tt.path) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if km == nil { - t.Error("expected non-nil KMS but got nil") + require.NoError(t, err) + _, err = os.Stat(tt.path) + require.NoError(t, err) + if tt.wantType != "" { + assert.Contains(t, tt.path, tt.wantType) } + os.Remove(tt.path) } }) } } + +func TestWriteCertificateToFileErrors(t *testing.T) { + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test Cert", + }, + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + cert, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + parsedCert, err := x509.ParseCertificate(cert) + require.NoError(t, err) + + tests := []struct { + name string + setup func(t *testing.T) string + wantError string + }{ + { + name: "directory_exists_as_file", + setup: func(t *testing.T) string { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "cert.crt") + err := os.MkdirAll(path, 0755) + require.NoError(t, err) + return path + }, + wantError: "failed to create file", + }, + { + name: "permission_denied", + setup: func(t *testing.T) string { + tmpDir := t.TempDir() + err := os.Chmod(tmpDir, 0000) + require.NoError(t, err) + return filepath.Join(tmpDir, "cert.crt") + }, + wantError: "permission denied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + err := WriteCertificateToFile(parsedCert, path) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func TestCreateCertificatesTemplateValidation(t *testing.T) { + tmpDir := t.TempDir() + + rootTemplate := filepath.Join(tmpDir, "root.json") + err := os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + config := KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + } + + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + sv := &mockSignerVerifier{ + key: key, + publicKeyFunc: func() (crypto.PublicKey, error) { + return key.Public(), nil + }, + } + + err = CreateCertificates(sv, config, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "", + "", + "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "error parsing leaf template: Fulcio leaf certificates must have codeSign extended key usage") +} diff --git a/pkg/certmaker/template.go b/pkg/certmaker/template.go index bc8b908a8..5d003641d 100644 --- a/pkg/certmaker/template.go +++ b/pkg/certmaker/template.go @@ -121,6 +121,9 @@ func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate, certT if tmpl.BasicConstraints.MaxPathLen != 0 { return fmt.Errorf("intermediate CA MaxPathLen must be 0") } + if !containsKeyUsage(tmpl.KeyUsage, "certSign") { + return fmt.Errorf("intermediate CA certificate must have certSign key usage") + } case "leaf": if parent == nil { return fmt.Errorf("parent certificate is required for non-root certificates") @@ -155,6 +158,16 @@ func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate, certT } } + // Time validation against parent + if parent != nil { + if notBefore.Before(parent.NotBefore) { + return fmt.Errorf("certificate notBefore time cannot be before parent's notBefore time") + } + if notAfter.After(parent.NotAfter) { + return fmt.Errorf("certificate notAfter time cannot be after parent's notAfter time") + } + } + return nil } diff --git a/pkg/certmaker/template_test.go b/pkg/certmaker/template_test.go index 055fbbdbf..c3a5f5632 100644 --- a/pkg/certmaker/template_test.go +++ b/pkg/certmaker/template_test.go @@ -21,6 +21,10 @@ import ( "os" "strings" "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestValidateTemplateFields(t *testing.T) { @@ -158,6 +162,8 @@ func TestValidateTemplateFields(t *testing.T) { Subject: pkix.Name{ CommonName: "Test CA", }, + NotBefore: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), }, certType: "leaf", wantError: "leaf certificate cannot have certSign key usage", @@ -215,15 +221,139 @@ func TestValidateTemplateFields(t *testing.T) { certType: "root", wantError: "NotBefore time must be before NotAfter time", }, + { + name: "leaf without CodeSigning usage", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{CommonName: "Test Leaf"}, + Issuer: struct { + CommonName string `json:"commonName"` + }{CommonName: "Test CA"}, + KeyUsage: []string{"digitalSignature"}, + BasicConstraints: struct { + IsCA bool `json:"isCA"` + MaxPathLen int `json:"maxPathLen"` + }{IsCA: false}, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + }, + parent: &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Test CA", + }, + NotBefore: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + certType: "leaf", + wantError: "Fulcio leaf certificates must have codeSign extended key usage", + }, + { + name: "valid intermediate CA", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{CommonName: "Test Intermediate CA"}, + Issuer: struct { + CommonName string `json:"commonName"` + }{CommonName: "Test Root CA"}, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"certSign", "crlSign"}, + BasicConstraints: struct { + IsCA bool `json:"isCA"` + MaxPathLen int `json:"maxPathLen"` + }{ + IsCA: true, + MaxPathLen: 0, + }, + }, + parent: &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Test Root CA", + }, + NotBefore: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + certType: "intermediate", + }, + { + name: "intermediate with wrong MaxPathLen", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{CommonName: "Test Intermediate CA"}, + Issuer: struct { + CommonName string `json:"commonName"` + }{CommonName: "Test Root CA"}, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"certSign", "crlSign"}, + BasicConstraints: struct { + IsCA bool `json:"isCA"` + MaxPathLen int `json:"maxPathLen"` + }{ + IsCA: true, + MaxPathLen: 1, + }, + }, + parent: &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Test Root CA", + }, + NotBefore: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + certType: "intermediate", + wantError: "intermediate CA MaxPathLen must be 0", + }, + { + name: "leaf with invalid time constraints", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{CommonName: "Test Leaf"}, + NotBefore: "2023-01-01T00:00:00Z", + NotAfter: "2026-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + ExtKeyUsage: []string{"CodeSigning"}, + BasicConstraints: struct { + IsCA bool `json:"isCA"` + MaxPathLen int `json:"maxPathLen"` + }{IsCA: false}, + }, + parent: &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Test CA", + }, + NotBefore: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + certType: "leaf", + wantError: "certificate notBefore time cannot be before parent's notBefore time", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidateTemplate(tt.tmpl, tt.parent, tt.certType) if tt.wantError != "" { - if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantError) - } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) } }) } @@ -231,7 +361,8 @@ func TestValidateTemplateFields(t *testing.T) { func TestParseTemplateErrors(t *testing.T) { tests := []struct { - name string + name string + content string wantError string }{ @@ -265,22 +396,15 @@ func TestParseTemplateErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpFile, err := os.CreateTemp("", "cert-template-*.json") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) defer os.Remove(tmpFile.Name()) err = os.WriteFile(tmpFile.Name(), []byte(tt.content), 0600) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) _, err = ParseTemplate(tmpFile.Name(), nil) - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantError) - } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) }) } @@ -305,26 +429,15 @@ func TestInvalidCertificateType(t *testing.T) { } err := ValidateTemplate(tmpl, nil, "invalid") - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), "invalid certificate type") { - t.Errorf("error %q should contain %q", err.Error(), "invalid certificate type") - } + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid certificate type") } func TestContainsExtKeyUsage(t *testing.T) { - if containsExtKeyUsage(nil, "CodeSigning") { - t.Error("empty list (nil) should return false") - } - if containsExtKeyUsage([]string{}, "CodeSigning") { - t.Error("empty list should return false") - } - if !containsExtKeyUsage([]string{"CodeSigning"}, "CodeSigning") { - t.Error("should find matching usage") - } - if containsExtKeyUsage([]string{"OtherUsage"}, "CodeSigning") { - t.Error("should not find non-matching usage") - } + assert.False(t, containsExtKeyUsage(nil, "CodeSigning"), "empty list (nil) should return false") + assert.False(t, containsExtKeyUsage([]string{}, "CodeSigning"), "empty list should return false") + assert.True(t, containsExtKeyUsage([]string{"CodeSigning"}, "CodeSigning"), "should find matching usage") + assert.False(t, containsExtKeyUsage([]string{"OtherUsage"}, "CodeSigning"), "should not find non-matching usage") } func containsExtKeyUsage(usages []string, target string) bool { @@ -370,6 +483,8 @@ func TestCreateCertificateFromTemplate(t *testing.T) { Subject: pkix.Name{ CommonName: "Test CA", }, + NotBefore: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), }, wantError: false, },