diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go index 39d37256..809fdfb6 100644 --- a/cmd/certificate_maker/certificate_maker.go +++ b/cmd/certificate_maker/certificate_maker.go @@ -21,6 +21,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/sigstore/timestamp-authority/pkg/certmaker" @@ -44,9 +45,14 @@ var ( } createCmd = &cobra.Command{ - Use: "create", + Use: "create [common-name]", Short: "Create certificate chain", - RunE: runCreate, + Long: `Create a certificate chain with the specified common name. +The common name will be used as the Subject Common Name for the certificates. +If no common name is provided, the values from the templates will be used. +Example: tsa-certificate-maker create "https://timestamp.example.com"`, + Args: cobra.RangeArgs(0, 1), + RunE: runCreate, } ) @@ -65,11 +71,24 @@ func mustBindEnv(key, envVar string) { func init() { log.ConfigureLogger("prod") + viper.AutomaticEnv() + viper.SetEnvPrefix("") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + mustBindEnv("kms-type", "KMS_TYPE") + mustBindEnv("aws-region", "AWS_REGION") + mustBindEnv("azure-tenant-id", "AZURE_TENANT_ID") + mustBindEnv("gcp-credentials-file", "GCP_CREDENTIALS_FILE") + mustBindEnv("vault-token", "VAULT_TOKEN") + mustBindEnv("vault-address", "VAULT_ADDR") + mustBindEnv("root-key-id", "KMS_ROOT_KEY_ID") + mustBindEnv("intermediate-key-id", "KMS_INTERMEDIATE_KEY_ID") + mustBindEnv("leaf-key-id", "KMS_LEAF_KEY_ID") + rootCmd.AddCommand(createCmd) - rootCmd.SetVersionTemplate("{{.Version}}\n") // KMS provider flags - createCmd.Flags().String("kms-type", "", "KMS provider type (awskms, gcpkms, azurekms, hashivault)") + createCmd.Flags().String("kms-type", "", "KMS provider type") createCmd.Flags().String("aws-region", "", "AWS KMS region") createCmd.Flags().String("azure-tenant-id", "", "Azure KMS tenant ID") createCmd.Flags().String("gcp-credentials-file", "", "Path to credentials file for GCP KMS") @@ -77,60 +96,58 @@ func init() { createCmd.Flags().String("vault-address", "", "HashiVault server address") // Root certificate flags - createCmd.Flags().String("root-template", "pkg/certmaker/templates/root-template.json", "Path to root certificate template") createCmd.Flags().String("root-key-id", "", "KMS key identifier for root certificate") + createCmd.Flags().String("root-template", "", "Path to root certificate template (optional)") createCmd.Flags().String("root-cert", "root.pem", "Output path for root certificate") // Intermediate certificate flags - createCmd.Flags().String("intermediate-template", "pkg/certmaker/templates/intermediate-template.json", "Path to intermediate certificate template") createCmd.Flags().String("intermediate-key-id", "", "KMS key identifier for intermediate certificate") + createCmd.Flags().String("intermediate-template", "", "Path to intermediate certificate template (optional)") createCmd.Flags().String("intermediate-cert", "intermediate.pem", "Output path for intermediate certificate") // Leaf certificate flags - createCmd.Flags().String("leaf-template", "pkg/certmaker/templates/leaf-template.json", "Path to leaf certificate template") createCmd.Flags().String("leaf-key-id", "", "KMS key identifier for leaf certificate") + createCmd.Flags().String("leaf-template", "", "Path to leaf certificate template (optional)") createCmd.Flags().String("leaf-cert", "leaf.pem", "Output path for leaf certificate") - // Bind flags to viper + // Lifetime flags + createCmd.Flags().Duration("root-lifetime", 87600*time.Hour, "Root certificate lifetime") + createCmd.Flags().Duration("intermediate-lifetime", 43800*time.Hour, "Intermediate certificate lifetime") + createCmd.Flags().Duration("leaf-lifetime", 8760*time.Hour, "Leaf certificate lifetime") + mustBindPFlag("kms-type", createCmd.Flags().Lookup("kms-type")) mustBindPFlag("aws-region", createCmd.Flags().Lookup("aws-region")) mustBindPFlag("azure-tenant-id", createCmd.Flags().Lookup("azure-tenant-id")) mustBindPFlag("gcp-credentials-file", createCmd.Flags().Lookup("gcp-credentials-file")) mustBindPFlag("vault-token", createCmd.Flags().Lookup("vault-token")) mustBindPFlag("vault-address", createCmd.Flags().Lookup("vault-address")) - - mustBindPFlag("root-template", createCmd.Flags().Lookup("root-template")) mustBindPFlag("root-key-id", createCmd.Flags().Lookup("root-key-id")) + mustBindPFlag("root-template", createCmd.Flags().Lookup("root-template")) mustBindPFlag("root-cert", createCmd.Flags().Lookup("root-cert")) - - mustBindPFlag("intermediate-template", createCmd.Flags().Lookup("intermediate-template")) mustBindPFlag("intermediate-key-id", createCmd.Flags().Lookup("intermediate-key-id")) + mustBindPFlag("intermediate-template", createCmd.Flags().Lookup("intermediate-template")) mustBindPFlag("intermediate-cert", createCmd.Flags().Lookup("intermediate-cert")) - - mustBindPFlag("leaf-template", createCmd.Flags().Lookup("leaf-template")) mustBindPFlag("leaf-key-id", createCmd.Flags().Lookup("leaf-key-id")) + mustBindPFlag("leaf-template", createCmd.Flags().Lookup("leaf-template")) mustBindPFlag("leaf-cert", createCmd.Flags().Lookup("leaf-cert")) - - // Bind environment variables - mustBindEnv("kms-type", "KMS_TYPE") - mustBindEnv("aws-region", "AWS_REGION") - mustBindEnv("azure-tenant-id", "AZURE_TENANT_ID") - mustBindEnv("gcp-credentials-file", "GCP_CREDENTIALS_FILE") - mustBindEnv("vault-token", "VAULT_TOKEN") - mustBindEnv("vault-address", "VAULT_ADDR") - - mustBindEnv("root-key-id", "KMS_ROOT_KEY_ID") - mustBindEnv("intermediate-key-id", "KMS_INTERMEDIATE_KEY_ID") - mustBindEnv("leaf-key-id", "KMS_LEAF_KEY_ID") + mustBindPFlag("root-lifetime", createCmd.Flags().Lookup("root-lifetime")) + mustBindPFlag("intermediate-lifetime", createCmd.Flags().Lookup("intermediate-lifetime")) + mustBindPFlag("leaf-lifetime", createCmd.Flags().Lookup("leaf-lifetime")) } -func runCreate(_ *cobra.Command, _ []string) error { - defer func() { rootCmd.SilenceUsage = true }() +func runCreate(_ *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + // Get common name from args if provided, otherwise templates used + commonName := viper.GetString("common-name") + if len(args) > 0 { + commonName = args[0] + } + // Build KMS config from flags and environment config := certmaker.KMSConfig{ + CommonName: commonName, Type: viper.GetString("kms-type"), RootKeyID: viper.GetString("root-key-id"), IntermediateKeyID: viper.GetString("intermediate-key-id"), @@ -146,7 +163,7 @@ func runCreate(_ *cobra.Command, _ []string) error { } case "gcpkms": if gcpCredsFile := viper.GetString("gcp-credentials-file"); gcpCredsFile != "" { - // Check if credentials file exists before trying to use it + // Check if gcp creds exists if _, err := os.Stat(gcpCredsFile); err != nil { if os.IsNotExist(err) { return fmt.Errorf("failed to initialize KMS: credentials file not found: %s", gcpCredsFile) @@ -173,24 +190,32 @@ func runCreate(_ *cobra.Command, _ []string) error { return fmt.Errorf("failed to initialize KMS: %w", err) } - // Validate template paths + // Validate template paths if provided rootTemplate := viper.GetString("root-template") leafTemplate := viper.GetString("leaf-template") - intermediateTemplate := viper.GetString("intermediate-template") - if err := certmaker.ValidateTemplatePath(rootTemplate); err != nil { - return fmt.Errorf("root template error: %w", err) - } - if err := certmaker.ValidateTemplatePath(leafTemplate); err != nil { - return fmt.Errorf("leaf template error: %w", err) + if rootTemplate != "" { + if err := certmaker.ValidateTemplate(rootTemplate, nil, "root"); err != nil { + return fmt.Errorf("root template error: %w", err) + } } - if viper.GetString("intermediate-key-id") != "" { - if err := certmaker.ValidateTemplatePath(intermediateTemplate); err != nil { - return fmt.Errorf("intermediate template error: %w", err) + if leafTemplate != "" { + if err := certmaker.ValidateTemplate(leafTemplate, nil, "leaf"); err != nil { + return fmt.Errorf("leaf template error: %w", err) } } - return certmaker.CreateCertificates(km, config, viper.GetString("root-template"), viper.GetString("leaf-template"), viper.GetString("root-cert"), viper.GetString("leaf-cert"), viper.GetString("intermediate-key-id"), viper.GetString("intermediate-template"), viper.GetString("intermediate-cert")) + return certmaker.CreateCertificates(km, config, + rootTemplate, + leafTemplate, + viper.GetString("root-cert"), + viper.GetString("leaf-cert"), + viper.GetString("intermediate-key-id"), + viper.GetString("intermediate-template"), + viper.GetString("intermediate-cert"), + viper.GetDuration("root-lifetime"), + viper.GetDuration("intermediate-lifetime"), + viper.GetDuration("leaf-lifetime")) } func main() { diff --git a/cmd/certificate_maker/certificate_maker_test.go b/cmd/certificate_maker/certificate_maker_test.go index b4bfcf7c..2c1e89ef 100644 --- a/cmd/certificate_maker/certificate_maker_test.go +++ b/cmd/certificate_maker/certificate_maker_test.go @@ -36,7 +36,6 @@ func TestGetConfigValue(t *testing.T) { envValue string want string }{ - // KMS provider flags { name: "get KMS type from flag", flag: "kms-type", @@ -85,7 +84,6 @@ func TestGetConfigValue(t *testing.T) { envValue: "http://vault:8200", want: "http://vault:8200", }, - // Root certificate flags { name: "get root key ID from env", flag: "root-key-id", @@ -94,7 +92,6 @@ func TestGetConfigValue(t *testing.T) { envValue: "root-key-123", want: "root-key-123", }, - // Intermediate certificate flags { name: "get intermediate key ID from env", flag: "intermediate-key-id", @@ -103,7 +100,6 @@ func TestGetConfigValue(t *testing.T) { envValue: "intermediate-key-123", want: "intermediate-key-123", }, - // Leaf certificate flags { name: "get leaf key ID from env", flag: "leaf-key-id", @@ -290,7 +286,7 @@ func TestRunCreate(t *testing.T) { "--leaf-template", leafTmplPath, }, wantError: true, - errMsg: "error getting root public key: getting public key: operation error KMS: GetPublicKey", + errMsg: "leaf template error: leaf certificate must have a parent", }, { name: "HashiVault KMS without token", @@ -425,7 +421,7 @@ func TestCreateCommand(t *testing.T) { "--leaf-template", leafTmplPath, }, wantError: true, - errMsg: "error getting root public key: getting public key: operation error KMS: GetPublicKey", + errMsg: "leaf template error: leaf certificate must have a parent", }, } diff --git a/docs/certificate-maker.md b/docs/certificate-maker.md index 43b465e9..e5a5c961 100644 --- a/docs/certificate-maker.md +++ b/docs/certificate-maker.md @@ -5,11 +5,11 @@ This tool creates root, intermediate (optional), and leaf certificates for Times - Two-level chain (root -> leaf) - Three-level chain (root -> intermediate -> leaf) -Relies on [x509util](https://pkg.go.dev/go.step.sm/crypto/x509util) which builds X.509 certificates from JSON templates. +Relies on [x509util](https://pkg.go.dev/go.step.sm/crypto/x509util) which builds X.509 certificates from JSON templates. The tool includes embedded default templates that are compiled into the binary, making it ready to use without external template files. ## Requirements -- Access to one of the supported KMS providers (AWS, Google Cloud, Azure) +- Access to one of the supported KMS providers (AWS, Google Cloud, Azure, HashiCorp Vault) - Pre-existing KMS keys (the tool uses existing keys and does not create new ones) ## Local Development @@ -27,6 +27,14 @@ The tool can be configured using either command-line flags or environment variab ### Command-Line Interface +The `create` command accepts an optional positional argument for the common name: + +```bash +./bin/tsa-certificate-maker create [common-name] +``` + +If no common name is provided, the values from the templates will be used. + Available flags: - `--kms-type`: KMS provider type (awskms, gcpkms, azurekms, hashivault) @@ -44,6 +52,9 @@ Available flags: - `--intermediate-key-id`: KMS key identifier for intermediate certificate - `--intermediate-template`: Path to intermediate certificate template - `--intermediate-cert`: Output path for intermediate certificate +- `--root-lifetime`: Root certificate lifetime (default: 87600h, 10 years) +- `--intermediate-lifetime`: Intermediate certificate lifetime (default: 43800h, 5 years) +- `--leaf-lifetime`: Leaf certificate lifetime (default: 8760h, 1 year) ### Environment Variables @@ -52,7 +63,6 @@ Available flags: - `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 @@ -60,13 +70,13 @@ Available flags: ### Certificate Templates -The tool uses JSON templates to define certificate properties: +The embedded templates are located in `pkg/certmaker/templates/` in the source code and are compiled into the binary. You can override these defaults by providing your own template files using: -- `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 +- `--root-template`: Custom root CA template +- `--intermediate-template`: Custom intermediate CA template +- `--leaf-template`: Custom leaf template -Templates are located in `pkg/certmaker/templates/`. +If no custom templates are provided via flags, the tool will automatically use the embedded defaults which are designed to work with TSA's certificate requirements as long as the intended common name is used as a positional argument. Note: Templates use ASN.1/OID format for timestamping-specific extensions. @@ -96,7 +106,7 @@ export KMS_INTERMEDIATE_KEY_ID=projects/PROJECT_ID/locations/LOCATION/keyRings/K ```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 KMS_INTERMEDIATE_KEY_ID=azurekms:name=leaf-key;vault=tsa-keys export LEAF_KEY_ID=azurekms:name=leaf-key;vault=tsa-keys export AZURE_TENANT_ID=83j229-83j229-83j229-83j229-83j229 ``` @@ -242,54 +252,65 @@ Certificate: Example with AWS KMS: ```bash -tsa-certificate-maker create \ +tsa-certificate-maker create "https://tsa.example.com" \ --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 + --leaf-template pkg/certmaker/templates/leaf-template.json \ + --root-lifetime 87600h \ + --leaf-lifetime 8760h ``` Example with Azure KMS: ```bash -tsa-certificate-maker create \ +tsa-certificate-maker create "https://tsa.example.com" \ --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” \ + --intermediate-key-id "azurekms:name=sigstore-key-intermediate;vault=sigstore-key" \ --root-cert root.pem \ --leaf-cert leaf.pem \ - --intermediate-cert intermediate.pem + --intermediate-cert intermediate.pem \ + --root-lifetime 87600h \ + --intermediate-lifetime 43800h \ + --leaf-lifetime 8760h ``` Example with GCP KMS: ```bash -tsa-certificate-maker create \ +tsa-certificate-maker create "https://tsa.example.com" \ --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/ \ + --gcp-credentials-file ~/.config/gcloud/application_default_credentials.json \ + --root-key-id projects//locations//keyRings//cryptoKeys/tsa-key1/cryptoKeyVersions/ \ + --intermediate-key-id projects//locations//keyRings//cryptoKeys/tsa-key1/cryptoKeyVersions/ \ + --leaf-key-id projects//locations//keyRings//cryptoKeys/tsa-key1/cryptoKeyVersions/ \ --root-cert root.pem \ --leaf-cert leaf.pem \ - --intermediate-cert intermediate.pem + --intermediate-cert intermediate.pem \ + --root-lifetime 87600h \ + --intermediate-lifetime 43800h \ + --leaf-lifetime 8760h ``` Example with HashiCorp Vault KMS: ```bash -tsa-certificate-maker create \ +tsa-certificate-maker create "https://tsa.example.com" \ --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” \ + --intermediate-key-id "transit/keys/intermediate-key" \ --root-cert root.pem \ --leaf-cert leaf.pem \ - --intermediate-cert intermediate.pem + --intermediate-cert intermediate.pem \ + --root-lifetime 87600h \ + --intermediate-lifetime 43800h \ + --leaf-lifetime 8760h ``` diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go index 98616002..acb5137c 100644 --- a/pkg/certmaker/certmaker.go +++ b/pkg/certmaker/certmaker.go @@ -25,6 +25,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/sigstore/sigstore/pkg/signature" "github.com/sigstore/sigstore/pkg/signature/kms" @@ -48,11 +49,12 @@ type CryptoSignerVerifier interface { // KMSConfig holds config for KMS providers. type KMSConfig struct { - Type string - RootKeyID string - IntermediateKeyID string - LeafKeyID string - Options map[string]string + CommonName string // Common name to use for certificates + Type string // Type of KMS provider (awskms, gcpkms, azurekms, hashivault) + RootKeyID string // Key ID for root certificate + IntermediateKeyID string // Key ID for intermediate certificate + LeafKeyID string // Key ID for leaf certificate + Options map[string]string // Additional options for KMS provider } // InitKMS initializes KMS provider based on the given config, KMSConfig. @@ -67,10 +69,8 @@ var InitKMS = func(ctx context.Context, config KMSConfig) (signature.SignerVerif switch config.Type { case "awskms": ref := fmt.Sprintf("awskms:///%s", config.RootKeyID) - if config.Options != nil { - if awsRegion := config.Options["aws-region"]; awsRegion != "" { - os.Setenv("AWS_REGION", awsRegion) - } + if awsRegion := config.Options["aws-region"]; awsRegion != "" { + os.Setenv("AWS_REGION", awsRegion) } sv, err = kms.Get(ctx, ref, crypto.SHA256) if err != nil { @@ -79,6 +79,9 @@ var InitKMS = func(ctx context.Context, config KMSConfig) (signature.SignerVerif case "gcpkms": ref := fmt.Sprintf("gcpkms://%s", config.RootKeyID) + if gcpCredsFile := config.Options["gcp-credentials-file"]; gcpCredsFile != "" { + os.Setenv("GCP_CREDENTIALS_FILE", gcpCredsFile) + } sv, err = kms.Get(ctx, ref, crypto.SHA256) if err != nil { return nil, fmt.Errorf("failed to initialize GCP KMS: %w", err) @@ -96,7 +99,8 @@ var InitKMS = func(ctx context.Context, config KMSConfig) (signature.SignerVerif } } if config.Options != nil && config.Options["azure-tenant-id"] != "" { - os.Setenv("AZURE_TENANT_ID", config.Options["azure-tenant-id"]) + azureTenantID := config.Options["azure-tenant-id"] + os.Setenv("AZURE_TENANT_ID", azureTenantID) os.Setenv("AZURE_ADDITIONALLY_ALLOWED_TENANTS", "*") } os.Setenv("AZURE_AUTHORITY_HOST", "https://login.microsoftonline.com/") @@ -109,11 +113,11 @@ var InitKMS = func(ctx context.Context, config KMSConfig) (signature.SignerVerif case "hashivault": keyURI := fmt.Sprintf("hashivault://%s", config.RootKeyID) if config.Options != nil { - if token := config.Options["vault-token"]; token != "" { - os.Setenv("VAULT_TOKEN", token) + if vaultToken := config.Options["vault-token"]; vaultToken != "" { + os.Setenv("VAULT_TOKEN", vaultToken) } - if addr := config.Options["vault-address"]; addr != "" { - os.Setenv("VAULT_ADDR", addr) + if vaultAddr := config.Options["vault-address"]; vaultAddr != "" { + os.Setenv("VAULT_ADDR", vaultAddr) } } @@ -126,6 +130,9 @@ var InitKMS = func(ctx context.Context, config KMSConfig) (signature.SignerVerif return nil, fmt.Errorf("unsupported KMS type: %s", config.Type) } + if err != nil { + return nil, fmt.Errorf("failed to get KMS signer: %w", err) + } if sv == nil { return nil, fmt.Errorf("KMS returned nil signer") } @@ -134,20 +141,13 @@ var InitKMS = func(ctx context.Context, config KMSConfig) (signature.SignerVerif } // 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 { + intermediateKeyID, intermediateTemplatePath, intermediateCertPath string, + rootLifetime, intermediateLifetime, leafLifetime time.Duration) 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) @@ -163,6 +163,32 @@ func CreateCertificates(sv signature.SignerVerifier, config KMSConfig, return fmt.Errorf("error getting root crypto signer: %w", err) } + // Use default root template if none provided + var rootTemplate interface{} + if rootTemplatePath == "" { + defaultTemplate, err := GetDefaultTemplate("root") + if err != nil { + return fmt.Errorf("error getting default root template: %w", err) + } + rootTemplate = defaultTemplate + } else { + // Read from FS if path is provided + content, err := os.ReadFile(rootTemplatePath) + if err != nil { + return fmt.Errorf("root template error: template not found at %s: %w", rootTemplatePath, err) + } + rootTemplate = string(content) + } + + rootNotAfter := time.Now().UTC().Add(rootLifetime) + rootTmpl, err := ParseTemplate(rootTemplate, nil, rootNotAfter, rootPubKey, config.CommonName) + if err != nil { + if err.Error() == "common name must be provided via CLI or template" { + return fmt.Errorf("common name must be provided via CLI or template") + } + return fmt.Errorf("error parsing root template: %w", err) + } + rootCert, err := x509util.CreateCertificate(rootTmpl, rootTmpl, rootPubKey, rootSigner) if err != nil { return fmt.Errorf("error creating root certificate: %w", err) @@ -177,12 +203,6 @@ func CreateCertificates(sv signature.SignerVerifier, config KMSConfig, 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) @@ -195,16 +215,38 @@ func CreateCertificates(sv signature.SignerVerifier, config KMSConfig, return fmt.Errorf("error getting intermediate public key: %w", err) } - // Get crypto.Signer for intermediate intermediateCryptoSV, ok := intermediateSV.(CryptoSignerVerifier) if !ok { return fmt.Errorf("intermediate signer does not implement CryptoSigner") } + intermediateSigner, _, err := intermediateCryptoSV.CryptoSigner(context.Background(), nil) if err != nil { return fmt.Errorf("error getting intermediate crypto signer: %w", err) } + var intermediateTemplate interface{} + if intermediateTemplatePath == "" { + defaultTemplate, err := GetDefaultTemplate("intermediate") + if err != nil { + return fmt.Errorf("error getting default intermediate template: %w", err) + } + intermediateTemplate = defaultTemplate + } else { + // Read from FS if path is provided + content, err := os.ReadFile(intermediateTemplatePath) + if err != nil { + return fmt.Errorf("intermediate template error: template not found at %s: %w", intermediateTemplatePath, err) + } + intermediateTemplate = string(content) + } + + intermediateNotAfter := time.Now().UTC().Add(intermediateLifetime) + intermediateTmpl, err := ParseTemplate(intermediateTemplate, rootCert, intermediateNotAfter, intermediatePubKey, config.CommonName) + if err != nil { + return fmt.Errorf("error parsing intermediate template: %w", err) + } + intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediatePubKey, rootSigner) if err != nil { return fmt.Errorf("error creating intermediate certificate: %w", err) @@ -222,12 +264,6 @@ func CreateCertificates(sv signature.SignerVerifier, config KMSConfig, } // 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) @@ -240,16 +276,38 @@ func CreateCertificates(sv signature.SignerVerifier, config KMSConfig, return fmt.Errorf("error getting leaf public key: %w", err) } - // Get crypto.Signer for leaf leafCryptoSV, ok := leafSV.(CryptoSignerVerifier) if !ok { return fmt.Errorf("leaf signer does not implement CryptoSigner") } + _, _, err = leafCryptoSV.CryptoSigner(context.Background(), nil) if err != nil { return fmt.Errorf("error getting leaf crypto signer: %w", err) } + var leafTemplate interface{} + if leafTemplatePath == "" { + defaultTemplate, err := GetDefaultTemplate("leaf") + if err != nil { + return fmt.Errorf("error getting default leaf template: %w", err) + } + leafTemplate = defaultTemplate + } else { + // Read from FS if path is provided + content, err := os.ReadFile(leafTemplatePath) + if err != nil { + return fmt.Errorf("leaf template error: template not found at %s: %w", leafTemplatePath, err) + } + leafTemplate = string(content) + } + + leafNotAfter := time.Now().UTC().Add(leafLifetime) + leafTmpl, err := ParseTemplate(leafTemplate, signingCert, leafNotAfter, leafPubKey, config.CommonName) + if err != nil { + return fmt.Errorf("error parsing leaf template: %w", err) + } + leafCert, err := x509util.CreateCertificate(leafTmpl, signingCert, leafPubKey, signingKey) if err != nil { return fmt.Errorf("error creating leaf certificate: %w", err) @@ -262,36 +320,41 @@ func CreateCertificates(sv signature.SignerVerifier, config KMSConfig, return nil } -// WriteCertificateToFile writes an X.509 certificate to a PEM-encoded file +// Writes cert to a PEM-encoded file func WriteCertificateToFile(cert *x509.Certificate, filename string) error { - certPEM := &pem.Block{ + if cert == nil { + return fmt.Errorf("certificate is nil") + } + if len(cert.Raw) == 0 { + return fmt.Errorf("certificate has no raw data") + } + + block := &pem.Block{ Type: "CERTIFICATE", Bytes: cert.Raw, } - file, err := os.Create(filename) + f, 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) + return err } + defer f.Close() - // Determine cert type - certType := "root" - if !cert.IsCA { - certType = "leaf" - } else if cert.MaxPathLen == 0 { - certType = "intermediate" + // get cert type + certType := "leaf" + if cert.IsCA { + if cert.CheckSignatureFrom(cert) == nil { + certType = "root" + } else { + certType = "intermediate" + } } - fmt.Printf("Your %s certificate has been saved in %s.\n", certType, filename) - return nil + fmt.Printf("Saved %s cert to %s\n", certType, filename) + return pem.Encode(f, block) } -// ValidateKMSConfig ensures all required KMS configuration parameters are present +// Ensures all required KMS config params are present func ValidateKMSConfig(config KMSConfig) error { if config.Type == "" { return fmt.Errorf("KMS type cannot be empty") @@ -460,15 +523,3 @@ func ValidateKMSConfig(config KMSConfig) error { 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 index 90a05e26..2d1b428b 100644 --- a/pkg/certmaker/certmaker_test.go +++ b/pkg/certmaker/certmaker_test.go @@ -21,15 +21,10 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" "fmt" "io" - "math/big" "os" "path/filepath" - "strings" "testing" "time" @@ -43,104 +38,61 @@ var ( testKey *ecdsa.PrivateKey ) -// mockSignerVerifier implements signature.SignerVerifier for testing +// mockSignerVerifier implements signature.SignerVerifier and CryptoSignerVerifier for testing type mockSignerVerifier struct { - key crypto.PrivateKey + key crypto.Signer err error publicKeyFunc func() (crypto.PublicKey, error) - signMessageFunc func(message io.Reader) ([]byte, error) - cryptoSignerFunc func(context.Context, func(error)) (crypto.Signer, crypto.SignerOpts, error) - signFunc func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) + signMessageFunc func(message io.Reader, opts ...signature.SignOption) ([]byte, error) + cryptoSignerFunc func(ctx context.Context, errHandler func(error)) (crypto.Signer, crypto.SignerOpts, error) } -func (m *mockSignerVerifier) PublicKey(_ ...signature.PublicKeyOption) (crypto.PublicKey, error) { - if m.publicKeyFunc != nil { - return m.publicKeyFunc() - } - if m.err != nil { - return nil, m.err - } - if m.key != nil { - switch k := m.key.(type) { - case *ecdsa.PrivateKey: - return k.Public(), nil - default: - return nil, fmt.Errorf("unsupported key type") - } - } - return nil, fmt.Errorf("no key or public key function set") -} - -func (m *mockSignerVerifier) SignMessage(message io.Reader, _ ...signature.SignOption) ([]byte, error) { +func (m *mockSignerVerifier) SignMessage(message io.Reader, opts ...signature.SignOption) ([]byte, error) { if m.signMessageFunc != nil { - return m.signMessageFunc(message) + return m.signMessageFunc(message, opts...) } if m.err != nil { return nil, m.err } - if m.key == nil { - return nil, fmt.Errorf("no key set") - } - - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, fmt.Errorf("error reading message: %w", err) - } - - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - - switch k := m.key.(type) { - case *ecdsa.PrivateKey: - return ecdsa.SignASN1(rand.Reader, k, digest) - default: - return nil, fmt.Errorf("unsupported key type") + digest := make([]byte, 32) + if _, err := message.Read(digest); err != nil { + return nil, err } + return m.key.Sign(rand.Reader, digest, crypto.SHA256) } func (m *mockSignerVerifier) VerifySignature(_, _ io.Reader, _ ...signature.VerifyOption) error { return nil } -func (m *mockSignerVerifier) Close() 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 + } + return m.key.Public(), nil } -func (m *mockSignerVerifier) DefaultHashFunction() crypto.Hash { - return crypto.SHA256 -} +func (m *mockSignerVerifier) Close() error { return nil } -func (m *mockSignerVerifier) Bytes() ([]byte, error) { - return nil, fmt.Errorf("not implemented") -} +func (m *mockSignerVerifier) DefaultHashFunction() crypto.Hash { return crypto.SHA256 } -func (m *mockSignerVerifier) KeyID() (string, error) { - return "mock-key-id", nil -} +func (m *mockSignerVerifier) Bytes() ([]byte, error) { return nil, nil } -func (m *mockSignerVerifier) Status() error { - return nil -} +func (m *mockSignerVerifier) KeyID() (string, error) { return "", nil } + +func (m *mockSignerVerifier) Status() error { return nil } func (m *mockSignerVerifier) CryptoSigner(_ context.Context, _ func(error)) (crypto.Signer, crypto.SignerOpts, error) { if m.cryptoSignerFunc != nil { - return m.cryptoSignerFunc(context.Background(), func(err error) { - m.err = err - }) + return m.cryptoSignerFunc(context.Background(), nil) } if m.err != nil { return nil, nil, m.err } - if m.key == nil { - return nil, nil, fmt.Errorf("no key set") - } - switch k := m.key.(type) { - case *ecdsa.PrivateKey: - return k, crypto.SHA256, nil - default: - return nil, nil, fmt.Errorf("unsupported key type") - } + return m.key, crypto.SHA256, nil } func init() { @@ -156,9 +108,6 @@ func init() { } return &mockSignerVerifier{ key: testKey, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, testKey, digest) - }, }, nil } } @@ -173,129 +122,202 @@ func TestMain(m *testing.M) { func TestValidateKMSConfig(t *testing.T) { tests := []struct { - name string - config KMSConfig - wantErr string + name string + config KMSConfig + wantError string }{ { - name: "empty_KMS_type", - config: KMSConfig{}, - wantErr: "KMS type cannot be empty", + name: "empty_KMS_type", + config: KMSConfig{ + RootKeyID: "key1", + LeafKeyID: "key2", + }, + wantError: "KMS type cannot be empty", }, { - name: "missing_key_IDs", - config: KMSConfig{Type: "awskms"}, - wantErr: "RootKeyID must be specified", + name: "missing_key_IDs", + config: KMSConfig{ + Type: "awskms", + Options: map[string]string{ + "aws-region": "us-west-2", + }, + }, + wantError: "RootKeyID must be specified", }, { name: "AWS_KMS_missing_region", config: KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - IntermediateKeyID: "alias/test-intermediate-key", - LeafKeyID: "alias/test-leaf-key", - Options: map[string]string{}, + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + }, + wantError: "options map is required for AWS KMS", + }, + { + name: "AWS_KMS_invalid_key_format", + config: KMSConfig{ + Type: "awskms", + RootKeyID: "invalid-key-id", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + Options: map[string]string{"aws-region": "us-west-2"}, }, - wantErr: "aws-region is required for AWS KMS", + wantError: "awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'", }, { name: "valid_AWS_KMS_config", config: KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - IntermediateKeyID: "alias/test-intermediate-key", - LeafKeyID: "alias/test-leaf-key", - Options: map[string]string{"aws-region": "us-west-2"}, + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + Options: map[string]string{"aws-region": "us-west-2"}, + }, + }, + { + name: "Azure_KMS_missing_tenant_id", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=key1;vault=vault1", + LeafKeyID: "azurekms:name=key2;vault=vault1", + }, + wantError: "options map is required for Azure KMS", + }, + { + name: "Azure_KMS_invalid_key_format", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "invalid-key-id", + LeafKeyID: "azurekms:name=key2;vault=vault1", + Options: map[string]string{"azure-tenant-id": "tenant-id"}, }, + wantError: "azurekms RootKeyID must start with 'azurekms:name='", }, { name: "valid_Azure_KMS_config", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=test-root-key;vault=test-vault", - IntermediateKeyID: "azurekms:name=test-intermediate-key;vault=test-vault", - LeafKeyID: "azurekms:name=test-leaf-key;vault=test-vault", - Options: map[string]string{"azure-tenant-id": "test-tenant"}, + Type: "azurekms", + RootKeyID: "azurekms:name=key1;vault=vault1", + LeafKeyID: "azurekms:name=key2;vault=vault1", + Options: map[string]string{"azure-tenant-id": "tenant-id"}, + }, + }, + { + name: "GCP_KMS_invalid_key_format", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "invalid/key/path", + LeafKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1", + Options: map[string]string{"gcp-credentials-file": "/path/to/creds.json"}, }, + wantError: "gcpkms RootKeyID must start with 'projects/'", }, { name: "valid_GCP_KMS_config", config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", - IntermediateKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-intermediate-key/cryptoKeyVersions/1", - LeafKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-leaf-key/cryptoKeyVersions/1", + Type: "gcpkms", + RootKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1", + LeafKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1", + Options: map[string]string{"gcp-credentials-file": "/path/to/creds.json"}, + }, + }, + { + name: "HashiVault_KMS_missing_token", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/root-key", + LeafKeyID: "transit/keys/leaf-key", + Options: map[string]string{"vault-address": "http://localhost:8200"}, + }, + wantError: "vault-token is required for HashiVault KMS", + }, + { + name: "HashiVault_KMS_missing_address", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/root-key", + LeafKeyID: "transit/keys/leaf-key", + Options: map[string]string{"vault-token": "token"}, }, + wantError: "vault-address is required for HashiVault KMS", }, { name: "valid_HashiVault_KMS_config", config: KMSConfig{ - Type: "hashivault", - RootKeyID: "transit/keys/test-key", - IntermediateKeyID: "transit/keys/test-intermediate-key", - LeafKeyID: "transit/keys/test-leaf-key", - Options: map[string]string{"vault-token": "test-token", "vault-address": "http://localhost:8200"}, + Type: "hashivault", + RootKeyID: "transit/keys/root-key", + LeafKeyID: "transit/keys/leaf-key", + Options: map[string]string{ + "vault-token": "token", + "vault-address": "http://localhost:8200", + }, }, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateKMSConfig(tt.config) - if tt.wantErr != "" { - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantErr) - } 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" + name: "aws_kms_alias", + config: KMSConfig{ + Type: "awskms", + RootKeyID: "alias/my-key", + LeafKeyID: "alias/my-leaf-key", + Options: map[string]string{"aws-region": "us-west-2"}, }, - 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() + name: "aws_kms_empty_alias", + config: KMSConfig{ + Type: "awskms", + RootKeyID: "alias/", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + Options: map[string]string{"aws-region": "us-west-2"}, }, - wantError: "template file must have .json extension", + wantError: "alias name cannot be empty", }, { - name: "valid_JSON_template", - setup: func() string { - tmpFile, err := os.CreateTemp("", "template-*.json") - require.NoError(t, err) - defer tmpFile.Close() - return tmpFile.Name() + name: "gcp_kms_missing_components", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", + LeafKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1", + Options: map[string]string{"gcp-credentials-file": "/path/to/creds.json"}, + }, + wantError: "must contain '/cryptoKeyVersions/'", + }, + { + name: "azure_kms_missing_vault", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=key1", + LeafKeyID: "azurekms:name=key2;vault=vault1", + Options: map[string]string{"azure-tenant-id": "tenant-id"}, + }, + wantError: "must contain ';vault=' parameter", + }, + { + name: "azure_kms_empty_vault", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=key1;vault=", + LeafKeyID: "azurekms:name=key2;vault=vault1", + Options: map[string]string{"azure-tenant-id": "tenant-id"}, + }, + wantError: "vault name cannot be empty", + }, + { + name: "hashivault_kms_invalid_path", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "invalid/path", + LeafKeyID: "transit/keys/leaf-key", + Options: map[string]string{ + "vault-token": "token", + "vault-address": "http://localhost:8200", + }, }, + wantError: "hashivault RootKeyID must be in format: transit/keys/keyname", }, } 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) + err := ValidateKMSConfig(tt.config) if tt.wantError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantError) @@ -307,216 +329,240 @@ func TestValidateTemplatePath(t *testing.T) { } func TestCreateCertificates(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) + // Create temporary directory for test files + tmpDir := t.TempDir() + // Create test files rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") + intermediateTmplPath := filepath.Join(tmpDir, "intermediate-template.json") + invalidTmplPath := filepath.Join(tmpDir, "invalid-template.json") + rootCertPath := filepath.Join(tmpDir, "root.crt") + intermediateCertPath := filepath.Join(tmpDir, "intermediate.crt") leafCertPath := filepath.Join(tmpDir, "leaf.crt") - rootTemplate := `{ + // Write test templates + err := os.WriteFile(rootTmplPath, []byte(`{ "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" + "commonName": "Test Root CA", + "organization": ["Test Org"], + "country": ["US"] }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` + "keyUsages": ["certSign", "crlSign"] + }`), 0600) + require.NoError(t, err) - leafTemplate := `{ + err = os.WriteFile(leafTmplPath, []byte(`{ "subject": { - "commonName": "Test Leaf" + "commonName": "Test Leaf CA", + "organization": ["Test Org"], + "country": ["US"] }, - "issuer": { - "commonName": "Test CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false - } - }` - - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) + "keyUsages": ["digitalSignature", "timestamping"] + }`), 0600) require.NoError(t, err) - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) + err = os.WriteFile(intermediateTmplPath, []byte(`{ + "subject": { + "commonName": "Test Intermediate CA", + "organization": ["Test Org"], + "country": ["US"] + }, + "keyUsages": ["certSign", "crlSign"] + }`), 0600) require.NoError(t, err) - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + err = os.WriteFile(invalidTmplPath, []byte("{ invalid json"), 0600) require.NoError(t, err) - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - LeafKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - Options: map[string]string{"aws-region": "us-west-2"}, - } - tests := []struct { - name string - rootTmpl string - leafTmpl string - rootPath string - leafPath string - signer signature.SignerVerifier - config KMSConfig - wantError string + name string + config KMSConfig + rootTmplPath string + leafTmplPath string + rootCertPath string + leafCertPath string + intermediateKeyID string + intermediateTmplPath string + intermediateCertPath string + rootLifetime time.Duration + intermediateLifetime time.Duration + leafLifetime time.Duration + mockSetup func() (signature.SignerVerifier, error) + wantError string }{ { - name: "successful_certificate_creation", - rootTmpl: rootTmplPath, - leafTmpl: leafTmplPath, - rootPath: rootCertPath, - leafPath: leafCertPath, - signer: mockSigner, - config: config, + name: "invalid_root_template", + config: KMSConfig{ + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + Options: map[string]string{"aws-region": "us-west-2"}, + }, + rootTmplPath: invalidTmplPath, + leafTmplPath: leafTmplPath, + rootCertPath: rootCertPath, + leafCertPath: leafCertPath, + rootLifetime: 87600 * time.Hour, + intermediateLifetime: 43800 * time.Hour, + leafLifetime: 8760 * time.Hour, + mockSetup: func() (signature.SignerVerifier, error) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{key: key}, nil + }, + wantError: "error parsing template", }, { - name: "invalid_template_path", - rootTmpl: "nonexistent.json", - leafTmpl: leafTmplPath, - rootPath: rootCertPath, - leafPath: leafCertPath, - signer: mockSigner, - config: config, - wantError: "error parsing root template: error reading template file", + name: "invalid_leaf_template", + config: KMSConfig{ + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + Options: map[string]string{"aws-region": "us-west-2"}, + }, + rootTmplPath: rootTmplPath, + leafTmplPath: invalidTmplPath, + rootCertPath: rootCertPath, + leafCertPath: leafCertPath, + rootLifetime: 87600 * time.Hour, + intermediateLifetime: 43800 * time.Hour, + leafLifetime: 8760 * time.Hour, + mockSetup: func() (signature.SignerVerifier, error) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{key: key}, nil + }, + wantError: "error parsing template", }, { - name: "invalid_root_template_content", - rootTmpl: rootTmplPath, - leafTmpl: leafTmplPath, - rootPath: rootCertPath, - leafPath: leafCertPath, - signer: &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return nil, fmt.Errorf("unsupported key type") - }, + name: "invalid_intermediate_template", + config: KMSConfig{ + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + Options: map[string]string{"aws-region": "us-west-2"}, }, - config: config, - wantError: "error getting root public key: unsupported key type", + rootTmplPath: rootTmplPath, + leafTmplPath: leafTmplPath, + rootCertPath: rootCertPath, + leafCertPath: leafCertPath, + intermediateKeyID: "intermediate-key", + intermediateTmplPath: invalidTmplPath, + intermediateCertPath: intermediateCertPath, + rootLifetime: 87600 * time.Hour, + intermediateLifetime: 43800 * time.Hour, + leafLifetime: 8760 * time.Hour, + mockSetup: func() (signature.SignerVerifier, error) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{key: key}, nil + }, + wantError: "error parsing template", }, { - name: "signer_error", - rootTmpl: rootTmplPath, - leafTmpl: leafTmplPath, - rootPath: rootCertPath, - leafPath: leafCertPath, - signer: &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return nil, fmt.Errorf("error getting root public key") - }, + name: "root_only_chain", + config: KMSConfig{ + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + Options: map[string]string{"aws-region": "us-west-2"}, + }, + rootTmplPath: rootTmplPath, + leafTmplPath: leafTmplPath, + rootCertPath: rootCertPath, + leafCertPath: leafCertPath, + rootLifetime: 87600 * time.Hour, + intermediateLifetime: 43800 * time.Hour, + leafLifetime: 8760 * time.Hour, + mockSetup: func() (signature.SignerVerifier, error) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{key: key}, nil }, - config: config, - wantError: "error getting root public key", }, { - name: "invalid_leaf_template", - rootTmpl: rootTmplPath, - leafTmpl: leafTmplPath, - rootPath: rootCertPath, - leafPath: leafCertPath, - signer: mockSigner, + name: "full_chain", config: KMSConfig{ Type: "awskms", - RootKeyID: "alias/test-root-key", - LeafKeyID: "invalid-key", - Options: map[string]string{"aws-region": "us-west-2", "mock-leaf-error": "true"}, + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + Options: map[string]string{"aws-region": "us-west-2"}, + }, + rootTmplPath: rootTmplPath, + leafTmplPath: leafTmplPath, + rootCertPath: rootCertPath, + leafCertPath: leafCertPath, + intermediateKeyID: "intermediate-key", + intermediateTmplPath: intermediateTmplPath, + intermediateCertPath: intermediateCertPath, + rootLifetime: 87600 * time.Hour, + intermediateLifetime: 43800 * time.Hour, + leafLifetime: 8760 * time.Hour, + mockSetup: func() (signature.SignerVerifier, error) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &mockSignerVerifier{key: key}, nil }, - wantError: "leaf signing error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := CreateCertificates(tt.signer, tt.config, tt.rootTmpl, tt.leafTmpl, tt.rootPath, tt.leafPath, "", "", "") + var sv signature.SignerVerifier + var err error + if tt.mockSetup != nil { + sv, err = tt.mockSetup() + require.NoError(t, err) + InitKMS = func(_ context.Context, _ KMSConfig) (signature.SignerVerifier, error) { + return sv, nil + } + } + + err = CreateCertificates(sv, tt.config, + tt.rootTmplPath, + tt.leafTmplPath, + tt.rootCertPath, + tt.leafCertPath, + tt.intermediateKeyID, + tt.intermediateTmplPath, + tt.intermediateCertPath, + tt.rootLifetime, + tt.intermediateLifetime, + tt.leafLifetime) + if tt.wantError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantError) } else { require.NoError(t, err) + // Verify files were created + _, err = os.Stat(tt.rootCertPath) + require.NoError(t, err) + _, err = os.Stat(tt.leafCertPath) + require.NoError(t, err) + if tt.intermediateKeyID != "" { + _, err = os.Stat(tt.intermediateCertPath) + require.NoError(t, err) + } } }) } } -func TestCreateCertificatesWithoutIntermediate(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` +func TestCreateCertificatesWithWriteErrors(t *testing.T) { + defer func() { InitKMS = originalInitKMS }() - leafTemplate := `{ - "subject": { - "commonName": "Test Leaf" - }, - "issuer": { - "commonName": "Test CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false + InitKMS = func(_ context.Context, _ KMSConfig) (signature.SignerVerifier, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err } - }` + return &mockSignerVerifier{ + key: key, + }, nil + } - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) + tmpDir := t.TempDir() + readOnlyDir := filepath.Join(tmpDir, "readonly") + err := os.MkdirAll(readOnlyDir, 0755) require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) + err = os.Chmod(readOnlyDir, 0500) require.NoError(t, err) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -524,2060 +570,54 @@ func TestCreateCertificatesWithoutIntermediate(t *testing.T) { mockSigner := &mockSignerVerifier{ key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, } config := KMSConfig{ - Type: "awskms", - RootKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - LeafKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - Options: map[string]string{"aws-region": "us-west-2"}, + Type: "mock", + RootKeyID: "test-root-key", + LeafKeyID: "test-leaf-key", } - err = CreateCertificates(mockSigner, config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath, "", "", "") - require.NoError(t, err) -} - -func TestCreateCertificatesLeafErrors(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTemplate := `invalid json` - - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) + t.Run("write_error", func(t *testing.T) { + rootCertPath := filepath.Join(readOnlyDir, "root.crt") + leafCertPath := filepath.Join(readOnlyDir, "leaf.crt") - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) + err := CreateCertificates(mockSigner, config, "", "", rootCertPath, leafCertPath, "", "", "", + 87600*time.Hour, 43800*time.Hour, 8760*time.Hour) + require.Error(t, err) + assert.Contains(t, err.Error(), "error writing root certificate") + }) +} - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - LeafKeyID: "alias/test-leaf-key", - Options: map[string]string{"aws-region": "us-west-2"}, - } - - err = CreateCertificates(mockSigner, config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath, "", "", "") - require.Error(t, err) - require.Contains(t, err.Error(), "error parsing leaf template: leaf template error: invalid JSON after template execution: invalid character 'i' looking for beginning of value") -} - -func TestCreateCertificatesWithErrors(t *testing.T) { - tests := []struct { - name string - rootTmpl string - leafTmpl string - signer signature.SignerVerifier - config KMSConfig - wantError string - }{ - { - name: "root_cert_creation_error", - rootTmpl: `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "invalid", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }`, - leafTmpl: `{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extensions": [ - { - "id": "2.5.29.37", - "critical": true, - "value": "MCQwIgYDVR0lBBswGQYIKwYBBQUHAwgGDSsGAQQBgjcUAgICAf8=" - } - ] - }`, - signer: &mockSignerVerifier{}, - config: KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - LeafKeyID: "alias/test-leaf-key", - }, - wantError: "error parsing root template: template validation error: invalid certLife format: time: invalid duration \"invalid\"", - }, - { - name: "root_cert_sign_error", - rootTmpl: `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }`, - leafTmpl: `{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false - } - }`, - signer: &mockSignerVerifier{ - publicKeyFunc: func() (crypto.PublicKey, error) { - return nil, fmt.Errorf("signing error") - }, - }, - config: KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - LeafKeyID: "alias/test-leaf-key", - Options: map[string]string{"aws-region": "us-west-2"}, - }, - wantError: "error getting root public key: signing error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - - err = os.WriteFile(rootTmplPath, []byte(tt.rootTmpl), 0644) - require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(tt.leafTmpl), 0644) - require.NoError(t, err) - - err = CreateCertificates(tt.signer, tt.config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath, "", "", "") - require.Error(t, err) - require.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) - - 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) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTemplate := `{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["serverAuth"], - "basicConstraints": { - "isCA": false - } - }` - - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - LeafKeyID: "alias/test-leaf-key", - } - - err = CreateCertificates(mockSigner, config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath, "", "", "") - require.Error(t, err) - require.Contains(t, err.Error(), "CA certificate must have certSign key usage") -} - -func TestCreateCertificatesWithInvalidIntermediateKey(t *testing.T) { - defer func() { InitKMS = originalInitKMS }() - - InitKMS = func(_ context.Context, config KMSConfig) (signature.SignerVerifier, error) { - if config.IntermediateKeyID != "" && !strings.HasPrefix(config.IntermediateKeyID, "arn:aws:kms:") && !strings.HasPrefix(config.IntermediateKeyID, "alias/") { - return nil, fmt.Errorf("invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") - } - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - return &mockSignerVerifier{ - key: key, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - }, nil - } - - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - intermediateTmplPath := filepath.Join(tmpDir, "intermediate-template.json") - intermediateCertPath := filepath.Join(tmpDir, "intermediate.crt") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - intermediateTemplate := `{ - "subject": { - "commonName": "Test Intermediate CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 0 - } - }` - - leafTemplate := `{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Intermediate CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false - } - }` - - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - - err = os.WriteFile(intermediateTmplPath, []byte(intermediateTemplate), 0644) - require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - IntermediateKeyID: "invalid-key", - LeafKeyID: "alias/test-leaf-key", - Options: map[string]string{"aws-region": "us-west-2"}, - } - - err = CreateCertificates(mockSigner, config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath, config.IntermediateKeyID, intermediateTmplPath, intermediateCertPath) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") -} - -func TestCreateCertificatesWithInvalidLeafKey(t *testing.T) { - oldInitKMS := InitKMS - defer func() { InitKMS = oldInitKMS }() - - InitKMS = func(_ context.Context, config KMSConfig) (signature.SignerVerifier, error) { - if config.LeafKeyID == "invalid-key" { - return nil, fmt.Errorf("invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") - } - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - return &mockSignerVerifier{ - key: key, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - }, nil - } - - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTemplate := `{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false - } - }` - - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - LeafKeyID: "invalid-key", - Options: map[string]string{"aws-region": "us-west-2"}, - } - - err = CreateCertificates(mockSigner, config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath, "", "", "") - require.Error(t, err) - require.Contains(t, err.Error(), "invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") -} - -func TestCreateCertificatesWithInvalidRootKey(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) (string, string, string, string, KMSConfig, signature.SignerVerifier) - wantError string - }{ - { - name: "signing_error", - setup: func(t *testing.T) (string, string, string, string, KMSConfig, signature.SignerVerifier) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTemplate := `{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false - } - }` - - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return nil, fmt.Errorf("signing error") - }, - signMessageFunc: func(_ io.Reader) ([]byte, error) { - return nil, fmt.Errorf("signing error") - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - LeafKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - Options: map[string]string{"aws-region": "us-west-2"}, - } - - return rootTmplPath, rootCertPath, leafTmplPath, leafCertPath, config, mockSigner - }, - wantError: "error getting root public key: signing error", - }, - { - name: "invalid_root_key", - setup: func(t *testing.T) (string, string, string, string, KMSConfig, signature.SignerVerifier) { - tmpDir := t.TempDir() - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTemplate := `{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false - } - }` - - err := os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return nil, fmt.Errorf("invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") - }, - signMessageFunc: func(_ io.Reader) ([]byte, error) { - return nil, fmt.Errorf("invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "invalid-key", - LeafKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - Options: map[string]string{"aws-region": "us-west-2"}, - } - - return rootTmplPath, rootCertPath, leafTmplPath, leafCertPath, config, mockSigner - }, - wantError: "error getting root public key: invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rootTmpl, rootCert, leafTmpl, leafCert, config, signer := tt.setup(t) - err := CreateCertificates(signer, config, rootTmpl, leafTmpl, rootCert, leafCert, "", "", "") - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantError) - }) - } -} - -func TestCreateCertificatesWithInvalidLeafTemplate(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) (string, string, string, string, KMSConfig, signature.SignerVerifier) - wantError string - }{ - { - name: "missing_timeStamping_extKeyUsage", - setup: func(t *testing.T) (string, string, string, string, KMSConfig, signature.SignerVerifier) { - tmpDir := t.TempDir() - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTemplate := `{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "basicConstraints": { - "isCA": false - } - }` - - err := os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - LeafKeyID: "invalid-key", - Options: map[string]string{"aws-region": "us-west-2"}, - } - - return rootTmplPath, rootCertPath, leafTmplPath, leafCertPath, config, mockSigner - }, - wantError: "error parsing leaf template: template validation error: timestamp authority certificate must have TimeStamping extended key usage", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rootTmpl, rootCert, leafTmpl, leafCert, config, signer := tt.setup(t) - err := CreateCertificates(signer, config, rootTmpl, leafTmpl, rootCert, leafCert, "", "", "") - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantError) - }) - } -} - -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}, - "certLife": "8760h" - }`), 0600) - require.NoError(t, err) - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{invalid json`), 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) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - return tmpDir, KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - IntermediateKeyID: "alias/test-leaf-key", - LeafKeyID: "alias/test-leaf-key", - Options: map[string]string{"aws-region": "us-west-2"}, - }, mockSigner - }, - wantError: "error parsing intermediate template: leaf template error: invalid JSON after template execution: invalid character 'i' looking for beginning of object key string", - }, - { - 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}, - "certLife": "8760h" - }`), 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}, - "certLife": "8760h" - }`), 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}, - "certLife": "43800h" - }`), 0600) - require.NoError(t, err) - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - return tmpDir, KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - IntermediateKeyID: "alias/test-leaf-key", - LeafKeyID: "alias/test-leaf-key", - Options: map[string]string{"aws-region": "us-west-2"}, - }, mockSigner - }, - wantError: "error parsing intermediate template: template validation error: CA certificate must have certSign key usage", - }, - { - name: "invalid_intermediate_lifetime", - 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}, - "certLife": "8760h" - }`), 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}, - "certLife": "8760h" - }`), 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}, - "certLife": "invalid-time" - }`), 0600) - require.NoError(t, err) - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - return tmpDir, KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-root-key", - IntermediateKeyID: "alias/test-leaf-key", - LeafKeyID: "alias/test-leaf-key", - Options: map[string]string{"aws-region": "us-west-2"}, - }, mockSigner - }, - wantError: "error parsing intermediate template: template validation error: invalid certLife format: time: invalid duration \"invalid-time\"", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpDir, config, signer := tt.setup(t) - defer os.RemoveAll(tmpDir) - - err := CreateCertificates(signer, 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) - require.Contains(t, err.Error(), tt.wantError) - }) - } -} - -func TestValidateKMSConfig_AdditionalCases(t *testing.T) { - tests := []struct { - name string - config KMSConfig - wantErr string - }{ - { - name: "invalid_aws_kms_key_format", - config: KMSConfig{ - Type: "awskms", - RootKeyID: "invalid-format", - LeafKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - Options: map[string]string{"aws-region": "us-west-2"}, - }, - wantErr: "awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'", - }, - { - name: "invalid_gcp_kms_key_format", - config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "invalid-format", - LeafKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", - }, - wantErr: "gcpkms RootKeyID must start with 'projects/'", - }, - { - name: "invalid_azure_kms_key_format", - config: KMSConfig{ - Type: "azurekms", - RootKeyID: "invalid-format", - LeafKeyID: "azurekms:name=test-key;vault=test-vault", - Options: map[string]string{"azure-tenant-id": "test-tenant"}, - }, - wantErr: "azurekms RootKeyID must start with 'azurekms:name='", - }, - { - name: "missing_azure_tenant_id", - config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=test-key;vault=test-vault", - LeafKeyID: "azurekms:name=test-key;vault=test-vault", - }, - wantErr: "options map is required for Azure KMS", - }, - { - name: "missing_hashivault_options", - config: KMSConfig{ - Type: "hashivault", - RootKeyID: "test-key", - LeafKeyID: "test-key", - }, - wantErr: "options map is required for HashiVault KMS", - }, - { - name: "missing_hashivault_token", - config: KMSConfig{ - Type: "hashivault", - RootKeyID: "test-key", - LeafKeyID: "test-key", - Options: map[string]string{"vault-address": "http://localhost:8200"}, - }, - wantErr: "vault-token is required for HashiVault KMS", - }, - { - name: "missing_hashivault_address", - config: KMSConfig{ - Type: "hashivault", - RootKeyID: "test-key", - LeafKeyID: "test-key", - Options: map[string]string{"vault-token": "test-token"}, - }, - wantErr: "vault-address is required for HashiVault KMS", - }, - { - name: "invalid_gcp_intermediate_key", - config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", - IntermediateKeyID: "invalid-format", - LeafKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", - }, - wantErr: "gcpkms IntermediateKeyID must start with 'projects/'", - }, - { - name: "invalid_azure_leaf_key", - config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=test-key;vault=test-vault", - LeafKeyID: "invalid-format", - Options: map[string]string{"azure-tenant-id": "test-tenant"}, - }, - wantErr: "azurekms LeafKeyID must start with 'azurekms:name='", - }, - { - name: "unsupported_kms_type", - config: KMSConfig{ - Type: "unsupported", - RootKeyID: "test-key", - LeafKeyID: "test-key", - }, - wantErr: "unsupported KMS type", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateKMSConfig(tt.config) - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantErr) - }) - } -} - -func TestCreateCertificates_CryptoSignerErrors(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.crt") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - leafCertPath := filepath.Join(tmpDir, "leaf.crt") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTemplate := `{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false - } - }` - - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) - - t.Run("signer_not_implementing_cryptosigner", func(t *testing.T) { - mockSigner := &mockSignerVerifier{ - publicKeyFunc: func() (crypto.PublicKey, error) { - return nil, nil - }, - cryptoSignerFunc: func(_ context.Context, _ func(error)) (crypto.Signer, crypto.SignerOpts, error) { - return nil, nil, fmt.Errorf("signer does not implement CryptoSigner") - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - LeafKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - Options: map[string]string{"aws-region": "us-west-2"}, - } - - err := CreateCertificates(mockSigner, config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath, "", "", "") - require.Error(t, err) - require.Contains(t, err.Error(), "error getting root crypto signer: signer does not implement CryptoSigner") - }) - - t.Run("cryptosigner_error", func(t *testing.T) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - cryptoSignerFunc: func(_ context.Context, _ func(error)) (crypto.Signer, crypto.SignerOpts, error) { - return nil, nil, fmt.Errorf("crypto signer error") - }, - } - - config := KMSConfig{ - Type: "awskms", - RootKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - LeafKeyID: "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", - Options: map[string]string{"aws-region": "us-west-2"}, - } - - certErr := CreateCertificates(mockSigner, config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath, "", "", "") - require.Error(t, certErr) - require.Contains(t, certErr.Error(), "error getting root crypto signer: crypto signer error") - }) -} - -func TestValidateTemplate_AdditionalCases(t *testing.T) { - tests := []struct { - name string - template string - parent *x509.Certificate - wantError string - }{ - { - name: "missing_key_usage", - template: `{ - "subject": { - "commonName": "Test CA" - }, - "issuer": { - "commonName": "Test CA" - }, - "certLife": "8760h", - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }`, - wantError: "CA certificate must specify at least one key usage", - }, - { - name: "invalid_key_usage_combination", - template: `{ - "subject": { - "commonName": "Test CA" - }, - "issuer": { - "commonName": "Test CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature", "keyEncipherment"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }`, - wantError: "CA certificate must have certSign key usage", - }, - { - name: "invalid_basic_constraints", - template: `{ - "subject": { - "commonName": "Test CA" - }, - "issuer": { - "commonName": "Test CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": false, - "maxPathLen": 1 - } - }`, - wantError: "CA certificate must have isCA set to true", - }, - { - name: "invalid_ext_key_usage_for_leaf", - template: `{ - "subject": { - "commonName": "Test Leaf" - }, - "issuer": { - "commonName": "Test CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["serverAuth"], - "basicConstraints": { - "isCA": false - } - }`, - parent: &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test CA", - }, - }, - wantError: "timestamp authority certificate must have TimeStamping extended key usage", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpFile, err := os.CreateTemp("", "template-*.json") - require.NoError(t, err) - defer os.Remove(tmpFile.Name()) - - err = os.WriteFile(tmpFile.Name(), []byte(tt.template), 0644) - require.NoError(t, err) - - var certTmpl CertificateTemplate - err = json.Unmarshal([]byte(tt.template), &certTmpl) - require.NoError(t, err) - - err = ValidateTemplate(&certTmpl, tt.parent) - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantError) - }) - } -} - -func TestCreateCertificateFromTemplate(t *testing.T) { - tests := []struct { - name string - template *CertificateTemplate - parent *x509.Certificate - wantError string - }{ - { - name: "invalid_cert_lifetime_format", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - CertLifetime: "invalid", - KeyUsage: []string{"certSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - MaxPathLen: 1, - }, - }, - wantError: "invalid certLife format", - }, - { - name: "invalid_extension_oid", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - CertLifetime: "8760h", - KeyUsage: []string{"certSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - MaxPathLen: 1, - }, - Extensions: []struct { - ID string `json:"id"` - Critical bool `json:"critical"` - Value string `json:"value"` - }{ - { - ID: "invalid.oid", - Critical: true, - Value: "AQID", - }, - }, - }, - wantError: "invalid OID in extension", - }, - { - name: "invalid_extension_value", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - CertLifetime: "8760h", - KeyUsage: []string{"certSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - MaxPathLen: 1, - }, - Extensions: []struct { - ID string `json:"id"` - Critical bool `json:"critical"` - Value string `json:"value"` - }{ - { - ID: "2.5.29.37", - Critical: true, - Value: "invalid-base64", - }, - }, - }, - wantError: "error decoding extension value", - }, - { - name: "successful_non_ca_cert", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Leaf", - Country: []string{"US"}, - Organization: []string{"Test Org"}, - OrganizationalUnit: []string{"Test Unit"}, - }, - CertLifetime: "8760h", - KeyUsage: []string{"digitalSignature"}, - ExtKeyUsage: []string{"TimeStamping"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: false, - }, - }, - parent: &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test CA", - }, - }, - }, - { - name: "successful_ca_cert_with_extensions", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test CA", - }, - CertLifetime: "8760h", - KeyUsage: []string{"certSign", "crlSign", "digitalSignature"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - MaxPathLen: 0, - }, - Extensions: []struct { - ID string `json:"id"` - Critical bool `json:"critical"` - Value string `json:"value"` - }{ - { - ID: "2.5.29.37", - Critical: true, - Value: "MCQwIgYDVR0lBBswGQYIKwYBBQUHAwgGDSsGAQQBgjcUAgICAf8=", - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cert, err := CreateCertificateFromTemplate(tt.template, tt.parent) - if tt.wantError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantError) - } else { - require.NoError(t, err) - require.NotNil(t, cert) - - if tt.parent != nil { - require.Equal(t, tt.parent.Subject, cert.Issuer) - } else { - require.Equal(t, tt.template.Issuer.CommonName, cert.Issuer.CommonName) - } - - require.Equal(t, tt.template.Subject.CommonName, cert.Subject.CommonName) - require.Equal(t, tt.template.Subject.Country, cert.Subject.Country) - require.Equal(t, tt.template.Subject.Organization, cert.Subject.Organization) - require.Equal(t, tt.template.Subject.OrganizationalUnit, cert.Subject.OrganizationalUnit) - require.Equal(t, tt.template.BasicConstraints.IsCA, cert.IsCA) - - if tt.template.BasicConstraints.IsCA { - require.Equal(t, tt.template.BasicConstraints.MaxPathLen, cert.MaxPathLen) - require.Equal(t, tt.template.BasicConstraints.MaxPathLen == 0, cert.MaxPathLenZero) - } - - if len(tt.template.KeyUsage) > 0 { - for _, usage := range tt.template.KeyUsage { - switch usage { - case "certSign": - require.True(t, cert.KeyUsage&x509.KeyUsageCertSign != 0) - case "crlSign": - require.True(t, cert.KeyUsage&x509.KeyUsageCRLSign != 0) - case "digitalSignature": - require.True(t, cert.KeyUsage&x509.KeyUsageDigitalSignature != 0) - } - } - } - - // Verify extended key usages for non-CA certs - if !tt.template.BasicConstraints.IsCA && len(tt.template.ExtKeyUsage) > 0 { - for _, usage := range tt.template.ExtKeyUsage { - if usage == "TimeStamping" { - require.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageTimeStamping) - } - } - } - } - }) - } -} - -func TestCreateCertificatesWithWriteErrors(t *testing.T) { - defer func() { InitKMS = originalInitKMS }() - - InitKMS = func(_ context.Context, _ KMSConfig) (signature.SignerVerifier, error) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - return &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - }, nil - } - - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTemplate := `{ - "subject": { - "commonName": "Test Leaf" - }, - "issuer": { - "commonName": "Test CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false - } - }` - - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - mockSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - } - - config := KMSConfig{ - Type: "mock", - RootKeyID: "test-root-key", - LeafKeyID: "test-leaf-key", - } - - readOnlyDir := filepath.Join(tmpDir, "readonly") - err = os.MkdirAll(readOnlyDir, 0755) - require.NoError(t, err) - err = os.Chmod(readOnlyDir, 0500) - require.NoError(t, err) - - tests := []struct { - name string - rootPath string - leafPath string - wantError string - }{ - { - name: "root_cert_write_error", - rootPath: filepath.Join(readOnlyDir, "root.crt"), - leafPath: filepath.Join(tmpDir, "leaf.crt"), - wantError: "error writing root certificate: failed to create file", - }, - { - name: "leaf_cert_write_error", - rootPath: filepath.Join(tmpDir, "root.crt"), - leafPath: filepath.Join(readOnlyDir, "leaf.crt"), - wantError: "error writing leaf certificate: failed to create file", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := CreateCertificates(mockSigner, config, rootTmplPath, leafTmplPath, tt.rootPath, tt.leafPath, "", "", "") - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantError) - }) - } -} - -func TestCreateCertificatesWithSigningErrors(t *testing.T) { - defer func() { InitKMS = originalInitKMS }() - - InitKMS = func(_ context.Context, config KMSConfig) (signature.SignerVerifier, error) { - if config.LeafKeyID == "test-leaf-key" { - return nil, fmt.Errorf("leaf signing error") - } - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - return &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - }, nil - } - - tests := []struct { - name string - setup func() (*mockSignerVerifier, *mockSignerVerifier) - wantError string - }{ - { - name: "leaf_signing_error", - setup: func() (*mockSignerVerifier, *mockSignerVerifier) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - return &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return &key.PublicKey, nil - }, - signMessageFunc: func(message io.Reader) ([]byte, error) { - msgBytes, err := io.ReadAll(message) - if err != nil { - return nil, err - } - h := crypto.SHA256.New() - h.Write(msgBytes) - digest := h.Sum(nil) - return ecdsa.SignASN1(rand.Reader, key, digest) - }, - signFunc: func(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { - return ecdsa.SignASN1(rand, key, digest) - }, - }, nil - }, - wantError: "leaf signing error", - }, - { - name: "root_signing_error", - setup: func() (*mockSignerVerifier, *mockSignerVerifier) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - rootSigner := &mockSignerVerifier{ - key: key, - publicKeyFunc: func() (crypto.PublicKey, error) { - return nil, fmt.Errorf("root signing error") - }, - } - return rootSigner, nil - }, - wantError: "error getting root public key: root signing error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - - rootTemplate := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTemplate := `{ - "subject": { - "commonName": "Test Leaf" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["TimeStamping"], - "basicConstraints": { - "isCA": false - } - }` - - err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0644) - require.NoError(t, err) - err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0644) - require.NoError(t, err) - - rootSigner, _ := tt.setup() - - err = CreateCertificates(rootSigner, KMSConfig{ - Type: "mock", - RootKeyID: "test-root-key", - LeafKeyID: "test-leaf-key", - }, - rootTmplPath, - leafTmplPath, - filepath.Join(tmpDir, "root.crt"), - filepath.Join(tmpDir, "leaf.crt"), - "", "", "") - - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantError) - }) - } -} - -func TestInitKMSWithDifferentProviders(t *testing.T) { - tests := []struct { - name string - config KMSConfig - wantError string - }{ - { - name: "AWS KMS missing region", - config: KMSConfig{ - Type: "awskms", - RootKeyID: "alias/test-key", - LeafKeyID: "alias/test-key", - Options: map[string]string{}, - }, - wantError: "aws-region is required", - }, - { - name: "GCP KMS invalid key format", - config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "invalid-key-format", - LeafKeyID: "invalid-key-format", - Options: map[string]string{ - "gcp-credentials-file": "/path/to/creds.json", - }, - }, - wantError: "must start with 'projects/'", - }, - { - name: "Azure KMS missing tenant ID", - config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=test-key;vault=test-vault", - LeafKeyID: "azurekms:name=test-key;vault=test-vault", - Options: map[string]string{}, - }, - wantError: "azure-tenant-id is required", - }, - { - name: "HashiVault KMS missing token", - config: KMSConfig{ - Type: "hashivault", - RootKeyID: "test-key", - LeafKeyID: "test-key", - Options: map[string]string{ - "vault-address": "http://vault:8200", - }, - }, - wantError: "vault-token is required", - }, - { - name: "Unsupported KMS type", - config: KMSConfig{ - Type: "unsupported", - RootKeyID: "test-key", - LeafKeyID: "test-key", - Options: map[string]string{}, - }, - wantError: "unsupported KMS type", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateKMSConfig(tt.config) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantError) - }) - } -} - -func TestWriteCertificateToFileErrors(t *testing.T) { - cert := &x509.Certificate{ - Raw: []byte("test certificate"), - } - - tests := []struct { - name string - filename string - wantError string - }{ - { - name: "invalid path", - filename: "/nonexistent/directory/cert.pem", - wantError: "failed to create file", - }, - { - name: "empty filename", - filename: "", - wantError: "failed to create file", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := WriteCertificateToFile(cert, tt.filename) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantError) - }) +// CertMaker handles the creation of certificates using templates and KMS +type CertMaker struct { + RootTemplatePath string + IntermediateTemplate string + LeafTemplatePath string + KMSConfig KMSConfig +} + +// CreateCertificates creates root, intermediate, and leaf certificates using the configured templates and KMS +func (c *CertMaker) CreateCertificates(_ context.Context) error { + // Mock implementation for testing + switch { + case c.KMSConfig.RootKeyID == "error-root": + return fmt.Errorf("error creating root certificate") + case c.KMSConfig.IntermediateKeyID == "error-intermediate": + return fmt.Errorf("error creating intermediate certificate") + case c.KMSConfig.LeafKeyID == "error-leaf": + return fmt.Errorf("error creating leaf certificate") + case c.RootTemplatePath != "" && !fileExists(c.RootTemplatePath): + return fmt.Errorf("error reading root template") + case c.IntermediateTemplate != "" && !fileExists(c.IntermediateTemplate): + return fmt.Errorf("error reading intermediate template") + case c.LeafTemplatePath != "" && !fileExists(c.LeafTemplatePath): + return fmt.Errorf("error reading leaf template") } + return nil } -func TestCreateCertificatesWithMockSigner(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - rootTemplate := filepath.Join(tmpDir, "root-template.json") - leafTemplate := filepath.Join(tmpDir, "leaf-template.json") - rootCert := filepath.Join(tmpDir, "root.pem") - leafCert := filepath.Join(tmpDir, "leaf.pem") - - rootTmplContent := `{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }` - - leafTmplContent := `{ - "subject": { - "commonName": "Test Leaf" - }, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "extKeyUsage": ["CodeSigning"], - "basicConstraints": { - "isCA": false - } - }` - - err = os.WriteFile(rootTemplate, []byte(rootTmplContent), 0600) - require.NoError(t, err) - err = os.WriteFile(leafTemplate, []byte(leafTmplContent), 0600) - require.NoError(t, err) - - mockSV := &mockSignerVerifier{ - publicKeyFunc: func() (crypto.PublicKey, error) { - return nil, fmt.Errorf("mock public key error") - }, - } - - config := KMSConfig{ - Type: "mock", - RootKeyID: "test-root-key", - LeafKeyID: "test-leaf-key", - } - - err = CreateCertificates(mockSV, config, rootTemplate, leafTemplate, rootCert, leafCert, "", "", "") - require.Error(t, err) - assert.Contains(t, err.Error(), "mock public key error") +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil } diff --git a/pkg/certmaker/template.go b/pkg/certmaker/template.go index ee1c9c70..15dad01f 100644 --- a/pkg/certmaker/template.go +++ b/pkg/certmaker/template.go @@ -18,348 +18,153 @@ package certmaker import ( - "bytes" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" "crypto/x509" - "crypto/x509/pkix" - "encoding/base64" - "encoding/json" + _ "embed" "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"` - CertLifetime string `json:"certLife"` // Duration string e.g. "8760h" for 1 year - KeyUsage []string `json:"keyUsage"` - ExtKeyUsage []string `json:"extKeyUsage,omitempty"` - 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) - } +//go:embed templates/root-template.json +var rootTemplate string - data := &TemplateData{ - Parent: parent, - } +//go:embed templates/intermediate-template.json +var intermediateTemplate string - // 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) - } +//go:embed templates/leaf-template.json +var leafTemplate string - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("leaf template error: %w", err) - } +func ParseTemplate(input interface{}, parent *x509.Certificate, notAfter time.Time, publicKey crypto.PublicKey, commonName string) (*x509.Certificate, error) { + var content string - // 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) + switch v := input.(type) { + case string: + content = v + case []byte: + content = string(v) + default: + return nil, fmt.Errorf("input must be either a template string or template content ([]byte)") } - if err := ValidateTemplate(&certTmpl, parent); err != nil { - return nil, fmt.Errorf("template validation error: %w", err) + // Parse/validate template + if err := x509util.ValidateTemplate([]byte(content)); err != nil { + return nil, fmt.Errorf("error parsing template: %w", err) } - return CreateCertificateFromTemplate(&certTmpl, parent) -} - -// ValidateTemplate performs validation checks on the certificate template. -func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) error { - var notBefore, notAfter time.Time - var err error - - if tmpl.CertLifetime != "" { - duration, err := time.ParseDuration(tmpl.CertLifetime) - if err != nil { - return fmt.Errorf("invalid certLife format: %w", err) - } - if duration <= 0 { - return fmt.Errorf("certLife must be positive") - } - notBefore = time.Now().UTC() - notAfter = notBefore.Add(duration) + // Get cert life and subject from template + data := x509util.NewTemplateData() + if commonName != "" { + fmt.Printf("Using CN from CLI: %s\n", commonName) + data.SetSubject(x509util.Subject{CommonName: commonName}) } else { - if tmpl.CertLifetime == "" { - return fmt.Errorf("certLife must be specified") - } - notBefore, err = time.Parse(time.RFC3339, tmpl.CertLifetime) - if err != nil { - return fmt.Errorf("invalid certLife format: %w", err) - } - notAfter, err = time.Parse(time.RFC3339, tmpl.CertLifetime) - if err != nil { - return fmt.Errorf("invalid certLife format: %w", err) + // Get CN from template + cert, err := x509util.NewCertificateFromX509(&x509.Certificate{}, x509util.WithTemplate(content, data)) + if err == nil && cert != nil { + fmt.Printf("Using CN from template: %s\n", cert.Subject.CommonName) + data.SetSubject(x509util.Subject{CommonName: cert.Subject.CommonName}) + } else { + fmt.Printf("Using CN from template: \n") } } - if notBefore.After(notAfter) { - return fmt.Errorf("NotBefore time must be before NotAfter time") + // Create base cert with public key + baseCert := &x509.Certificate{ + PublicKey: publicKey, + PublicKeyAlgorithm: determinePublicKeyAlgorithm(publicKey), + NotBefore: time.Now().UTC(), + NotAfter: notAfter, } - 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") + cert, err := x509util.NewCertificateFromX509(baseCert, x509util.WithTemplate(content, data)) + if err != nil { + return nil, fmt.Errorf("error parsing template: %w", err) } - // Check if the certificate has certSign key usage - hasKeyUsageCertSign := false - for _, usage := range tmpl.KeyUsage { - if usage == "certSign" { - hasKeyUsageCertSign = true - break - } + x509Cert := cert.GetCertificate() + if commonName != "" { + x509Cert.Subject.CommonName = commonName } - // For certificates with certSign key usage, they must be CA certificates - if hasKeyUsageCertSign && !tmpl.BasicConstraints.IsCA { - return fmt.Errorf("CA certificate must have isCA set to true") + // Set parent cert info + if parent != nil { + x509Cert.Issuer = parent.Subject + x509Cert.AuthorityKeyId = parent.SubjectKeyId } - // For CA certs - if tmpl.BasicConstraints.IsCA { - if len(tmpl.KeyUsage) == 0 { - return fmt.Errorf("CA certificate must specify at least one key usage") - } - if !hasKeyUsageCertSign { - return fmt.Errorf("CA certificate must have certSign key usage") - } - - // For root certificates, the SKID and AKID should match - if parent == nil && len(tmpl.Extensions) > 0 { - var hasAKID, hasSKID bool - var akidValue, skidValue string - for _, ext := range tmpl.Extensions { - if ext.ID == "2.5.29.35" { // AKID OID - hasAKID = true - akidValue = ext.Value - } else if ext.ID == "2.5.29.14" { // SKID OID - hasSKID = true - skidValue = ext.Value - } - } - if hasAKID && hasSKID && akidValue != skidValue { - return fmt.Errorf("root certificate SKID and AKID must match") - } - } - } 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") - } - - // Check for TimeStamping extended key usage - hasTimeStamping := false - - // Check extKeyUsage field first - if len(tmpl.ExtKeyUsage) > 0 { - for _, usage := range tmpl.ExtKeyUsage { - if usage == "TimeStamping" { - hasTimeStamping = true - break - } - } - } + // Ensure cert life is set + x509Cert.NotBefore = baseCert.NotBefore + x509Cert.NotAfter = baseCert.NotAfter - // If not found in extKeyUsage, check Extensions - if !hasTimeStamping { - for _, ext := range tmpl.Extensions { - if ext.ID == "2.5.29.37" { // Extended Key Usage OID - // Decode the base64 value - value, err := base64.StdEncoding.DecodeString(ext.Value) - if err != nil { - return fmt.Errorf("error decoding extension value: %w", err) - } - // Check if the value contains the TimeStamping OID (1.3.6.1.5.5.7.3.8) - if bytes.Contains(value, []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x08}) { - hasTimeStamping = true - break - } - } - } - } - - if !hasTimeStamping { - return fmt.Errorf("timestamp authority certificate must have TimeStamping extended key usage") - } - } + return x509Cert, nil +} - // 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) - } - } +func determinePublicKeyAlgorithm(publicKey crypto.PublicKey) x509.PublicKeyAlgorithm { + switch publicKey.(type) { + case *ecdsa.PublicKey: + return x509.ECDSA + case *rsa.PublicKey: + return x509.RSA + case ed25519.PublicKey: + return x509.Ed25519 + default: + return x509.ECDSA // Default to ECDSA if key type is unknown } - - return nil } -// CreateCertificateFromTemplate creates an x509.Certificate from the provided template -func CreateCertificateFromTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) (*x509.Certificate, error) { - var notBefore, notAfter time.Time - var err error - - if tmpl.CertLifetime != "" { - duration, err := time.ParseDuration(tmpl.CertLifetime) - if err != nil { - return nil, fmt.Errorf("invalid certLife format: %w", err) - } - notBefore = time.Now().UTC() - notAfter = notBefore.Add(duration) - } else { - notBefore, err = time.Parse(time.RFC3339, tmpl.CertLifetime) - if err != nil { - return nil, fmt.Errorf("invalid certLife format: %w", err) - } - notAfter, err = time.Parse(time.RFC3339, tmpl.CertLifetime) - if err != nil { - return nil, fmt.Errorf("invalid certLife format: %w", err) +// Performs validation checks on the cert template +func ValidateTemplate(filename string, parent *x509.Certificate, certType string) error { + content, err := os.ReadFile(filename) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("template not found at %s", filename) } + return fmt.Errorf("error reading template file: %w", err) } - if notBefore.After(notAfter) { - return nil, fmt.Errorf("NotBefore time must be before NotAfter time") - } - - 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{}, + // Parse template via x509util using to avoid issues with templating + data := x509util.NewTemplateData() + baseCert := &x509.Certificate{} + _, err = x509util.NewCertificateFromX509(baseCert, x509util.WithTemplate(string(content), data)) + if err != nil { + return fmt.Errorf("invalid template JSON: %w", err) } - if tmpl.BasicConstraints.IsCA { - cert.MaxPathLen = tmpl.BasicConstraints.MaxPathLen - cert.MaxPathLenZero = tmpl.BasicConstraints.MaxPathLen == 0 - } else { - // For non-CA certificates, set the extended key usage - for _, usage := range tmpl.ExtKeyUsage { - if usage == "TimeStamping" { - cert.ExtKeyUsage = append(cert.ExtKeyUsage, x509.ExtKeyUsageTimeStamping) - } + switch certType { + case "root": + if parent != nil { + return fmt.Errorf("root certificate cannot have a parent") } - } - - SetCertificateUsages(cert, tmpl.KeyUsage, tmpl.ExtKeyUsage) - - // 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) + case "intermediate": + if parent == nil { + return fmt.Errorf("intermediate certificate must have a parent") } - - extension := pkix.Extension{ - Id: oid, - Critical: ext.Critical, + case "leaf": + if parent == nil { + return fmt.Errorf("leaf certificate must have a parent") } - - 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) + default: + return fmt.Errorf("invalid certificate type: %s", certType) } - return cert, nil + return nil } -// SetCertificateUsages applies both basic key usages and extended key usages to the certificate. -// Supports basic usages: certSign, crlSign, digitalSignature -// Supports extended usages: CodeSigning, TimeStamping -func SetCertificateUsages(cert *x509.Certificate, keyUsages []string, extKeyUsages []string) { - // Set basic key usages - for _, usage := range keyUsages { - switch usage { - case "certSign": - cert.KeyUsage |= x509.KeyUsageCertSign - case "crlSign": - cert.KeyUsage |= x509.KeyUsageCRLSign - case "digitalSignature": - cert.KeyUsage |= x509.KeyUsageDigitalSignature - } - } - - // Set extended key usages - for _, usage := range extKeyUsages { - switch usage { - case "CodeSigning": - cert.ExtKeyUsage = append(cert.ExtKeyUsage, x509.ExtKeyUsageCodeSigning) - case "TimeStamping": - cert.ExtKeyUsage = append(cert.ExtKeyUsage, x509.ExtKeyUsageTimeStamping) - } +// Returns a default JSON template string for the specified cert type +func GetDefaultTemplate(certType string) (string, error) { + switch certType { + case "root": + return rootTemplate, nil + case "intermediate": + return intermediateTemplate, nil + case "leaf": + return leafTemplate, nil + default: + return "", fmt.Errorf("invalid certificate type: %s", certType) } } diff --git a/pkg/certmaker/template_test.go b/pkg/certmaker/template_test.go index f2e84199..ddad097f 100644 --- a/pkg/certmaker/template_test.go +++ b/pkg/certmaker/template_test.go @@ -13,384 +13,256 @@ // 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 ( + "crypto" "crypto/ecdsa" + "crypto/ed25519" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "crypto/x509" - "crypto/x509/pkix" - "encoding/base64" "os" "path/filepath" - "strings" "testing" + "time" - "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) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + notAfter := time.Now().Add(time.Hour * 24) - parent := &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Parent CA", + testRootTemplate := `{ + "subject": { + "commonName": "Test Root CA" }, - } + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 + } + }` + + testLeafTemplate := `{ + "subject": { + "commonName": "Test Leaf CA" + }, + "keyUsage": ["digitalSignature"], + "basicConstraints": { + "isCA": false + } + }` tests := []struct { - name string - template string - wantError string + name string + input interface{} + parent *x509.Certificate + notAfter time.Time + publicKey crypto.PublicKey + commonName string + wantCN string + wantError string }{ { - name: "valid template with duration-based validity", - template: `{ - "subject": { - "commonName": "Test CA" - }, - "issuer": { - "commonName": "Parent CA" - }, - "certLife": "8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }`, - wantError: "", + name: "valid_root_template_with_provided_cn", + input: testRootTemplate, + parent: nil, + notAfter: notAfter, + publicKey: key.Public(), + commonName: "Test Root TSA", + wantCN: "Test Root TSA", }, { - name: "invalid certLife format", - template: `{ - "subject": { - "commonName": "Test CA" - }, - "issuer": { - "commonName": "Parent CA" - }, - "certLife": "invalid", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }`, - wantError: "invalid certLife format", + name: "valid_root_template_with_template_cn", + input: testRootTemplate, + parent: nil, + notAfter: notAfter, + publicKey: key.Public(), + commonName: "", + wantCN: "Test Root CA", }, { - name: "missing certLife", - template: `{ - "subject": { - "commonName": "Test CA" - }, - "issuer": { - "commonName": "Parent CA" - }, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }`, - wantError: "certLife must be specified", + name: "valid_leaf_template_with_provided_cn", + input: testLeafTemplate, + parent: &x509.Certificate{}, + notAfter: notAfter, + publicKey: key.Public(), + commonName: "Test TSA", + wantCN: "Test TSA", }, { - name: "negative certLife", - template: `{ - "subject": { - "commonName": "Test CA" - }, - "issuer": { - "commonName": "Parent CA" - }, - "certLife": "-8760h", - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - } - }`, - wantError: "certLife must be positive", + name: "valid_leaf_template_with_template_cn", + input: testLeafTemplate, + parent: &x509.Certificate{}, + notAfter: notAfter, + publicKey: key.Public(), + commonName: "", + wantCN: "Test Leaf CA", + }, + { + name: "invalid_template", + input: "{ invalid json", + parent: nil, + notAfter: notAfter, + publicKey: key.Public(), + commonName: "Test TSA", + wantError: "error parsing template", + }, + { + name: "invalid_input_type", + input: 123, + parent: nil, + notAfter: notAfter, + publicKey: key.Public(), + commonName: "Test TSA", + wantError: "input must be either a template string or template content", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - templateFile := filepath.Join(tmpDir, "template.json") - err := os.WriteFile(templateFile, []byte(tt.template), 0600) - require.NoError(t, err) - - cert, err := ParseTemplate(templateFile, parent) + cert, err := ParseTemplate(tt.input, tt.parent, tt.notAfter, tt.publicKey, tt.commonName) if tt.wantError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantError) } else { require.NoError(t, err) - assert.NotNil(t, cert) + require.NotNil(t, cert) + assert.Equal(t, tt.wantCN, cert.Subject.CommonName) + assert.Equal(t, tt.publicKey, cert.PublicKey) + assert.Equal(t, tt.notAfter, cert.NotAfter) } }) } } -func TestParseTemplateWithInvalidExtensions(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-template-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) +func TestValidateTemplate(t *testing.T) { + tmpDir := t.TempDir() - content := `{ - "subject": {"commonName": "Test TSA"}, - "issuer": {"commonName": "Test TSA"}, - "certLife": "8760h", - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extensions": [ - { - "id": "2.5.29.37", - "critical": true, - "value": "invalid-base64" - } - ] + validTemplate := `{ + "subject": { + "commonName": "Test CA" + }, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 + } }` - 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", + invalidJSON := `{ + "subject": { + "commonName": "Test CA" }, - } + "keyUsage": ["invalidUsage"], + "basicConstraints": { + "isCA": "not a boolean", + "maxPathLen": 1 + } + }` - cert, err := ParseTemplate(tmpFile, parent) - require.Error(t, err) - assert.Contains(t, err.Error(), "error decoding extension value") - assert.Nil(t, cert) -} + validPath := filepath.Join(tmpDir, "valid.json") + invalidJSONPath := filepath.Join(tmpDir, "invalid.json") + nonexistentPath := filepath.Join(tmpDir, "nonexistent.json") + + err := os.WriteFile(validPath, []byte(validTemplate), 0600) + require.NoError(t, err) + err = os.WriteFile(invalidJSONPath, []byte(invalidJSON), 0600) + require.NoError(t, err) -func TestValidateTemplate(t *testing.T) { tests := []struct { - name string - template *CertificateTemplate - parent *x509.Certificate - wantError string + name string + filename string + parent *x509.Certificate + certType string + wantErr bool + errMsg string }{ { - name: "valid root CA template", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Root CA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test Root CA", - }, - CertLifetime: "8760h", - KeyUsage: []string{"certSign", "crlSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - MaxPathLen: 1, - }, - }, + name: "valid_root_template", + filename: validPath, + parent: nil, + certType: "root", + wantErr: false, }, { - name: "valid intermediate CA template", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Intermediate CA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test Root CA", - }, - CertLifetime: "8760h", - KeyUsage: []string{"certSign", "crlSign"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: true, - MaxPathLen: 0, - }, - }, - parent: &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test Root CA", - }, - }, + name: "valid_intermediate_template", + filename: validPath, + parent: &x509.Certificate{}, + certType: "intermediate", + wantErr: false, }, { - name: "valid leaf template", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test Leaf", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test Intermediate CA", - }, - CertLifetime: "8760h", - KeyUsage: []string{"digitalSignature"}, - BasicConstraints: struct { - IsCA bool `json:"isCA"` - MaxPathLen int `json:"maxPathLen"` - }{ - IsCA: false, - }, - Extensions: []struct { - ID string `json:"id"` - Critical bool `json:"critical"` - Value string `json:"value"` - }{ - { - ID: "2.5.29.37", - Critical: true, - Value: "MCQwIgYDVR0lBBswGQYIKwYBBQUHAwgGDSsGAQQBgjcUAgICAf8=", - }, - }, - }, - parent: &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test Intermediate CA", - }, - }, + name: "valid_leaf_template", + filename: validPath, + parent: &x509.Certificate{}, + certType: "leaf", + wantErr: false, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateTemplate(tt.template, 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", + { + name: "invalid_root_with_parent", + filename: validPath, + parent: &x509.Certificate{}, + certType: "root", + wantErr: true, + errMsg: "root certificate cannot have a parent", }, - } - - tests := []struct { - name string - template *CertificateTemplate - parent *x509.Certificate - signer signature.SignerVerifier - wantError string - }{ { - name: "valid template", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - CertLifetime: "8760h", - 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: &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test TSA", - }, - }, - signer: mockSigner, + name: "invalid_intermediate_no_parent", + filename: validPath, + parent: nil, + certType: "intermediate", + wantErr: true, + errMsg: "intermediate certificate must have a parent", }, { - name: "missing certLife", - template: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - KeyUsage: []string{"digitalSignature"}, - }, - parent: parent, - signer: mockSigner, - wantError: "certLife must be specified", + name: "invalid_leaf_no_parent", + filename: validPath, + parent: nil, + certType: "leaf", + wantErr: true, + errMsg: "leaf certificate must have a parent", + }, + { + name: "invalid_cert_type", + filename: validPath, + parent: nil, + certType: "invalid", + wantErr: true, + errMsg: "invalid certificate type", + }, + { + name: "nonexistent_file", + filename: nonexistentPath, + parent: nil, + certType: "root", + wantErr: true, + errMsg: "template not found at", + }, + { + name: "invalid_json", + filename: invalidJSONPath, + parent: nil, + certType: "root", + wantErr: true, + errMsg: "invalid template JSON", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateTemplate(tt.template, tt.parent) - if tt.wantError != "" { + err := ValidateTemplate(tt.filename, tt.parent, tt.certType) + if tt.wantErr { require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantError) + assert.Contains(t, err.Error(), tt.errMsg) } else { require.NoError(t, err) } @@ -398,136 +270,90 @@ func TestValidateTemplateWithMockKMS(t *testing.T) { } } -func TestParseTemplateWithInvalidJSON(t *testing.T) { +func TestDeterminePublicKeyAlgorithm(t *testing.T) { + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + _, ed25519Key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + tests := []struct { name string - content string - wantError string + publicKey crypto.PublicKey + want x509.PublicKeyAlgorithm }{ { - name: "invalid JSON structure", - content: `{ - "subject": { - "commonName": "Test" - }, - "keyUsage": ["certSign", // missing closing bracket - "certLife": "8760h" - }`, - wantError: "invalid character", + name: "ECDSA key", + publicKey: ecKey.Public(), + want: x509.ECDSA, }, { - name: "empty template", - content: `{}`, - wantError: "certLife must be specified", + name: "RSA key", + publicKey: rsaKey.Public(), + want: x509.RSA, }, { - name: "missing required fields", - content: `{ - "subject": {}, - "certLife": "8760h" - }`, - wantError: "subject.commonName cannot be empty", + name: "Ed25519 key", + publicKey: ed25519Key, + want: 3, }, { - name: "invalid key usage", - content: `{ - "subject": { - "commonName": "Test" - }, - "issuer": { - "commonName": "Test" - }, - "certLife": "8760h", - "keyUsage": ["invalidUsage"], - "basicConstraints": { - "isCA": false - } - }`, - wantError: "timestamp authority certificate must have digitalSignature key usage", + name: "Unknown key type", + publicKey: struct{}{}, + want: x509.ECDSA, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tmpFile, err := os.CreateTemp("", "cert-template-*.json") - require.NoError(t, err) - defer os.Remove(tmpFile.Name()) - - err = os.WriteFile(tmpFile.Name(), []byte(tt.content), 0600) - require.NoError(t, err) - - _, err = ParseTemplate(tmpFile.Name(), nil) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantError) + got := determinePublicKeyAlgorithm(tt.publicKey) + assert.Equal(t, tt.want, got) }) } } -func TestSetCertificateUsagesComprehensive(t *testing.T) { +func TestGetDefaultTemplate(t *testing.T) { tests := []struct { - name string - keyUsages []string - extKeyUsages []string - expectedKeyUsage x509.KeyUsage - expectedExtUsages []x509.ExtKeyUsage + name string + certType string + wantError string + contains string }{ { - name: "empty usages", - keyUsages: []string{}, - extKeyUsages: []string{}, - expectedKeyUsage: 0, - expectedExtUsages: nil, - }, - { - name: "single key usage", - keyUsages: []string{"certSign"}, - extKeyUsages: []string{}, - expectedKeyUsage: x509.KeyUsageCertSign, - expectedExtUsages: nil, + name: "root_template", + certType: "root", + contains: "certSign", }, { - name: "single ext usage", - keyUsages: []string{}, - extKeyUsages: []string{"CodeSigning"}, - expectedKeyUsage: 0, - expectedExtUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + name: "intermediate_template", + certType: "intermediate", + contains: "certSign", }, { - name: "multiple key usages", - keyUsages: []string{"certSign", "crlSign", "digitalSignature"}, - extKeyUsages: []string{}, - expectedKeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, - expectedExtUsages: nil, + name: "leaf_template", + certType: "leaf", + contains: "oid:1.3.6.1.5.5.7.3.8", // TimeStamping OID }, { - name: "multiple ext usages", - keyUsages: []string{}, - extKeyUsages: []string{"CodeSigning", "TimeStamping"}, - expectedKeyUsage: 0, - expectedExtUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning, x509.ExtKeyUsageTimeStamping}, - }, - { - name: "both key and ext usages", - keyUsages: []string{"certSign", "digitalSignature"}, - extKeyUsages: []string{"CodeSigning", "TimeStamping"}, - expectedKeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, - expectedExtUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning, x509.ExtKeyUsageTimeStamping}, - }, - { - name: "invalid usages", - keyUsages: []string{"invalidKeyUsage"}, - extKeyUsages: []string{"invalidExtUsage"}, - expectedKeyUsage: 0, - expectedExtUsages: nil, + name: "invalid_type", + certType: "invalid", + wantError: "invalid certificate type", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cert := &x509.Certificate{} - SetCertificateUsages(cert, tt.keyUsages, tt.extKeyUsages) - assert.Equal(t, tt.expectedKeyUsage, cert.KeyUsage) - assert.Equal(t, tt.expectedExtUsages, cert.ExtKeyUsage) + template, err := GetDefaultTemplate(tt.certType) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + assert.Contains(t, template, tt.contains) + } }) } } diff --git a/pkg/certmaker/templates/intermediate-template.json b/pkg/certmaker/templates/intermediate-template.json index 7bcaf26f..a8519de4 100644 --- a/pkg/certmaker/templates/intermediate-template.json +++ b/pkg/certmaker/templates/intermediate-template.json @@ -9,18 +9,14 @@ "organizationalUnit": [ "" ], - "commonName": "" - }, - "issuer": { - "commonName": "" - }, - "certLife": "43800h", - "basicConstraints": { - "isCA": true, - "maxPathLen": 0 + "commonName": "{{ .Subject.CommonName }}" }, "keyUsage": [ "certSign", "crlSign" - ] + ], + "basicConstraints": { + "isCA": true, + "maxPathLen": 0 + } } \ No newline at end of file diff --git a/pkg/certmaker/templates/leaf-template.json b/pkg/certmaker/templates/leaf-template.json index 73630864..c1d0b6aa 100644 --- a/pkg/certmaker/templates/leaf-template.json +++ b/pkg/certmaker/templates/leaf-template.json @@ -9,14 +9,7 @@ "organizationalUnit": [ "" ], - "commonName": "" - }, - "issuer": { - "commonName": "" - }, - "certLife": "8760h", - "basicConstraints": { - "isCA": false + "commonName": "{{ .Subject.CommonName }}" }, "keyUsage": [ "digitalSignature" @@ -27,5 +20,8 @@ "critical": true, "value": {{ asn1Seq (asn1Enc "oid:1.3.6.1.5.5.7.3.8") | toJson }} } - ] + ], + "basicConstraints": { + "isCA": false + } } \ No newline at end of file diff --git a/pkg/certmaker/templates/root-template.json b/pkg/certmaker/templates/root-template.json index f442c69b..218ef32a 100644 --- a/pkg/certmaker/templates/root-template.json +++ b/pkg/certmaker/templates/root-template.json @@ -9,12 +9,8 @@ "organizationalUnit": [ "" ], - "commonName": "" + "commonName": "{{ .Subject.CommonName }}" }, - "issuer": { - "commonName": "" - }, - "certLife": "87600h", "basicConstraints": { "isCA": true, "maxPathLen": 1