diff --git a/.gitignore b/.gitignore index 77023e62..3045225b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ myblob ts_chain.pem enc-keyset.cfg chain.crt.pem +.DS_Store diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go new file mode 100644 index 00000000..a993c028 --- /dev/null +++ b/cmd/certificate_maker/certificate_maker.go @@ -0,0 +1,184 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Package main implements a certificate creation utility for Timestamp Authority. +// It supports creating root and leaf certificates using (AWS, GCP, Azure). +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/sigstore/timestamp-authority/pkg/certmaker" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +// CLI flags and env vars for config. +// Supports AWS KMS, Google Cloud KMS, and Azure Key Vault configurations. +var ( + logger *zap.Logger + version string + + rootCmd = &cobra.Command{ + Use: "tsa-certificate-maker", + Short: "Create certificate chains for Timestamp Authority", + Long: `A tool for creating root, intermediate, and leaf certificates for Timestamp Authority with timestamping capabilities`, + Version: version, + } + + createCmd = &cobra.Command{ + Use: "create", + Short: "Create certificate chain", + RunE: runCreate, + } + + kmsType string + kmsRegion string + kmsKeyID string + kmsTenantID string + kmsCredsFile string + rootTemplatePath string + leafTemplatePath string + rootKeyID string + leafKeyID string + rootCertPath string + leafCertPath string + intermediateKeyID string + intermediateTemplate string + intermediateCert string + kmsVaultToken string + kmsVaultAddr string + + rawJSON = []byte(`{ + "level": "debug", + "encoding": "json", + "outputPaths": ["stdout"], + "errorOutputPaths": ["stderr"], + "initialFields": {"service": "tsa-certificate-maker"}, + "encoderConfig": { + "messageKey": "message", + "levelKey": "level", + "levelEncoder": "lowercase", + "timeKey": "timestamp", + "timeEncoder": "iso8601" + } + }`) +) + +func init() { + logger = initLogger() + + rootCmd.AddCommand(createCmd) + + 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") + createCmd.Flags().StringVar(&kmsCredsFile, "gcp-credentials-file", "", "Path to credentials file for GCP KMS") + createCmd.Flags().StringVar(&rootTemplatePath, "root-template", + "pkg/certmaker/templates/root-template.json", "Path to root certificate template") + createCmd.Flags().StringVar(&leafTemplatePath, "leaf-template", + "pkg/certmaker/templates/leaf-template.json", "Path to leaf certificate template") + createCmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "KMS key identifier for root certificate") + createCmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "KMS key identifier for leaf certificate") + createCmd.Flags().StringVar(&rootCertPath, "root-cert", "root.pem", "Output path for root certificate") + createCmd.Flags().StringVar(&leafCertPath, "leaf-cert", "leaf.pem", "Output path for leaf certificate") + 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 { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Build KMS config from flags and environment + config := certmaker.KMSConfig{ + Type: getConfigValue(kmsType, "KMS_TYPE"), + Region: getConfigValue(kmsRegion, "AWS_REGION"), + RootKeyID: getConfigValue(rootKeyID, "KMS_ROOT_KEY_ID"), + IntermediateKeyID: getConfigValue(intermediateKeyID, "KMS_INTERMEDIATE_KEY_ID"), + LeafKeyID: getConfigValue(leafKeyID, "KMS_LEAF_KEY_ID"), + Options: make(map[string]string), + } + + // Handle KMS provider options + switch config.Type { + case "gcpkms": + if credsFile := getConfigValue(kmsCredsFile, "GCP_CREDENTIALS_FILE"); credsFile != "" { + // Check if credentials file exists before trying to use it + if _, err := os.Stat(credsFile); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("failed to initialize KMS: credentials file not found: %s", credsFile) + } + return fmt.Errorf("failed to initialize KMS: error accessing credentials file: %w", err) + } + config.Options["credentials-file"] = credsFile + } + case "azurekms": + 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) + if err != nil { + return fmt.Errorf("failed to initialize KMS: %w", err) + } + + // Validate template paths + if err := certmaker.ValidateTemplatePath(rootTemplatePath); err != nil { + return fmt.Errorf("root template error: %w", err) + } + if err := certmaker.ValidateTemplatePath(leafTemplatePath); err != nil { + return fmt.Errorf("leaf template error: %w", err) + } + + return certmaker.CreateCertificates(km, config, rootTemplatePath, leafTemplatePath, rootCertPath, leafCertPath, intermediateKeyID, intermediateTemplate, intermediateCert) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + logger.Fatal("Command failed", zap.Error(err)) + } +} + +func getConfigValue(flagValue, envVar string) string { + if flagValue != "" { + return flagValue + } + return os.Getenv(envVar) +} + +func initLogger() *zap.Logger { + var cfg zap.Config + if err := json.Unmarshal(rawJSON, &cfg); err != nil { + panic(err) + } + return zap.Must(cfg.Build()) +} diff --git a/cmd/certificate_maker/certificate_maker_test.go b/cmd/certificate_maker/certificate_maker_test.go new file mode 100644 index 00000000..f34f40ac --- /dev/null +++ b/cmd/certificate_maker/certificate_maker_test.go @@ -0,0 +1,377 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetConfigValue(t *testing.T) { + tests := []struct { + name string + flagValue string + envVar string + envValue string + want string + }{ + { + name: "flag value takes precedence", + flagValue: "flag-value", + envVar: "TEST_ENV", + envValue: "env-value", + want: "flag-value", + }, + { + name: "env value used when flag empty", + flagValue: "", + envVar: "TEST_ENV", + envValue: "env-value", + want: "env-value", + }, + { + name: "empty when both unset", + flagValue: "", + envVar: "TEST_ENV", + envValue: "", + want: "", + }, + { + name: "GCP credentials file from env", + flagValue: "", + envVar: "GCP_CREDENTIALS_FILE", + envValue: "/path/to/creds.json", + want: "/path/to/creds.json", + }, + { + name: "Azure tenant ID from env", + flagValue: "", + envVar: "AZURE_TENANT_ID", + envValue: "tenant-123", + want: "tenant-123", + }, + { + name: "AWS KMS region from env", + flagValue: "", + envVar: "AWS_REGION", + envValue: "us-west-2", + want: "us-west-2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv(tt.envVar, tt.envValue) + defer os.Unsetenv(tt.envVar) + } + got := getConfigValue(tt.flagValue, tt.envVar) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestInitLogger(t *testing.T) { + logger := initLogger() + require.NotNil(t, logger) +} + +func TestInitLoggerWithDebug(t *testing.T) { + os.Setenv("DEBUG", "true") + defer os.Unsetenv("DEBUG") + logger := initLogger() + require.NotNil(t, logger) +} + +func TestInitLoggerWithInvalidLevel(t *testing.T) { + os.Setenv("DEBUG", "invalid") + defer os.Unsetenv("DEBUG") + + logger := initLogger() + require.NotNil(t, logger) + + os.Setenv("DEBUG", "") + logger = initLogger() + require.NotNil(t, logger) +} + +func TestRunCreate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + rootTemplate := `{ + "subject": { + "commonName": "Test TSA Root CA" + }, + "issuer": { + "commonName": "Test TSA Root CA" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z", + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 + } + }` + + leafTemplate := `{ + "subject": { + "commonName": "Test TSA" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z", + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": { + "isCA": false + } + }` + + rootTmplPath := filepath.Join(tmpDir, "root-template.json") + leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") + err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0600) + require.NoError(t, err) + err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0600) + require.NoError(t, err) + + tests := []struct { + name string + args []string + envVars map[string]string + wantError bool + errMsg string + }{ + { + name: "missing KMS type", + args: []string{ + "--aws-region", "us-west-2", + "--root-key-id", "test-root-key", + "--leaf-key-id", "test-leaf-key", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "KMS type cannot be empty", + }, + { + name: "invalid KMS type", + args: []string{ + "--kms-type", "invalid", + "--aws-region", "us-west-2", + "--root-key-id", "test-root-key", + "--leaf-key-id", "test-leaf-key", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "unsupported KMS type", + }, + { + name: "missing root template", + args: []string{ + "--kms-type", "awskms", + "--aws-region", "us-west-2", + "--root-key-id", "alias/test-key", + "--leaf-key-id", "alias/test-key", + "--root-template", "nonexistent.json", + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "root template error: template not found at nonexistent.json", + }, + { + name: "missing leaf template", + args: []string{ + "--kms-type", "awskms", + "--aws-region", "us-west-2", + "--root-key-id", "alias/test-key", + "--leaf-key-id", "alias/test-key", + "--root-template", rootTmplPath, + "--leaf-template", "nonexistent.json", + }, + wantError: true, + errMsg: "leaf template error: template not found at nonexistent.json", + }, + { + name: "GCP KMS with credentials file", + args: []string{ + "--kms-type", "gcpkms", + "--root-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", + "--leaf-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/leaf-key/cryptoKeyVersions/1", + "--gcp-credentials-file", "/nonexistent/credentials.json", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "failed to initialize KMS: credentials file not found", + }, + { + name: "Azure KMS without tenant ID", + args: []string{ + "--kms-type", "azurekms", + "--root-key-id", "azurekms:name=test-key;vault=test-vault", + "--leaf-key-id", "azurekms:name=leaf-key;vault=test-vault", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "tenant-id is required", + }, + { + name: "AWS KMS test", + args: []string{ + "--kms-type", "awskms", + "--aws-region", "us-west-2", + "--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: "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", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.envVars { + os.Setenv(k, v) + defer os.Unsetenv(k) + } + + cmd := &cobra.Command{ + Use: "test", + RunE: runCreate, + } + + 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") + cmd.Flags().StringVar(&leafTemplatePath, "leaf-template", "", "Path to leaf certificate template") + cmd.Flags().StringVar(&rootCertPath, "root-cert", "root.pem", "Output path for root certificate") + cmd.Flags().StringVar(&leafCertPath, "leaf-cert", "leaf.pem", "Output path for leaf certificate") + cmd.Flags().StringVar(&intermediateKeyID, "intermediate-key-id", "", "KMS key identifier for intermediate certificate") + cmd.Flags().StringVar(&intermediateTemplate, "intermediate-template", "", "Path to intermediate certificate template") + cmd.Flags().StringVar(&intermediateCert, "intermediate-cert", "intermediate.pem", "Output path for intermediate certificate") + + cmd.SetArgs(tt.args) + err := cmd.Execute() + + if tt.wantError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCreateCommand(t *testing.T) { + cmd := &cobra.Command{ + Use: "test", + RunE: func(_ *cobra.Command, _ []string) error { + return nil + }, + } + + cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS type") + cmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") + cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "Root key ID") + cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "Leaf key ID") + + err := cmd.Execute() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + err = cmd.ParseFlags([]string{ + "--kms-type", "awskms", + "--aws-region", "us-west-2", + "--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.Errorf("unexpected error: %v", err) + } + + if kmsType != "awskms" { + t.Errorf("got kms-type %v, want awskms", kmsType) + } + if kmsRegion != "us-west-2" { + t.Errorf("got aws-region %v, want us-west-2", kmsRegion) + } + if rootKeyID != "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab" { + t.Errorf("got root-key-id %v, want arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", rootKeyID) + } + if leafKeyID != "arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654" { + t.Errorf("got leaf-key-id %v, want arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654", leafKeyID) + } +} + +func TestRootCommand(t *testing.T) { + rootCmd.SetArgs([]string{"--help"}) + err := rootCmd.Execute() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + rootCmd.SetArgs([]string{"unknown"}) + err = rootCmd.Execute() + if err == nil { + t.Errorf("expected error, but got nil") + } +} diff --git a/go.mod b/go.mod index e90998e9..e88172e2 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.10.0 github.com/urfave/negroni v1.0.0 go.step.sm/crypto v0.55.0 go.uber.org/zap v1.27.0 @@ -48,6 +49,7 @@ require ( cloud.google.com/go/iam v1.2.2 // indirect cloud.google.com/go/kms v1.20.2 // indirect cloud.google.com/go/longrunning v0.6.2 // indirect + dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect 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 @@ -55,6 +57,9 @@ require ( 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 + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go v1.55.5 // indirect github.com/aws/aws-sdk-go-v2 v1.32.6 // indirect @@ -75,6 +80,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // 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/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect @@ -106,6 +112,7 @@ require ( github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/vault/api v1.15.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jellydator/ttlcache/v3 v3.3.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect @@ -116,12 +123,15 @@ require ( github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect @@ -129,6 +139,7 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect diff --git a/go.sum b/go.sum index 73d443bb..c2fb1919 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0 cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= cloud.google.com/go/security v1.18.2 h1:9Nzp9LGjiDvHqy7X7Q9GrS5lIHN0bI8RvDjkrl4ILO0= cloud.google.com/go/security v1.18.2/go.mod h1:3EwTcYw8554iEtgK8VxAjZaq2unFehcsgFIF9nOvQmU= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M= @@ -34,6 +36,12 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 h1:gUDtaZk8heteyfdmv+pcfHvhR9llnh7c7GMwZ8RVG04= github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= @@ -216,6 +224,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= @@ -253,12 +263,16 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= @@ -302,6 +316,8 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA= github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sigstore/sigstore v1.8.11 h1:tEqeQqbT+awtM87ec9KEeSUxT/AFvJNawneYJyAkFrQ= github.com/sigstore/sigstore v1.8.11/go.mod h1:fdrFQosxCQ4wTL5H1NrZcQkqQ72AQbPjtpcL2QOGKV0= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.11 h1:4jIEBOtqDZHyQNQSw/guGmIY0y3CVdOGQu3l2FNlqpY= diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go new file mode 100644 index 00000000..484df079 --- /dev/null +++ b/pkg/certmaker/certmaker.go @@ -0,0 +1,455 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Package certmaker implements a certificate creation utility for Timestamp Authority. +// It supports creating root, intermediate, and leaf certs using (AWS, GCP, Azure, HashiVault). +package certmaker + +import ( + "bytes" + "context" + "crypto" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "os" + "strings" + + "github.com/sigstore/sigstore/pkg/signature" + "github.com/sigstore/sigstore/pkg/signature/kms" + "github.com/sigstore/sigstore/pkg/signature/options" + "go.step.sm/crypto/x509util" + + // 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" +) + +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 + Region string + RootKeyID string + IntermediateKeyID string + LeafKeyID string + Options map[string]string +} + +// InitKMS initializes KMS provider based on the given config, KMSConfig. +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) + } + + // Falls back to LeafKeyID if root is not set + keyID := config.RootKeyID + if keyID == "" { + keyID = config.LeafKeyID + } + + var sv signature.SignerVerifier + var err error + + switch config.Type { + case "awskms": + 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) + } + + 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) + } + + case "azurekms": + 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", "*") + } + 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 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(sv signature.SignerVerifier, config KMSConfig, + rootTemplatePath, leafTemplatePath string, + rootCertPath, leafCertPath string, + intermediateKeyID, intermediateTemplatePath, intermediateCertPath string) error { + + // Create root cert + rootTmpl, err := ParseTemplate(rootTemplatePath, nil) + if err != nil { + return fmt.Errorf("error parsing root template: %w", err) + } + + // Get public key from signer + rootPubKey, err := sv.PublicKey() + if err != nil { + return fmt.Errorf("error getting root public key: %w", err) + } + + signer := signerWrapper{sv} + + rootCert, err := x509util.CreateCertificate(rootTmpl, rootTmpl, rootPubKey, signer) + if err != nil { + return fmt.Errorf("error creating root certificate: %w", err) + } + + if err := WriteCertificateToFile(rootCert, rootCertPath); err != nil { + return fmt.Errorf("error writing root certificate: %w", err) + } + + var signingCert *x509.Certificate + var signingKey crypto.Signer + + if intermediateKeyID != "" { + // Create intermediate cert if key ID is provided + intermediateTmpl, err := ParseTemplate(intermediateTemplatePath, rootCert) + if err != nil { + return fmt.Errorf("error parsing intermediate template: %w", err) + } + + // 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 getting intermediate public key: %w", err) + } + + intermediateSigner := signerWrapper{intermediateSV} + + intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediatePubKey, signerWrapper{sv}) + if err != nil { + return fmt.Errorf("error creating intermediate certificate: %w", err) + } + + if err := WriteCertificateToFile(intermediateCert, intermediateCertPath); err != nil { + return fmt.Errorf("error writing intermediate certificate: %w", err) + } + + signingCert = intermediateCert + signingKey = intermediateSigner + } else { + signingCert = rootCert + signingKey = signer + } + + // Create leaf cert + leafTmpl, err := ParseTemplate(leafTemplatePath, signingCert) + if err != nil { + return fmt.Errorf("error parsing leaf template: %w", err) + } + + // 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 initializing leaf KMS: %w", err) + } + + 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) + } + + if err := WriteCertificateToFile(leafCert, leafCertPath); err != nil { + return fmt.Errorf("error writing leaf certificate: %w", err) + } + + return nil +} + +// WriteCertificateToFile writes an X.509 certificate to a PEM-encoded file +func WriteCertificateToFile(cert *x509.Certificate, filename string) error { + certPEM := &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + } + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", filename, err) + } + defer file.Close() + + if err := pem.Encode(file, certPEM); err != nil { + return fmt.Errorf("failed to write certificate to file %s: %w", filename, err) + } + + // Determine cert type + certType := "root" + if !cert.IsCA { + certType = "leaf" + } else if cert.MaxPathLen == 0 { + certType = "intermediate" + } + + fmt.Printf("Your %s certificate has been saved in %s.\n", certType, filename) + return nil +} + +// ValidateKMSConfig ensures all required KMS configuration parameters are present +func ValidateKMSConfig(config KMSConfig) error { + if config.Type == "" { + return fmt.Errorf("KMS type cannot be empty") + } + if config.RootKeyID == "" && config.LeafKeyID == "" { + return fmt.Errorf("at least one of RootKeyID or LeafKeyID must be specified") + } + + switch config.Type { + case "awskms": + // AWS KMS validation + if config.Region == "" { + return fmt.Errorf("region is required for AWS KMS") + } + validateAWSKeyID := func(keyID, keyType string) error { + if keyID == "" { + return nil + } + 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) + } + if parts[3] != config.Region { + return fmt.Errorf("region in ARN (%s) does not match configured region (%s)", parts[3], config.Region) + } + case strings.HasPrefix(keyID, "alias/"): + if strings.TrimPrefix(keyID, "alias/") == "" { + return fmt.Errorf("alias name cannot be empty for %s", keyType) + } + default: + return fmt.Errorf("awskms %s must start with 'arn:aws:kms:' or 'alias/'", keyType) + } + return nil + } + if err := validateAWSKeyID(config.RootKeyID, "RootKeyID"); err != nil { + return err + } + if err := validateAWSKeyID(config.IntermediateKeyID, "IntermediateKeyID"); err != nil { + return err + } + if err := validateAWSKeyID(config.LeafKeyID, "LeafKeyID"); err != nil { + return err + } + + case "gcpkms": + // GCP KMS validation + validateGCPKeyID := func(keyID, keyType string) error { + if keyID == "" { + return nil + } + requiredComponents := []struct { + component string + message string + }{ + {"projects/", "must start with 'projects/'"}, + {"/locations/", "must contain '/locations/'"}, + {"/keyRings/", "must contain '/keyRings/'"}, + {"/cryptoKeys/", "must contain '/cryptoKeys/'"}, + {"/cryptoKeyVersions/", "must contain '/cryptoKeyVersions/'"}, + } + for _, req := range requiredComponents { + if !strings.Contains(keyID, req.component) { + return fmt.Errorf("gcpkms %s %s", keyType, req.message) + } + } + return nil + } + if err := validateGCPKeyID(config.RootKeyID, "RootKeyID"); err != nil { + return err + } + if err := validateGCPKeyID(config.IntermediateKeyID, "IntermediateKeyID"); err != nil { + return err + } + if err := validateGCPKeyID(config.LeafKeyID, "LeafKeyID"); err != nil { + return err + } + + case "azurekms": + // Azure KMS validation + if config.Options == nil { + return fmt.Errorf("options map is required for Azure KMS") + } + if config.Options["tenant-id"] == "" { + return fmt.Errorf("tenant-id is required for Azure KMS") + } + validateAzureKeyID := func(keyID, keyType string) error { + if keyID == "" { + return nil + } + if !strings.HasPrefix(keyID, "azurekms:name=") { + return fmt.Errorf("azurekms %s must start with 'azurekms:name='", keyType) + } + nameStart := strings.Index(keyID, "name=") + 5 + vaultIndex := strings.Index(keyID, ";vault=") + if vaultIndex == -1 { + return fmt.Errorf("azurekms %s must contain ';vault=' parameter", keyType) + } + if strings.TrimSpace(keyID[nameStart:vaultIndex]) == "" { + return fmt.Errorf("key name cannot be empty for %s", keyType) + } + if strings.TrimSpace(keyID[vaultIndex+7:]) == "" { + return fmt.Errorf("vault name cannot be empty for %s", keyType) + } + return nil + } + if err := validateAzureKeyID(config.RootKeyID, "RootKeyID"); err != nil { + return err + } + if err := validateAzureKeyID(config.IntermediateKeyID, "IntermediateKeyID"); err != nil { + return err + } + if err := validateAzureKeyID(config.LeafKeyID, "LeafKeyID"); err != nil { + 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) + } + + return nil +} + +// ValidateTemplatePath checks if the template file exists and has a .json extension +func ValidateTemplatePath(path string) error { + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("template not found at %s: %w", path, err) + } + if !strings.HasSuffix(path, ".json") { + return fmt.Errorf("template file must have .json extension: %s", path) + } + + return nil +} diff --git a/pkg/certmaker/certmaker_test.go b/pkg/certmaker/certmaker_test.go new file mode 100644 index 00000000..a1140d8b --- /dev/null +++ b/pkg/certmaker/certmaker_test.go @@ -0,0 +1,1919 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package certmaker + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sigstore/sigstore/pkg/signature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockSignerVerifier implements signature.SignerVerifier for testing +type mockSignerVerifier struct { + key crypto.PrivateKey + err error + publicKeyFunc func() (crypto.PublicKey, error) +} + +func (m *mockSignerVerifier) SignMessage(message io.Reader, _ ...signature.SignOption) ([]byte, error) { + 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") + } +} + +func (m *mockSignerVerifier) VerifySignature(_, _ io.Reader, _ ...signature.VerifyOption) error { + return nil +} + +func (m *mockSignerVerifier) PublicKey(_ ...signature.PublicKeyOption) (crypto.PublicKey, error) { + if m.publicKeyFunc != nil { + return m.publicKeyFunc() + } + if m.err != nil { + return nil, m.err + } + switch k := m.key.(type) { + case *ecdsa.PrivateKey: + return k.Public(), nil + default: + return nil, fmt.Errorf("unsupported key type") + } +} + +func (m *mockSignerVerifier) Close() error { + return nil +} + +func (m *mockSignerVerifier) DefaultHashFunction() crypto.Hash { + return crypto.SHA256 +} + +func (m *mockSignerVerifier) Bytes() ([]byte, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockSignerVerifier) KeyID() (string, error) { + return "mock-key-id", nil +} + +func (m *mockSignerVerifier) Status() error { + return nil +} + +// At package level +var ( + // Store the original function + originalInitKMS = InitKMS // Changed from initKMS to InitKMS +) + +func TestValidateKMSConfig(t *testing.T) { + tests := []struct { + name string + config KMSConfig + wantError string + }{ + { + name: "empty_KMS_type", + config: KMSConfig{ + RootKeyID: "test-key", + }, + wantError: "KMS type cannot be empty", + }, + { + name: "missing_key_IDs", + config: KMSConfig{ + Type: "awskms", + }, + wantError: "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/test-key", + }, + wantError: "region is required for AWS KMS", + }, + { + 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: "Azure_KMS_missing_vault_parameter", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + wantError: "azurekms RootKeyID must contain ';vault=' parameter", + }, + { + name: "unsupported_KMS_type", + config: KMSConfig{ + Type: "unsupported", + RootKeyID: "test-key", + }, + wantError: "unsupported KMS type", + }, + { + name: "valid_AWS_KMS_config", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + }, + }, + { + name: "valid_Azure_KMS_config", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + }, + { + name: "valid_GCP_KMS_config", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "gcpkms://projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", + }, + }, + { + name: "GCP_KMS_missing_cryptoKeyVersions", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "gcpkms://projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key", + }, + wantError: "gcpkms RootKeyID must contain '/cryptoKeyVersions/'", + }, + { + name: "GCP_KMS_invalid_key_format", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "invalid-key", + }, + wantError: "gcpkms RootKeyID must start with 'projects/'", + }, + { + name: "AWS_KMS_invalid_key_format", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "invalid-key", + }, + wantError: "awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'", + }, + { + name: "Azure_KMS_invalid_key_format", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "invalid-key", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + wantError: "azurekms RootKeyID must start with 'azurekms:name='", + }, + { + name: "HashiVault_KMS_missing_options", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/my-key", + }, + wantError: "options map is required for HashiVault KMS", + }, + { + 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: "address is required for HashiVault KMS", + }, + { + name: "HashiVault_KMS_invalid_key_format", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "invalid-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, + }, + wantError: "hashivault RootKeyID must be in format: transit/keys/keyname", + }, + { + name: "valid_HashiVault_KMS_config", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/my-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateKMSConfig(tt.config) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateTemplatePath(t *testing.T) { + tests := []struct { + name string + setup func() string + wantError string + }{ + { + name: "nonexistent_file", + setup: func() string { + return "/nonexistent/template.json" + }, + wantError: "no such file or directory", + }, + { + 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: "valid_JSON_template", + setup: func() string { + tmpFile, err := os.CreateTemp("", "template-*.json") + require.NoError(t, err) + defer tmpFile.Close() + return tmpFile.Name() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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 { + require.NoError(t, err) + } + }) + } +} + +func TestCreateCertificates(t *testing.T) { + // Save original and restore after test + defer func() { InitKMS = originalInitKMS }() // Changed from initKMS to InitKMS + + // Create a mock key + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + mockSV := &mockSignerVerifier{ + key: key, + publicKeyFunc: func() (crypto.PublicKey, error) { + return key.Public(), nil + }, + } + + // Replace initKMS with mock version + InitKMS = func(_ context.Context, _ KMSConfig) (signature.SignerVerifier, error) { + return mockSV, nil + } + + tests := []struct { + name string + setup func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) + wantError string + }{ + { + name: "successful_certificate_creation", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + 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"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, 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) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + }, + { + name: "invalid_template_path", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing root template", + }, + { + name: "invalid_root_template_content", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{invalid json`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing root template", + }, + { + name: "signer_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, 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) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key, err: fmt.Errorf("signer error")} + }, + wantError: "error getting root public key", + }, + { + name: "invalid_intermediate_template", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, 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) + require.NoError(t, err) + + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{invalid json`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing intermediate template", + }, + { + name: "invalid_leaf_template", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, 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) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{invalid json`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing leaf template", + }, + { + name: "root_cert_write_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, 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) + require.NoError(t, err) + + // Create a directory where a file should be to cause a write error + rootCertDir := filepath.Join(tmpDir, "out", "root.crt") + require.NoError(t, os.MkdirAll(rootCertDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error writing root certificate", + }, + { + name: "successful_certificate_creation_without_intermediate", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + 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"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + }, + { + name: "successful_certificate_creation_with_intermediate", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + // Create root template + 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) + + // Create intermediate template + 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) + require.NoError(t, err) + + // Create leaf template + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + }, + { + name: "intermediate_cert_creation_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + // Create root template + 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) + + // Create invalid intermediate template + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "template validation error: CA certificate must have certSign key usage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, config, sv := tt.setup(t) + defer os.RemoveAll(tmpDir) + + var intermediateKeyID, intermediateTemplate, intermediateCert string + if strings.Contains(tt.name, "intermediate") { + intermediateKeyID = config.IntermediateKeyID + intermediateTemplate = filepath.Join(tmpDir, "intermediate.json") + intermediateCert = filepath.Join(tmpDir, "out", "intermediate.crt") + } + + err := CreateCertificates(sv, config, + filepath.Join(tmpDir, "root.json"), + filepath.Join(tmpDir, "leaf.json"), + filepath.Join(tmpDir, "out", "root.crt"), + filepath.Join(tmpDir, "out", "leaf.crt"), + intermediateKeyID, + intermediateTemplate, + intermediateCert) + + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + // Verify certificates were created + rootCertPath := filepath.Join(tmpDir, "out", "root.crt") + leafCertPath := filepath.Join(tmpDir, "out", "leaf.crt") + require.FileExists(t, rootCertPath) + require.FileExists(t, leafCertPath) + } + }) + } +} + +func TestInitKMS(t *testing.T) { + tests := []struct { + name string + config KMSConfig + shouldError bool + }{ + { + name: "invalid_config", + config: KMSConfig{ + Type: "invalid", + }, + shouldError: true, + }, + { + name: "aws_kms_invalid_key", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "invalid-key", + }, + shouldError: true, + }, + { + name: "azure_kms_missing_tenant", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + }, + shouldError: true, + }, + { + name: "gcp_kms_invalid_key", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "invalid-key", + }, + shouldError: true, + }, + { + name: "hashivault_kms_invalid_key", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "invalid-key", + }, + shouldError: true, + }, + { + name: "unsupported_kms_type", + config: KMSConfig{ + Type: "unsupported", + RootKeyID: "test-key", + }, + shouldError: 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", + }, + shouldError: true, + }, + { + name: "azure_kms_valid_config", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + shouldError: false, + }, + { + name: "gcp_kms_valid_config", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", + }, + shouldError: false, + }, + { + name: "hashivault_kms_valid_config", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/my-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, + }, + shouldError: 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", + }, + shouldError: true, + }, + { + name: "aws_kms_with_endpoint", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/test-key", + }, + shouldError: false, + }, + { + name: "azure_kms_with_valid_format", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + shouldError: false, + }, + { + name: "azure_kms_with_name_vault_uri", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + shouldError: false, + }, + { + name: "gcp_kms_with_cryptoKeyVersions", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "projects/project-id/locations/global/keyRings/keyring-name/cryptoKeys/key-name/cryptoKeyVersions/1", + }, + shouldError: false, + }, + { + name: "hashivault_kms_with_transit_keys", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/test-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, + }, + shouldError: true, + }, + { + name: "aws_kms_with_alias", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/test-key", + }, + shouldError: 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", + }, + shouldError: true, + }, + { + name: "azure_kms_with_vault_uri", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + shouldError: false, + }, + { + name: "gcp_kms_with_uri", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "gcpkms://projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", + }, + shouldError: true, + }, + { + name: "hashivault_kms_with_uri", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "hashivault://transit/keys/test-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, + }, + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + _, err := InitKMS(ctx, tt.config) + if tt.shouldError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCreateCertificatesWithoutIntermediate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + 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}, + "extKeyUsage": ["CodeSigning"], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + config := KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/root-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + } + + err = CreateCertificates(&mockSignerVerifier{key: key}, config, + rootTemplate, + leafTemplate, + filepath.Join(outDir, "root.crt"), + filepath.Join(outDir, "leaf.crt"), + "", // No intermediate key ID + "", // No intermediate template + "") // No intermediate cert path + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to initialize AWS KMS") +} + +func TestCreateCertificatesLeafErrors(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + 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(`{invalid json`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + config := KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/root-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + } + + err = CreateCertificates(&mockSignerVerifier{key: key}, config, + rootTemplate, + leafTemplate, + filepath.Join(outDir, "root.crt"), + filepath.Join(outDir, "leaf.crt"), + "", // No intermediate key ID + "", // No intermediate template + "") // No intermediate cert path + + require.Error(t, err) + assert.Contains(t, err.Error(), "error parsing leaf template") +} + +func TestCreateCertificatesWithErrors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) + wantError string + }{ + { + name: "root_cert_creation_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, 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": "invalid-time", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/root-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing root template: template validation error: invalid notBefore time format", + }, + { + name: "root_cert_sign_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, 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) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/root-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + }, &mockSignerVerifier{key: key, err: fmt.Errorf("signing error")} + }, + wantError: "error getting root public key: signing error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, config, sv := tt.setup(t) + defer os.RemoveAll(tmpDir) + + err := CreateCertificates(sv, config, + filepath.Join(tmpDir, "root.json"), + filepath.Join(tmpDir, "leaf.json"), + filepath.Join(tmpDir, "out", "root.crt"), + filepath.Join(tmpDir, "out", "leaf.crt"), + "", + "", + "") + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func TestWriteCertificateToFileWithErrors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) (*x509.Certificate, string) + wantError string + }{ + { + name: "file_write_error", + setup: func(t *testing.T) (*x509.Certificate, string) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test CA", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 365), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + cert, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + parsedCert, err := x509.ParseCertificate(cert) + require.NoError(t, err) + + // Create a read-only directory to cause a write error + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + require.NoError(t, os.Chmod(tmpDir, 0500)) + certPath := filepath.Join(tmpDir, "cert.crt") + + return parsedCert, certPath + }, + wantError: "failed to create file", + }, + { + name: "invalid_cert_path", + setup: func(t *testing.T) (*x509.Certificate, string) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test CA", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 365), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + cert, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + parsedCert, err := x509.ParseCertificate(cert) + require.NoError(t, err) + + return parsedCert, "/nonexistent/directory/cert.crt" + }, + wantError: "failed to create file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cert, path := tt.setup(t) + if strings.HasPrefix(path, "/var") || strings.HasPrefix(path, "/tmp") { + defer os.RemoveAll(filepath.Dir(path)) + } + + err := WriteCertificateToFile(cert, path) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func TestValidateTemplateWithInvalidExtKeyUsage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + templateFile := filepath.Join(tmpDir, "template.json") + err = os.WriteFile(templateFile, []byte(`{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test TSA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": true}, + "extensions": [ + { + "id": "2.5.29.37", + "critical": true, + "value": "MCQwIgYDVR0lBBswGQYIKwYBBQUHAwgGDSsGAQQBgjcUAgICAf8=" + } + ], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + } + + template, err := ParseTemplate(templateFile, parent) + require.Error(t, err) + assert.Contains(t, err.Error(), "CA certificate must have certSign key usage") + assert.Nil(t, template) +} + +func TestCreateCertificatesWithInvalidIntermediateKey(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid root template + 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) + + // Create valid leaf template + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create valid intermediate template + 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) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Test with invalid intermediate key ID format + err = CreateCertificates( + &mockSignerVerifier{key: key}, + KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms://test-endpoint/alias/test-key", + IntermediateKeyID: "invalid-intermediate-key", + LeafKeyID: "awskms://test-endpoint/alias/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "invalid-intermediate-key", + intermediateTemplate, + filepath.Join(tmpDir, "intermediate.crt"), + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error initializing intermediate KMS: invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") +} + +func TestCreateCertificatesWithInvalidLeafKey(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid root template + 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) + + // Create valid leaf template + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Test with invalid leaf key ID format + err = CreateCertificates( + &mockSignerVerifier{key: key}, + KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + LeafKeyID: "invalid-leaf-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error initializing leaf KMS") +} + +func TestCreateCertificatesWithInvalidRootCert(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create invalid root template (missing required fields) + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {}, + "issuer": {}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create valid leaf template + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + err = CreateCertificates( + &mockSignerVerifier{key: key}, + 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/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "subject.commonName cannot be empty") +} + +func TestCreateCertificatesWithInvalidCertPath(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid templates + 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"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Create a directory where a file should be and make it read-only + invalidPath := filepath.Join(tmpDir, "invalid") + err = os.MkdirAll(invalidPath, 0444) // Changed permissions to read-only + require.NoError(t, err) + + err = CreateCertificates( + &mockSignerVerifier{key: key}, + 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/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(invalidPath, "root.crt"), + filepath.Join(invalidPath, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error writing root certificate") +} + +func TestWriteCertificateToFileWithPEMError(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a directory where a file should be to cause a write error + certPath := filepath.Join(tmpDir, "cert.pem") + err = os.MkdirAll(certPath, 0755) // Create a directory instead of a file + require.NoError(t, err) + + // Create a valid certificate + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(certBytes) + require.NoError(t, err) + + // Try to write to a path that is a directory, which should fail + err = WriteCertificateToFile(cert, certPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create file") +} + +func TestCreateCertificatesWithInvalidRootKey(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid templates + 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"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Test with signing error + err = CreateCertificates( + &mockSignerVerifier{key: nil, err: fmt.Errorf("signing error")}, + 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/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error getting root public key: signing error") +} + +func TestCreateCertificatesWithInvalidLeafTemplate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid root template + 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) + + // Create invalid leaf template (missing TimeStamping extKeyUsage) + 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) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + err = CreateCertificates( + &mockSignerVerifier{key: key}, + KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms://test-endpoint/alias/test-key", + LeafKeyID: "awskms://test-endpoint/alias/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error initializing leaf KMS: invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") +} + +func TestCreateCertificatesWithIntermediateErrors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) + wantError string + }{ + { + name: "intermediate_template_parse_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + 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"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{invalid json`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, 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", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing intermediate template", + }, + { + name: "intermediate_cert_write_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + 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"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, 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) + require.NoError(t, err) + + // Create a directory where the intermediate cert file should be + intermediateCertDir := filepath.Join(outDir, "intermediate.crt") + require.NoError(t, os.MkdirAll(intermediateCertDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, 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", + }, &mockSignerVerifier{key: key} + }, + wantError: "error initializing intermediate KMS", + }, + { + name: "leaf_cert_with_intermediate_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + 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"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "invalid-time", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, 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) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, 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", + }, &mockSignerVerifier{key: key} + }, + wantError: "error initializing intermediate KMS", + }, + { + name: "invalid_intermediate_template_validation", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + 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"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, 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", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing intermediate template: template validation error: CA certificate must have certSign key usage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, config, sv := tt.setup(t) + defer os.RemoveAll(tmpDir) + + err := CreateCertificates(sv, config, + filepath.Join(tmpDir, "root.json"), + filepath.Join(tmpDir, "leaf.json"), + filepath.Join(tmpDir, "out", "root.crt"), + filepath.Join(tmpDir, "out", "leaf.crt"), + config.IntermediateKeyID, + filepath.Join(tmpDir, "intermediate.json"), + filepath.Join(tmpDir, "out", "intermediate.crt")) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} diff --git a/pkg/certmaker/template.go b/pkg/certmaker/template.go new file mode 100644 index 00000000..d1fbaaaf --- /dev/null +++ b/pkg/certmaker/template.go @@ -0,0 +1,258 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Package certmaker provides template parsing and certificate generation functionality +// for creating X.509 certificates from JSON templates per RFC3161 standards. +package certmaker + +import ( + "bytes" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "os" + "strconv" + "strings" + "text/template" + "time" + + "go.step.sm/crypto/x509util" +) + +// CertificateTemplate defines the structure for the JSON certificate templates +type CertificateTemplate struct { + Subject struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + } `json:"subject"` + Issuer struct { + CommonName string `json:"commonName"` + } `json:"issuer"` + NotBefore string `json:"notBefore"` + NotAfter string `json:"notAfter"` + KeyUsage []string `json:"keyUsage"` + BasicConstraints struct { + IsCA bool `json:"isCA"` + MaxPathLen int `json:"maxPathLen"` + } `json:"basicConstraints"` + Extensions []struct { + ID string `json:"id"` + Critical bool `json:"critical"` + Value string `json:"value"` + } `json:"extensions,omitempty"` +} + +// TemplateData holds context data passed to the template parser +type TemplateData struct { + Parent *x509.Certificate +} + +// ParseTemplate creates an x509 certificate from JSON template +func ParseTemplate(filename string, parent *x509.Certificate) (*x509.Certificate, error) { + content, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("error reading template file: %w", err) + } + + data := &TemplateData{ + Parent: parent, + } + + // Borrows x509util functions to create template + tmpl, err := template.New("cert").Funcs(x509util.GetFuncMap()).Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("leaf template error: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("leaf template error: %w", err) + } + + // Parse template as JSON + var certTmpl CertificateTemplate + if err := json.Unmarshal(buf.Bytes(), &certTmpl); err != nil { + return nil, fmt.Errorf("leaf template error: invalid JSON after template execution: %w", err) + } + + if err := ValidateTemplate(&certTmpl, parent); err != nil { + return nil, fmt.Errorf("template validation error: %w", err) + } + + return CreateCertificateFromTemplate(&certTmpl, parent) +} + +// ValidateTemplate performs validation checks on the certificate template. +func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) error { + if tmpl.NotBefore == "" { + return fmt.Errorf("notBefore time must be specified") + } + if tmpl.NotAfter == "" { + return fmt.Errorf("notAfter time must be specified") + } + if _, err := time.Parse(time.RFC3339, tmpl.NotBefore); err != nil { + return fmt.Errorf("invalid notBefore time format: %w", err) + } + if _, err := time.Parse(time.RFC3339, tmpl.NotAfter); err != nil { + return fmt.Errorf("invalid notAfter time format: %w", err) + } + if tmpl.Subject.CommonName == "" { + return fmt.Errorf("template subject.commonName cannot be empty") + } + if parent == nil && tmpl.Issuer.CommonName == "" { + return fmt.Errorf("template issuer.commonName cannot be empty for root certificate") + } + + // For CA certs + if tmpl.BasicConstraints.IsCA { + if len(tmpl.KeyUsage) == 0 { + return fmt.Errorf("CA certificate must specify at least one key usage") + } + hasKeyUsageCertSign := false + for _, usage := range tmpl.KeyUsage { + if usage == "certSign" { + hasKeyUsageCertSign = true + break + } + } + if !hasKeyUsageCertSign { + return fmt.Errorf("CA certificate must have certSign key usage") + } + } else { + // For non-CA certs + if len(tmpl.KeyUsage) == 0 { + return fmt.Errorf("certificate must specify at least one key usage") + } + hasDigitalSignature := false + for _, usage := range tmpl.KeyUsage { + if usage == "digitalSignature" { + hasDigitalSignature = true + break + } + } + if !hasDigitalSignature { + return fmt.Errorf("timestamp authority certificate must have digitalSignature key usage") + } + } + + // Validate extensions + for _, ext := range tmpl.Extensions { + if ext.ID == "" { + return fmt.Errorf("extension ID cannot be empty") + } + // Validate OID format + for _, n := range strings.Split(ext.ID, ".") { + if _, err := strconv.Atoi(n); err != nil { + return fmt.Errorf("invalid OID component in extension: %s", ext.ID) + } + } + } + + notBefore, _ := time.Parse(time.RFC3339, tmpl.NotBefore) + notAfter, _ := time.Parse(time.RFC3339, tmpl.NotAfter) + if notBefore.After(notAfter) { + return fmt.Errorf("NotBefore time must be before NotAfter time") + } + + return nil +} + +// CreateCertificateFromTemplate creates an x509.Certificate from the provided template +func CreateCertificateFromTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) (*x509.Certificate, error) { + notBefore, err := time.Parse(time.RFC3339, tmpl.NotBefore) + if err != nil { + return nil, fmt.Errorf("invalid notBefore time format: %w", err) + } + + notAfter, err := time.Parse(time.RFC3339, tmpl.NotAfter) + if err != nil { + return nil, fmt.Errorf("invalid notAfter time format: %w", err) + } + + cert := &x509.Certificate{ + Subject: pkix.Name{ + Country: tmpl.Subject.Country, + Organization: tmpl.Subject.Organization, + OrganizationalUnit: tmpl.Subject.OrganizationalUnit, + CommonName: tmpl.Subject.CommonName, + }, + Issuer: func() pkix.Name { + if parent != nil { + return parent.Subject + } + return pkix.Name{CommonName: tmpl.Issuer.CommonName} + }(), + SerialNumber: big.NewInt(time.Now().Unix()), + NotBefore: notBefore, + NotAfter: notAfter, + BasicConstraintsValid: true, + IsCA: tmpl.BasicConstraints.IsCA, + ExtraExtensions: []pkix.Extension{}, + } + + if tmpl.BasicConstraints.IsCA { + cert.MaxPathLen = tmpl.BasicConstraints.MaxPathLen + cert.MaxPathLenZero = tmpl.BasicConstraints.MaxPathLen == 0 + } + + SetKeyUsages(cert, tmpl.KeyUsage) + + // Sets extensions (e.g. Timestamping) + for _, ext := range tmpl.Extensions { + var oid []int + for _, n := range strings.Split(ext.ID, ".") { + i, err := strconv.Atoi(n) + if err != nil { + return nil, fmt.Errorf("invalid OID in extension: %s", ext.ID) + } + oid = append(oid, i) + } + + extension := pkix.Extension{ + Id: oid, + Critical: ext.Critical, + } + + value, err := base64.StdEncoding.DecodeString(ext.Value) + if err != nil { + return nil, fmt.Errorf("error decoding extension value: %w", err) + } + extension.Value = value + + cert.ExtraExtensions = append(cert.ExtraExtensions, extension) + } + + return cert, nil +} + +// SetKeyUsages applies the specified key usage to cert(s) +// supporting certSign, crlSign, and digitalSignature usages. +func SetKeyUsages(cert *x509.Certificate, usages []string) { + for _, usage := range usages { + switch usage { + case "certSign": + cert.KeyUsage |= x509.KeyUsageCertSign + case "crlSign": + cert.KeyUsage |= x509.KeyUsageCRLSign + case "digitalSignature": + cert.KeyUsage |= x509.KeyUsageDigitalSignature + } + } +} diff --git a/pkg/certmaker/template_test.go b/pkg/certmaker/template_test.go new file mode 100644 index 00000000..075fb778 --- /dev/null +++ b/pkg/certmaker/template_test.go @@ -0,0 +1,381 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package certmaker + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sigstore/sigstore/pkg/signature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTemplate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-template-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + } + + tests := []struct { + name string + content string + parent *x509.Certificate + wantError string + }{ + { + name: "valid template", + content: `{ + "subject": { + "commonName": "Test TSA" + }, + "issuer": { + "commonName": "Test TSA" + }, + "keyUsage": [ + "digitalSignature" + ], + "basicConstraints": { + "isCA": false + }, + "extensions": [ + { + "id": "2.5.29.37", + "critical": true, + "value": "MCQwIgYDVR0lBBswGQYIKwYBBQUHAwgGDSsGAQQBgjcUAgICAf8=" + } + ], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`, + parent: parent, + }, + { + name: "missing required fields", + content: `{ + "issuer": {"commonName": "Test TSA"}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`, + wantError: "subject.commonName cannot be empty", + }, + { + name: "invalid time format", + content: `{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test TSA"}, + "notBefore": "invalid", + "notAfter": "2025-01-01T00:00:00Z" + }`, + wantError: "invalid notBefore time format", + }, + { + name: "missing digital signature usage", + content: `{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test TSA"}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z", + "keyUsage": ["certSign"], + "basicConstraints": {"isCA": false} + }`, + wantError: "timestamp authority certificate must have digitalSignature key usage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpFile := filepath.Join(tmpDir, "template.json") + err := os.WriteFile(tmpFile, []byte(tt.content), 0600) + require.NoError(t, err) + + cert, err := ParseTemplate(tmpFile, tt.parent) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + if cert == nil { + t.Error("Expected non-nil certificate") + } + } + }) + } +} + +func TestParseTemplateWithInvalidExtensions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-template-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + content := `{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test TSA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": false}, + "extensions": [ + { + "id": "2.5.29.37", + "critical": true, + "value": "invalid-base64" + } + ], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }` + + tmpFile := filepath.Join(tmpDir, "template.json") + err = os.WriteFile(tmpFile, []byte(content), 0600) + require.NoError(t, err) + + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + } + + cert, err := ParseTemplate(tmpFile, parent) + require.Error(t, err) + assert.Contains(t, err.Error(), "error decoding extension value") + assert.Nil(t, cert) +} + +func TestValidateTemplate(t *testing.T) { + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + } + + tests := []struct { + name string + tmpl *CertificateTemplate + parent *x509.Certificate + wantError string + }{ + { + name: "valid TSA template", + 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 TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + Extensions: []struct { + ID string `json:"id"` + Critical bool `json:"critical"` + Value string `json:"value"` + }{ + { + ID: "2.5.29.37", + Critical: true, + Value: base64.StdEncoding.EncodeToString([]byte{0x30, 0x24, 0x30, 0x22, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x08}), + }, + }, + }, + parent: parent, + }, + { + name: "empty notBefore time", + 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 TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + }, + wantError: "notBefore time must be specified", + }, + { + name: "empty notAfter time", + 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 TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "2024-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + }, + wantError: "notAfter time must be specified", + }, + { + name: "invalid notBefore format", + 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 TSA", + }, + NotBefore: "invalid", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + }, + wantError: "invalid notBefore time format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTemplate(tt.tmpl, tt.parent) + 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()) + } + } else if err != nil { + t.Errorf("Unexpected error: %v", err) + } + }) + } +} + +func TestValidateTemplateWithMockKMS(t *testing.T) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + mockSigner := &mockSignerVerifier{ + key: privKey, + } + + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + } + + tests := []struct { + name string + tmpl *CertificateTemplate + parent *x509.Certificate + signer signature.SignerVerifier + wantError string + }{ + { + name: "valid TSA template with mock KMS", + 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 TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + Extensions: []struct { + ID string `json:"id"` + Critical bool `json:"critical"` + Value string `json:"value"` + }{ + { + ID: "2.5.29.37", + Critical: true, + Value: base64.StdEncoding.EncodeToString([]byte{0x30, 0x24, 0x30, 0x22, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x08}), + }, + }, + }, + parent: parent, + signer: mockSigner, + }, + { + name: "invalid TSA template with mock KMS", + 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 TSA", + }, + NotBefore: "invalid", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + }, + parent: parent, + signer: mockSigner, + wantError: "invalid notBefore time format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTemplate(tt.tmpl, tt.parent) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/certmaker/templates/intermediate-template.json b/pkg/certmaker/templates/intermediate-template.json new file mode 100644 index 00000000..e9d9650d --- /dev/null +++ b/pkg/certmaker/templates/intermediate-template.json @@ -0,0 +1,27 @@ +{ + "subject": { + "country": [ + "US" + ], + "organization": [ + "Sigstore" + ], + "organizationalUnit": [ + "Timestamp Authority Intermediate CA" + ], + "commonName": "https://tsa.com" + }, + "issuer": { + "commonName": "https://tsa.com" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2034-01-01T00:00:00Z", + "basicConstraints": { + "isCA": true, + "maxPathLen": 0 + }, + "keyUsage": [ + "certSign", + "crlSign" + ] +} \ No newline at end of file diff --git a/pkg/certmaker/templates/leaf-template.json b/pkg/certmaker/templates/leaf-template.json new file mode 100644 index 00000000..a5ab9c73 --- /dev/null +++ b/pkg/certmaker/templates/leaf-template.json @@ -0,0 +1,32 @@ +{ + "subject": { + "country": [ + "US" + ], + "organization": [ + "Sigstore" + ], + "organizationalUnit": [ + "Timestamp Authority Leaf CA" + ], + "commonName": "https://tsa.com" + }, + "issuer": { + "commonName": "https://tsa.com" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2034-01-01T00:00:00Z", + "basicConstraints": { + "isCA": false + }, + "keyUsage": [ + "digitalSignature" + ], + "extensions": [ + { + "id": "2.5.29.37", + "critical": true, + "value": {{ asn1Seq (asn1Enc "oid:1.3.6.1.5.5.7.3.8") | toJson }} + } + ] +} \ No newline at end of file diff --git a/pkg/certmaker/templates/root-template.json b/pkg/certmaker/templates/root-template.json new file mode 100644 index 00000000..f6d32919 --- /dev/null +++ b/pkg/certmaker/templates/root-template.json @@ -0,0 +1,27 @@ +{ + "subject": { + "country": [ + "US" + ], + "organization": [ + "Sigstore" + ], + "organizationalUnit": [ + "Timestamp Authority Root CA" + ], + "commonName": "https://tsa.com" + }, + "issuer": { + "commonName": "https://tsa.com" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2034-01-01T00:00:00Z", + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 + }, + "keyUsage": [ + "certSign", + "crlSign" + ] +} \ No newline at end of file