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/Makefile b/Makefile index c0c942e6..90a077c6 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ .PHONY: all test clean clean-gen lint gosec ko ko-local -all: timestamp-cli timestamp-server +all: timestamp-cli timestamp-server cert-maker GENSRC = pkg/generated/client/%.go pkg/generated/models/%.go pkg/generated/restapi/%.go OPENAPIDEPS = openapi.yaml @@ -79,13 +79,17 @@ timestamp-cli: $(SRCS) ## Build the TSA CLI timestamp-server: $(SRCS) ## Build the TSA server CGO_ENABLED=0 go build -trimpath -ldflags "$(SERVER_LDFLAGS)" -o bin/timestamp-server ./cmd/timestamp-server +.PHONY: cert-maker +cert-maker: ## Build the TSA Certificate Maker tool + CGO_ENABLED=0 go build -trimpath -ldflags "$(CLI_LDFLAGS)" -o bin/tsa-certificate-maker ./cmd/certificate_maker + test: timestamp-cli ## Run tests go test ./... clean: ## Clean all builds rm -rf dist rm -rf hack/tools/bin - rm -rf bin/timestamp-cli bin/timestamp-server + rm -rf bin/timestamp-cli bin/timestamp-server bin/tsa-certificate-maker clean-gen: clean ## Clean generated code rm -rf $(shell find pkg/generated -iname "*.go"|grep -v pkg/generated/restapi/configure_timestamp_server.go) diff --git a/README.md b/README.md index bfcf4266..d699cd81 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,16 @@ To deploy to production, the timestamp authority currently supports signing with a certificate chain (leaf, any intermediates, and root), where the certificate chain's purpose (extended key usage) is for timestamping. We do not recommend the file signer for production since the signing key will only be password protected. +### Certificate Maker + +TSA's Certificate Maker is a tool for creating RFC 3161 compliant certificate chains for Timestamp Authority. It supports: + +* Two-level chains (root -> leaf) +* Three-level chains (root -> intermediate -> leaf) +* Multiple KMS providers (AWS, Google Cloud, Azure, HashiCorp Vault) + +For detailed usage instructions and examples, see the [Certificate Maker documentation](docs/certificate-maker.md). + ### Cloud KMS Create an asymmetric cloud KMS signing key in either GCP, AWS, Azure, or Vault, that will be used to sign timestamps. 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..acd938d5 --- /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: "error getting root public key: getting public key", + }, + { + 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/docs/certificate-maker.md b/docs/certificate-maker.md new file mode 100644 index 00000000..0bd34bde --- /dev/null +++ b/docs/certificate-maker.md @@ -0,0 +1,293 @@ +# TSA Certificate Maker + +This tool creates root, intermediate (optional), and leaf certificates for Timestamp Authority ([certificate requirements](tsa-policy.md)): + +- Two-level chain (root -> leaf) +- Three-level chain (root -> intermediate -> leaf) + +## Requirements + +- Access to one of the supported KMS providers (AWS, Google Cloud, Azure) +- Pre-existing KMS keys (the tool uses existing keys and does not create new ones) + +## Local Development + +Build the binary: + +```bash +make cert-maker +./bin/tsa-certificate-maker --help +``` + +## Usage + +The tool can be configured using either command-line flags or environment variables. + +### Command-Line Interface + +Available flags: + +- `--kms-type`: KMS provider type (awskms, gcpkms, azurekms, hashivault) +- `--root-key-id`: KMS key identifier for root certificate +- `--leaf-key-id`: KMS key identifier for leaf certificate +- `--aws-region`: AWS region (required for AWS KMS) +- `--azure-tenant-id`: Azure KMS tenant ID +- `--gcp-credentials-file`: Path to credentials file (for Google Cloud KMS) +- `--vault-address`: HashiCorp Vault address +- `--vault-token`: HashiCorp Vault token +- `--root-template`: Path to root certificate template +- `--leaf-template`: Path to leaf certificate template +- `--root-cert`: Output path for root certificate (default: root.pem) +- `--leaf-cert`: Output path for leaf certificate (default: leaf.pem) +- `--intermediate-key-id`: KMS key identifier for intermediate certificate +- `--intermediate-template`: Path to intermediate certificate template +- `--intermediate-cert`: Output path for intermediate certificate + +### Environment Variables + +- `KMS_TYPE`: KMS provider type ("awskms", "gcpkms", "azurekms", "hashivault") +- `ROOT_KEY_ID`: Key identifier for root certificate +- `KMS_INTERMEDIATE_KEY_ID`: Key identifier for intermediate certificate +- `LEAF_KEY_ID`: Key identifier for leaf certificate +- `AWS_REGION`: AWS Region (required for AWS KMS) +- `KMS_VAULT_NAME`: Azure Key Vault name +- `AZURE_TENANT_ID`: Azure tenant ID +- `GCP_CREDENTIALS_FILE`: Path to credentials file (for Google Cloud KMS) +- `VAULT_ADDR`: HashiCorp Vault address +- `VAULT_TOKEN`: HashiCorp Vault token + +### Certificate Templates + +The tool uses JSON templates to define certificate properties: + +- `root-template.json`: Defines root CA certificate properties +- `intermediate-template.json`: Defines intermediate CA certificate properties (when using --intermediate-key-id) +- `leaf-template.json`: Defines leaf certificate properties + +Templates are located in `pkg/certmaker/templates/`. + +Note: Templates use ASN.1/OID format for timestamping-specific extensions. + +### Provider-Specific Configuration Examples + +#### AWS KMS + +```shell +export KMS_TYPE=awskms +export AWS_REGION=us-east-1 +export ROOT_KEY_ID=alias/root-key +export KMS_INTERMEDIATE_KEY_ID=alias/intermediate-key +export LEAF_KEY_ID=alias/leaf-key +``` + +#### Google Cloud KMS + +```shell +export KMS_TYPE=gcpkms +export ROOT_KEY_ID=projects/PROJECT_ID/locations/LOCATION/keyRings/KEYRING/cryptoKeys/KEY_NAME/cryptoKeyVersions/VERSION +export LEAF_KEY_ID=projects/PROJECT_ID/locations/LOCATION/keyRings/KEYRING/cryptoKeys/KEY_NAME/cryptoKeyVersions/VERSION +export KMS_INTERMEDIATE_KEY_ID=projects/PROJECT_ID/locations/LOCATION/keyRings/KEYRING/cryptoKeys/KEY_NAME/cryptoKeyVersions/VERSION +``` + +#### Azure KMS + +```shell +export KMS_TYPE=azurekms +export ROOT_KEY_ID=azurekms:name=root-key;vault=tsa-keys +export KMS_INTERMEDIATE_KEY_ID=azurekms:name=leaf-key;vault=fulcio-keys +export LEAF_KEY_ID=azurekms:name=leaf-key;vault=tsa-keys +export AZURE_TENANT_ID=83j229-83j229-83j229-83j229-83j229 +``` + +#### HashiCorp Vault KMS + +```shell +export KMS_TYPE=hashivault +export ROOT_KEY_ID=transit/keys/root-key +export KMS_INTERMEDIATE_KEY_ID=transit/keys/intermediate-key +export LEAF_KEY_ID=transit/keys/leaf-key +export VAULT_ADDR=http://vault:8200 +export VAULT_TOKEN=token +``` + +### Example Certificate Outputs + +#### TSA Leaf Certificate + +```bash +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 1733012132 (0x674baaa4) + Signature Algorithm: ecdsa-with-SHA256 + Issuer: C=US, O=Sigstore, OU=Timestamp Authority Intermediate CA, CN=https://tsa.com + Validity + Not Before: Jan 1 00:00:00 2024 GMT + Not After : Jan 1 00:00:00 2034 GMT + Subject: C=US, O=Sigstore, OU=Timestamp Authority Leaf CA, CN=https://tsa.com + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + pub: + 04:f8:ca:84:0d:9d:31:da:d0:94:1f:2a:53:ff:3f: + f2:39:ca:90:5b:8c:26:29:28:02:a7:e2:10:80:92: + 1b:9f:3a:03:c7:cd:36:7a:2c:2b:1c:0c:95:bc:86: + 73:b4:55:46:0e:50:29:34:1e:07:a6:64:41:13:ca: + 36:5d:d4:71:dd + ASN1 OID: prime256v1 + NIST CURVE: P-256 + X509v3 extensions: + X509v3 Key Usage: critical + Digital Signature + X509v3 Basic Constraints: critical + CA:FALSE + X509v3 Subject Key Identifier: + 0D:1B:3F:95:18:04:65:60:AD:E3:28:D0:B7:43:45:BD:FE:63:5A:DF + X509v3 Authority Key Identifier: + 0D:1B:3F:95:18:04:65:60:AD:E3:28:D0:B7:43:45:BD:FE:63:5A:DF + X509v3 Extended Key Usage: critical + Time Stamping + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:44:02:20:27:6e:80:88:de:6c:0f:57:be:10:f7:1d:32:97: + 73:a5:dc:6a:92:3e:26:90:4b:4b:02:05:7c:a8:85:5f:74:f4: + 02:20:5d:50:57:15:96:90:d9:82:7d:97:50:c4:8c:b7:97:a3: + 8e:0b:a3:ab:dd:26:bd:dc:cc:19:0d:99:63:5a:ce:6e +``` + +#### TSA Intermediate Certificate + +```bash +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 1733012132 (0x674baaa4) + Signature Algorithm: ecdsa-with-SHA256 + Issuer: C=US, O=Sigstore, OU=Timestamp Authority Root CA, CN=https://tsa.com + Validity + Not Before: Jan 1 00:00:00 2024 GMT + Not After : Jan 1 00:00:00 2034 GMT + Subject: C=US, O=Sigstore, OU=Timestamp Authority Intermediate CA, CN=https://tsa.com + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + pub: + 04:f8:ca:84:0d:9d:31:da:d0:94:1f:2a:53:ff:3f: + f2:39:ca:90:5b:8c:26:29:28:02:a7:e2:10:80:92: + 1b:9f:3a:03:c7:cd:36:7a:2c:2b:1c:0c:95:bc:86: + 73:b4:55:46:0e:50:29:34:1e:07:a6:64:41:13:ca: + 36:5d:d4:71:dd + ASN1 OID: prime256v1 + NIST CURVE: P-256 + X509v3 extensions: + X509v3 Key Usage: critical + Certificate Sign, CRL Sign + X509v3 Basic Constraints: critical + CA:TRUE, pathlen:0 + X509v3 Subject Key Identifier: + 0D:1B:3F:95:18:04:65:60:AD:E3:28:D0:B7:43:45:BD:FE:63:5A:DF + X509v3 Authority Key Identifier: + BB:84:41:46:F0:A6:90:38:C0:73:1E:11:F4:58:7C:44:9B:C6:45:89 + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:45:02:20:04:13:5f:f9:16:d8:b3:d8:cf:22:a4:f7:70:1a: + f4:25:c5:63:97:14:2f:ac:d6:af:15:3d:e6:ad:a7:0a:08:c8: + 02:21:00:d7:63:02:ed:ef:74:9e:05:a8:86:03:ff:12:01:fb: + 21:10:74:6b:db:e7:64:65:29:3b:ae:4d:de:57:98:5c:2b +``` + +#### TSA Root Certificate + +```bash +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 1733012131 (0x674baaa3) + Signature Algorithm: ecdsa-with-SHA256 + Issuer: C=US, O=Sigstore, OU=Timestamp Authority Root CA, CN=https://tsa.com + Validity + Not Before: Jan 1 00:00:00 2024 GMT + Not After : Jan 1 00:00:00 2034 GMT + Subject: C=US, O=Sigstore, OU=Timestamp Authority Root CA, CN=https://tsa.com + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + pub: + 04:73:77:29:2b:48:de:da:82:53:60:36:ac:9e:b7: + e1:78:3e:e1:d6:58:f1:7e:fa:b2:2a:28:c5:c8:d4: + 25:c6:e8:5c:d1:63:a8:22:3e:a6:7b:bb:3b:d7:f3: + 98:c8:25:52:12:2a:c1:fb:9b:56:af:97:77:a4:48: + 89:be:49:bc:63 + ASN1 OID: prime256v1 + NIST CURVE: P-256 + X509v3 extensions: + X509v3 Key Usage: critical + Certificate Sign, CRL Sign + X509v3 Basic Constraints: critical + CA:TRUE, pathlen:1 + X509v3 Subject Key Identifier: + BB:84:41:46:F0:A6:90:38:C0:73:1E:11:F4:58:7C:44:9B:C6:45:89 + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:44:02:20:5a:d8:12:e0:ad:f9:2e:18:8c:5c:40:11:62:67: + 64:3d:20:22:6b:29:48:e5:ef:c6:99:90:46:1a:6c:1c:41:bc: + 02:20:3b:cd:84:49:cf:3a:d2:9c:0d:32:59:93:b0:e5:3a:41: + ae:02:53:88:d0:e1:9a:38:9d:1b:a5:d2:71:db:cf:a4 +``` + +## Running the Tool + +Example with AWS KMS: + +```bash +tsa-certificate-maker create \ + --kms-type awskms \ + --aws-region us-east-1 \ + --root-key-id alias/tsa-root \ + --leaf-key-id alias/tsa-leaf \ + --root-template pkg/certmaker/templates/root-template.json \ + --leaf-template pkg/certmaker/templates/leaf-template.json +``` + +Example with Azure KMS: + +```bash +tsa-certificate-maker create \ + --kms-type azurekms \ + --azure-tenant-id 1b4a4fed-fed8-4823-a8a0-3d5cea83d122 \ + --root-key-id "azurekms:name=sigstore-key;vault=sigstore-key" \ + --leaf-key-id "azurekms:name=sigstore-key-intermediate;vault=sigstore-key" \ + --intermediate-key-id "azurekms:name=sigstore-key-intermediate;vault=sigstore-key” \ + --root-cert root.pem \ + --leaf-cert leaf.pem \ + --intermediate-cert intermediate.pem +``` + +Example with GCP KMS: + +```bash +tsa-certificate-maker create \ + --kms-type gcpkms \ + ---gcp-credentials-file ~/.config/gcloud/application_default_credentials.json \ + --root-key-id projects//locations//keyRings//cryptoKeys/fulcio-key1/cryptoKeyVersions/ \ + --intermediate-key-id projects//locations//keyRings//cryptoKeys/fulcio-key1/cryptoKeyVersions/ \ + --leaf-key-id projects//locations//keyRings//cryptoKeys/fulcio-key1/cryptoKeyVersions/ \ + --root-cert root.pem \ + --leaf-cert leaf.pem \ + --intermediate-cert intermediate.pem +``` + +Example with HashiCorp Vault KMS: + +```bash +tsa-certificate-maker create \ + --kms-type hashivault \ + --vault-address http://vault:8200 \ + --vault-token token \ + --root-key-id "transit/keys/root-key" \ + --leaf-key-id "transit/keys/leaf-key" \ + --intermediate-key-id "transit/keys/intermediate-key” \ + --root-cert root.pem \ + --leaf-cert leaf.pem \ + --intermediate-cert intermediate.pem +``` diff --git a/go.mod b/go.mod index bf234ea4..ab432213 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.56.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.4 // 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.1 // 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.7 // 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 @@ -105,6 +111,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 @@ -115,12 +122,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 @@ -128,6 +138,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.9.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 3544966c..1cdc0cf7 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,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.3 h1:ya9gfY1ign6Yy25VMMMgZ9xy7D/TczDB0ElXcyWmEVE= cloud.google.com/go/security v1.18.3/go.mod h1:NmlSnEe7vzenMRoTLehUwa/ZTZHDQE59IPRevHcpCe4= +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= @@ -32,6 +34,12 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= 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/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.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/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= @@ -188,6 +196,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= @@ -225,12 +235,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= @@ -273,6 +287,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.9.0 h1:rf1HIbL64nUpEIZnjLZ3mcNEL9NBPB0iuVjyxvq3LZc= github.com/secure-systems-lab/go-securesystemslib v0.9.0/go.mod h1:DVHKMcZ+V4/woA/peqr+L0joiRXbPpQ042GgJckkFgw= +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.12 h1:S8xMVZbE2z9ZBuQUEG737pxdLjnbOIcFi5v9UFfkJFc= github.com/sigstore/sigstore v1.8.12/go.mod h1:+PYQAa8rfw0QdPpBcT+Gl3egKD9c+TUgAlF12H3Nmjo= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12 h1:EC3UmIaa7nV9sCgSpVevmvgvTYTkMqyrRbj5ojPp7tE= 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