From d63231a8f36454b74a35494fc367d7f8d1b79970 Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Thu, 21 Nov 2024 15:20:00 -0500 Subject: [PATCH 1/9] feat: adds cert templates. Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- tsa-intermediate-template.json | 34 ++++++++++++++++++++++++++++++++++ tsa-root-template.json | 27 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tsa-intermediate-template.json create mode 100644 tsa-root-template.json diff --git a/tsa-intermediate-template.json b/tsa-intermediate-template.json new file mode 100644 index 00000000..7aaff054 --- /dev/null +++ b/tsa-intermediate-template.json @@ -0,0 +1,34 @@ +{ + "subject": { + "country": [ + "US" + ], + "organization": [ + "Sigstore" + ], + "organizationalUnit": [ + "Timestamp Authority Intermediate CA" + ], + "commonName": "https://tsa.com" + }, + "issuer": { + "commonName": "https://tsa.com" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2034-01-01T00:00:00Z", + "serialNumber": 2, + "basicConstraints": { + "isCA": false, + "maxPathLen": 0 + }, + "keyUsage": [ + "digitalSignature" + ], + "extensions": [ + { + "id": "2.5.29.37", + "critical": true, + "value": "asn1Seq (asn1Enc oid:1.3.6.1.5.5.7.3.8) | toJson" + } + ] +} \ No newline at end of file diff --git a/tsa-root-template.json b/tsa-root-template.json new file mode 100644 index 00000000..f6d32919 --- /dev/null +++ b/tsa-root-template.json @@ -0,0 +1,27 @@ +{ + "subject": { + "country": [ + "US" + ], + "organization": [ + "Sigstore" + ], + "organizationalUnit": [ + "Timestamp Authority Root CA" + ], + "commonName": "https://tsa.com" + }, + "issuer": { + "commonName": "https://tsa.com" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2034-01-01T00:00:00Z", + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 + }, + "keyUsage": [ + "certSign", + "crlSign" + ] +} \ No newline at end of file From 3cd591ef383920c834e6da885c5be0a6c7297559 Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Fri, 22 Nov 2024 07:53:48 -0500 Subject: [PATCH 2/9] feat: splits/adds cert-utility to pgk/cmd. Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- .gitignore | 1 + cmd/certificate_maker/certificate_maker.go | 159 ++++++++ go.mod | 23 +- go.sum | 50 ++- pkg/certmaker/certmaker.go | 230 ++++++++++++ pkg/certmaker/certmaker_test.go | 343 ++++++++++++++++++ pkg/certmaker/template.go | 152 ++++++++ .../templates/intermediate-template.json | 0 .../certmaker/templates/root-template.json | 0 9 files changed, 939 insertions(+), 19 deletions(-) create mode 100644 cmd/certificate_maker/certificate_maker.go create mode 100644 pkg/certmaker/certmaker.go create mode 100644 pkg/certmaker/certmaker_test.go create mode 100644 pkg/certmaker/template.go rename tsa-intermediate-template.json => pkg/certmaker/templates/intermediate-template.json (100%) rename tsa-root-template.json => pkg/certmaker/templates/root-template.json (100%) diff --git a/.gitignore b/.gitignore index 77023e62..3045225b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ myblob ts_chain.pem enc-keyset.cfg chain.crt.pem +.DS_Store diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go new file mode 100644 index 00000000..1948249e --- /dev/null +++ b/cmd/certificate_maker/certificate_maker.go @@ -0,0 +1,159 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Package main implements a certificate creation utility for Timestamp Authority. +// It supports creating root and intermediate certificates using(AWS, GCP, Azure). +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/sigstore/timestamp-authority/pkg/certmaker" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +var ( + logger *zap.Logger + version string + + rootCmd = &cobra.Command{ + Use: "tsa-certificate-maker", + Short: "Create certificate chains for Timestamp Authority", + Long: `A tool for creating root and intermediate certificates for Timestamp Authority with timestamping capabilities`, + Version: version, + } + + createCmd = &cobra.Command{ + Use: "create", + Short: "Create certificate chain", + RunE: runCreate, + } + + kmsType string + kmsRegion string + kmsKeyID string + kmsVaultName string + kmsTenantID string + kmsCredsFile string + rootTemplatePath string + intermTemplatePath string + rootKeyID string + intermediateKeyID string + rootCertPath string + intermCertPath string + + rawJSON = []byte(`{ + "level": "debug", + "encoding": "json", + "outputPaths": ["stdout"], + "errorOutputPaths": ["stderr"], + "initialFields": {"service": "tsa-certificate-maker"}, + "encoderConfig": { + "messageKey": "message", + "levelKey": "level", + "levelEncoder": "lowercase", + "timeKey": "timestamp", + "timeEncoder": "iso8601" + } + }`) +) + +func init() { + logger = initLogger() + + rootCmd.AddCommand(createCmd) + + createCmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, cloudkms, azurekms)") + createCmd.Flags().StringVar(&kmsRegion, "kms-region", "", "KMS region") + createCmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") + createCmd.Flags().StringVar(&kmsVaultName, "kms-vault-name", "", "Azure KMS vault name") + createCmd.Flags().StringVar(&kmsTenantID, "kms-tenant-id", "", "Azure KMS tenant ID") + createCmd.Flags().StringVar(&kmsCredsFile, "kms-credentials-file", "", "Path to credentials file (for Google Cloud KMS)") + createCmd.Flags().StringVar(&rootTemplatePath, "root-template", + "pkg/certmaker/templates/root-template.json", "Path to root certificate template") + createCmd.Flags().StringVar(&intermTemplatePath, "intermediate-template", + "pkg/certmaker/templates/intermediate-template.json", "Path to intermediate certificate template") + createCmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "KMS key identifier for root certificate") + createCmd.Flags().StringVar(&intermediateKeyID, "intermediate-key-id", "", "KMS key identifier for intermediate certificate") + createCmd.Flags().StringVar(&rootCertPath, "root-cert", "root.pem", "Output path for root certificate") + createCmd.Flags().StringVar(&intermCertPath, "intermediate-cert", "intermediate.pem", "Output path for intermediate certificate") +} + +func runCreate(cmd *cobra.Command, args []string) error { + // Build KMS config from flags and environment + config := certmaker.KMSConfig{ + Type: getConfigValue(kmsType, "KMS_TYPE"), + Region: getConfigValue(kmsRegion, "KMS_REGION"), + RootKeyID: getConfigValue(rootKeyID, "KMS_ROOT_KEY_ID"), + IntermediateKeyID: getConfigValue(intermediateKeyID, "KMS_INTERMEDIATE_KEY_ID"), + Options: make(map[string]string), + } + + // Handle KMS provider options + switch config.Type { + case "cloudkms": + if credsFile := getConfigValue(kmsCredsFile, "KMS_CREDENTIALS_FILE"); credsFile != "" { + config.Options["credentials-file"] = credsFile + } + case "azurekms": + if vaultName := getConfigValue(kmsVaultName, "KMS_VAULT_NAME"); vaultName != "" { + config.Options["vault-name"] = vaultName + } + if tenantID := getConfigValue(kmsTenantID, "KMS_TENANT_ID"); tenantID != "" { + config.Options["tenant-id"] = tenantID + } + } + + ctx := context.Background() + km, err := certmaker.InitKMS(ctx, config) + if err != nil { + return fmt.Errorf("failed to initialize KMS: %w", err) + } + + // Validate template paths + if err := certmaker.ValidateTemplatePath(rootTemplatePath); err != nil { + return fmt.Errorf("root template error: %w", err) + } + if err := certmaker.ValidateTemplatePath(intermTemplatePath); err != nil { + return fmt.Errorf("intermediate template error: %w", err) + } + + return certmaker.CreateCertificates(km, config, rootTemplatePath, intermTemplatePath, rootCertPath, intermCertPath) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + logger.Fatal("Command failed", zap.Error(err)) + } +} + +func getConfigValue(flagValue, envVar string) string { + if flagValue != "" { + return flagValue + } + return os.Getenv(envVar) +} + +func initLogger() *zap.Logger { + var cfg zap.Config + if err := json.Unmarshal(rawJSON, &cfg); err != nil { + panic(err) + } + return zap.Must(cfg.Build()) +} diff --git a/go.mod b/go.mod index e90998e9..bc2264ef 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 github.com/urfave/negroni v1.0.0 go.step.sm/crypto v0.55.0 go.uber.org/zap v1.27.0 @@ -46,15 +47,21 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect cloud.google.com/go/compute/metadata v0.5.2 // indirect cloud.google.com/go/iam v1.2.2 // indirect - cloud.google.com/go/kms v1.20.2 // indirect - cloud.google.com/go/longrunning v0.6.2 // indirect + cloud.google.com/go/kms v1.20.1 // indirect + cloud.google.com/go/longrunning v0.6.1 // indirect + dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go v1.55.5 // indirect github.com/aws/aws-sdk-go-v2 v1.32.6 // indirect @@ -75,6 +82,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect @@ -106,6 +114,7 @@ require ( github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/vault/api v1.15.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jellydator/ttlcache/v3 v3.3.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect @@ -116,12 +125,15 @@ require ( github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect @@ -129,6 +141,7 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect diff --git a/go.sum b/go.sum index 73d443bb..82ddbfd7 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0 cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= cloud.google.com/go/security v1.18.2 h1:9Nzp9LGjiDvHqy7X7Q9GrS5lIHN0bI8RvDjkrl4ILO0= cloud.google.com/go/security v1.18.2/go.mod h1:3EwTcYw8554iEtgK8VxAjZaq2unFehcsgFIF9nOvQmU= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M= @@ -25,15 +27,25 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvUL github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 h1:7rKG7UmnrxX4N53TFhkYqjc+kVUZuw0fL8I3Fh+Ld9E= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 h1:DRiANoJTiW6obBQe3SqZizkuV1PEgfiiGivmVocDy64= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0/go.mod h1:qLIye2hwb/ZouqhpSD9Zn3SJipvpEnz1Ywl3VUk9Y0s= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 h1:gUDtaZk8heteyfdmv+pcfHvhR9llnh7c7GMwZ8RVG04= github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= @@ -158,6 +170,8 @@ github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4er github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= @@ -216,6 +230,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= @@ -253,12 +269,16 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= @@ -302,16 +322,18 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA= github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU= -github.com/sigstore/sigstore v1.8.11 h1:tEqeQqbT+awtM87ec9KEeSUxT/AFvJNawneYJyAkFrQ= -github.com/sigstore/sigstore v1.8.11/go.mod h1:fdrFQosxCQ4wTL5H1NrZcQkqQ72AQbPjtpcL2QOGKV0= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.11 h1:4jIEBOtqDZHyQNQSw/guGmIY0y3CVdOGQu3l2FNlqpY= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.11/go.mod h1:rzfk1r8p6Mgjp5tidjzNC+/Kh1h6Eh/ON7xI7ApqBSM= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.11 h1:GXL/OitAMBbLg61nbbk0bXOgOIgDgyFE+9T2Ng3P3o8= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.11/go.mod h1:a9KhG9LZJFcGJB2PtFga1jUIUB0gr0Ix44TDMMXUjJU= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.11 h1:jxKeAMOzaxjwEfmpMMYxF5Vf35tEhQOUXURaUx0ctgo= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.11/go.mod h1:fIAOBcL2s+Vq2Fp9WZByUDdWAmhNuZkJGLCUVUjkdtI= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.11 h1:nH6Cpsz9c7v8jpGiJcH+3+zijfdJha+9mK07MAzZjbc= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.11/go.mod h1:bTBdhPvdaDsHccD9zsSHe/q4ah2OXkdfL/qK7JCuRno= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sigstore/sigstore v1.8.10 h1:r4t+TYzJlG9JdFxMy+um9GZhZ2N1hBTyTex0AHEZxFs= +github.com/sigstore/sigstore v1.8.10/go.mod h1:BekjqxS5ZtHNJC4u3Q3Stvfx2eyisbW/lUZzmPU2u4A= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.10 h1:e5GfVngPjGap/N3ODefayt7vKIPS1/v3hWLZ9+4MrN4= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.10/go.mod h1:HOr3AdFPKdND2FNl/sUD5ZifPl1OMJvrbf9xIaaWcus= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.10 h1:9tZEpfIL/ewAG9G87AHe3aVoy8Ujos2F1qLfCckX6jQ= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.10/go.mod h1:VnIAcitund62R45ezK/dtUeEhuRtB3LsAgJ8m0H34zc= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.10 h1:Xre51HdjIIaVo5ox5zyL+6h0tkrx7Ke9Neh7fLmmZK0= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.10/go.mod h1:VNfdklQDbyGJog8S7apdxiEfmYmCkKyxrsCL9xprkTY= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.10 h1:HjfjL3x3dP2kaGqQHVog974cTcKfzFaGjfZyLQ9KXrg= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.10/go.mod h1:jaeEjkTW1p3gUyPjz9lTcT4TydCs208FoyAwIs6bIT4= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go new file mode 100644 index 00000000..7c2e399b --- /dev/null +++ b/pkg/certmaker/certmaker.go @@ -0,0 +1,230 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Package certmaker implements a certificate creation utility for Timestamp Authority. +// It supports creating root and intermediate certificates using (AWS, GCP, Azure). +package certmaker + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "os" + "strings" + + "go.step.sm/crypto/kms/apiv1" + "go.step.sm/crypto/kms/awskms" + "go.step.sm/crypto/kms/azurekms" + "go.step.sm/crypto/kms/cloudkms" + "go.step.sm/crypto/x509util" +) + +type KMSConfig struct { + Type string // KMS provider type: "awskms", "cloudkms", "azurekms" + Region string // AWS region or Cloud location + RootKeyID string // Root CA key identifier + IntermediateKeyID string // Intermediate CA key identifier + Options map[string]string // Provider-specific options +} + +func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { + if err := ValidateKMSConfig(config); err != nil { + return nil, fmt.Errorf("invalid KMS configuration: %w", err) + } + + opts := apiv1.Options{ + Type: apiv1.Type(config.Type), + URI: "", + } + + // Use RootKeyID as the primary key ID, fall back to IntermediateKeyID if root is not set + keyID := config.RootKeyID + if keyID == "" { + keyID = config.IntermediateKeyID + } + + switch config.Type { + case "awskms": + opts.URI = fmt.Sprintf("awskms:///%s?region=%s", keyID, config.Region) + return awskms.New(ctx, opts) + case "cloudkms": + opts.URI = fmt.Sprintf("cloudkms:%s", keyID) + if credFile, ok := config.Options["credentials-file"]; ok { + opts.URI += fmt.Sprintf("?credentials-file=%s", credFile) + } + return cloudkms.New(ctx, opts) + case "azurekms": + opts.URI = fmt.Sprintf("azurekms://%s.vault.azure.net/keys/%s", + config.Options["vault-name"], keyID) + if config.Options["tenant-id"] != "" { + opts.URI += fmt.Sprintf("?tenant-id=%s", config.Options["tenant-id"]) + } + return azurekms.New(ctx, opts) + default: + return nil, fmt.Errorf("unsupported KMS type: %s", config.Type) + } +} + +// CreateCertificates generates a certificate chain using the configured KMS provider. +// It creates both root and intermediate certificates using the provided templates +// and KMS signing keys. +func CreateCertificates(km apiv1.KeyManager, config KMSConfig, rootTemplatePath, intermediateTemplatePath, rootCertPath, intermCertPath string) error { + // Parse templates + rootTmpl, err := ParseTemplate(rootTemplatePath, nil) + if err != nil { + return fmt.Errorf("error parsing root template: %w", err) + } + + rootKeyName := config.RootKeyID + if config.Type == "azurekms" { + rootKeyName = fmt.Sprintf("azurekms:vault=%s;name=%s", + config.Options["vault-name"], config.RootKeyID) + } + + rootSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ + SigningKey: rootKeyName, + }) + if err != nil { + return fmt.Errorf("error creating root signer: %w", err) + } + + // Create root certificate + rootCert, err := x509util.CreateCertificate(rootTmpl, rootTmpl, rootSigner.Public(), rootSigner) + if err != nil { + return fmt.Errorf("error creating root certificate: %w", err) + } + + // Parse intermediate template + intermediateTmpl, err := ParseTemplate(intermediateTemplatePath, rootCert) + if err != nil { + return fmt.Errorf("error parsing intermediate template: %w", err) + } + + intermediateKeyName := config.IntermediateKeyID + if config.Type == "azurekms" { + intermediateKeyName = fmt.Sprintf("azurekms:vault=%s;name=%s", + config.Options["vault-name"], config.IntermediateKeyID) + } + + intermediateSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ + SigningKey: intermediateKeyName, + }) + if err != nil { + return fmt.Errorf("error creating intermediate signer: %w", err) + } + + // Create intermediate certificate + intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediateSigner.Public(), rootSigner) + if err != nil { + return fmt.Errorf("error creating intermediate certificate: %w", err) + } + + if err := WriteCertificateToFile(rootCert, rootCertPath); err != nil { + return fmt.Errorf("error writing root certificate: %w", err) + } + + if err := WriteCertificateToFile(intermediateCert, intermCertPath); err != nil { + return fmt.Errorf("error writing intermediate certificate: %w", err) + } + + // Verify certificate chain + pool := x509.NewCertPool() + pool.AddCert(rootCert) + if _, err := intermediateCert.Verify(x509.VerifyOptions{ + Roots: pool, + }); err != nil { + return fmt.Errorf("CA.Intermediate.Verify() error = %v", err) + } + + return nil +} + +// WriteCertificateToFile writes an X.509 certificate to a PEM-encoded file +func WriteCertificateToFile(cert *x509.Certificate, filename string) error { + certPEM := &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + } + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", filename, err) + } + defer file.Close() + + if err := pem.Encode(file, certPEM); err != nil { + return fmt.Errorf("failed to write certificate to file %s: %w", filename, err) + } + + return nil +} + +// ValidateKMSConfig ensures all required KMS configuration parameters are present +func ValidateKMSConfig(config KMSConfig) error { + if config.Type == "" { + return fmt.Errorf("KMS type cannot be empty") + } + if config.RootKeyID == "" && config.IntermediateKeyID == "" { + return fmt.Errorf("at least one of RootKeyID or IntermediateKeyID must be specified") + } + + switch config.Type { + case "awskms": + if config.Region == "" { + return fmt.Errorf("region is required for AWS KMS") + } + case "cloudkms": + if config.RootKeyID != "" && !strings.HasPrefix(config.RootKeyID, "projects/") { + return fmt.Errorf("cloudkms RootKeyID must start with 'projects/'") + } + if config.IntermediateKeyID != "" && !strings.HasPrefix(config.IntermediateKeyID, "projects/") { + return fmt.Errorf("cloudkms IntermediateKeyID must start with 'projects/'") + } + case "azurekms": + if config.Options["vault-name"] == "" { + return fmt.Errorf("vault-name is required for Azure KMS") + } + if config.Options["tenant-id"] == "" { + return fmt.Errorf("tenant-id is required for Azure KMS") + } + } + + return nil +} + +// ValidateTemplatePath validates that a template file exists and contains valid JSON +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) + } + + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("error reading template file: %w", err) + } + + var js json.RawMessage + if err := json.Unmarshal(content, &js); err != nil { + return fmt.Errorf("invalid JSON in template file: %w", err) + } + + return nil +} diff --git a/pkg/certmaker/certmaker_test.go b/pkg/certmaker/certmaker_test.go new file mode 100644 index 00000000..ee7dc742 --- /dev/null +++ b/pkg/certmaker/certmaker_test.go @@ -0,0 +1,343 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package certmaker + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/kms/apiv1" + "go.step.sm/crypto/x509util" +) + +// mockKMS provides an in-memory KMS for testing +type mockKMS struct { + keys map[string]*ecdsa.PrivateKey + signers map[string]crypto.Signer +} + +func newMockKMS() *mockKMS { + m := &mockKMS{ + keys: make(map[string]*ecdsa.PrivateKey), + signers: make(map[string]crypto.Signer), + } + + // Pre-create test keys + rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(fmt.Errorf("failed to generate root key: %v", err)) + } + intermediateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(fmt.Errorf("failed to generate intermediate key: %v", err)) + } + + m.keys["root-key"] = rootKey + m.keys["intermediate-key"] = intermediateKey + + return m +} + +func (m *mockKMS) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, error) { + key, ok := m.keys[req.SigningKey] + if !ok { + return nil, fmt.Errorf("key not found: %s", req.SigningKey) + } + m.signers[req.SigningKey] = key + return key, nil +} + +func (m *mockKMS) GetPublicKey(req *apiv1.GetPublicKeyRequest) (crypto.PublicKey, error) { + key, ok := m.keys[req.Name] + if !ok { + return nil, fmt.Errorf("key not found: %s", req.Name) + } + return key.Public(), nil +} + +func (m *mockKMS) Close() error { + return nil +} + +func (m *mockKMS) CreateKey(req *apiv1.CreateKeyRequest) (*apiv1.CreateKeyResponse, error) { + return nil, fmt.Errorf("CreateKey is not supported in mockKMS") +} + +// TestParseTemplate tests JSON template parsing +func TestParseTemplate(t *testing.T) { + tmpFile, err := os.CreateTemp("", "cert-template-*.json") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + templateContent := `{ + "subject": { + "commonName": "Test CA" + }, + "issuer": { + "commonName": "Test CA" + }, + "keyUsage": [ + "certSign", + "crlSign" + ], + "basicConstraints": { + "isCA": true, + "maxPathLen": 0 + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }` + + err = os.WriteFile(tmpFile.Name(), []byte(templateContent), 0600) + require.NoError(t, err) + + tmpl, err := ParseTemplate(tmpFile.Name(), nil) + require.NoError(t, err) + assert.Equal(t, "Test CA", tmpl.Subject.CommonName) + assert.True(t, tmpl.IsCA) + assert.Equal(t, 0, tmpl.MaxPathLen) +} + +// TestCreateCertificates tests certificate chain creation +func TestCreateCertificates(t *testing.T) { + t.Run("Fulcio", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-fulcio-*") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(tmpDir) }) + + // Root template (same for both) + rootContent := `{ + "subject": { + "commonName": "https://blah.com" + }, + "issuer": { + "commonName": "https://blah.com" + }, + "keyUsage": [ + "certSign", + "crlSign" + ], + "extKeyUsage": [ + "CodeSigning" + ], + "basicConstraints": { + "isCA": true, + "maxPathLen": 0 + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }` + + // Fulcio intermediate template + intermediateContent := `{ + "subject": { + "commonName": "https://blah.com" + }, + "issuer": { + "commonName": "https://blah.com" + }, + "keyUsage": [ + "certSign", + "crlSign" + ], + "extKeyUsage": [ + "CodeSigning" + ], + "basicConstraints": { + "isCA": true, + "maxPathLen": 0 + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }` + + testCertificateCreation(t, tmpDir, rootContent, intermediateContent) + }) + + t.Run("TSA", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(tmpDir) }) + + // Root template (same for both) + rootContent := `{ + "subject": { + "commonName": "https://blah.com" + }, + "issuer": { + "commonName": "https://blah.com" + }, + "keyUsage": [ + "certSign", + "crlSign" + ], + "extKeyUsage": [ + "CodeSigning" + ], + "basicConstraints": { + "isCA": true, + "maxPathLen": 0 + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }` + + // TSA intermediate template + intermediateContent := `{ + "subject": { + "commonName": "https://blah.com" + }, + "issuer": { + "commonName": "https://blah.com" + }, + "keyUsage": [ + "certSign", + "crlSign" + ], + "basicConstraints": { + "isCA": false + }, + "extensions": [ + { + "id": "2.5.29.37", + "critical": true, + "value": "asn1Seq (asn1Enc oid:1.3.6.1.5.5.7.3.8) | toJson" + } + ], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }` + + testCertificateCreation(t, tmpDir, rootContent, intermediateContent) + }) +} + +// TestWriteCertificateToFile tests PEM file writing +func TestWriteCertificateToFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-write-test-*") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(tmpDir) }) + + km := newMockKMS() + signer, err := km.CreateSigner(&apiv1.CreateSignerRequest{ + SigningKey: "root-key", + }) + require.NoError(t, err) + + template := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Test Cert", + }, + } + + cert, err := x509util.CreateCertificate(template, template, signer.Public(), signer) + require.NoError(t, err) + + testFile := filepath.Join(tmpDir, "test-cert.pem") + err = WriteCertificateToFile(cert, testFile) + require.NoError(t, err) + + content, err := os.ReadFile(testFile) + require.NoError(t, err) + + block, _ := pem.Decode(content) + require.NotNil(t, block) + assert.Equal(t, "CERTIFICATE", block.Type) + + parsedCert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + assert.Equal(t, "Test Cert", parsedCert.Subject.CommonName) +} + +// testCertificateCreation creates and verifies certificate chains +func testCertificateCreation(t *testing.T, tmpDir, rootContent, intermediateContent string) { + rootTmplPath := filepath.Join(tmpDir, "root-template.json") + intermediateTmplPath := filepath.Join(tmpDir, "intermediate-template.json") + rootCertPath := filepath.Join(tmpDir, "root.pem") + intermediateCertPath := filepath.Join(tmpDir, "intermediate.pem") + + err := os.WriteFile(rootTmplPath, []byte(rootContent), 0600) + require.NoError(t, err) + + err = os.WriteFile(intermediateTmplPath, []byte(intermediateContent), 0600) + require.NoError(t, err) + + km := newMockKMS() + config := KMSConfig{ + Type: "mockkms", + RootKeyID: "root-key", + IntermediateKeyID: "intermediate-key", + Options: make(map[string]string), + } + + err = CreateCertificates(km, config, rootTmplPath, intermediateTmplPath, rootCertPath, intermediateCertPath) + require.NoError(t, err) +} + +func TestValidateKMSConfig(t *testing.T) { + tests := []struct { + name string + config KMSConfig + wantErr bool + }{ + { + name: "valid azure config", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "root-key", + IntermediateKeyID: "intermediate-key", + Options: map[string]string{ + "vault-name": "test-vault", + "tenant-id": "test-tenant", + }, + }, + wantErr: false, + }, + { + name: "missing key IDs", + config: KMSConfig{ + Type: "azurekms", + Options: map[string]string{ + "vault-name": "test-vault", + "tenant-id": "test-tenant", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateKMSConfig(tt.config) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/certmaker/template.go b/pkg/certmaker/template.go new file mode 100644 index 00000000..0c339ece --- /dev/null +++ b/pkg/certmaker/template.go @@ -0,0 +1,152 @@ +// Package certmaker provides template parsing and certificate generation functionality +// for creating X.509 certificates from JSON templates per RFC3161 standards. It supports both root and +// intermediate certificate creation with configurable properties including key usage, +// extended key usage, and basic constraints. +package certmaker + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "fmt" + "math/big" + "os" + "time" +) + +type CertificateTemplate struct { + Subject struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + } `json:"subject"` + Issuer struct { + CommonName string `json:"commonName"` + } `json:"issuer"` + NotBefore string `json:"notBefore"` + NotAfter string `json:"notAfter"` + KeyUsage []string `json:"keyUsage"` + 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"` +} + +// 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) + } + + var tmpl CertificateTemplate + if err := json.Unmarshal(content, &tmpl); err != nil { + return nil, fmt.Errorf("error parsing template JSON: %w", err) + } + + if err := ValidateTemplate(&tmpl, parent); err != nil { + return nil, err + } + + return CreateCertificateFromTemplate(&tmpl, parent) +} + +func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) error { + 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") + } + + if tmpl.BasicConstraints.IsCA && len(tmpl.KeyUsage) == 0 { + return fmt.Errorf("CA certificate must specify at least one key usage") + } + + if tmpl.BasicConstraints.IsCA { + hasKeyUsageCertSign := false + for _, usage := range tmpl.KeyUsage { + if usage == "certSign" { + hasKeyUsageCertSign = true + break + } + } + if !hasKeyUsageCertSign { + return fmt.Errorf("CA certificate must have certSign key usage") + } + } + + return nil +} + +func CreateCertificateFromTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) (*x509.Certificate, error) { + notBefore, err := time.Parse(time.RFC3339, tmpl.NotBefore) + if err != nil { + return nil, fmt.Errorf("invalid notBefore time format: %w", err) + } + + notAfter, err := time.Parse(time.RFC3339, tmpl.NotAfter) + if err != nil { + return nil, fmt.Errorf("invalid notAfter time format: %w", err) + } + + cert := &x509.Certificate{ + Subject: pkix.Name{ + Country: tmpl.Subject.Country, + Organization: tmpl.Subject.Organization, + OrganizationalUnit: tmpl.Subject.OrganizationalUnit, + CommonName: tmpl.Subject.CommonName, + }, + Issuer: func() pkix.Name { + if parent != nil { + return parent.Subject + } + return pkix.Name{CommonName: tmpl.Issuer.CommonName} + }(), + SerialNumber: big.NewInt(time.Now().Unix()), + NotBefore: notBefore, + NotAfter: notAfter, + BasicConstraintsValid: true, + IsCA: tmpl.BasicConstraints.IsCA, + } + + if tmpl.BasicConstraints.IsCA { + cert.MaxPathLen = tmpl.BasicConstraints.MaxPathLen + cert.MaxPathLenZero = tmpl.BasicConstraints.MaxPathLen == 0 + } + + SetKeyUsages(cert, tmpl.KeyUsage) + SetExtKeyUsages(cert, tmpl.ExtKeyUsage) + + return cert, nil +} + +func SetKeyUsages(cert *x509.Certificate, usages []string) { + for _, usage := range usages { + switch usage { + case "certSign": + cert.KeyUsage |= x509.KeyUsageCertSign + case "crlSign": + cert.KeyUsage |= x509.KeyUsageCRLSign + case "digitalSignature": + cert.KeyUsage |= x509.KeyUsageDigitalSignature + } + } +} + +func SetExtKeyUsages(cert *x509.Certificate, usages []string) { + for _, usage := range usages { + switch usage { + case "timeStamping": + cert.ExtKeyUsage = append(cert.ExtKeyUsage, x509.ExtKeyUsageTimeStamping) + } + } +} diff --git a/tsa-intermediate-template.json b/pkg/certmaker/templates/intermediate-template.json similarity index 100% rename from tsa-intermediate-template.json rename to pkg/certmaker/templates/intermediate-template.json diff --git a/tsa-root-template.json b/pkg/certmaker/templates/root-template.json similarity index 100% rename from tsa-root-template.json rename to pkg/certmaker/templates/root-template.json From 87ad194bec454e5c946a5327af9a8d87b320fe1e Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Mon, 25 Nov 2024 21:25:54 -0500 Subject: [PATCH 3/9] fix: enables timestamping / improves validation / includes leaf wording. Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- cmd/certificate_maker/certificate_maker.go | 59 ++++---- pkg/certmaker/certmaker.go | 77 +++++----- pkg/certmaker/certmaker_test.go | 133 ++++++------------ pkg/certmaker/template.go | 123 +++++++++++++--- ...diate-template.json => leaf-template.json} | 4 +- 5 files changed, 211 insertions(+), 185 deletions(-) rename pkg/certmaker/templates/{intermediate-template.json => leaf-template.json} (83%) diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go index 1948249e..85ee413c 100644 --- a/cmd/certificate_maker/certificate_maker.go +++ b/cmd/certificate_maker/certificate_maker.go @@ -14,7 +14,7 @@ // // Package main implements a certificate creation utility for Timestamp Authority. -// It supports creating root and intermediate certificates using(AWS, GCP, Azure). +// It supports creating root and leaf certificates using (AWS, GCP, Azure). package main import ( @@ -22,12 +22,15 @@ import ( "encoding/json" "fmt" "os" + "time" "github.com/sigstore/timestamp-authority/pkg/certmaker" "github.com/spf13/cobra" "go.uber.org/zap" ) +// CLI flags and env vars for config. +// Supports AWS KMS, Google Cloud KMS, and Azure Key Vault configurations. var ( logger *zap.Logger version string @@ -35,7 +38,7 @@ var ( rootCmd = &cobra.Command{ Use: "tsa-certificate-maker", Short: "Create certificate chains for Timestamp Authority", - Long: `A tool for creating root and intermediate certificates for Timestamp Authority with timestamping capabilities`, + Long: `A tool for creating root and leaf certificates for Timestamp Authority with timestamping capabilities`, Version: version, } @@ -45,18 +48,18 @@ var ( RunE: runCreate, } - kmsType string - kmsRegion string - kmsKeyID string - kmsVaultName string - kmsTenantID string - kmsCredsFile string - rootTemplatePath string - intermTemplatePath string - rootKeyID string - intermediateKeyID string - rootCertPath string - intermCertPath string + kmsType string + kmsRegion string + kmsKeyID string + kmsVaultName string + kmsTenantID string + kmsCredsFile string + rootTemplatePath string + leafTemplatePath string + rootKeyID string + leafKeyID string + rootCertPath string + leafCertPath string rawJSON = []byte(`{ "level": "debug", @@ -87,22 +90,25 @@ func init() { createCmd.Flags().StringVar(&kmsCredsFile, "kms-credentials-file", "", "Path to credentials file (for Google Cloud KMS)") createCmd.Flags().StringVar(&rootTemplatePath, "root-template", "pkg/certmaker/templates/root-template.json", "Path to root certificate template") - createCmd.Flags().StringVar(&intermTemplatePath, "intermediate-template", - "pkg/certmaker/templates/intermediate-template.json", "Path to intermediate certificate template") + createCmd.Flags().StringVar(&leafTemplatePath, "leaf-template", + "pkg/certmaker/templates/leaf-template.json", "Path to leaf certificate template") createCmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "KMS key identifier for root certificate") - createCmd.Flags().StringVar(&intermediateKeyID, "intermediate-key-id", "", "KMS key identifier for intermediate certificate") + createCmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "KMS key identifier for leaf certificate") createCmd.Flags().StringVar(&rootCertPath, "root-cert", "root.pem", "Output path for root certificate") - createCmd.Flags().StringVar(&intermCertPath, "intermediate-cert", "intermediate.pem", "Output path for intermediate certificate") + createCmd.Flags().StringVar(&leafCertPath, "leaf-cert", "leaf.pem", "Output path for leaf certificate") } func runCreate(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // Build KMS config from flags and environment config := certmaker.KMSConfig{ - Type: getConfigValue(kmsType, "KMS_TYPE"), - Region: getConfigValue(kmsRegion, "KMS_REGION"), - RootKeyID: getConfigValue(rootKeyID, "KMS_ROOT_KEY_ID"), - IntermediateKeyID: getConfigValue(intermediateKeyID, "KMS_INTERMEDIATE_KEY_ID"), - Options: make(map[string]string), + Type: getConfigValue(kmsType, "KMS_TYPE"), + Region: getConfigValue(kmsRegion, "KMS_REGION"), + RootKeyID: getConfigValue(rootKeyID, "KMS_ROOT_KEY_ID"), + LeafKeyID: getConfigValue(leafKeyID, "KMS_LEAF_KEY_ID"), + Options: make(map[string]string), } // Handle KMS provider options @@ -120,7 +126,6 @@ func runCreate(cmd *cobra.Command, args []string) error { } } - ctx := context.Background() km, err := certmaker.InitKMS(ctx, config) if err != nil { return fmt.Errorf("failed to initialize KMS: %w", err) @@ -130,11 +135,11 @@ func runCreate(cmd *cobra.Command, args []string) error { if err := certmaker.ValidateTemplatePath(rootTemplatePath); err != nil { return fmt.Errorf("root template error: %w", err) } - if err := certmaker.ValidateTemplatePath(intermTemplatePath); err != nil { - return fmt.Errorf("intermediate template error: %w", err) + if err := certmaker.ValidateTemplatePath(leafTemplatePath); err != nil { + return fmt.Errorf("leaf template error: %w", err) } - return certmaker.CreateCertificates(km, config, rootTemplatePath, intermTemplatePath, rootCertPath, intermCertPath) + return certmaker.CreateCertificates(km, config, rootTemplatePath, leafTemplatePath, rootCertPath, leafCertPath) } func main() { diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go index 7c2e399b..a2c5c5de 100644 --- a/pkg/certmaker/certmaker.go +++ b/pkg/certmaker/certmaker.go @@ -20,7 +20,6 @@ package certmaker import ( "context" "crypto/x509" - "encoding/json" "encoding/pem" "fmt" "os" @@ -33,28 +32,30 @@ import ( "go.step.sm/crypto/x509util" ) +// KMSConfig holds config for KMS providers. type KMSConfig struct { - Type string // KMS provider type: "awskms", "cloudkms", "azurekms" - Region string // AWS region or Cloud location - RootKeyID string // Root CA key identifier - IntermediateKeyID string // Intermediate CA key identifier - Options map[string]string // Provider-specific options + Type string // KMS provider type: "awskms", "cloudkms", "azurekms" + Region string // AWS region or Cloud location + RootKeyID string // Root CA key identifier + LeafKeyID string // Leaf CA key identifier + Options map[string]string // Provider-specific options } +// InitKMS initializes KMS provider based on the given config, KMSConfig. +// Supports AWS KMS, Google Cloud KMS, and Azure Key Vault. func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { if err := ValidateKMSConfig(config); err != nil { return nil, fmt.Errorf("invalid KMS configuration: %w", err) } - opts := apiv1.Options{ Type: apiv1.Type(config.Type), URI: "", } - // Use RootKeyID as the primary key ID, fall back to IntermediateKeyID if root is not set + // Falls back to IntermediateKeyID if root is not set keyID := config.RootKeyID if keyID == "" { - keyID = config.IntermediateKeyID + keyID = config.LeafKeyID } switch config.Type { @@ -83,12 +84,11 @@ func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { // It creates both root and intermediate certificates using the provided templates // and KMS signing keys. func CreateCertificates(km apiv1.KeyManager, config KMSConfig, rootTemplatePath, intermediateTemplatePath, rootCertPath, intermCertPath string) error { - // Parse templates + // Parse root template rootTmpl, err := ParseTemplate(rootTemplatePath, nil) if err != nil { return fmt.Errorf("error parsing root template: %w", err) } - rootKeyName := config.RootKeyID if config.Type == "azurekms" { rootKeyName = fmt.Sprintf("azurekms:vault=%s;name=%s", @@ -102,24 +102,25 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, rootTemplatePath, return fmt.Errorf("error creating root signer: %w", err) } - // Create root certificate + // Create root cert rootCert, err := x509util.CreateCertificate(rootTmpl, rootTmpl, rootSigner.Public(), rootSigner) if err != nil { return fmt.Errorf("error creating root certificate: %w", err) } + if err := WriteCertificateToFile(rootCert, rootCertPath); err != nil { + return fmt.Errorf("error writing root certificate: %w", err) + } - // Parse intermediate template + // Parse / sign intermediate template intermediateTmpl, err := ParseTemplate(intermediateTemplatePath, rootCert) if err != nil { return fmt.Errorf("error parsing intermediate template: %w", err) } - - intermediateKeyName := config.IntermediateKeyID + intermediateKeyName := config.LeafKeyID if config.Type == "azurekms" { intermediateKeyName = fmt.Sprintf("azurekms:vault=%s;name=%s", - config.Options["vault-name"], config.IntermediateKeyID) + config.Options["vault-name"], config.LeafKeyID) } - intermediateSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ SigningKey: intermediateKeyName, }) @@ -127,16 +128,11 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, rootTemplatePath, return fmt.Errorf("error creating intermediate signer: %w", err) } - // Create intermediate certificate + // Create intermediate/leaf cert intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediateSigner.Public(), rootSigner) if err != nil { return fmt.Errorf("error creating intermediate certificate: %w", err) } - - if err := WriteCertificateToFile(rootCert, rootCertPath); err != nil { - return fmt.Errorf("error writing root certificate: %w", err) - } - if err := WriteCertificateToFile(intermediateCert, intermCertPath); err != nil { return fmt.Errorf("error writing intermediate certificate: %w", err) } @@ -144,10 +140,12 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, rootTemplatePath, // Verify certificate chain pool := x509.NewCertPool() pool.AddCert(rootCert) - if _, err := intermediateCert.Verify(x509.VerifyOptions{ - Roots: pool, - }); err != nil { - return fmt.Errorf("CA.Intermediate.Verify() error = %v", err) + opts := x509.VerifyOptions{ + Roots: pool, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping}, + } + if _, err := intermediateCert.Verify(opts); err != nil { + return fmt.Errorf("certificate chain verification failed: %w", err) } return nil @@ -165,11 +163,15 @@ func WriteCertificateToFile(cert *x509.Certificate, filename string) error { 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) } + certType := "root" + if cert.Subject.OrganizationalUnit != nil && cert.Subject.OrganizationalUnit[0] == "TSA Intermediate CA" { + certType = "intermediate" + } + fmt.Printf("Your %s certificate has been saved in %s.\n", certType, filename) return nil } @@ -178,8 +180,8 @@ func ValidateKMSConfig(config KMSConfig) error { if config.Type == "" { return fmt.Errorf("KMS type cannot be empty") } - if config.RootKeyID == "" && config.IntermediateKeyID == "" { - return fmt.Errorf("at least one of RootKeyID or IntermediateKeyID must be specified") + if config.RootKeyID == "" && config.LeafKeyID == "" { + return fmt.Errorf("at least one of RootKeyID or LeafKeyID must be specified") } switch config.Type { @@ -191,8 +193,8 @@ func ValidateKMSConfig(config KMSConfig) error { if config.RootKeyID != "" && !strings.HasPrefix(config.RootKeyID, "projects/") { return fmt.Errorf("cloudkms RootKeyID must start with 'projects/'") } - if config.IntermediateKeyID != "" && !strings.HasPrefix(config.IntermediateKeyID, "projects/") { - return fmt.Errorf("cloudkms IntermediateKeyID must start with 'projects/'") + if config.LeafKeyID != "" && !strings.HasPrefix(config.LeafKeyID, "projects/") { + return fmt.Errorf("cloudkms LeafKeyID must start with 'projects/'") } case "azurekms": if config.Options["vault-name"] == "" { @@ -211,20 +213,9 @@ 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) } - content, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("error reading template file: %w", err) - } - - var js json.RawMessage - if err := json.Unmarshal(content, &js); err != nil { - return fmt.Errorf("invalid JSON in template file: %w", err) - } - return nil } diff --git a/pkg/certmaker/certmaker_test.go b/pkg/certmaker/certmaker_test.go index ee7dc742..9facd47e 100644 --- a/pkg/certmaker/certmaker_test.go +++ b/pkg/certmaker/certmaker_test.go @@ -51,13 +51,13 @@ func newMockKMS() *mockKMS { if err != nil { panic(fmt.Errorf("failed to generate root key: %v", err)) } - intermediateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { - panic(fmt.Errorf("failed to generate intermediate key: %v", err)) + panic(fmt.Errorf("failed to generate leaf key: %v", err)) } m.keys["root-key"] = rootKey - m.keys["intermediate-key"] = intermediateKey + m.keys["leaf-key"] = leafKey return m } @@ -124,115 +124,64 @@ func TestParseTemplate(t *testing.T) { // TestCreateCertificates tests certificate chain creation func TestCreateCertificates(t *testing.T) { - t.Run("Fulcio", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-fulcio-*") + t.Run("TSA", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") require.NoError(t, err) t.Cleanup(func() { os.RemoveAll(tmpDir) }) - // Root template (same for both) + // root template (same for both) rootContent := `{ "subject": { - "commonName": "https://blah.com" + "country": ["US"], + "organization": ["Sigstore"], + "organizationalUnit": ["Timestamp Authority Root CA"], + "commonName": "https://tsa.com" }, "issuer": { - "commonName": "https://blah.com" + "commonName": "https://tsa.com" }, - "keyUsage": [ - "certSign", - "crlSign" - ], - "extKeyUsage": [ - "CodeSigning" - ], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2034-01-01T00:00:00Z", "basicConstraints": { "isCA": true, - "maxPathLen": 0 - }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }` - - // Fulcio intermediate template - intermediateContent := `{ - "subject": { - "commonName": "https://blah.com" - }, - "issuer": { - "commonName": "https://blah.com" + "maxPathLen": 1 }, "keyUsage": [ "certSign", "crlSign" - ], - "extKeyUsage": [ - "CodeSigning" - ], - "basicConstraints": { - "isCA": true, - "maxPathLen": 0 - }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" + ] }` - testCertificateCreation(t, tmpDir, rootContent, intermediateContent) - }) - - t.Run("TSA", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(tmpDir) }) - - // Root template (same for both) - rootContent := `{ + // leaf template + leafContent := `{ "subject": { - "commonName": "https://blah.com" + "country": ["US"], + "organization": ["Sigstore"], + "organizationalUnit": ["Timestamp Authority"], + "commonName": "https://tsa.com" }, "issuer": { - "commonName": "https://blah.com" + "commonName": "https://tsa.com" }, - "keyUsage": [ - "certSign", - "crlSign" - ], - "extKeyUsage": [ - "CodeSigning" - ], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2034-01-01T00:00:00Z", "basicConstraints": { - "isCA": true, + "isCA": false, "maxPathLen": 0 }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }` - - // TSA intermediate template - intermediateContent := `{ - "subject": { - "commonName": "https://blah.com" - }, - "issuer": { - "commonName": "https://blah.com" - }, "keyUsage": [ - "certSign", - "crlSign" + "digitalSignature" ], - "basicConstraints": { - "isCA": false - }, "extensions": [ { "id": "2.5.29.37", "critical": true, - "value": "asn1Seq (asn1Enc oid:1.3.6.1.5.5.7.3.8) | toJson" + "value": {{ asn1Seq (asn1Enc "oid:1.3.6.1.5.5.7.3.8") | toJson }} } - ], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" + ] }` - testCertificateCreation(t, tmpDir, rootContent, intermediateContent) + testCertificateCreation(t, tmpDir, rootContent, leafContent) }) } @@ -274,27 +223,27 @@ func TestWriteCertificateToFile(t *testing.T) { } // testCertificateCreation creates and verifies certificate chains -func testCertificateCreation(t *testing.T, tmpDir, rootContent, intermediateContent string) { +func testCertificateCreation(t *testing.T, tmpDir, rootContent, leafContent string) { rootTmplPath := filepath.Join(tmpDir, "root-template.json") - intermediateTmplPath := filepath.Join(tmpDir, "intermediate-template.json") + leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") rootCertPath := filepath.Join(tmpDir, "root.pem") - intermediateCertPath := filepath.Join(tmpDir, "intermediate.pem") + leafCertPath := filepath.Join(tmpDir, "leaf.pem") err := os.WriteFile(rootTmplPath, []byte(rootContent), 0600) require.NoError(t, err) - err = os.WriteFile(intermediateTmplPath, []byte(intermediateContent), 0600) + err = os.WriteFile(leafTmplPath, []byte(leafContent), 0600) require.NoError(t, err) km := newMockKMS() config := KMSConfig{ - Type: "mockkms", - RootKeyID: "root-key", - IntermediateKeyID: "intermediate-key", - Options: make(map[string]string), + Type: "mockkms", + RootKeyID: "root-key", + LeafKeyID: "leaf-key", + Options: make(map[string]string), } - err = CreateCertificates(km, config, rootTmplPath, intermediateTmplPath, rootCertPath, intermediateCertPath) + err = CreateCertificates(km, config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath) require.NoError(t, err) } @@ -307,9 +256,9 @@ func TestValidateKMSConfig(t *testing.T) { { name: "valid azure config", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "root-key", - IntermediateKeyID: "intermediate-key", + Type: "azurekms", + RootKeyID: "root-key", + LeafKeyID: "leaf-key", Options: map[string]string{ "vault-name": "test-vault", "tenant-id": "test-tenant", diff --git a/pkg/certmaker/template.go b/pkg/certmaker/template.go index 0c339ece..4fb58bfa 100644 --- a/pkg/certmaker/template.go +++ b/pkg/certmaker/template.go @@ -5,15 +5,24 @@ package certmaker import ( + "bytes" "crypto/x509" "crypto/x509/pkix" + "encoding/base64" "encoding/json" "fmt" "math/big" "os" + "strconv" + "strings" + "text/template" "time" + + "go.step.sm/crypto/x509util" ) +// CertificateTemplate defines the JSON structure for X.509 certificate templates +// including subject, issuer, validity period, and certificate constraints. type CertificateTemplate struct { Subject struct { Country []string `json:"country,omitempty"` @@ -27,7 +36,6 @@ type CertificateTemplate struct { NotBefore string `json:"notBefore"` NotAfter string `json:"notAfter"` KeyUsage []string `json:"keyUsage"` - ExtKeyUsage []string `json:"extKeyUsage,omitempty"` BasicConstraints struct { IsCA bool `json:"isCA"` MaxPathLen int `json:"maxPathLen"` @@ -39,6 +47,11 @@ type CertificateTemplate struct { } `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) @@ -46,18 +59,37 @@ func ParseTemplate(filename string, parent *x509.Certificate) (*x509.Certificate return nil, fmt.Errorf("error reading template file: %w", err) } - var tmpl CertificateTemplate - if err := json.Unmarshal(content, &tmpl); err != nil { - return nil, fmt.Errorf("error parsing template JSON: %w", err) + data := &TemplateData{ + Parent: parent, + } + + // Borrows x509util functions to create template + tmpl, err := template.New("cert").Funcs(x509util.GetFuncMap()).Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("leaf template error: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("leaf template error: %w", err) + } + + // Parse template as JSON + var certTmpl CertificateTemplate + if err := json.Unmarshal(buf.Bytes(), &certTmpl); err != nil { + return nil, fmt.Errorf("leaf template error: invalid JSON after template execution: %w", err) } - if err := ValidateTemplate(&tmpl, parent); err != nil { - return nil, err + if err := ValidateTemplate(&certTmpl, parent); err != nil { + return nil, fmt.Errorf("template validation error: %w", err) } - return CreateCertificateFromTemplate(&tmpl, parent) + return CreateCertificateFromTemplate(&certTmpl, parent) } +// ValidateTemplate performs validation checks on the certificate template. +// CA certs: verifies proper key usage is set. +// non-CA certs: verifies digitalSignature usage is set. func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) error { if tmpl.Subject.CommonName == "" { return fmt.Errorf("template subject.commonName cannot be empty") @@ -67,11 +99,11 @@ func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) error return fmt.Errorf("template issuer.commonName cannot be empty for root certificate") } - if tmpl.BasicConstraints.IsCA && len(tmpl.KeyUsage) == 0 { - return fmt.Errorf("CA certificate must specify at least one key usage") - } - + // For CA certs if tmpl.BasicConstraints.IsCA { + if len(tmpl.KeyUsage) == 0 { + return fmt.Errorf("CA certificate must specify at least one key usage") + } hasKeyUsageCertSign := false for _, usage := range tmpl.KeyUsage { if usage == "certSign" { @@ -82,11 +114,42 @@ func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) error if !hasKeyUsageCertSign { return fmt.Errorf("CA certificate must have certSign key usage") } + } else { + // For non-CA certs + if len(tmpl.KeyUsage) == 0 { + return fmt.Errorf("certificate must specify at least one key usage") + } + hasDigitalSignature := false + for _, usage := range tmpl.KeyUsage { + if usage == "digitalSignature" { + hasDigitalSignature = true + break + } + } + if !hasDigitalSignature { + return fmt.Errorf("timestamp authority certificate must have digitalSignature key usage") + } + } + + // Validate extensions + for _, ext := range tmpl.Extensions { + if ext.ID == "" { + return fmt.Errorf("extension ID cannot be empty") + } + // Validate OID format + for _, n := range strings.Split(ext.ID, ".") { + if _, err := strconv.Atoi(n); err != nil { + return fmt.Errorf("invalid OID component in extension: %s", ext.ID) + } + } } return nil } +// CreateCertificateFromTemplate generates an x509.Certificate from the provided template +// applying all specified attributes including subject, issuer, validity period, +// constraints and extensions. func CreateCertificateFromTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) (*x509.Certificate, error) { notBefore, err := time.Parse(time.RFC3339, tmpl.NotBefore) if err != nil { @@ -116,6 +179,7 @@ func CreateCertificateFromTemplate(tmpl *CertificateTemplate, parent *x509.Certi NotAfter: notAfter, BasicConstraintsValid: true, IsCA: tmpl.BasicConstraints.IsCA, + ExtraExtensions: []pkix.Extension{}, } if tmpl.BasicConstraints.IsCA { @@ -124,11 +188,37 @@ func CreateCertificateFromTemplate(tmpl *CertificateTemplate, parent *x509.Certi } SetKeyUsages(cert, tmpl.KeyUsage) - SetExtKeyUsages(cert, tmpl.ExtKeyUsage) + + // Sets extensions + for _, ext := range tmpl.Extensions { + var oid []int + for _, n := range strings.Split(ext.ID, ".") { + i, err := strconv.Atoi(n) + if err != nil { + return nil, fmt.Errorf("invalid OID in extension: %s", ext.ID) + } + oid = append(oid, i) + } + + extension := pkix.Extension{ + Id: oid, + Critical: ext.Critical, + } + + value, err := base64.StdEncoding.DecodeString(ext.Value) + if err != nil { + return nil, fmt.Errorf("error decoding extension value: %w", err) + } + extension.Value = value + + cert.ExtraExtensions = append(cert.ExtraExtensions, extension) + } return cert, nil } +// SetKeyUsages applies the specified key usage to cert(s) +// supporting certSign, crlSign, and digitalSignature usages. func SetKeyUsages(cert *x509.Certificate, usages []string) { for _, usage := range usages { switch usage { @@ -141,12 +231,3 @@ func SetKeyUsages(cert *x509.Certificate, usages []string) { } } } - -func SetExtKeyUsages(cert *x509.Certificate, usages []string) { - for _, usage := range usages { - switch usage { - case "timeStamping": - cert.ExtKeyUsage = append(cert.ExtKeyUsage, x509.ExtKeyUsageTimeStamping) - } - } -} diff --git a/pkg/certmaker/templates/intermediate-template.json b/pkg/certmaker/templates/leaf-template.json similarity index 83% rename from pkg/certmaker/templates/intermediate-template.json rename to pkg/certmaker/templates/leaf-template.json index 7aaff054..79f8c3e9 100644 --- a/pkg/certmaker/templates/intermediate-template.json +++ b/pkg/certmaker/templates/leaf-template.json @@ -7,7 +7,7 @@ "Sigstore" ], "organizationalUnit": [ - "Timestamp Authority Intermediate CA" + "Timestamp Authority Leaf CA" ], "commonName": "https://tsa.com" }, @@ -28,7 +28,7 @@ { "id": "2.5.29.37", "critical": true, - "value": "asn1Seq (asn1Enc oid:1.3.6.1.5.5.7.3.8) | toJson" + "value": {{ asn1Seq (asn1Enc "oid:1.3.6.1.5.5.7.3.8") | toJson }} } ] } \ No newline at end of file From ac6f2c5d0a2705073e49450b72d2dc6c14e4c9d0 Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Sat, 30 Nov 2024 12:31:49 -0500 Subject: [PATCH 4/9] feat: adds optional intermediate flag(s). Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- cmd/certificate_maker/certificate_maker.go | 50 +- .../certificate_maker_test.go | 290 ++++++++ pkg/certmaker/certmaker.go | 207 ++++-- pkg/certmaker/certmaker_test.go | 681 ++++++++++++++---- pkg/certmaker/template.go | 49 +- pkg/certmaker/template_test.go | 335 +++++++++ .../templates/intermediate-template.json | 27 + pkg/certmaker/templates/leaf-template.json | 4 +- 8 files changed, 1402 insertions(+), 241 deletions(-) create mode 100644 cmd/certificate_maker/certificate_maker_test.go create mode 100644 pkg/certmaker/template_test.go create mode 100644 pkg/certmaker/templates/intermediate-template.json diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go index 85ee413c..696cbaf3 100644 --- a/cmd/certificate_maker/certificate_maker.go +++ b/cmd/certificate_maker/certificate_maker.go @@ -38,7 +38,7 @@ var ( rootCmd = &cobra.Command{ Use: "tsa-certificate-maker", Short: "Create certificate chains for Timestamp Authority", - Long: `A tool for creating root and leaf certificates for Timestamp Authority with timestamping capabilities`, + Long: `A tool for creating root, intermediate, and leaf certificates for Timestamp Authority with timestamping capabilities`, Version: version, } @@ -48,18 +48,20 @@ var ( RunE: runCreate, } - kmsType string - kmsRegion string - kmsKeyID string - kmsVaultName string - kmsTenantID string - kmsCredsFile string - rootTemplatePath string - leafTemplatePath string - rootKeyID string - leafKeyID string - rootCertPath string - leafCertPath string + kmsType string + kmsRegion string + kmsKeyID string + kmsTenantID string + kmsCredsFile string + rootTemplatePath string + leafTemplatePath string + rootKeyID string + leafKeyID string + rootCertPath string + leafCertPath string + intermediateKeyID string + intermediateTemplate string + intermediateCert string rawJSON = []byte(`{ "level": "debug", @@ -85,7 +87,6 @@ func init() { createCmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, cloudkms, azurekms)") createCmd.Flags().StringVar(&kmsRegion, "kms-region", "", "KMS region") createCmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") - createCmd.Flags().StringVar(&kmsVaultName, "kms-vault-name", "", "Azure KMS vault name") createCmd.Flags().StringVar(&kmsTenantID, "kms-tenant-id", "", "Azure KMS tenant ID") createCmd.Flags().StringVar(&kmsCredsFile, "kms-credentials-file", "", "Path to credentials file (for Google Cloud KMS)") createCmd.Flags().StringVar(&rootTemplatePath, "root-template", @@ -96,19 +97,23 @@ func init() { createCmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "KMS key identifier for leaf certificate") createCmd.Flags().StringVar(&rootCertPath, "root-cert", "root.pem", "Output path for root certificate") createCmd.Flags().StringVar(&leafCertPath, "leaf-cert", "leaf.pem", "Output path for leaf certificate") + createCmd.Flags().StringVar(&intermediateKeyID, "intermediate-key-id", "", "KMS key identifier for intermediate certificate") + createCmd.Flags().StringVar(&intermediateTemplate, "intermediate-template", "pkg/certmaker/templates/intermediate-template.json", "Path to intermediate certificate template") + createCmd.Flags().StringVar(&intermediateCert, "intermediate-cert", "intermediate.pem", "Output path for intermediate certificate") } -func runCreate(cmd *cobra.Command, args []string) error { +func runCreate(_ *cobra.Command, _ []string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Build KMS config from flags and environment config := certmaker.KMSConfig{ - Type: getConfigValue(kmsType, "KMS_TYPE"), - Region: getConfigValue(kmsRegion, "KMS_REGION"), - RootKeyID: getConfigValue(rootKeyID, "KMS_ROOT_KEY_ID"), - LeafKeyID: getConfigValue(leafKeyID, "KMS_LEAF_KEY_ID"), - Options: make(map[string]string), + Type: getConfigValue(kmsType, "KMS_TYPE"), + Region: getConfigValue(kmsRegion, "KMS_REGION"), + RootKeyID: getConfigValue(rootKeyID, "KMS_ROOT_KEY_ID"), + IntermediateKeyID: getConfigValue(intermediateKeyID, "KMS_INTERMEDIATE_KEY_ID"), + LeafKeyID: getConfigValue(leafKeyID, "KMS_LEAF_KEY_ID"), + Options: make(map[string]string), } // Handle KMS provider options @@ -118,9 +123,6 @@ func runCreate(cmd *cobra.Command, args []string) error { config.Options["credentials-file"] = credsFile } case "azurekms": - if vaultName := getConfigValue(kmsVaultName, "KMS_VAULT_NAME"); vaultName != "" { - config.Options["vault-name"] = vaultName - } if tenantID := getConfigValue(kmsTenantID, "KMS_TENANT_ID"); tenantID != "" { config.Options["tenant-id"] = tenantID } @@ -139,7 +141,7 @@ func runCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("leaf template error: %w", err) } - return certmaker.CreateCertificates(km, config, rootTemplatePath, leafTemplatePath, rootCertPath, leafCertPath) + return certmaker.CreateCertificates(km, config, rootTemplatePath, leafTemplatePath, rootCertPath, leafCertPath, intermediateKeyID, intermediateTemplate, intermediateCert) } func main() { diff --git a/cmd/certificate_maker/certificate_maker_test.go b/cmd/certificate_maker/certificate_maker_test.go new file mode 100644 index 00000000..bb9aceb3 --- /dev/null +++ b/cmd/certificate_maker/certificate_maker_test.go @@ -0,0 +1,290 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetConfigValue(t *testing.T) { + tests := []struct { + name string + flagValue string + envVar string + envValue string + want string + }{ + { + name: "flag value takes precedence", + flagValue: "flag-value", + envVar: "TEST_ENV", + envValue: "env-value", + want: "flag-value", + }, + { + name: "env value used when flag empty", + flagValue: "", + envVar: "TEST_ENV", + envValue: "env-value", + want: "env-value", + }, + { + name: "empty when both unset", + flagValue: "", + envVar: "TEST_ENV", + envValue: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv(tt.envVar, tt.envValue) + defer os.Unsetenv(tt.envVar) + } + got := getConfigValue(tt.flagValue, tt.envVar) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestInitLogger(t *testing.T) { + logger := initLogger() + require.NotNil(t, logger) +} + +func TestRunCreate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create test template files + rootTemplate := `{ + "subject": { + "commonName": "Test TSA Root CA" + }, + "issuer": { + "commonName": "Test TSA Root CA" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z", + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 + } + }` + + leafTemplate := `{ + "subject": { + "commonName": "Test TSA" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z", + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": { + "isCA": false + } + }` + + rootTmplPath := filepath.Join(tmpDir, "root-template.json") + leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") + err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0600) + require.NoError(t, err) + err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0600) + require.NoError(t, err) + + tests := []struct { + name string + args []string + envVars map[string]string + wantError bool + errMsg string + }{ + { + name: "missing KMS type", + args: []string{ + "--kms-region", "us-west-2", + "--root-key-id", "test-root-key", + "--leaf-key-id", "test-leaf-key", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "KMS type cannot be empty", + }, + { + name: "invalid KMS type", + args: []string{ + "--kms-type", "invalid", + "--kms-region", "us-west-2", + "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "unsupported KMS type", + }, + { + name: "missing root template", + args: []string{ + "--kms-type", "awskms", + "--kms-region", "us-west-2", + "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--root-template", "nonexistent.json", + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "template not found", + }, + { + name: "missing leaf template", + args: []string{ + "--kms-type", "awskms", + "--kms-region", "us-west-2", + "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--root-template", rootTmplPath, + "--leaf-template", "nonexistent.json", + }, + wantError: true, + errMsg: "template not found", + }, + { + name: "GCP KMS with credentials file", + args: []string{ + "--kms-type", "cloudkms", + "--root-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", + "--leaf-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/leaf-key", + "--kms-credentials-file", "/nonexistent/credentials.json", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "credentials file not found", + }, + { + name: "Azure KMS without tenant ID", + args: []string{ + "--kms-type", "azurekms", + "--root-key-id", "azurekms:name=test-key;vault=test-vault", + "--leaf-key-id", "azurekms:name=leaf-key;vault=test-vault", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "tenant-id is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set environment variables + for k, v := range tt.envVars { + os.Setenv(k, v) + defer os.Unsetenv(k) + } + + cmd := &cobra.Command{ + Use: "test", + RunE: runCreate, + } + + // Add all flags that runCreate expects + cmd.Flags().StringVar(&kmsType, "kms-type", "", "") + cmd.Flags().StringVar(&kmsRegion, "kms-region", "", "") + cmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "") + cmd.Flags().StringVar(&kmsTenantID, "kms-tenant-id", "", "") + cmd.Flags().StringVar(&kmsCredsFile, "kms-credentials-file", "", "") + cmd.Flags().StringVar(&rootTemplatePath, "root-template", "", "") + cmd.Flags().StringVar(&leafTemplatePath, "leaf-template", "", "") + cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "") + cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "") + cmd.Flags().StringVar(&rootCertPath, "root-cert", "root.pem", "") + cmd.Flags().StringVar(&leafCertPath, "leaf-cert", "leaf.pem", "") + cmd.Flags().StringVar(&intermediateKeyID, "intermediate-key-id", "", "") + cmd.Flags().StringVar(&intermediateTemplate, "intermediate-template", "", "") + cmd.Flags().StringVar(&intermediateCert, "intermediate-cert", "", "") + + cmd.SetArgs(tt.args) + err := cmd.Execute() + + if tt.wantError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCreateCommand(t *testing.T) { + // Create a test command + cmd := &cobra.Command{ + Use: "test", + RunE: func(_ *cobra.Command, _ []string) error { + return nil + }, + } + + // Add flags + cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS type") + cmd.Flags().StringVar(&kmsRegion, "kms-region", "", "KMS region") + cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "Root key ID") + cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "Leaf key ID") + + // Test missing required flags + err := cmd.Execute() + require.NoError(t, err) // No required flags set yet + + // Test flag parsing + err = cmd.ParseFlags([]string{ + "--kms-type", "awskms", + "--kms-region", "us-west-2", + "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", + "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654", + }) + require.NoError(t, err) + + // Verify flag values + assert.Equal(t, "awskms", kmsType) + assert.Equal(t, "us-west-2", kmsRegion) + assert.Equal(t, "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", rootKeyID) + assert.Equal(t, "arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654", leafKeyID) +} + +func TestRootCommand(t *testing.T) { + // Test help output + rootCmd.SetArgs([]string{"--help"}) + err := rootCmd.Execute() + require.NoError(t, err) + + // Test unknown command + rootCmd.SetArgs([]string{"unknown"}) + err = rootCmd.Execute() + require.Error(t, err) +} diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go index a2c5c5de..5448b50a 100644 --- a/pkg/certmaker/certmaker.go +++ b/pkg/certmaker/certmaker.go @@ -14,11 +14,12 @@ // // Package certmaker implements a certificate creation utility for Timestamp Authority. -// It supports creating root and intermediate certificates using (AWS, GCP, Azure). +// It supports creating root, intermediate, and leaf certs using (AWS, GCP, Azure). package certmaker import ( "context" + "crypto" "crypto/x509" "encoding/pem" "fmt" @@ -34,11 +35,12 @@ import ( // KMSConfig holds config for KMS providers. type KMSConfig struct { - Type string // KMS provider type: "awskms", "cloudkms", "azurekms" - Region string // AWS region or Cloud location - RootKeyID string // Root CA key identifier - LeafKeyID string // Leaf CA key identifier - Options map[string]string // Provider-specific options + Type string + Region string + RootKeyID string + IntermediateKeyID string + LeafKeyID string + Options map[string]string } // InitKMS initializes KMS provider based on the given config, KMSConfig. @@ -52,7 +54,7 @@ func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { URI: "", } - // Falls back to IntermediateKeyID if root is not set + // Falls back to LeafKeyID if root is not set keyID := config.RootKeyID if keyID == "" { keyID = config.LeafKeyID @@ -65,12 +67,21 @@ func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { case "cloudkms": opts.URI = fmt.Sprintf("cloudkms:%s", keyID) if credFile, ok := config.Options["credentials-file"]; ok { + if _, err := os.Stat(credFile); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("credentials file not found: %s", credFile) + } + return nil, fmt.Errorf("error accessing credentials file: %w", err) + } opts.URI += fmt.Sprintf("?credentials-file=%s", credFile) } - return cloudkms.New(ctx, opts) + km, err := cloudkms.New(ctx, opts) + if err != nil { + return nil, fmt.Errorf("failed to initialize Cloud KMS: %w", err) + } + return km, nil case "azurekms": - opts.URI = fmt.Sprintf("azurekms://%s.vault.azure.net/keys/%s", - config.Options["vault-name"], keyID) + opts.URI = keyID if config.Options["tenant-id"] != "" { opts.URI += fmt.Sprintf("?tenant-id=%s", config.Options["tenant-id"]) } @@ -80,72 +91,89 @@ func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { } } -// CreateCertificates generates a certificate chain using the configured KMS provider. -// It creates both root and intermediate certificates using the provided templates -// and KMS signing keys. -func CreateCertificates(km apiv1.KeyManager, config KMSConfig, rootTemplatePath, intermediateTemplatePath, rootCertPath, intermCertPath string) error { - // Parse root template +// CreateCertificates creates certificates using the provided KMS and templates. +// It creates 3 certificates (root -> intermediate -> leaf) if intermediateKeyID is provided, +// otherwise creates just 2 certs (root -> leaf). +func CreateCertificates(km apiv1.KeyManager, config KMSConfig, + rootTemplatePath, leafTemplatePath string, + rootCertPath, leafCertPath string, + intermediateKeyID, intermediateTemplatePath, intermediateCertPath string) error { + + // Create root cert rootTmpl, err := ParseTemplate(rootTemplatePath, nil) if err != nil { return fmt.Errorf("error parsing root template: %w", err) } - rootKeyName := config.RootKeyID - if config.Type == "azurekms" { - rootKeyName = fmt.Sprintf("azurekms:vault=%s;name=%s", - config.Options["vault-name"], config.RootKeyID) - } rootSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ - SigningKey: rootKeyName, + SigningKey: config.RootKeyID, }) if err != nil { return fmt.Errorf("error creating root signer: %w", err) } - // Create root cert rootCert, err := x509util.CreateCertificate(rootTmpl, rootTmpl, rootSigner.Public(), rootSigner) if err != nil { return fmt.Errorf("error creating root certificate: %w", err) } + if err := WriteCertificateToFile(rootCert, rootCertPath); err != nil { return fmt.Errorf("error writing root certificate: %w", err) } - // Parse / sign intermediate template - intermediateTmpl, err := ParseTemplate(intermediateTemplatePath, rootCert) - if err != nil { - return fmt.Errorf("error parsing intermediate template: %w", err) + var signingCert *x509.Certificate + var signingKey crypto.Signer + + if intermediateKeyID != "" { + // Create intermediate cert if key ID is provided + intermediateTmpl, err := ParseTemplate(intermediateTemplatePath, rootCert) + if err != nil { + return fmt.Errorf("error parsing intermediate template: %w", err) + } + + intermediateSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ + SigningKey: intermediateKeyID, + }) + if err != nil { + return fmt.Errorf("error creating intermediate signer: %w", err) + } + + intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediateSigner.Public(), rootSigner) + if err != nil { + return fmt.Errorf("error creating intermediate certificate: %w", err) + } + + if err := WriteCertificateToFile(intermediateCert, intermediateCertPath); err != nil { + return fmt.Errorf("error writing intermediate certificate: %w", err) + } + + signingCert = intermediateCert + signingKey = intermediateSigner + } else { + signingCert = rootCert + signingKey = rootSigner } - intermediateKeyName := config.LeafKeyID - if config.Type == "azurekms" { - intermediateKeyName = fmt.Sprintf("azurekms:vault=%s;name=%s", - config.Options["vault-name"], config.LeafKeyID) + + // Create leaf cert + leafTmpl, err := ParseTemplate(leafTemplatePath, signingCert) + if err != nil { + return fmt.Errorf("error parsing leaf template: %w", err) } - intermediateSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ - SigningKey: intermediateKeyName, + + leafSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ + SigningKey: config.LeafKeyID, }) if err != nil { - return fmt.Errorf("error creating intermediate signer: %w", err) + return fmt.Errorf("error creating leaf signer: %w", err) } - // Create intermediate/leaf cert - intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediateSigner.Public(), rootSigner) + leafCert, err := x509util.CreateCertificate(leafTmpl, signingCert, leafSigner.Public(), signingKey) if err != nil { - return fmt.Errorf("error creating intermediate certificate: %w", err) - } - if err := WriteCertificateToFile(intermediateCert, intermCertPath); err != nil { - return fmt.Errorf("error writing intermediate certificate: %w", err) + return fmt.Errorf("error creating leaf certificate: %w", err) } - // Verify certificate chain - pool := x509.NewCertPool() - pool.AddCert(rootCert) - opts := x509.VerifyOptions{ - Roots: pool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping}, - } - if _, err := intermediateCert.Verify(opts); err != nil { - return fmt.Errorf("certificate chain verification failed: %w", err) + if err := WriteCertificateToFile(leafCert, leafCertPath); err != nil { + return fmt.Errorf("error writing leaf certificate: %w", err) } return nil @@ -163,14 +191,19 @@ func WriteCertificateToFile(cert *x509.Certificate, filename string) error { return fmt.Errorf("failed to create file %s: %w", filename, err) } defer file.Close() + if err := pem.Encode(file, certPEM); err != nil { return fmt.Errorf("failed to write certificate to file %s: %w", filename, err) } + // Determine cert type certType := "root" - if cert.Subject.OrganizationalUnit != nil && cert.Subject.OrganizationalUnit[0] == "TSA Intermediate CA" { + if !cert.IsCA { + certType = "leaf" + } else if cert.MaxPathLen == 0 { certType = "intermediate" } + fmt.Printf("Your %s certificate has been saved in %s.\n", certType, filename) return nil } @@ -186,29 +219,89 @@ func ValidateKMSConfig(config KMSConfig) error { switch config.Type { case "awskms": + // AWS KMS validation if config.Region == "" { return fmt.Errorf("region is required for AWS KMS") } + validateAWSKeyID := func(keyID, keyType string) error { + if keyID == "" { + return nil + } + if !strings.HasPrefix(keyID, "arn:aws:kms:") && !strings.HasPrefix(keyID, "alias/") { + return fmt.Errorf("awskms %s must start with 'arn:aws:kms:' or 'alias/'", keyType) + } + return nil + } + if err := validateAWSKeyID(config.RootKeyID, "RootKeyID"); err != nil { + return err + } + if err := validateAWSKeyID(config.IntermediateKeyID, "IntermediateKeyID"); err != nil { + return err + } + if err := validateAWSKeyID(config.LeafKeyID, "LeafKeyID"); err != nil { + return err + } + case "cloudkms": - if config.RootKeyID != "" && !strings.HasPrefix(config.RootKeyID, "projects/") { - return fmt.Errorf("cloudkms RootKeyID must start with 'projects/'") + // GCP KMS validation + validateGCPKeyID := func(keyID, keyType string) error { + if keyID == "" { + return nil + } + if !strings.HasPrefix(keyID, "projects/") { + return fmt.Errorf("cloudkms %s must start with 'projects/'", keyType) + } + if !strings.Contains(keyID, "/locations/") || !strings.Contains(keyID, "/keyRings/") { + return fmt.Errorf("invalid cloudkms key format for %s: %s", keyType, keyID) + } + return nil } - if config.LeafKeyID != "" && !strings.HasPrefix(config.LeafKeyID, "projects/") { - return fmt.Errorf("cloudkms LeafKeyID must start with 'projects/'") + if err := validateGCPKeyID(config.RootKeyID, "RootKeyID"); err != nil { + return err } - case "azurekms": - if config.Options["vault-name"] == "" { - return fmt.Errorf("vault-name is required for Azure KMS") + if err := validateGCPKeyID(config.IntermediateKeyID, "IntermediateKeyID"); err != nil { + return err } + if err := validateGCPKeyID(config.LeafKeyID, "LeafKeyID"); err != nil { + return err + } + + case "azurekms": + // Azure KMS validation if config.Options["tenant-id"] == "" { return fmt.Errorf("tenant-id is required for Azure KMS") } + validateAzureKeyID := func(keyID, keyType string) error { + if keyID == "" { + return nil + } + // Validate format: azurekms:name=;vault= + if !strings.HasPrefix(keyID, "azurekms:name=") { + return fmt.Errorf("azurekms %s must start with 'azurekms:name='", keyType) + } + if !strings.Contains(keyID, ";vault=") { + return fmt.Errorf("azurekms %s must contain ';vault=' parameter", keyType) + } + return nil + } + if err := validateAzureKeyID(config.RootKeyID, "RootKeyID"); err != nil { + return err + } + if err := validateAzureKeyID(config.IntermediateKeyID, "IntermediateKeyID"); err != nil { + return err + } + if err := validateAzureKeyID(config.LeafKeyID, "LeafKeyID"); err != nil { + return err + } + + default: + return fmt.Errorf("unsupported KMS type: %s", config.Type) } return nil } -// ValidateTemplatePath validates that a template file exists and contains valid JSON +// 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) diff --git a/pkg/certmaker/certmaker_test.go b/pkg/certmaker/certmaker_test.go index 9facd47e..b6e0eb55 100644 --- a/pkg/certmaker/certmaker_test.go +++ b/pkg/certmaker/certmaker_test.go @@ -16,6 +16,7 @@ package certmaker import ( + "context" "crypto" "crypto/ecdsa" "crypto/elliptic" @@ -24,9 +25,11 @@ import ( "crypto/x509/pkix" "encoding/pem" "fmt" + "math/big" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,35 +37,37 @@ import ( "go.step.sm/crypto/x509util" ) -// mockKMS provides an in-memory KMS for testing -type mockKMS struct { +// mockKMSProvider is a mock implementation of apiv1.KeyManager +type mockKMSProvider struct { + name string keys map[string]*ecdsa.PrivateKey signers map[string]crypto.Signer } -func newMockKMS() *mockKMS { - m := &mockKMS{ +func newMockKMSProvider() *mockKMSProvider { + m := &mockKMSProvider{ + name: "test", keys: make(map[string]*ecdsa.PrivateKey), signers: make(map[string]crypto.Signer), } // Pre-create test keys - rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - panic(fmt.Errorf("failed to generate root key: %v", err)) - } - leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - panic(fmt.Errorf("failed to generate leaf key: %v", err)) - } + rootKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + intermediateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leafKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) m.keys["root-key"] = rootKey + m.keys["intermediate-key"] = intermediateKey m.keys["leaf-key"] = leafKey return m } -func (m *mockKMS) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, error) { +func (m *mockKMSProvider) CreateKey(*apiv1.CreateKeyRequest) (*apiv1.CreateKeyResponse, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockKMSProvider) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, error) { key, ok := m.keys[req.SigningKey] if !ok { return nil, fmt.Errorf("key not found: %s", req.SigningKey) @@ -71,7 +76,7 @@ func (m *mockKMS) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, e return key, nil } -func (m *mockKMS) GetPublicKey(req *apiv1.GetPublicKeyRequest) (crypto.PublicKey, error) { +func (m *mockKMSProvider) GetPublicKey(req *apiv1.GetPublicKeyRequest) (crypto.PublicKey, error) { key, ok := m.keys[req.Name] if !ok { return nil, fmt.Errorf("key not found: %s", req.Name) @@ -79,62 +84,403 @@ func (m *mockKMS) GetPublicKey(req *apiv1.GetPublicKeyRequest) (crypto.PublicKey return key.Public(), nil } -func (m *mockKMS) Close() error { +func (m *mockKMSProvider) Close() error { return nil } -func (m *mockKMS) CreateKey(req *apiv1.CreateKeyRequest) (*apiv1.CreateKeyResponse, error) { - return nil, fmt.Errorf("CreateKey is not supported in mockKMS") +func TestValidateKMSConfig(t *testing.T) { + tests := []struct { + name string + config KMSConfig + wantError string + }{ + { + name: "empty KMS type", + config: KMSConfig{ + RootKeyID: "key-id", + }, + wantError: "KMS type cannot be empty", + }, + { + name: "missing key IDs", + config: KMSConfig{ + Type: "awskms", + }, + wantError: "at least one of RootKeyID or LeafKeyID must be specified", + }, + { + name: "AWS KMS missing region", + config: KMSConfig{ + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", + }, + wantError: "region is required for AWS KMS", + }, + { + name: "AWS KMS invalid root key ID", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "invalid-key-id", + }, + wantError: "awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'", + }, + { + name: "AWS KMS invalid intermediate key ID", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", + IntermediateKeyID: "invalid-key-id", + }, + wantError: "awskms IntermediateKeyID must start with 'arn:aws:kms:' or 'alias/'", + }, + { + name: "AWS KMS invalid leaf key ID", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + LeafKeyID: "invalid-key-id", + }, + wantError: "awskms LeafKeyID must start with 'arn:aws:kms:' or 'alias/'", + }, + { + name: "GCP KMS invalid root key ID", + config: KMSConfig{ + Type: "cloudkms", + RootKeyID: "invalid-key-id", + }, + wantError: "cloudkms RootKeyID must start with 'projects/'", + }, + { + name: "GCP KMS invalid intermediate key ID", + config: KMSConfig{ + Type: "cloudkms", + RootKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", + IntermediateKeyID: "invalid-key-id", + }, + wantError: "cloudkms IntermediateKeyID must start with 'projects/'", + }, + { + name: "GCP KMS invalid leaf key ID", + config: KMSConfig{ + Type: "cloudkms", + LeafKeyID: "invalid-key-id", + }, + wantError: "cloudkms LeafKeyID must start with 'projects/'", + }, + { + name: "GCP KMS missing required parts", + config: KMSConfig{ + Type: "cloudkms", + RootKeyID: "projects/my-project", + }, + wantError: "invalid cloudkms key format", + }, + { + name: "Azure KMS missing tenant ID", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=my-key;vault=my-vault", + }, + wantError: "tenant-id is required for Azure KMS", + }, + { + name: "Azure KMS invalid root key ID prefix", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "invalid-key-id", + Options: map[string]string{ + "tenant-id": "tenant-id", + }, + }, + wantError: "azurekms RootKeyID must start with 'azurekms:name='", + }, + { + name: "Azure KMS missing vault parameter", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=my-key", + Options: map[string]string{ + "tenant-id": "tenant-id", + }, + }, + wantError: "azurekms RootKeyID must contain ';vault=' parameter", + }, + { + name: "Azure KMS invalid intermediate key ID", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=my-key;vault=my-vault", + IntermediateKeyID: "invalid-key-id", + Options: map[string]string{ + "tenant-id": "tenant-id", + }, + }, + wantError: "azurekms IntermediateKeyID must start with 'azurekms:name='", + }, + { + name: "Azure KMS invalid leaf key ID", + config: KMSConfig{ + Type: "azurekms", + LeafKeyID: "invalid-key-id", + Options: map[string]string{ + "tenant-id": "tenant-id", + }, + }, + wantError: "azurekms LeafKeyID must start with 'azurekms:name='", + }, + { + name: "unsupported KMS type", + config: KMSConfig{ + Type: "invalidkms", + RootKeyID: "key-id", + }, + 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) + }) + } + + // Test valid configurations + validConfigs := []KMSConfig{ + { + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", + }, + { + Type: "awskms", + Region: "us-west-2", + LeafKeyID: "alias/my-key", + }, + { + Type: "cloudkms", + RootKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", + }, + { + Type: "azurekms", + RootKeyID: "azurekms:name=my-key;vault=my-vault", + Options: map[string]string{ + "tenant-id": "tenant-id", + }, + }, + } + + for _, config := range validConfigs { + t.Run(fmt.Sprintf("valid %s config", config.Type), func(t *testing.T) { + err := ValidateKMSConfig(config) + require.NoError(t, err) + }) + } +} + +func TestValidateTemplatePath(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "template-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a valid JSON file + validPath := filepath.Join(tmpDir, "valid.json") + err = os.WriteFile(validPath, []byte("{}"), 0600) + require.NoError(t, err) + + // Create a non-JSON file + nonJSONPath := filepath.Join(tmpDir, "invalid.txt") + err = os.WriteFile(nonJSONPath, []byte("{}"), 0600) + require.NoError(t, err) + + tests := []struct { + name string + path string + wantError string + }{ + { + name: "valid JSON file", + path: validPath, + }, + { + name: "non-existent file", + path: "/nonexistent/template.json", + wantError: "template not found", + }, + { + name: "wrong extension", + path: nonJSONPath, + wantError: "template file must have .json extension", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTemplatePath(tt.path) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + } + }) + } } -// TestParseTemplate tests JSON template parsing -func TestParseTemplate(t *testing.T) { - tmpFile, err := os.CreateTemp("", "cert-template-*.json") +func TestWriteCertificateToFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-write-test-*") require.NoError(t, err) - defer os.Remove(tmpFile.Name()) + t.Cleanup(func() { os.RemoveAll(tmpDir) }) - templateContent := `{ + // Create a key pair + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Create a certificate template + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test Cert", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + MaxPathLenZero: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + SignatureAlgorithm: x509.ECDSAWithSHA256, + PublicKeyAlgorithm: x509.ECDSA, + } + + // Create a self-signed certificate + cert, err := x509util.CreateCertificate(template, template, key.Public(), key) + require.NoError(t, err) + + t.Run("success", func(t *testing.T) { + testFile := filepath.Join(tmpDir, "test-cert.pem") + err = WriteCertificateToFile(cert, testFile) + require.NoError(t, err) + + content, err := os.ReadFile(testFile) + require.NoError(t, err) + + block, _ := pem.Decode(content) + require.NotNil(t, block) + assert.Equal(t, "CERTIFICATE", block.Type) + + parsedCert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + assert.Equal(t, "Test Cert", parsedCert.Subject.CommonName) + }) + + t.Run("error writing to file", func(t *testing.T) { + // Try to write to a non-existent directory + testFile := filepath.Join(tmpDir, "nonexistent", "test-cert.pem") + err = WriteCertificateToFile(cert, testFile) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create file") + }) +} + +func TestCreateCertificates(t *testing.T) { + rootContent := `{ "subject": { - "commonName": "Test CA" + "country": ["US"], + "organization": ["Sigstore"], + "organizationalUnit": ["Timestamp Authority Root CA"], + "commonName": "https://tsa.com" }, "issuer": { - "commonName": "Test CA" + "commonName": "https://tsa.com" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2034-01-01T00:00:00Z", + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 }, "keyUsage": [ "certSign", "crlSign" - ], - "basicConstraints": { - "isCA": true, - "maxPathLen": 0 + ] + }` + + leafContent := `{ + "subject": { + "country": ["US"], + "organization": ["Sigstore"], + "organizationalUnit": ["Timestamp Authority"], + "commonName": "https://tsa.com" + }, + "issuer": { + "commonName": "https://tsa.com" }, "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" + "notAfter": "2034-01-01T00:00:00Z", + "basicConstraints": { + "isCA": false + }, + "keyUsage": [ + "digitalSignature" + ], + "extKeyUsage": [ + "timeStamping" + ] }` - err = os.WriteFile(tmpFile.Name(), []byte(templateContent), 0600) - require.NoError(t, err) + t.Run("TSA without intermediate", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(tmpDir) }) - tmpl, err := ParseTemplate(tmpFile.Name(), nil) - require.NoError(t, err) - assert.Equal(t, "Test CA", tmpl.Subject.CommonName) - assert.True(t, tmpl.IsCA) - assert.Equal(t, 0, tmpl.MaxPathLen) -} + km := newMockKMSProvider() + config := KMSConfig{ + Type: "mockkms", + RootKeyID: "root-key", + LeafKeyID: "leaf-key", + Options: make(map[string]string), + } -// TestCreateCertificates tests certificate chain creation -func TestCreateCertificates(t *testing.T) { - t.Run("TSA", func(t *testing.T) { + rootTmplPath := filepath.Join(tmpDir, "root-template.json") + leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") + rootCertPath := filepath.Join(tmpDir, "root.pem") + leafCertPath := filepath.Join(tmpDir, "leaf.pem") + + err = os.WriteFile(rootTmplPath, []byte(rootContent), 0600) + require.NoError(t, err) + + err = os.WriteFile(leafTmplPath, []byte(leafContent), 0600) + require.NoError(t, err) + + err = CreateCertificates(km, config, + rootTmplPath, leafTmplPath, + rootCertPath, leafCertPath, + "", "", "") + require.NoError(t, err) + + // Verify certificates were created + _, err = os.Stat(rootCertPath) + require.NoError(t, err) + _, err = os.Stat(leafCertPath) + require.NoError(t, err) + }) + + t.Run("TSA with intermediate", func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") require.NoError(t, err) t.Cleanup(func() { os.RemoveAll(tmpDir) }) - // root template (same for both) - rootContent := `{ + intermediateContent := `{ "subject": { "country": ["US"], "organization": ["Sigstore"], - "organizationalUnit": ["Timestamp Authority Root CA"], + "organizationalUnit": ["TSA Intermediate CA"], "commonName": "https://tsa.com" }, "issuer": { @@ -144,7 +490,7 @@ func TestCreateCertificates(t *testing.T) { "notAfter": "2034-01-01T00:00:00Z", "basicConstraints": { "isCA": true, - "maxPathLen": 1 + "maxPathLen": 0 }, "keyUsage": [ "certSign", @@ -152,140 +498,185 @@ func TestCreateCertificates(t *testing.T) { ] }` - // leaf template - leafContent := `{ - "subject": { - "country": ["US"], - "organization": ["Sigstore"], - "organizationalUnit": ["Timestamp Authority"], - "commonName": "https://tsa.com" - }, - "issuer": { - "commonName": "https://tsa.com" - }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2034-01-01T00:00:00Z", - "basicConstraints": { - "isCA": false, - "maxPathLen": 0 - }, - "keyUsage": [ - "digitalSignature" - ], - "extensions": [ - { - "id": "2.5.29.37", - "critical": true, - "value": {{ asn1Seq (asn1Enc "oid:1.3.6.1.5.5.7.3.8") | toJson }} - } - ] - }` - - testCertificateCreation(t, tmpDir, rootContent, leafContent) - }) -} + km := newMockKMSProvider() + config := KMSConfig{ + Type: "mockkms", + RootKeyID: "root-key", + IntermediateKeyID: "intermediate-key", + LeafKeyID: "leaf-key", + Options: make(map[string]string), + } + + rootTmplPath := filepath.Join(tmpDir, "root-template.json") + leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") + intermediateTmplPath := filepath.Join(tmpDir, "intermediate-template.json") + rootCertPath := filepath.Join(tmpDir, "root.pem") + intermediateCertPath := filepath.Join(tmpDir, "intermediate.pem") + leafCertPath := filepath.Join(tmpDir, "leaf.pem") + + err = os.WriteFile(rootTmplPath, []byte(rootContent), 0600) + require.NoError(t, err) + err = os.WriteFile(intermediateTmplPath, []byte(intermediateContent), 0600) + require.NoError(t, err) + err = os.WriteFile(leafTmplPath, []byte(leafContent), 0600) + require.NoError(t, err) -// TestWriteCertificateToFile tests PEM file writing -func TestWriteCertificateToFile(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-write-test-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(tmpDir) }) + err = CreateCertificates(km, config, + rootTmplPath, leafTmplPath, + rootCertPath, leafCertPath, + "intermediate-key", intermediateTmplPath, intermediateCertPath) + require.NoError(t, err) - km := newMockKMS() - signer, err := km.CreateSigner(&apiv1.CreateSignerRequest{ - SigningKey: "root-key", + // Verify certificates were created + _, err = os.Stat(rootCertPath) + require.NoError(t, err) + _, err = os.Stat(intermediateCertPath) + require.NoError(t, err) + _, err = os.Stat(leafCertPath) + require.NoError(t, err) }) - require.NoError(t, err) - - template := &x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Test Cert", - }, - } - - cert, err := x509util.CreateCertificate(template, template, signer.Public(), signer) - require.NoError(t, err) - testFile := filepath.Join(tmpDir, "test-cert.pem") - err = WriteCertificateToFile(cert, testFile) - require.NoError(t, err) + t.Run("invalid root template path", func(t *testing.T) { + km := newMockKMSProvider() + config := KMSConfig{ + Type: "mockkms", + RootKeyID: "root-key", + LeafKeyID: "leaf-key", + Options: make(map[string]string), + } + + err := CreateCertificates(km, config, + "/nonexistent/root.json", "/nonexistent/leaf.json", + "/nonexistent/root.pem", "/nonexistent/leaf.pem", + "", "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "error reading template file") + }) - content, err := os.ReadFile(testFile) - require.NoError(t, err) + t.Run("invalid intermediate template path", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(tmpDir) }) - block, _ := pem.Decode(content) - require.NotNil(t, block) - assert.Equal(t, "CERTIFICATE", block.Type) + km := newMockKMSProvider() + config := KMSConfig{ + Type: "mockkms", + RootKeyID: "root-key", + IntermediateKeyID: "intermediate-key", + LeafKeyID: "leaf-key", + Options: make(map[string]string), + } + + rootTmplPath := filepath.Join(tmpDir, "root-template.json") + leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") + rootCertPath := filepath.Join(tmpDir, "root.pem") + leafCertPath := filepath.Join(tmpDir, "leaf.pem") + + err = os.WriteFile(rootTmplPath, []byte(rootContent), 0600) + require.NoError(t, err) + err = os.WriteFile(leafTmplPath, []byte(leafContent), 0600) + require.NoError(t, err) - parsedCert, err := x509.ParseCertificate(block.Bytes) - require.NoError(t, err) - assert.Equal(t, "Test Cert", parsedCert.Subject.CommonName) -} + err = CreateCertificates(km, config, + rootTmplPath, leafTmplPath, + rootCertPath, leafCertPath, + "intermediate-key", "/nonexistent/intermediate.json", "/nonexistent/intermediate.pem") + require.Error(t, err) + assert.Contains(t, err.Error(), "error reading template file") + }) -// testCertificateCreation creates and verifies certificate chains -func testCertificateCreation(t *testing.T, tmpDir, rootContent, leafContent string) { - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - rootCertPath := filepath.Join(tmpDir, "root.pem") - leafCertPath := filepath.Join(tmpDir, "leaf.pem") + t.Run("invalid leaf template path", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(tmpDir) }) - err := os.WriteFile(rootTmplPath, []byte(rootContent), 0600) - require.NoError(t, err) + km := newMockKMSProvider() + config := KMSConfig{ + Type: "mockkms", + RootKeyID: "root-key", + LeafKeyID: "leaf-key", + Options: make(map[string]string), + } - err = os.WriteFile(leafTmplPath, []byte(leafContent), 0600) - require.NoError(t, err) + rootTmplPath := filepath.Join(tmpDir, "root-template.json") + rootCertPath := filepath.Join(tmpDir, "root.pem") + leafCertPath := filepath.Join(tmpDir, "leaf.pem") - km := newMockKMS() - config := KMSConfig{ - Type: "mockkms", - RootKeyID: "root-key", - LeafKeyID: "leaf-key", - Options: make(map[string]string), - } + err = os.WriteFile(rootTmplPath, []byte(rootContent), 0600) + require.NoError(t, err) - err = CreateCertificates(km, config, rootTmplPath, leafTmplPath, rootCertPath, leafCertPath) - require.NoError(t, err) + err = CreateCertificates(km, config, + rootTmplPath, "/nonexistent/leaf.json", + rootCertPath, leafCertPath, + "", "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "error reading template file") + }) } -func TestValidateKMSConfig(t *testing.T) { +func TestInitKMS(t *testing.T) { + ctx := context.Background() tests := []struct { - name string - config KMSConfig - wantErr bool + name string + config KMSConfig + wantError string }{ { - name: "valid azure config", + name: "AWS KMS", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "root-key", - LeafKeyID: "leaf-key", + Type: "awskms", + Region: "us-west-2", + RootKeyID: "test-key", Options: map[string]string{ - "vault-name": "test-vault", - "tenant-id": "test-tenant", + "access-key-id": "test-access-key", + "secret-access-key": "test-secret-key", }, }, - wantErr: false, }, { - name: "missing key IDs", + name: "GCP KMS", + config: KMSConfig{ + Type: "cloudkms", + RootKeyID: "test-key", + Options: map[string]string{ + "credentials-file": "/path/to/credentials.json", + }, + }, + }, + { + name: "Azure KMS", config: KMSConfig{ - Type: "azurekms", + Type: "azurekms", + RootKeyID: "test-key", Options: map[string]string{ - "vault-name": "test-vault", - "tenant-id": "test-tenant", + "tenant-id": "test-tenant", + "client-id": "test-client", + "client-secret": "test-secret", }, }, - wantErr: true, + }, + { + name: "unsupported KMS type", + config: KMSConfig{ + Type: "unsupportedkms", + RootKeyID: "test-key", + }, + wantError: "unsupported KMS type", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateKMSConfig(tt.config) - if tt.wantErr { - assert.Error(t, err) + km, err := InitKMS(ctx, tt.config) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + assert.Nil(t, km) } else { - assert.NoError(t, err) + // Since we can't actually connect to KMS providers in tests, + // we expect an error but not the "unsupported KMS type" error + require.Error(t, err) + assert.NotContains(t, err.Error(), "unsupported KMS type") } }) } diff --git a/pkg/certmaker/template.go b/pkg/certmaker/template.go index 4fb58bfa..d1fbaaaf 100644 --- a/pkg/certmaker/template.go +++ b/pkg/certmaker/template.go @@ -1,7 +1,20 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + // Package certmaker provides template parsing and certificate generation functionality -// for creating X.509 certificates from JSON templates per RFC3161 standards. It supports both root and -// intermediate certificate creation with configurable properties including key usage, -// extended key usage, and basic constraints. +// for creating X.509 certificates from JSON templates per RFC3161 standards. package certmaker import ( @@ -21,8 +34,7 @@ import ( "go.step.sm/crypto/x509util" ) -// CertificateTemplate defines the JSON structure for X.509 certificate templates -// including subject, issuer, validity period, and certificate constraints. +// CertificateTemplate defines the structure for the JSON certificate templates type CertificateTemplate struct { Subject struct { Country []string `json:"country,omitempty"` @@ -88,13 +100,22 @@ func ParseTemplate(filename string, parent *x509.Certificate) (*x509.Certificate } // ValidateTemplate performs validation checks on the certificate template. -// CA certs: verifies proper key usage is set. -// non-CA certs: verifies digitalSignature usage is set. func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) error { + if tmpl.NotBefore == "" { + return fmt.Errorf("notBefore time must be specified") + } + if tmpl.NotAfter == "" { + return fmt.Errorf("notAfter time must be specified") + } + if _, err := time.Parse(time.RFC3339, tmpl.NotBefore); err != nil { + return fmt.Errorf("invalid notBefore time format: %w", err) + } + if _, err := time.Parse(time.RFC3339, tmpl.NotAfter); err != nil { + return fmt.Errorf("invalid notAfter time format: %w", err) + } if tmpl.Subject.CommonName == "" { return fmt.Errorf("template subject.commonName cannot be empty") } - if parent == nil && tmpl.Issuer.CommonName == "" { return fmt.Errorf("template issuer.commonName cannot be empty for root certificate") } @@ -144,12 +165,16 @@ func ValidateTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) error } } + notBefore, _ := time.Parse(time.RFC3339, tmpl.NotBefore) + notAfter, _ := time.Parse(time.RFC3339, tmpl.NotAfter) + if notBefore.After(notAfter) { + return fmt.Errorf("NotBefore time must be before NotAfter time") + } + return nil } -// CreateCertificateFromTemplate generates an x509.Certificate from the provided template -// applying all specified attributes including subject, issuer, validity period, -// constraints and extensions. +// CreateCertificateFromTemplate creates an x509.Certificate from the provided template func CreateCertificateFromTemplate(tmpl *CertificateTemplate, parent *x509.Certificate) (*x509.Certificate, error) { notBefore, err := time.Parse(time.RFC3339, tmpl.NotBefore) if err != nil { @@ -189,7 +214,7 @@ func CreateCertificateFromTemplate(tmpl *CertificateTemplate, parent *x509.Certi SetKeyUsages(cert, tmpl.KeyUsage) - // Sets extensions + // Sets extensions (e.g. Timestamping) for _, ext := range tmpl.Extensions { var oid []int for _, n := range strings.Split(ext.ID, ".") { diff --git a/pkg/certmaker/template_test.go b/pkg/certmaker/template_test.go new file mode 100644 index 00000000..77442fe8 --- /dev/null +++ b/pkg/certmaker/template_test.go @@ -0,0 +1,335 @@ +// Copyright 2024 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package certmaker + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTemplate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-template-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a parent certificate for template data + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + } + + tests := []struct { + name string + content string + parent *x509.Certificate + wantError string + }{ + { + name: "valid template", + content: `{ + "subject": { + "commonName": "Test TSA" + }, + "issuer": { + "commonName": "Test TSA" + }, + "keyUsage": [ + "digitalSignature" + ], + "basicConstraints": { + "isCA": false + }, + "extensions": [ + { + "id": "2.5.29.37", + "critical": true, + "value": "MCQwIgYDVR0lBBswGQYIKwYBBQUHAwgGDSsGAQQBgjcUAgICAf8=" + } + ], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`, + parent: parent, + }, + { + name: "missing required fields", + content: `{ + "issuer": {"commonName": "Test TSA"}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`, + wantError: "subject.commonName cannot be empty", + }, + { + name: "invalid time format", + content: `{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test TSA"}, + "notBefore": "invalid", + "notAfter": "2025-01-01T00:00:00Z" + }`, + wantError: "invalid notBefore time format", + }, + { + name: "missing digital signature usage", + content: `{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test TSA"}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z", + "keyUsage": ["certSign"], + "basicConstraints": {"isCA": false} + }`, + wantError: "timestamp authority certificate must have digitalSignature key usage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp file for template + tmpFile := filepath.Join(tmpDir, "template.json") + err := os.WriteFile(tmpFile, []byte(tt.content), 0600) + require.NoError(t, err) + + cert, err := ParseTemplate(tmpFile, tt.parent) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + assert.Nil(t, cert) + } else { + require.NoError(t, err) + require.NotNil(t, cert) + } + }) + } +} + +func TestValidateTemplate(t *testing.T) { + // Create a parent certificate for testing + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + } + + tests := []struct { + name string + tmpl *CertificateTemplate + parent *x509.Certificate + wantError string + }{ + { + name: "valid TSA template", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + Extensions: []struct { + ID string `json:"id"` + Critical bool `json:"critical"` + Value string `json:"value"` + }{ + { + ID: "2.5.29.37", + Critical: true, + Value: base64.StdEncoding.EncodeToString([]byte{0x30, 0x24, 0x30, 0x22, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x08}), + }, + }, + }, + parent: parent, + }, + { + name: "empty notBefore time", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + }, + wantError: "notBefore time must be specified", + }, + { + name: "empty notAfter time", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "2024-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + }, + wantError: "notAfter time must be specified", + }, + { + name: "invalid notBefore format", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "invalid", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + }, + wantError: "invalid notBefore time format", + }, + { + name: "invalid extension OID", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + Extensions: []struct { + ID string `json:"id"` + Critical bool `json:"critical"` + Value string `json:"value"` + }{ + { + ID: "invalid.oid", + Critical: true, + Value: "AQID", + }, + }, + }, + wantError: "invalid OID component in extension", + }, + { + name: "empty extension ID", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + Extensions: []struct { + ID string `json:"id"` + Critical bool `json:"critical"` + Value string `json:"value"` + }{ + { + ID: "", + Critical: true, + Value: "AQID", + }, + }, + }, + wantError: "extension ID cannot be empty", + }, + { + name: "notBefore after notAfter", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "2025-01-01T00:00:00Z", // Later than NotAfter + NotAfter: "2024-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + }, + wantError: "NotBefore time must be before NotAfter time", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTemplate(tt.tmpl, tt.parent) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/certmaker/templates/intermediate-template.json b/pkg/certmaker/templates/intermediate-template.json new file mode 100644 index 00000000..e9d9650d --- /dev/null +++ b/pkg/certmaker/templates/intermediate-template.json @@ -0,0 +1,27 @@ +{ + "subject": { + "country": [ + "US" + ], + "organization": [ + "Sigstore" + ], + "organizationalUnit": [ + "Timestamp Authority Intermediate CA" + ], + "commonName": "https://tsa.com" + }, + "issuer": { + "commonName": "https://tsa.com" + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2034-01-01T00:00:00Z", + "basicConstraints": { + "isCA": true, + "maxPathLen": 0 + }, + "keyUsage": [ + "certSign", + "crlSign" + ] +} \ No newline at end of file diff --git a/pkg/certmaker/templates/leaf-template.json b/pkg/certmaker/templates/leaf-template.json index 79f8c3e9..a5ab9c73 100644 --- a/pkg/certmaker/templates/leaf-template.json +++ b/pkg/certmaker/templates/leaf-template.json @@ -16,10 +16,8 @@ }, "notBefore": "2024-01-01T00:00:00Z", "notAfter": "2034-01-01T00:00:00Z", - "serialNumber": 2, "basicConstraints": { - "isCA": false, - "maxPathLen": 0 + "isCA": false }, "keyUsage": [ "digitalSignature" From 9b3c77ae57624280b71125bf64a516b0c419e517 Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:35:29 -0500 Subject: [PATCH 5/9] fix: changes cloudkms flag to gcpkms and makes azure/gcp flags more descriptive. Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- cmd/certificate_maker/certificate_maker.go | 15 ++++++-- .../certificate_maker_test.go | 38 +++++++++---------- go.mod | 2 +- go.sum | 4 +- pkg/certmaker/certmaker.go | 11 +++--- pkg/certmaker/certmaker_test.go | 20 +++++----- 6 files changed, 49 insertions(+), 41 deletions(-) diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go index 696cbaf3..4cae6bd1 100644 --- a/cmd/certificate_maker/certificate_maker.go +++ b/cmd/certificate_maker/certificate_maker.go @@ -84,11 +84,11 @@ func init() { rootCmd.AddCommand(createCmd) - createCmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, cloudkms, azurekms)") + createCmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms)") createCmd.Flags().StringVar(&kmsRegion, "kms-region", "", "KMS region") createCmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") - createCmd.Flags().StringVar(&kmsTenantID, "kms-tenant-id", "", "Azure KMS tenant ID") - createCmd.Flags().StringVar(&kmsCredsFile, "kms-credentials-file", "", "Path to credentials file (for Google Cloud KMS)") + createCmd.Flags().StringVar(&kmsTenantID, "azure-tenant-id", "", "Azure KMS tenant ID") + createCmd.Flags().StringVar(&kmsCredsFile, "gcpkms-credentials-file", "", "Path to credentials file for GCP KMS") createCmd.Flags().StringVar(&rootTemplatePath, "root-template", "pkg/certmaker/templates/root-template.json", "Path to root certificate template") createCmd.Flags().StringVar(&leafTemplatePath, "leaf-template", @@ -118,8 +118,15 @@ func runCreate(_ *cobra.Command, _ []string) error { // Handle KMS provider options switch config.Type { - case "cloudkms": + case "gcpkms": if credsFile := getConfigValue(kmsCredsFile, "KMS_CREDENTIALS_FILE"); credsFile != "" { + // Check if credentials file exists before trying to use it + if _, err := os.Stat(credsFile); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("failed to initialize KMS: credentials file not found: %s", credsFile) + } + return fmt.Errorf("failed to initialize KMS: error accessing credentials file: %w", err) + } config.Options["credentials-file"] = credsFile } case "azurekms": diff --git a/cmd/certificate_maker/certificate_maker_test.go b/cmd/certificate_maker/certificate_maker_test.go index bb9aceb3..9ae4a91d 100644 --- a/cmd/certificate_maker/certificate_maker_test.go +++ b/cmd/certificate_maker/certificate_maker_test.go @@ -176,15 +176,15 @@ func TestRunCreate(t *testing.T) { { name: "GCP KMS with credentials file", args: []string{ - "--kms-type", "cloudkms", - "--root-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", - "--leaf-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/leaf-key", - "--kms-credentials-file", "/nonexistent/credentials.json", + "--kms-type", "gcpkms", + "--root-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", + "--leaf-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/leaf-key/cryptoKeyVersions/1", + "--gcpkms-credentials-file", "/nonexistent/credentials.json", "--root-template", rootTmplPath, "--leaf-template", leafTmplPath, }, wantError: true, - errMsg: "credentials file not found", + errMsg: "failed to initialize KMS: credentials file not found", }, { name: "Azure KMS without tenant ID", @@ -214,20 +214,20 @@ func TestRunCreate(t *testing.T) { } // Add all flags that runCreate expects - cmd.Flags().StringVar(&kmsType, "kms-type", "", "") - cmd.Flags().StringVar(&kmsRegion, "kms-region", "", "") - cmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "") - cmd.Flags().StringVar(&kmsTenantID, "kms-tenant-id", "", "") - cmd.Flags().StringVar(&kmsCredsFile, "kms-credentials-file", "", "") - cmd.Flags().StringVar(&rootTemplatePath, "root-template", "", "") - cmd.Flags().StringVar(&leafTemplatePath, "leaf-template", "", "") - cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "") - cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "") - cmd.Flags().StringVar(&rootCertPath, "root-cert", "root.pem", "") - cmd.Flags().StringVar(&leafCertPath, "leaf-cert", "leaf.pem", "") - cmd.Flags().StringVar(&intermediateKeyID, "intermediate-key-id", "", "") - cmd.Flags().StringVar(&intermediateTemplate, "intermediate-template", "", "") - cmd.Flags().StringVar(&intermediateCert, "intermediate-cert", "", "") + cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms)") + cmd.Flags().StringVar(&kmsRegion, "kms-region", "", "KMS region") + cmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") + cmd.Flags().StringVar(&kmsTenantID, "azure-tenant-id", "", "Azure KMS tenant ID") + cmd.Flags().StringVar(&kmsCredsFile, "gcpkms-credentials-file", "", "Path to credentials file for GCP KMS") + cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "KMS key identifier for root certificate") + cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "KMS key identifier for leaf certificate") + cmd.Flags().StringVar(&rootTemplatePath, "root-template", "", "Path to root certificate template") + cmd.Flags().StringVar(&leafTemplatePath, "leaf-template", "", "Path to leaf certificate template") + cmd.Flags().StringVar(&rootCertPath, "root-cert", "root.pem", "Output path for root certificate") + cmd.Flags().StringVar(&leafCertPath, "leaf-cert", "leaf.pem", "Output path for leaf certificate") + cmd.Flags().StringVar(&intermediateKeyID, "intermediate-key-id", "", "KMS key identifier for intermediate certificate") + cmd.Flags().StringVar(&intermediateTemplate, "intermediate-template", "", "Path to intermediate certificate template") + cmd.Flags().StringVar(&intermediateCert, "intermediate-cert", "intermediate.pem", "Output path for intermediate certificate") cmd.SetArgs(tt.args) err := cmd.Execute() diff --git a/go.mod b/go.mod index bc2264ef..0a129af4 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( cloud.google.com/go/compute/metadata v0.5.2 // indirect cloud.google.com/go/iam v1.2.2 // indirect cloud.google.com/go/kms v1.20.1 // indirect - cloud.google.com/go/longrunning v0.6.1 // indirect + cloud.google.com/go/longrunning v0.6.2 // indirect dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 // indirect diff --git a/go.sum b/go.sum index 82ddbfd7..be3f5314 100644 --- a/go.sum +++ b/go.sum @@ -170,8 +170,6 @@ github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4er github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= @@ -388,6 +386,8 @@ go.step.sm/crypto v0.55.0 h1:575Q7NahuM/ZRxUVN1GkO2e1aDYQJqIIg+nbfOajQJk= go.step.sm/crypto v0.55.0/go.mod h1:MgEmD1lgwsuzZwTgI0GwKapHjKVEQLVggSvHuf3bYnU= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go index 5448b50a..c8bb4be9 100644 --- a/pkg/certmaker/certmaker.go +++ b/pkg/certmaker/certmaker.go @@ -64,7 +64,8 @@ func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { case "awskms": opts.URI = fmt.Sprintf("awskms:///%s?region=%s", keyID, config.Region) return awskms.New(ctx, opts) - case "cloudkms": + case "gcpkms": + opts.Type = apiv1.Type("cloudkms") opts.URI = fmt.Sprintf("cloudkms:%s", keyID) if credFile, ok := config.Options["credentials-file"]; ok { if _, err := os.Stat(credFile); err != nil { @@ -77,7 +78,7 @@ func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { } km, err := cloudkms.New(ctx, opts) if err != nil { - return nil, fmt.Errorf("failed to initialize Cloud KMS: %w", err) + return nil, fmt.Errorf("failed to initialize GCP KMS: %w", err) } return km, nil case "azurekms": @@ -242,17 +243,17 @@ func ValidateKMSConfig(config KMSConfig) error { return err } - case "cloudkms": + case "gcpkms": // GCP KMS validation validateGCPKeyID := func(keyID, keyType string) error { if keyID == "" { return nil } if !strings.HasPrefix(keyID, "projects/") { - return fmt.Errorf("cloudkms %s must start with 'projects/'", keyType) + return fmt.Errorf("gcpkms %s must start with 'projects/'", keyType) } if !strings.Contains(keyID, "/locations/") || !strings.Contains(keyID, "/keyRings/") { - return fmt.Errorf("invalid cloudkms key format for %s: %s", keyType, keyID) + return fmt.Errorf("invalid gcpkms key format for %s: %s", keyType, keyID) } return nil } diff --git a/pkg/certmaker/certmaker_test.go b/pkg/certmaker/certmaker_test.go index b6e0eb55..3def594b 100644 --- a/pkg/certmaker/certmaker_test.go +++ b/pkg/certmaker/certmaker_test.go @@ -147,35 +147,35 @@ func TestValidateKMSConfig(t *testing.T) { { name: "GCP KMS invalid root key ID", config: KMSConfig{ - Type: "cloudkms", + Type: "gcpkms", RootKeyID: "invalid-key-id", }, - wantError: "cloudkms RootKeyID must start with 'projects/'", + wantError: "gcpkms RootKeyID must start with 'projects/'", }, { name: "GCP KMS invalid intermediate key ID", config: KMSConfig{ - Type: "cloudkms", + Type: "gcpkms", RootKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", IntermediateKeyID: "invalid-key-id", }, - wantError: "cloudkms IntermediateKeyID must start with 'projects/'", + wantError: "gcpkms IntermediateKeyID must start with 'projects/'", }, { name: "GCP KMS invalid leaf key ID", config: KMSConfig{ - Type: "cloudkms", + Type: "gcpkms", LeafKeyID: "invalid-key-id", }, - wantError: "cloudkms LeafKeyID must start with 'projects/'", + wantError: "gcpkms LeafKeyID must start with 'projects/'", }, { name: "GCP KMS missing required parts", config: KMSConfig{ - Type: "cloudkms", + Type: "gcpkms", RootKeyID: "projects/my-project", }, - wantError: "invalid cloudkms key format", + wantError: "invalid gcpkms key format", }, { name: "Azure KMS missing tenant ID", @@ -261,7 +261,7 @@ func TestValidateKMSConfig(t *testing.T) { LeafKeyID: "alias/my-key", }, { - Type: "cloudkms", + Type: "gcpkms", RootKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", }, { @@ -636,7 +636,7 @@ func TestInitKMS(t *testing.T) { { name: "GCP KMS", config: KMSConfig{ - Type: "cloudkms", + Type: "gcpkms", RootKeyID: "test-key", Options: map[string]string{ "credentials-file": "/path/to/credentials.json", From a6e5f4b7bda5d5e50ae7e97eebdcb175954e4186 Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:49:28 -0500 Subject: [PATCH 6/9] fix: makes env vars for azure tenant-id and gcp credentials file more consistent w/ flags. Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- cmd/certificate_maker/certificate_maker.go | 4 ++-- cmd/certificate_maker/certificate_maker_test.go | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go index 4cae6bd1..5361a342 100644 --- a/cmd/certificate_maker/certificate_maker.go +++ b/cmd/certificate_maker/certificate_maker.go @@ -119,7 +119,7 @@ func runCreate(_ *cobra.Command, _ []string) error { // Handle KMS provider options switch config.Type { case "gcpkms": - if credsFile := getConfigValue(kmsCredsFile, "KMS_CREDENTIALS_FILE"); credsFile != "" { + if credsFile := getConfigValue(kmsCredsFile, "GCP_CREDENTIALS_FILE"); credsFile != "" { // Check if credentials file exists before trying to use it if _, err := os.Stat(credsFile); err != nil { if os.IsNotExist(err) { @@ -130,7 +130,7 @@ func runCreate(_ *cobra.Command, _ []string) error { config.Options["credentials-file"] = credsFile } case "azurekms": - if tenantID := getConfigValue(kmsTenantID, "KMS_TENANT_ID"); tenantID != "" { + if tenantID := getConfigValue(kmsTenantID, "AZURE_TENANT_ID"); tenantID != "" { config.Options["tenant-id"] = tenantID } } diff --git a/cmd/certificate_maker/certificate_maker_test.go b/cmd/certificate_maker/certificate_maker_test.go index 9ae4a91d..8b0844d0 100644 --- a/cmd/certificate_maker/certificate_maker_test.go +++ b/cmd/certificate_maker/certificate_maker_test.go @@ -54,6 +54,20 @@ func TestGetConfigValue(t *testing.T) { envValue: "", want: "", }, + { + name: "GCP credentials file from env", + flagValue: "", + envVar: "GCP_CREDENTIALS_FILE", + envValue: "/path/to/creds.json", + want: "/path/to/creds.json", + }, + { + name: "Azure tenant ID from env", + flagValue: "", + envVar: "AZURE_TENANT_ID", + envValue: "tenant-123", + want: "tenant-123", + }, } for _, tt := range tests { From ff4845c913d473e4e5994311bf1ab6f48299726c Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Thu, 12 Dec 2024 13:19:56 -0500 Subject: [PATCH 7/9] fix: changes kms-region flag to aws-region and gcpkms-credentials-file flag to gcp-credentials-file. Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- cmd/certificate_maker/certificate_maker.go | 6 ++-- .../certificate_maker_test.go | 29 ++++++++++++------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go index 5361a342..70dbda84 100644 --- a/cmd/certificate_maker/certificate_maker.go +++ b/cmd/certificate_maker/certificate_maker.go @@ -85,10 +85,10 @@ func init() { rootCmd.AddCommand(createCmd) createCmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms)") - createCmd.Flags().StringVar(&kmsRegion, "kms-region", "", "KMS region") + createCmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") createCmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") createCmd.Flags().StringVar(&kmsTenantID, "azure-tenant-id", "", "Azure KMS tenant ID") - createCmd.Flags().StringVar(&kmsCredsFile, "gcpkms-credentials-file", "", "Path to credentials file for GCP KMS") + createCmd.Flags().StringVar(&kmsCredsFile, "gcp-credentials-file", "", "Path to credentials file for GCP KMS") createCmd.Flags().StringVar(&rootTemplatePath, "root-template", "pkg/certmaker/templates/root-template.json", "Path to root certificate template") createCmd.Flags().StringVar(&leafTemplatePath, "leaf-template", @@ -109,7 +109,7 @@ func runCreate(_ *cobra.Command, _ []string) error { // Build KMS config from flags and environment config := certmaker.KMSConfig{ Type: getConfigValue(kmsType, "KMS_TYPE"), - Region: getConfigValue(kmsRegion, "KMS_REGION"), + Region: getConfigValue(kmsRegion, "AWS_REGION"), RootKeyID: getConfigValue(rootKeyID, "KMS_ROOT_KEY_ID"), IntermediateKeyID: getConfigValue(intermediateKeyID, "KMS_INTERMEDIATE_KEY_ID"), LeafKeyID: getConfigValue(leafKeyID, "KMS_LEAF_KEY_ID"), diff --git a/cmd/certificate_maker/certificate_maker_test.go b/cmd/certificate_maker/certificate_maker_test.go index 8b0844d0..ac810e77 100644 --- a/cmd/certificate_maker/certificate_maker_test.go +++ b/cmd/certificate_maker/certificate_maker_test.go @@ -68,6 +68,13 @@ func TestGetConfigValue(t *testing.T) { envValue: "tenant-123", want: "tenant-123", }, + { + name: "AWS KMS region from env", + flagValue: "", + envVar: "AWS_REGION", + envValue: "us-west-2", + want: "us-west-2", + }, } for _, tt := range tests { @@ -139,7 +146,7 @@ func TestRunCreate(t *testing.T) { { name: "missing KMS type", args: []string{ - "--kms-region", "us-west-2", + "--aws-region", "us-west-2", "--root-key-id", "test-root-key", "--leaf-key-id", "test-leaf-key", "--root-template", rootTmplPath, @@ -152,9 +159,9 @@ func TestRunCreate(t *testing.T) { name: "invalid KMS type", args: []string{ "--kms-type", "invalid", - "--kms-region", "us-west-2", - "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", - "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--aws-region", "us-west-2", + "--root-key-id", "test-root-key", + "--leaf-key-id", "test-leaf-key", "--root-template", rootTmplPath, "--leaf-template", leafTmplPath, }, @@ -165,7 +172,7 @@ func TestRunCreate(t *testing.T) { name: "missing root template", args: []string{ "--kms-type", "awskms", - "--kms-region", "us-west-2", + "--aws-region", "us-west-2", "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", "--root-template", "nonexistent.json", @@ -178,7 +185,7 @@ func TestRunCreate(t *testing.T) { name: "missing leaf template", args: []string{ "--kms-type", "awskms", - "--kms-region", "us-west-2", + "--aws-region", "us-west-2", "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", "--root-template", rootTmplPath, @@ -193,7 +200,7 @@ func TestRunCreate(t *testing.T) { "--kms-type", "gcpkms", "--root-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", "--leaf-key-id", "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/leaf-key/cryptoKeyVersions/1", - "--gcpkms-credentials-file", "/nonexistent/credentials.json", + "--gcp-credentials-file", "/nonexistent/credentials.json", "--root-template", rootTmplPath, "--leaf-template", leafTmplPath, }, @@ -229,10 +236,10 @@ func TestRunCreate(t *testing.T) { // Add all flags that runCreate expects cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms)") - cmd.Flags().StringVar(&kmsRegion, "kms-region", "", "KMS region") + cmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") cmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") cmd.Flags().StringVar(&kmsTenantID, "azure-tenant-id", "", "Azure KMS tenant ID") - cmd.Flags().StringVar(&kmsCredsFile, "gcpkms-credentials-file", "", "Path to credentials file for GCP KMS") + cmd.Flags().StringVar(&kmsCredsFile, "gcp-credentials-file", "", "Path to credentials file for GCP KMS") cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "KMS key identifier for root certificate") cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "KMS key identifier for leaf certificate") cmd.Flags().StringVar(&rootTemplatePath, "root-template", "", "Path to root certificate template") @@ -267,7 +274,7 @@ func TestCreateCommand(t *testing.T) { // Add flags cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS type") - cmd.Flags().StringVar(&kmsRegion, "kms-region", "", "KMS region") + cmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "Root key ID") cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "Leaf key ID") @@ -278,7 +285,7 @@ func TestCreateCommand(t *testing.T) { // Test flag parsing err = cmd.ParseFlags([]string{ "--kms-type", "awskms", - "--kms-region", "us-west-2", + "--aws-region", "us-west-2", "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654", }) From ae6c73563178202e0a682c53421e9421210e0bed Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Sat, 14 Dec 2024 13:38:07 -0500 Subject: [PATCH 8/9] fix: improves kms key validation across providers. Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- .../certificate_maker_test.go | 76 +- go.mod | 11 +- go.sum | 28 +- pkg/certmaker/certmaker.go | 45 +- pkg/certmaker/certmaker_test.go | 1123 +++++++++++------ pkg/certmaker/template_test.go | 133 +- 6 files changed, 860 insertions(+), 556 deletions(-) diff --git a/cmd/certificate_maker/certificate_maker_test.go b/cmd/certificate_maker/certificate_maker_test.go index ac810e77..f711605d 100644 --- a/cmd/certificate_maker/certificate_maker_test.go +++ b/cmd/certificate_maker/certificate_maker_test.go @@ -18,11 +18,10 @@ package main import ( "os" "path/filepath" + "strings" "testing" "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestGetConfigValue(t *testing.T) { @@ -84,22 +83,27 @@ func TestGetConfigValue(t *testing.T) { defer os.Unsetenv(tt.envVar) } got := getConfigValue(tt.flagValue, tt.envVar) - assert.Equal(t, tt.want, got) + if got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } }) } } func TestInitLogger(t *testing.T) { logger := initLogger() - require.NotNil(t, logger) + if logger == nil { + t.Errorf("logger is nil") + } } func TestRunCreate(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cert-test-*") - require.NoError(t, err) + if err != nil { + t.Errorf("unexpected error: %v", err) + } defer os.RemoveAll(tmpDir) - // Create test template files rootTemplate := `{ "subject": { "commonName": "Test TSA Root CA" @@ -132,9 +136,13 @@ func TestRunCreate(t *testing.T) { rootTmplPath := filepath.Join(tmpDir, "root-template.json") leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0600) - require.NoError(t, err) + if err != nil { + t.Errorf("unexpected error: %v", err) + } err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0600) - require.NoError(t, err) + if err != nil { + t.Errorf("unexpected error: %v", err) + } tests := []struct { name string @@ -223,7 +231,6 @@ func TestRunCreate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Set environment variables for k, v := range tt.envVars { os.Setenv(k, v) defer os.Unsetenv(k) @@ -234,7 +241,6 @@ func TestRunCreate(t *testing.T) { RunE: runCreate, } - // Add all flags that runCreate expects cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms)") cmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") cmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") @@ -254,17 +260,21 @@ func TestRunCreate(t *testing.T) { err := cmd.Execute() if tt.wantError { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errMsg) + if err == nil { + t.Errorf("expected error, but got nil") + } else if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("error %q should contain %q", err.Error(), tt.errMsg) + } } else { - require.NoError(t, err) + if err != nil { + t.Errorf("unexpected error: %v", err) + } } }) } } func TestCreateCommand(t *testing.T) { - // Create a test command cmd := &cobra.Command{ Use: "test", RunE: func(_ *cobra.Command, _ []string) error { @@ -272,40 +282,50 @@ func TestCreateCommand(t *testing.T) { }, } - // Add flags cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS type") cmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "Root key ID") cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "Leaf key ID") - // Test missing required flags err := cmd.Execute() - require.NoError(t, err) // No required flags set yet + if err != nil { + t.Errorf("unexpected error: %v", err) + } - // Test flag parsing err = cmd.ParseFlags([]string{ "--kms-type", "awskms", "--aws-region", "us-west-2", "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654", }) - require.NoError(t, err) + if err != nil { + t.Errorf("unexpected error: %v", err) + } - // Verify flag values - assert.Equal(t, "awskms", kmsType) - assert.Equal(t, "us-west-2", kmsRegion) - assert.Equal(t, "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", rootKeyID) - assert.Equal(t, "arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654", leafKeyID) + if kmsType != "awskms" { + t.Errorf("got kms-type %v, want awskms", kmsType) + } + if kmsRegion != "us-west-2" { + t.Errorf("got aws-region %v, want us-west-2", kmsRegion) + } + if rootKeyID != "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab" { + t.Errorf("got root-key-id %v, want arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", rootKeyID) + } + if leafKeyID != "arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654" { + t.Errorf("got leaf-key-id %v, want arn:aws:kms:us-west-2:123456789012:key/9876fedc-ba98-7654-3210-fedcba987654", leafKeyID) + } } func TestRootCommand(t *testing.T) { - // Test help output rootCmd.SetArgs([]string{"--help"}) err := rootCmd.Execute() - require.NoError(t, err) + if err != nil { + t.Errorf("unexpected error: %v", err) + } - // Test unknown command rootCmd.SetArgs([]string{"unknown"}) err = rootCmd.Execute() - require.Error(t, err) + if err == nil { + t.Errorf("expected error, but got nil") + } } diff --git a/go.mod b/go.mod index 0a129af4..6fdb15d4 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,6 @@ require ( github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.9.0 github.com/urfave/negroni v1.0.0 go.step.sm/crypto v0.55.0 go.uber.org/zap v1.27.0 @@ -47,7 +46,7 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect cloud.google.com/go/compute/metadata v0.5.2 // indirect cloud.google.com/go/iam v1.2.2 // indirect - cloud.google.com/go/kms v1.20.1 // indirect + cloud.google.com/go/kms v1.20.2 // indirect cloud.google.com/go/longrunning v0.6.2 // indirect dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect @@ -56,9 +55,9 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect @@ -82,7 +81,6 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect @@ -133,7 +131,6 @@ require ( github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect diff --git a/go.sum b/go.sum index be3f5314..5f637698 100644 --- a/go.sum +++ b/go.sum @@ -31,10 +31,10 @@ github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2 github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 h1:DRiANoJTiW6obBQe3SqZizkuV1PEgfiiGivmVocDy64= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0/go.mod h1:qLIye2hwb/ZouqhpSD9Zn3SJipvpEnz1Ywl3VUk9Y0s= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 h1:7rKG7UmnrxX4N53TFhkYqjc+kVUZuw0fL8I3Fh+Ld9E= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 h1:gUDtaZk8heteyfdmv+pcfHvhR9llnh7c7GMwZ8RVG04= @@ -322,16 +322,16 @@ github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbm github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/sigstore/sigstore v1.8.10 h1:r4t+TYzJlG9JdFxMy+um9GZhZ2N1hBTyTex0AHEZxFs= -github.com/sigstore/sigstore v1.8.10/go.mod h1:BekjqxS5ZtHNJC4u3Q3Stvfx2eyisbW/lUZzmPU2u4A= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.10 h1:e5GfVngPjGap/N3ODefayt7vKIPS1/v3hWLZ9+4MrN4= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.10/go.mod h1:HOr3AdFPKdND2FNl/sUD5ZifPl1OMJvrbf9xIaaWcus= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.10 h1:9tZEpfIL/ewAG9G87AHe3aVoy8Ujos2F1qLfCckX6jQ= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.10/go.mod h1:VnIAcitund62R45ezK/dtUeEhuRtB3LsAgJ8m0H34zc= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.10 h1:Xre51HdjIIaVo5ox5zyL+6h0tkrx7Ke9Neh7fLmmZK0= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.10/go.mod h1:VNfdklQDbyGJog8S7apdxiEfmYmCkKyxrsCL9xprkTY= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.10 h1:HjfjL3x3dP2kaGqQHVog974cTcKfzFaGjfZyLQ9KXrg= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.10/go.mod h1:jaeEjkTW1p3gUyPjz9lTcT4TydCs208FoyAwIs6bIT4= +github.com/sigstore/sigstore v1.8.11 h1:tEqeQqbT+awtM87ec9KEeSUxT/AFvJNawneYJyAkFrQ= +github.com/sigstore/sigstore v1.8.11/go.mod h1:fdrFQosxCQ4wTL5H1NrZcQkqQ72AQbPjtpcL2QOGKV0= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.11 h1:4jIEBOtqDZHyQNQSw/guGmIY0y3CVdOGQu3l2FNlqpY= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.11/go.mod h1:rzfk1r8p6Mgjp5tidjzNC+/Kh1h6Eh/ON7xI7ApqBSM= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.11 h1:GXL/OitAMBbLg61nbbk0bXOgOIgDgyFE+9T2Ng3P3o8= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.11/go.mod h1:a9KhG9LZJFcGJB2PtFga1jUIUB0gr0Ix44TDMMXUjJU= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.11 h1:jxKeAMOzaxjwEfmpMMYxF5Vf35tEhQOUXURaUx0ctgo= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.11/go.mod h1:fIAOBcL2s+Vq2Fp9WZByUDdWAmhNuZkJGLCUVUjkdtI= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.11 h1:nH6Cpsz9c7v8jpGiJcH+3+zijfdJha+9mK07MAzZjbc= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.11/go.mod h1:bTBdhPvdaDsHccD9zsSHe/q4ah2OXkdfL/qK7JCuRno= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go index c8bb4be9..b9a80820 100644 --- a/pkg/certmaker/certmaker.go +++ b/pkg/certmaker/certmaker.go @@ -228,7 +228,19 @@ func ValidateKMSConfig(config KMSConfig) error { if keyID == "" { return nil } - if !strings.HasPrefix(keyID, "arn:aws:kms:") && !strings.HasPrefix(keyID, "alias/") { + if strings.HasPrefix(keyID, "arn:aws:kms:") { + parts := strings.Split(keyID, ":") + if len(parts) < 6 { + return fmt.Errorf("invalid AWS KMS ARN format for %s", keyType) + } + if parts[3] != config.Region { + return fmt.Errorf("region in ARN (%s) does not match configured region (%s)", parts[3], config.Region) + } + } else if strings.HasPrefix(keyID, "alias/") { + if strings.TrimPrefix(keyID, "alias/") == "" { + return fmt.Errorf("alias name cannot be empty for %s", keyType) + } + } else { return fmt.Errorf("awskms %s must start with 'arn:aws:kms:' or 'alias/'", keyType) } return nil @@ -249,11 +261,20 @@ func ValidateKMSConfig(config KMSConfig) error { if keyID == "" { return nil } - if !strings.HasPrefix(keyID, "projects/") { - return fmt.Errorf("gcpkms %s must start with 'projects/'", keyType) + requiredComponents := []struct { + component string + message string + }{ + {"projects/", "must start with 'projects/'"}, + {"/locations/", "must contain '/locations/'"}, + {"/keyRings/", "must contain '/keyRings/'"}, + {"/cryptoKeys/", "must contain '/cryptoKeys/'"}, + {"/cryptoKeyVersions/", "must contain '/cryptoKeyVersions/'"}, } - if !strings.Contains(keyID, "/locations/") || !strings.Contains(keyID, "/keyRings/") { - return fmt.Errorf("invalid gcpkms key format for %s: %s", keyType, keyID) + for _, req := range requiredComponents { + if !strings.Contains(keyID, req.component) { + return fmt.Errorf("gcpkms %s %s", keyType, req.message) + } } return nil } @@ -269,6 +290,9 @@ func ValidateKMSConfig(config KMSConfig) error { case "azurekms": // Azure KMS validation + if config.Options == nil { + return fmt.Errorf("options map is required for Azure KMS") + } if config.Options["tenant-id"] == "" { return fmt.Errorf("tenant-id is required for Azure KMS") } @@ -276,13 +300,20 @@ func ValidateKMSConfig(config KMSConfig) error { if keyID == "" { return nil } - // Validate format: azurekms:name=;vault= if !strings.HasPrefix(keyID, "azurekms:name=") { return fmt.Errorf("azurekms %s must start with 'azurekms:name='", keyType) } - if !strings.Contains(keyID, ";vault=") { + nameStart := strings.Index(keyID, "name=") + 5 + vaultIndex := strings.Index(keyID, ";vault=") + if vaultIndex == -1 { return fmt.Errorf("azurekms %s must contain ';vault=' parameter", keyType) } + if strings.TrimSpace(keyID[nameStart:vaultIndex]) == "" { + return fmt.Errorf("key name cannot be empty for %s", keyType) + } + if strings.TrimSpace(keyID[vaultIndex+7:]) == "" { + return fmt.Errorf("vault name cannot be empty for %s", keyType) + } return nil } if err := validateAzureKeyID(config.RootKeyID, "RootKeyID"); err != nil { diff --git a/pkg/certmaker/certmaker_test.go b/pkg/certmaker/certmaker_test.go index 3def594b..9ee42709 100644 --- a/pkg/certmaker/certmaker_test.go +++ b/pkg/certmaker/certmaker_test.go @@ -21,46 +21,32 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "crypto/x509" - "crypto/x509/pkix" "encoding/pem" "fmt" - "math/big" "os" "path/filepath" + "strings" "testing" - "time" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "go.step.sm/crypto/kms/apiv1" - "go.step.sm/crypto/x509util" ) -// mockKMSProvider is a mock implementation of apiv1.KeyManager type mockKMSProvider struct { - name string - keys map[string]*ecdsa.PrivateKey - signers map[string]crypto.Signer + keys map[string]crypto.Signer } func newMockKMSProvider() *mockKMSProvider { - m := &mockKMSProvider{ - name: "test", - keys: make(map[string]*ecdsa.PrivateKey), - signers: make(map[string]crypto.Signer), + keys := make(map[string]crypto.Signer) + for _, id := range []string{"root-key", "intermediate-key", "leaf-key"} { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + keys[id] = priv } - - // Pre-create test keys - rootKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - intermediateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - leafKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - - m.keys["root-key"] = rootKey - m.keys["intermediate-key"] = intermediateKey - m.keys["leaf-key"] = leafKey - - return m + return &mockKMSProvider{keys: keys} } func (m *mockKMSProvider) CreateKey(*apiv1.CreateKeyRequest) (*apiv1.CreateKeyResponse, error) { @@ -72,7 +58,6 @@ func (m *mockKMSProvider) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.S if !ok { return nil, fmt.Errorf("key not found: %s", req.SigningKey) } - m.signers[req.SigningKey] = key return key, nil } @@ -145,7 +130,7 @@ func TestValidateKMSConfig(t *testing.T) { wantError: "awskms LeafKeyID must start with 'arn:aws:kms:' or 'alias/'", }, { - name: "GCP KMS invalid root key ID", + name: "GCP_KMS_invalid_root_key_ID", config: KMSConfig{ Type: "gcpkms", RootKeyID: "invalid-key-id", @@ -153,16 +138,16 @@ func TestValidateKMSConfig(t *testing.T) { wantError: "gcpkms RootKeyID must start with 'projects/'", }, { - name: "GCP KMS invalid intermediate key ID", + name: "GCP_KMS_invalid_intermediate_key_ID", config: KMSConfig{ Type: "gcpkms", - RootKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", + RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", IntermediateKeyID: "invalid-key-id", }, wantError: "gcpkms IntermediateKeyID must start with 'projects/'", }, { - name: "GCP KMS invalid leaf key ID", + name: "GCP_KMS_invalid_leaf_key_ID", config: KMSConfig{ Type: "gcpkms", LeafKeyID: "invalid-key-id", @@ -173,31 +158,21 @@ func TestValidateKMSConfig(t *testing.T) { name: "GCP KMS missing required parts", config: KMSConfig{ Type: "gcpkms", - RootKeyID: "projects/my-project", + RootKeyID: "projects/test-project", }, - wantError: "invalid gcpkms key format", + wantError: "gcpkms RootKeyID must contain '/locations/'", }, { - name: "Azure KMS missing tenant ID", + name: "Azure_KMS_missing_tenant_ID", config: KMSConfig{ Type: "azurekms", RootKeyID: "azurekms:name=my-key;vault=my-vault", + Options: map[string]string{}, }, wantError: "tenant-id is required for Azure KMS", }, { - name: "Azure KMS invalid root key ID prefix", - config: KMSConfig{ - Type: "azurekms", - RootKeyID: "invalid-key-id", - Options: map[string]string{ - "tenant-id": "tenant-id", - }, - }, - wantError: "azurekms RootKeyID must start with 'azurekms:name='", - }, - { - name: "Azure KMS missing vault parameter", + name: "Azure_KMS_missing_vault_parameter", config: KMSConfig{ Type: "azurekms", RootKeyID: "azurekms:name=my-key", @@ -207,29 +182,6 @@ func TestValidateKMSConfig(t *testing.T) { }, wantError: "azurekms RootKeyID must contain ';vault=' parameter", }, - { - name: "Azure KMS invalid intermediate key ID", - config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=my-key;vault=my-vault", - IntermediateKeyID: "invalid-key-id", - Options: map[string]string{ - "tenant-id": "tenant-id", - }, - }, - wantError: "azurekms IntermediateKeyID must start with 'azurekms:name='", - }, - { - name: "Azure KMS invalid leaf key ID", - config: KMSConfig{ - Type: "azurekms", - LeafKeyID: "invalid-key-id", - Options: map[string]string{ - "tenant-id": "tenant-id", - }, - }, - wantError: "azurekms LeafKeyID must start with 'azurekms:name='", - }, { name: "unsupported KMS type", config: KMSConfig{ @@ -238,445 +190,826 @@ func TestValidateKMSConfig(t *testing.T) { }, 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) - }) - } - - // Test valid configurations - validConfigs := []KMSConfig{ - { - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", - }, { - Type: "awskms", - Region: "us-west-2", - LeafKeyID: "alias/my-key", - }, - { - Type: "gcpkms", - RootKeyID: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", + name: "aws_kms_invalid_arn_format", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:invalid", + }, + wantError: "invalid AWS KMS ARN format for RootKeyID", }, { - Type: "azurekms", - RootKeyID: "azurekms:name=my-key;vault=my-vault", - Options: map[string]string{ - "tenant-id": "tenant-id", + name: "aws_kms_region_mismatch", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-east-1:123456789012:key/test-key", }, + wantError: "region in ARN (us-east-1) does not match configured region (us-west-2)", }, - } - - for _, config := range validConfigs { - t.Run(fmt.Sprintf("valid %s config", config.Type), func(t *testing.T) { - err := ValidateKMSConfig(config) - require.NoError(t, err) - }) - } -} - -func TestValidateTemplatePath(t *testing.T) { - // Create a temporary directory for test files - tmpDir, err := os.MkdirTemp("", "template-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - // Create a valid JSON file - validPath := filepath.Join(tmpDir, "valid.json") - err = os.WriteFile(validPath, []byte("{}"), 0600) - require.NoError(t, err) - - // Create a non-JSON file - nonJSONPath := filepath.Join(tmpDir, "invalid.txt") - err = os.WriteFile(nonJSONPath, []byte("{}"), 0600) - require.NoError(t, err) - - tests := []struct { - name string - path string - wantError string - }{ { - name: "valid JSON file", - path: validPath, + name: "aws_kms_empty_alias", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/", + }, + wantError: "alias name cannot be empty for RootKeyID", }, { - name: "non-existent file", - path: "/nonexistent/template.json", - wantError: "template not found", + name: "azure_kms_empty_key_name", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + wantError: "key name cannot be empty for RootKeyID", }, { - name: "wrong extension", - path: nonJSONPath, - wantError: "template file must have .json extension", + name: "azure_kms_empty_vault_name", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + wantError: "vault name cannot be empty for RootKeyID", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateTemplatePath(tt.path) + err := ValidateKMSConfig(tt.config) if tt.wantError != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantError) - } else { - require.NoError(t, err) + 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 TestWriteCertificateToFile(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-write-test-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(tmpDir) }) - - // Create a key pair - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - // Create a certificate template - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - CommonName: "Test Cert", - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(24 * time.Hour), - BasicConstraintsValid: true, - IsCA: true, - MaxPathLen: 0, - MaxPathLenZero: true, - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, - SignatureAlgorithm: x509.ECDSAWithSHA256, - PublicKeyAlgorithm: x509.ECDSA, - } - - // Create a self-signed certificate - cert, err := x509util.CreateCertificate(template, template, key.Public(), key) - require.NoError(t, err) - - t.Run("success", func(t *testing.T) { - testFile := filepath.Join(tmpDir, "test-cert.pem") - err = WriteCertificateToFile(cert, testFile) - require.NoError(t, err) - - content, err := os.ReadFile(testFile) - require.NoError(t, err) - - block, _ := pem.Decode(content) - require.NotNil(t, block) - assert.Equal(t, "CERTIFICATE", block.Type) - - parsedCert, err := x509.ParseCertificate(block.Bytes) - require.NoError(t, err) - assert.Equal(t, "Test Cert", parsedCert.Subject.CommonName) - }) - - t.Run("error writing to file", func(t *testing.T) { - // Try to write to a non-existent directory - testFile := filepath.Join(tmpDir, "nonexistent", "test-cert.pem") - err = WriteCertificateToFile(cert, testFile) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to create file") - }) -} - func TestCreateCertificates(t *testing.T) { - rootContent := `{ - "subject": { - "country": ["US"], - "organization": ["Sigstore"], - "organizationalUnit": ["Timestamp Authority Root CA"], - "commonName": "https://tsa.com" - }, - "issuer": { - "commonName": "https://tsa.com" - }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2034-01-01T00:00:00Z", - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - }, - "keyUsage": [ - "certSign", - "crlSign" - ] - }` - - leafContent := `{ - "subject": { - "country": ["US"], - "organization": ["Sigstore"], - "organizationalUnit": ["Timestamp Authority"], - "commonName": "https://tsa.com" - }, - "issuer": { - "commonName": "https://tsa.com" - }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2034-01-01T00:00:00Z", - "basicConstraints": { - "isCA": false - }, - "keyUsage": [ - "digitalSignature" - ], - "extKeyUsage": [ - "timeStamping" - ] - }` - t.Run("TSA without intermediate", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(tmpDir) }) + tmpDir, err := os.MkdirTemp("", "cert-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": { + "commonName": "Test Root CA" + }, + "issuer": { + "commonName": "Test Root CA" + }, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write root template: %v", err) + } + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": { + "commonName": "Test TSA" + }, + "issuer": { + "commonName": "Test Root CA" + }, + "keyUsage": ["digitalSignature"], + "basicConstraints": { + "isCA": false + }, + "extKeyUsage": ["timeStamping"], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write leaf template: %v", err) + } - km := newMockKMSProvider() config := KMSConfig{ - Type: "mockkms", + Type: "test", RootKeyID: "root-key", LeafKeyID: "leaf-key", - Options: make(map[string]string), } - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - rootCertPath := filepath.Join(tmpDir, "root.pem") - leafCertPath := filepath.Join(tmpDir, "leaf.pem") - - err = os.WriteFile(rootTmplPath, []byte(rootContent), 0600) - require.NoError(t, err) - - err = os.WriteFile(leafTmplPath, []byte(leafContent), 0600) - require.NoError(t, err) + outDir := filepath.Join(tmpDir, "out") + err = os.MkdirAll(outDir, 0755) + if err != nil { + t.Fatalf("Failed to create output directory: %v", err) + } - err = CreateCertificates(km, config, - rootTmplPath, leafTmplPath, - rootCertPath, leafCertPath, + kms := newMockKMSProvider() + err = CreateCertificates(kms, config, + rootTemplate, leafTemplate, + filepath.Join(outDir, "root.crt"), filepath.Join(outDir, "leaf.crt"), "", "", "") - require.NoError(t, err) + if err != nil { + t.Fatalf("Failed to create certificates: %v", err) + } - // Verify certificates were created - _, err = os.Stat(rootCertPath) - require.NoError(t, err) - _, err = os.Stat(leafCertPath) - require.NoError(t, err) + verifyGeneratedCertificates(t, outDir) }) t.Run("TSA with intermediate", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(tmpDir) }) + tmpDir, err := os.MkdirTemp("", "cert-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) - intermediateContent := `{ + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ "subject": { - "country": ["US"], - "organization": ["Sigstore"], - "organizationalUnit": ["TSA Intermediate CA"], - "commonName": "https://tsa.com" + "commonName": "Test Root CA" }, "issuer": { - "commonName": "https://tsa.com" + "commonName": "Test Root CA" + }, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": { + "isCA": true, + "maxPathLen": 1 }, "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2034-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write root template: %v", err) + } + + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": { + "commonName": "Test Intermediate CA" + }, + "issuer": { + "commonName": "Test Root CA" + }, + "keyUsage": ["certSign", "crlSign"], "basicConstraints": { "isCA": true, "maxPathLen": 0 }, - "keyUsage": [ - "certSign", - "crlSign" - ] - }` + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write intermediate template: %v", err) + } + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": { + "commonName": "Test TSA" + }, + "issuer": { + "commonName": "Test Intermediate CA" + }, + "keyUsage": ["digitalSignature"], + "basicConstraints": { + "isCA": false + }, + "extKeyUsage": ["timeStamping"], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write leaf template: %v", err) + } - km := newMockKMSProvider() config := KMSConfig{ - Type: "mockkms", + Type: "test", RootKeyID: "root-key", IntermediateKeyID: "intermediate-key", LeafKeyID: "leaf-key", - Options: make(map[string]string), } - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - intermediateTmplPath := filepath.Join(tmpDir, "intermediate-template.json") - rootCertPath := filepath.Join(tmpDir, "root.pem") - intermediateCertPath := filepath.Join(tmpDir, "intermediate.pem") - leafCertPath := filepath.Join(tmpDir, "leaf.pem") - - err = os.WriteFile(rootTmplPath, []byte(rootContent), 0600) - require.NoError(t, err) - err = os.WriteFile(intermediateTmplPath, []byte(intermediateContent), 0600) - require.NoError(t, err) - err = os.WriteFile(leafTmplPath, []byte(leafContent), 0600) - require.NoError(t, err) - - err = CreateCertificates(km, config, - rootTmplPath, leafTmplPath, - rootCertPath, leafCertPath, - "intermediate-key", intermediateTmplPath, intermediateCertPath) - require.NoError(t, err) - - // Verify certificates were created - _, err = os.Stat(rootCertPath) - require.NoError(t, err) - _, err = os.Stat(intermediateCertPath) - require.NoError(t, err) - _, err = os.Stat(leafCertPath) - require.NoError(t, err) + outDir := filepath.Join(tmpDir, "out") + err = os.MkdirAll(outDir, 0755) + if err != nil { + t.Fatalf("Failed to create output directory: %v", err) + } + + kms := newMockKMSProvider() + err = CreateCertificates(kms, config, + rootTemplate, leafTemplate, + filepath.Join(outDir, "root.crt"), filepath.Join(outDir, "leaf.crt"), + "intermediate-key", intermediateTemplate, filepath.Join(outDir, "intermediate.crt")) + if err != nil { + t.Fatalf("Failed to create certificates: %v", err) + } + + verifyGeneratedCertificates(t, outDir) }) t.Run("invalid root template path", func(t *testing.T) { - km := newMockKMSProvider() + kms := newMockKMSProvider() config := KMSConfig{ - Type: "mockkms", + Type: "test", RootKeyID: "root-key", LeafKeyID: "leaf-key", - Options: make(map[string]string), } - err := CreateCertificates(km, config, + err := CreateCertificates(kms, config, "/nonexistent/root.json", "/nonexistent/leaf.json", - "/nonexistent/root.pem", "/nonexistent/leaf.pem", + "/nonexistent/root.crt", "/nonexistent/leaf.crt", "", "", "") - require.Error(t, err) - assert.Contains(t, err.Error(), "error reading template file") + if err == nil { + t.Error("Expected error but got none") + } + if !strings.Contains(err.Error(), "error reading template file") { + t.Errorf("Expected error containing 'error reading template file', got %v", err) + } }) +} - t.Run("invalid intermediate template path", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(tmpDir) }) +func verifyGeneratedCertificates(t *testing.T, outDir string) { + files := []string{ + "root.crt", + "leaf.crt", + } - km := newMockKMSProvider() - config := KMSConfig{ - Type: "mockkms", - RootKeyID: "root-key", - IntermediateKeyID: "intermediate-key", - LeafKeyID: "leaf-key", - Options: make(map[string]string), + intermediateExists := false + intermediatePath := filepath.Join(outDir, "intermediate.crt") + if _, err := os.Stat(intermediatePath); err == nil { + intermediateExists = true + files = append(files, "intermediate.crt") + } + + for _, f := range files { + path := filepath.Join(outDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("Expected file %s does not exist", f) } + } - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") - rootCertPath := filepath.Join(tmpDir, "root.pem") - leafCertPath := filepath.Join(tmpDir, "leaf.pem") - - err = os.WriteFile(rootTmplPath, []byte(rootContent), 0600) - require.NoError(t, err) - err = os.WriteFile(leafTmplPath, []byte(leafContent), 0600) - require.NoError(t, err) - - err = CreateCertificates(km, config, - rootTmplPath, leafTmplPath, - rootCertPath, leafCertPath, - "intermediate-key", "/nonexistent/intermediate.json", "/nonexistent/intermediate.pem") - require.Error(t, err) - assert.Contains(t, err.Error(), "error reading template file") - }) + rootCertPath := filepath.Join(outDir, "root.crt") + rootCertBytes, err := os.ReadFile(rootCertPath) + if err != nil { + t.Fatalf("Failed to read root certificate: %v", err) + } - t.Run("invalid leaf template path", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "cert-test-tsa-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(tmpDir) }) + rootBlock, _ := pem.Decode(rootCertBytes) + if rootBlock == nil { + t.Fatal("Failed to decode root certificate PEM") + } - km := newMockKMSProvider() - config := KMSConfig{ - Type: "mockkms", - RootKeyID: "root-key", - LeafKeyID: "leaf-key", - Options: make(map[string]string), + rootCert, err := x509.ParseCertificate(rootBlock.Bytes) + if err != nil { + t.Fatalf("Failed to parse root certificate: %v", err) + } + + if rootCert.Subject.CommonName != "Test Root CA" { + t.Errorf("Expected root CN %q, got %q", "Test Root CA", rootCert.Subject.CommonName) + } + + if !rootCert.IsCA { + t.Error("Expected root certificate to be CA") + } + + var intermediateCert *x509.Certificate + if intermediateExists { + intermediateCertBytes, err := os.ReadFile(intermediatePath) + if err != nil { + t.Fatalf("Failed to read intermediate certificate: %v", err) + } + + intermediateBlock, _ := pem.Decode(intermediateCertBytes) + if intermediateBlock == nil { + t.Fatal("Failed to decode intermediate certificate PEM") } - rootTmplPath := filepath.Join(tmpDir, "root-template.json") - rootCertPath := filepath.Join(tmpDir, "root.pem") - leafCertPath := filepath.Join(tmpDir, "leaf.pem") + intermediateCert, err = x509.ParseCertificate(intermediateBlock.Bytes) + if err != nil { + t.Fatalf("Failed to parse intermediate certificate: %v", err) + } - err = os.WriteFile(rootTmplPath, []byte(rootContent), 0600) - require.NoError(t, err) + if intermediateCert.Subject.CommonName != "Test Intermediate CA" { + t.Errorf("Expected intermediate CN %q, got %q", "Test Intermediate CA", intermediateCert.Subject.CommonName) + } - err = CreateCertificates(km, config, - rootTmplPath, "/nonexistent/leaf.json", - rootCertPath, leafCertPath, - "", "", "") - require.Error(t, err) - assert.Contains(t, err.Error(), "error reading template file") - }) + if !intermediateCert.IsCA { + t.Error("Expected intermediate certificate to be CA") + } + } + + leafCertPath := filepath.Join(outDir, "leaf.crt") + leafCertBytes, err := os.ReadFile(leafCertPath) + if err != nil { + t.Fatalf("Failed to read leaf certificate: %v", err) + } + + leafBlock, _ := pem.Decode(leafCertBytes) + if leafBlock == nil { + t.Fatal("Failed to decode leaf certificate PEM") + } + + leafCert, err := x509.ParseCertificate(leafBlock.Bytes) + if err != nil { + t.Fatalf("Failed to parse leaf certificate: %v", err) + } + + if leafCert.Subject.CommonName != "Test TSA" { + t.Errorf("Expected leaf CN %q, got %q", "Test TSA", leafCert.Subject.CommonName) + } + + if leafCert.IsCA { + t.Error("Expected leaf certificate not to be CA") + } + + roots := x509.NewCertPool() + roots.AddCert(rootCert) + + intermediates := x509.NewCertPool() + if intermediateCert != nil { + intermediates.AddCert(intermediateCert) + } + + opts := x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + KeyUsages: []x509.ExtKeyUsage{ + x509.ExtKeyUsageTimeStamping, + }, + } + + if _, err := leafCert.Verify(opts); err != nil { + t.Errorf("Failed to verify certificate chain: %v", err) + } } func TestInitKMS(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "kms-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate private key: %v", err) + } + + privKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privKey), + }) + + credsFile := filepath.Join(tmpDir, "test-credentials.json") + err = os.WriteFile(credsFile, []byte(fmt.Sprintf(`{ + "type": "service_account", + "project_id": "test-project", + "private_key_id": "test-key-id", + "private_key": %q, + "client_email": "test@test-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test@test-project.iam.gserviceaccount.com" + }`, string(privKeyPEM))), 0600) + if err != nil { + t.Fatalf("Failed to write credentials file: %v", err) + } + ctx := context.Background() tests := []struct { name string config KMSConfig - wantError string + wantError bool + errMsg string }{ { - name: "AWS KMS", + name: "valid AWS KMS config", config: KMSConfig{ Type: "awskms", Region: "us-west-2", - RootKeyID: "test-key", - Options: map[string]string{ - "access-key-id": "test-access-key", - "secret-access-key": "test-secret-key", - }, + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + Options: map[string]string{}, }, + wantError: false, }, { - name: "GCP KMS", + name: "valid GCP KMS config", config: KMSConfig{ Type: "gcpkms", - RootKeyID: "test-key", + RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", + LeafKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/leaf-key/cryptoKeyVersions/1", Options: map[string]string{ - "credentials-file": "/path/to/credentials.json", + "credentials-file": credsFile, }, }, + wantError: false, }, { - name: "Azure KMS", + name: "valid Azure KMS config", config: KMSConfig{ Type: "azurekms", - RootKeyID: "test-key", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + LeafKeyID: "azurekms:name=leaf-key;vault=test-vault", Options: map[string]string{ - "tenant-id": "test-tenant", - "client-id": "test-client", - "client-secret": "test-secret", + "tenant-id": "test-tenant", }, }, + wantError: false, }, { - name: "unsupported KMS type", + name: "AWS KMS missing region", config: KMSConfig{ - Type: "unsupportedkms", - RootKeyID: "test-key", + Type: "awskms", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", }, - wantError: "unsupported KMS type", + wantError: true, + errMsg: "region is required for AWS KMS", + }, + { + name: "GCP KMS invalid credentials", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", + Options: map[string]string{ + "credentials-file": "/nonexistent/credentials.json", + }, + }, + wantError: true, + errMsg: "credentials file not found", + }, + { + name: "Azure KMS missing tenant ID", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{}, + }, + wantError: true, + errMsg: "tenant-id is required", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { km, err := InitKMS(ctx, tt.config) + if tt.wantError { + if err == nil { + t.Error("expected error but got nil") + } else if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("error %q should contain %q", err.Error(), tt.errMsg) + } + if km != nil { + t.Error("expected nil KMS but got non-nil") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if km == nil { + t.Error("expected non-nil KMS but got nil") + } + } + }) + } +} + +func TestValidateTemplatePath(t *testing.T) { + tests := []struct { + name string + path string + setup func() string + wantError string + }{ + { + name: "nonexistent file", + path: "/nonexistent/template.json", + wantError: "template not found", + }, + { + name: "wrong extension", + path: "template.txt", + setup: func() string { + f, err := os.CreateTemp("", "template.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return f.Name() + }, + wantError: "must have .json extension", + }, + { + name: "valid JSON template", + path: "valid.json", + setup: func() string { + f, err := os.CreateTemp("", "template*.json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + err = os.WriteFile(f.Name(), []byte(`{"key": "value"}`), 0600) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return f.Name() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.path + if tt.setup != nil { + path = tt.setup() + defer os.Remove(path) + } + + err := ValidateTemplatePath(path) if tt.wantError != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantError) - assert.Nil(t, km) + if err == nil { + t.Errorf("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.wantError) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantError) + } } else { - // Since we can't actually connect to KMS providers in tests, - // we expect an error but not the "unsupported KMS type" error - require.Error(t, err) - assert.NotContains(t, err.Error(), "unsupported KMS type") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestCreateCertificatesErrors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) + wantError string + }{ + { + name: "error creating intermediate signer", + setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write root template: %v", err) + } + + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write intermediate template: %v", err) + } + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": false}, + "extKeyUsage": ["timeStamping"], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write leaf template: %v", err) + } + + config := KMSConfig{ + Type: "test", + RootKeyID: "root-key", + IntermediateKeyID: "nonexistent-key", + LeafKeyID: "leaf-key", + } + + return tmpDir, config, newMockKMSProvider() + }, + wantError: "error creating intermediate signer", + }, + { + name: "error creating leaf signer", + setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write root template: %v", err) + } + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": false}, + "extKeyUsage": ["timeStamping"], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write leaf template: %v", err) + } + + config := KMSConfig{ + Type: "test", + RootKeyID: "root-key", + LeafKeyID: "nonexistent-key", + } + + return tmpDir, config, newMockKMSProvider() + }, + wantError: "error creating leaf signer", + }, + { + name: "error creating root certificate", + setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {}, + "issuer": {}, + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write root template: %v", err) + } + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": false}, + "extKeyUsage": ["timeStamping"], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write leaf template: %v", err) + } + + config := KMSConfig{ + Type: "test", + RootKeyID: "root-key", + LeafKeyID: "leaf-key", + } + + return tmpDir, config, newMockKMSProvider() + }, + wantError: "error parsing root template: template validation error: notBefore time must be specified", + }, + { + name: "error writing certificates", + setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write root template: %v", err) + } + + outDir := filepath.Join(tmpDir, "out") + err = os.MkdirAll(outDir, 0444) + if err != nil { + t.Fatalf("Failed to create output directory: %v", err) + } + + config := KMSConfig{ + Type: "test", + RootKeyID: "root-key", + LeafKeyID: "leaf-key", + } + + return tmpDir, config, newMockKMSProvider() + }, + wantError: "error writing root certificate", + }, + { + name: "error with nonexistent signer", + setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + if err != nil { + t.Fatalf("Failed to write root template: %v", err) + } + + config := KMSConfig{ + Type: "test", + RootKeyID: "nonexistent-key", + LeafKeyID: "leaf-key", + } + + return tmpDir, config, newMockKMSProvider() + }, + wantError: "error creating root signer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, config, kms := tt.setup(t) + defer os.RemoveAll(tmpDir) + + outDir := filepath.Join(tmpDir, "out") + err := os.MkdirAll(outDir, 0755) + if err != nil { + t.Fatalf("Failed to create output directory: %v", err) + } + + var intermediateKeyID string + if tt.name == "error creating intermediate signer" { + intermediateKeyID = "nonexistent-key" + } + + err = CreateCertificates(kms, config, + filepath.Join(tmpDir, "root.json"), + filepath.Join(tmpDir, "leaf.json"), + filepath.Join(outDir, "root.crt"), + filepath.Join(outDir, "leaf.crt"), + intermediateKeyID, + filepath.Join(tmpDir, "intermediate.json"), + filepath.Join(outDir, "intermediate.crt")) + + 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) } }) } diff --git a/pkg/certmaker/template_test.go b/pkg/certmaker/template_test.go index 77442fe8..876b184b 100644 --- a/pkg/certmaker/template_test.go +++ b/pkg/certmaker/template_test.go @@ -21,18 +21,17 @@ import ( "encoding/base64" "os" "path/filepath" + "strings" "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestParseTemplate(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cert-template-*") - require.NoError(t, err) + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } defer os.RemoveAll(tmpDir) - // Create a parent certificate for template data parent := &x509.Certificate{ Subject: pkix.Name{ CommonName: "Parent CA", @@ -107,26 +106,35 @@ func TestParseTemplate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create temp file for template tmpFile := filepath.Join(tmpDir, "template.json") err := os.WriteFile(tmpFile, []byte(tt.content), 0600) - require.NoError(t, err) + if err != nil { + t.Fatalf("Failed to write template file: %v", err) + } cert, err := ParseTemplate(tmpFile, tt.parent) if tt.wantError != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantError) - assert.Nil(t, cert) + 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()) + } + if cert != nil { + t.Error("Expected nil certificate when error occurs") + } } else { - require.NoError(t, err) - require.NotNil(t, cert) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if cert == nil { + t.Error("Expected non-nil certificate") + } } }) } } func TestValidateTemplate(t *testing.T) { - // Create a parent certificate for testing parent := &x509.Certificate{ Subject: pkix.Name{ CommonName: "Parent CA", @@ -231,104 +239,19 @@ func TestValidateTemplate(t *testing.T) { }, wantError: "invalid notBefore time format", }, - { - name: "invalid extension OID", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - NotBefore: "2024-01-01T00:00:00Z", - NotAfter: "2025-01-01T00:00:00Z", - KeyUsage: []string{"digitalSignature"}, - Extensions: []struct { - ID string `json:"id"` - Critical bool `json:"critical"` - Value string `json:"value"` - }{ - { - ID: "invalid.oid", - Critical: true, - Value: "AQID", - }, - }, - }, - wantError: "invalid OID component in extension", - }, - { - name: "empty extension ID", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - NotBefore: "2024-01-01T00:00:00Z", - NotAfter: "2025-01-01T00:00:00Z", - KeyUsage: []string{"digitalSignature"}, - Extensions: []struct { - ID string `json:"id"` - Critical bool `json:"critical"` - Value string `json:"value"` - }{ - { - ID: "", - Critical: true, - Value: "AQID", - }, - }, - }, - wantError: "extension ID cannot be empty", - }, - { - name: "notBefore after notAfter", - tmpl: &CertificateTemplate{ - Subject: struct { - Country []string `json:"country,omitempty"` - Organization []string `json:"organization,omitempty"` - OrganizationalUnit []string `json:"organizationalUnit,omitempty"` - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - Issuer: struct { - CommonName string `json:"commonName"` - }{ - CommonName: "Test TSA", - }, - NotBefore: "2025-01-01T00:00:00Z", // Later than NotAfter - NotAfter: "2024-01-01T00:00:00Z", - KeyUsage: []string{"digitalSignature"}, - }, - wantError: "NotBefore time must be before NotAfter time", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidateTemplate(tt.tmpl, tt.parent) if tt.wantError != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantError) - } else { - require.NoError(t, err) + 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) } }) } From 90ac7514258f56d1cabb69397f5cdec8fd6086e6 Mon Sep 17 00:00:00 2001 From: ianhundere <138915+ianhundere@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:44:53 -0500 Subject: [PATCH 9/9] feat: adds sigstore/sigstore for kms and hashivault support. Signed-off-by: ianhundere <138915+ianhundere@users.noreply.github.com> --- cmd/certificate_maker/certificate_maker.go | 13 +- .../certificate_maker_test.go | 108 +- go.mod | 5 +- go.sum | 6 - pkg/certmaker/certmaker.go | 209 +- pkg/certmaker/certmaker_test.go | 2341 ++++++++++++----- pkg/certmaker/template_test.go | 151 +- 7 files changed, 2010 insertions(+), 823 deletions(-) diff --git a/cmd/certificate_maker/certificate_maker.go b/cmd/certificate_maker/certificate_maker.go index 70dbda84..a993c028 100644 --- a/cmd/certificate_maker/certificate_maker.go +++ b/cmd/certificate_maker/certificate_maker.go @@ -62,6 +62,8 @@ var ( intermediateKeyID string intermediateTemplate string intermediateCert string + kmsVaultToken string + kmsVaultAddr string rawJSON = []byte(`{ "level": "debug", @@ -84,7 +86,7 @@ func init() { rootCmd.AddCommand(createCmd) - createCmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms)") + createCmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms, hashivault)") createCmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") createCmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") createCmd.Flags().StringVar(&kmsTenantID, "azure-tenant-id", "", "Azure KMS tenant ID") @@ -100,6 +102,8 @@ func init() { createCmd.Flags().StringVar(&intermediateKeyID, "intermediate-key-id", "", "KMS key identifier for intermediate certificate") createCmd.Flags().StringVar(&intermediateTemplate, "intermediate-template", "pkg/certmaker/templates/intermediate-template.json", "Path to intermediate certificate template") createCmd.Flags().StringVar(&intermediateCert, "intermediate-cert", "intermediate.pem", "Output path for intermediate certificate") + createCmd.Flags().StringVar(&kmsVaultToken, "vault-token", "", "HashiVault token") + createCmd.Flags().StringVar(&kmsVaultAddr, "vault-address", "", "HashiVault server address") } func runCreate(_ *cobra.Command, _ []string) error { @@ -133,6 +137,13 @@ func runCreate(_ *cobra.Command, _ []string) error { if tenantID := getConfigValue(kmsTenantID, "AZURE_TENANT_ID"); tenantID != "" { config.Options["tenant-id"] = tenantID } + case "hashivault": + if token := getConfigValue(kmsVaultToken, "VAULT_TOKEN"); token != "" { + config.Options["token"] = token + } + if addr := getConfigValue(kmsVaultAddr, "VAULT_ADDR"); addr != "" { + config.Options["address"] = addr + } } km, err := certmaker.InitKMS(ctx, config) diff --git a/cmd/certificate_maker/certificate_maker_test.go b/cmd/certificate_maker/certificate_maker_test.go index f711605d..f34f40ac 100644 --- a/cmd/certificate_maker/certificate_maker_test.go +++ b/cmd/certificate_maker/certificate_maker_test.go @@ -18,10 +18,11 @@ package main import ( "os" "path/filepath" - "strings" "testing" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetConfigValue(t *testing.T) { @@ -83,25 +84,38 @@ func TestGetConfigValue(t *testing.T) { defer os.Unsetenv(tt.envVar) } got := getConfigValue(tt.flagValue, tt.envVar) - if got != tt.want { - t.Errorf("got %v, want %v", got, tt.want) - } + assert.Equal(t, tt.want, got) }) } } func TestInitLogger(t *testing.T) { logger := initLogger() - if logger == nil { - t.Errorf("logger is nil") - } + require.NotNil(t, logger) +} + +func TestInitLoggerWithDebug(t *testing.T) { + os.Setenv("DEBUG", "true") + defer os.Unsetenv("DEBUG") + logger := initLogger() + require.NotNil(t, logger) +} + +func TestInitLoggerWithInvalidLevel(t *testing.T) { + os.Setenv("DEBUG", "invalid") + defer os.Unsetenv("DEBUG") + + logger := initLogger() + require.NotNil(t, logger) + + os.Setenv("DEBUG", "") + logger = initLogger() + require.NotNil(t, logger) } func TestRunCreate(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Errorf("unexpected error: %v", err) - } + require.NoError(t, err) defer os.RemoveAll(tmpDir) rootTemplate := `{ @@ -136,13 +150,9 @@ func TestRunCreate(t *testing.T) { rootTmplPath := filepath.Join(tmpDir, "root-template.json") leafTmplPath := filepath.Join(tmpDir, "leaf-template.json") err = os.WriteFile(rootTmplPath, []byte(rootTemplate), 0600) - if err != nil { - t.Errorf("unexpected error: %v", err) - } + require.NoError(t, err) err = os.WriteFile(leafTmplPath, []byte(leafTemplate), 0600) - if err != nil { - t.Errorf("unexpected error: %v", err) - } + require.NoError(t, err) tests := []struct { name string @@ -181,26 +191,26 @@ func TestRunCreate(t *testing.T) { args: []string{ "--kms-type", "awskms", "--aws-region", "us-west-2", - "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", - "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--root-key-id", "alias/test-key", + "--leaf-key-id", "alias/test-key", "--root-template", "nonexistent.json", "--leaf-template", leafTmplPath, }, wantError: true, - errMsg: "template not found", + errMsg: "root template error: template not found at nonexistent.json", }, { name: "missing leaf template", args: []string{ "--kms-type", "awskms", "--aws-region", "us-west-2", - "--root-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", - "--leaf-key-id", "arn:aws:kms:us-west-2:123456789012:key/test-key", + "--root-key-id", "alias/test-key", + "--leaf-key-id", "alias/test-key", "--root-template", rootTmplPath, "--leaf-template", "nonexistent.json", }, wantError: true, - errMsg: "template not found", + errMsg: "leaf template error: template not found at nonexistent.json", }, { name: "GCP KMS with credentials file", @@ -227,6 +237,45 @@ func TestRunCreate(t *testing.T) { wantError: true, errMsg: "tenant-id is required", }, + { + name: "AWS KMS test", + args: []string{ + "--kms-type", "awskms", + "--aws-region", "us-west-2", + "--root-key-id", "alias/test-key", + "--leaf-key-id", "alias/test-key", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "NotFoundException: Alias arn:aws:kms:us-west-2", + }, + { + name: "HashiVault KMS without token", + args: []string{ + "--kms-type", "hashivault", + "--root-key-id", "transit/keys/test-key", + "--leaf-key-id", "transit/keys/leaf-key", + "--vault-address", "http://vault:8200", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "token is required for HashiVault KMS", + }, + { + name: "HashiVault KMS without address", + args: []string{ + "--kms-type", "hashivault", + "--root-key-id", "transit/keys/test-key", + "--leaf-key-id", "transit/keys/leaf-key", + "--vault-token", "test-token", + "--root-template", rootTmplPath, + "--leaf-template", leafTmplPath, + }, + wantError: true, + errMsg: "address is required for HashiVault KMS", + }, } for _, tt := range tests { @@ -241,11 +290,13 @@ func TestRunCreate(t *testing.T) { RunE: runCreate, } - cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms)") + cmd.Flags().StringVar(&kmsType, "kms-type", "", "KMS provider type (awskms, gcpkms, azurekms, hashivault)") cmd.Flags().StringVar(&kmsRegion, "aws-region", "", "AWS KMS region") cmd.Flags().StringVar(&kmsKeyID, "kms-key-id", "", "KMS key identifier") cmd.Flags().StringVar(&kmsTenantID, "azure-tenant-id", "", "Azure KMS tenant ID") cmd.Flags().StringVar(&kmsCredsFile, "gcp-credentials-file", "", "Path to credentials file for GCP KMS") + cmd.Flags().StringVar(&kmsVaultToken, "vault-token", "", "HashiVault token") + cmd.Flags().StringVar(&kmsVaultAddr, "vault-address", "", "HashiVault server address") cmd.Flags().StringVar(&rootKeyID, "root-key-id", "", "KMS key identifier for root certificate") cmd.Flags().StringVar(&leafKeyID, "leaf-key-id", "", "KMS key identifier for leaf certificate") cmd.Flags().StringVar(&rootTemplatePath, "root-template", "", "Path to root certificate template") @@ -260,15 +311,10 @@ func TestRunCreate(t *testing.T) { err := cmd.Execute() if tt.wantError { - if err == nil { - t.Errorf("expected error, but got nil") - } else if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("error %q should contain %q", err.Error(), tt.errMsg) - } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } + require.NoError(t, err) } }) } diff --git a/go.mod b/go.mod index 6fdb15d4..e88172e2 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.10.0 github.com/urfave/negroni v1.0.0 go.step.sm/crypto v0.55.0 go.uber.org/zap v1.27.0 @@ -53,8 +54,6 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 // indirect @@ -81,6 +80,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect @@ -131,6 +131,7 @@ require ( github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect diff --git a/go.sum b/go.sum index 5f637698..c2fb1919 100644 --- a/go.sum +++ b/go.sum @@ -27,10 +27,6 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvUL github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 h1:7rKG7UmnrxX4N53TFhkYqjc+kVUZuw0fL8I3Fh+Ld9E= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw= @@ -386,8 +382,6 @@ go.step.sm/crypto v0.55.0 h1:575Q7NahuM/ZRxUVN1GkO2e1aDYQJqIIg+nbfOajQJk= go.step.sm/crypto v0.55.0/go.mod h1:MgEmD1lgwsuzZwTgI0GwKapHjKVEQLVggSvHuf3bYnU= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go index b9a80820..484df079 100644 --- a/pkg/certmaker/certmaker.go +++ b/pkg/certmaker/certmaker.go @@ -14,25 +14,48 @@ // // Package certmaker implements a certificate creation utility for Timestamp Authority. -// It supports creating root, intermediate, and leaf certs using (AWS, GCP, Azure). +// It supports creating root, intermediate, and leaf certs using (AWS, GCP, Azure, HashiVault). package certmaker import ( + "bytes" "context" "crypto" "crypto/x509" "encoding/pem" "fmt" + "io" "os" "strings" - "go.step.sm/crypto/kms/apiv1" - "go.step.sm/crypto/kms/awskms" - "go.step.sm/crypto/kms/azurekms" - "go.step.sm/crypto/kms/cloudkms" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/sigstore/sigstore/pkg/signature/kms" + "github.com/sigstore/sigstore/pkg/signature/options" "go.step.sm/crypto/x509util" + + // Initialize AWS KMS provider + _ "github.com/sigstore/sigstore/pkg/signature/kms/aws" + // Initialize Azure KMS provider + _ "github.com/sigstore/sigstore/pkg/signature/kms/azure" + // Initialize GCP KMS provider + _ "github.com/sigstore/sigstore/pkg/signature/kms/gcp" + // Initialize HashiVault KMS provider + _ "github.com/sigstore/sigstore/pkg/signature/kms/hashivault" ) +type signerWrapper struct { + signature.SignerVerifier +} + +func (s signerWrapper) Public() crypto.PublicKey { + key, _ := s.PublicKey() + return key +} + +func (s signerWrapper) Sign(_ io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { + return s.SignMessage(bytes.NewReader(digest), options.WithDigest(digest)) +} + // KMSConfig holds config for KMS providers. type KMSConfig struct { Type string @@ -44,15 +67,10 @@ type KMSConfig struct { } // InitKMS initializes KMS provider based on the given config, KMSConfig. -// Supports AWS KMS, Google Cloud KMS, and Azure Key Vault. -func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { +var InitKMS = func(ctx context.Context, config KMSConfig) (signature.SignerVerifier, error) { if err := ValidateKMSConfig(config); err != nil { return nil, fmt.Errorf("invalid KMS configuration: %w", err) } - opts := apiv1.Options{ - Type: apiv1.Type(config.Type), - URI: "", - } // Falls back to LeafKeyID if root is not set keyID := config.RootKeyID @@ -60,42 +78,80 @@ func InitKMS(ctx context.Context, config KMSConfig) (apiv1.KeyManager, error) { keyID = config.LeafKeyID } + var sv signature.SignerVerifier + var err error + switch config.Type { case "awskms": - opts.URI = fmt.Sprintf("awskms:///%s?region=%s", keyID, config.Region) - return awskms.New(ctx, opts) - case "gcpkms": - opts.Type = apiv1.Type("cloudkms") - opts.URI = fmt.Sprintf("cloudkms:%s", keyID) - if credFile, ok := config.Options["credentials-file"]; ok { - if _, err := os.Stat(credFile); err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("credentials file not found: %s", credFile) - } - return nil, fmt.Errorf("error accessing credentials file: %w", err) - } - opts.URI += fmt.Sprintf("?credentials-file=%s", credFile) + ref := fmt.Sprintf("awskms:///%s", keyID) + if config.Region != "" { + os.Setenv("AWS_REGION", config.Region) } - km, err := cloudkms.New(ctx, opts) + sv, err = kms.Get(ctx, ref, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to initialize AWS KMS: %w", err) + } + + case "gcpkms": + ref := fmt.Sprintf("gcpkms://%s", keyID) + sv, err = kms.Get(ctx, ref, crypto.SHA256) if err != nil { return nil, fmt.Errorf("failed to initialize GCP KMS: %w", err) } - return km, nil + case "azurekms": - opts.URI = keyID - if config.Options["tenant-id"] != "" { - opts.URI += fmt.Sprintf("?tenant-id=%s", config.Options["tenant-id"]) + keyURI := keyID + if strings.HasPrefix(keyID, "azurekms:name=") { + nameStart := strings.Index(keyID, "name=") + 5 + vaultIndex := strings.Index(keyID, ";vault=") + if vaultIndex != -1 { + keyName := strings.TrimSpace(keyID[nameStart:vaultIndex]) + vaultName := strings.TrimSpace(keyID[vaultIndex+7:]) + keyURI = fmt.Sprintf("azurekms://%s.vault.azure.net/%s", vaultName, keyName) + } + } + if config.Options != nil && config.Options["tenant-id"] != "" { + os.Setenv("AZURE_TENANT_ID", config.Options["tenant-id"]) + os.Setenv("AZURE_ADDITIONALLY_ALLOWED_TENANTS", "*") + } + os.Setenv("AZURE_AUTHORITY_HOST", "https://login.microsoftonline.com/") + + sv, err = kms.Get(ctx, keyURI, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to initialize Azure KMS: %w", err) } - return azurekms.New(ctx, opts) + + case "hashivault": + keyURI := fmt.Sprintf("hashivault://%s", keyID) + if config.Options != nil { + if token := config.Options["token"]; token != "" { + os.Setenv("VAULT_TOKEN", token) + } + if addr := config.Options["address"]; addr != "" { + os.Setenv("VAULT_ADDR", addr) + } + } + + sv, err = kms.Get(ctx, keyURI, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to initialize HashiVault KMS: %w", err) + } + default: return nil, fmt.Errorf("unsupported KMS type: %s", config.Type) } + + if sv == nil { + return nil, fmt.Errorf("KMS returned nil signer") + } + + return sv, nil } // CreateCertificates creates certificates using the provided KMS and templates. // It creates 3 certificates (root -> intermediate -> leaf) if intermediateKeyID is provided, // otherwise creates just 2 certs (root -> leaf). -func CreateCertificates(km apiv1.KeyManager, config KMSConfig, +func CreateCertificates(sv signature.SignerVerifier, config KMSConfig, rootTemplatePath, leafTemplatePath string, rootCertPath, leafCertPath string, intermediateKeyID, intermediateTemplatePath, intermediateCertPath string) error { @@ -106,14 +162,15 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, return fmt.Errorf("error parsing root template: %w", err) } - rootSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ - SigningKey: config.RootKeyID, - }) + // Get public key from signer + rootPubKey, err := sv.PublicKey() if err != nil { - return fmt.Errorf("error creating root signer: %w", err) + return fmt.Errorf("error getting root public key: %w", err) } - rootCert, err := x509util.CreateCertificate(rootTmpl, rootTmpl, rootSigner.Public(), rootSigner) + signer := signerWrapper{sv} + + rootCert, err := x509util.CreateCertificate(rootTmpl, rootTmpl, rootPubKey, signer) if err != nil { return fmt.Errorf("error creating root certificate: %w", err) } @@ -132,14 +189,22 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, return fmt.Errorf("error parsing intermediate template: %w", err) } - intermediateSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ - SigningKey: intermediateKeyID, - }) + // Initialize new KMS for intermediate key + intermediateConfig := config + intermediateConfig.RootKeyID = intermediateKeyID + intermediateSV, err := InitKMS(context.Background(), intermediateConfig) if err != nil { - return fmt.Errorf("error creating intermediate signer: %w", err) + return fmt.Errorf("error initializing intermediate KMS: %w", err) } - intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediateSigner.Public(), rootSigner) + intermediatePubKey, err := intermediateSV.PublicKey() + if err != nil { + return fmt.Errorf("error getting intermediate public key: %w", err) + } + + intermediateSigner := signerWrapper{intermediateSV} + + intermediateCert, err := x509util.CreateCertificate(intermediateTmpl, rootCert, intermediatePubKey, signerWrapper{sv}) if err != nil { return fmt.Errorf("error creating intermediate certificate: %w", err) } @@ -152,7 +217,7 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, signingKey = intermediateSigner } else { signingCert = rootCert - signingKey = rootSigner + signingKey = signer } // Create leaf cert @@ -161,14 +226,20 @@ func CreateCertificates(km apiv1.KeyManager, config KMSConfig, return fmt.Errorf("error parsing leaf template: %w", err) } - leafSigner, err := km.CreateSigner(&apiv1.CreateSignerRequest{ - SigningKey: config.LeafKeyID, - }) + // Initialize new KMS for leaf key + leafConfig := config + leafConfig.RootKeyID = config.LeafKeyID + leafSV, err := InitKMS(context.Background(), leafConfig) if err != nil { - return fmt.Errorf("error creating leaf signer: %w", err) + return fmt.Errorf("error initializing leaf KMS: %w", err) } - leafCert, err := x509util.CreateCertificate(leafTmpl, signingCert, leafSigner.Public(), signingKey) + leafPubKey, err := leafSV.PublicKey() + if err != nil { + return fmt.Errorf("error getting leaf public key: %w", err) + } + + leafCert, err := x509util.CreateCertificate(leafTmpl, signingCert, leafPubKey, signingKey) if err != nil { return fmt.Errorf("error creating leaf certificate: %w", err) } @@ -228,7 +299,8 @@ func ValidateKMSConfig(config KMSConfig) error { if keyID == "" { return nil } - if strings.HasPrefix(keyID, "arn:aws:kms:") { + switch { + case strings.HasPrefix(keyID, "arn:aws:kms:"): parts := strings.Split(keyID, ":") if len(parts) < 6 { return fmt.Errorf("invalid AWS KMS ARN format for %s", keyType) @@ -236,11 +308,11 @@ func ValidateKMSConfig(config KMSConfig) error { if parts[3] != config.Region { return fmt.Errorf("region in ARN (%s) does not match configured region (%s)", parts[3], config.Region) } - } else if strings.HasPrefix(keyID, "alias/") { + case strings.HasPrefix(keyID, "alias/"): if strings.TrimPrefix(keyID, "alias/") == "" { return fmt.Errorf("alias name cannot be empty for %s", keyType) } - } else { + default: return fmt.Errorf("awskms %s must start with 'arn:aws:kms:' or 'alias/'", keyType) } return nil @@ -326,6 +398,43 @@ func ValidateKMSConfig(config KMSConfig) error { return err } + case "hashivault": + // HashiVault KMS validation + if config.Options == nil { + return fmt.Errorf("options map is required for HashiVault KMS") + } + if config.Options["address"] == "" { + return fmt.Errorf("address is required for HashiVault KMS") + } + if config.Options["token"] == "" { + return fmt.Errorf("token is required for HashiVault KMS") + } + validateHashiVaultKeyID := func(keyID, keyType string) error { + if keyID == "" { + return nil + } + parts := strings.Split(keyID, "/") + if len(parts) < 3 { + return fmt.Errorf("hashivault %s must be in format: transit/keys/keyname", keyType) + } + if parts[0] != "transit" || parts[1] != "keys" { + return fmt.Errorf("hashivault %s must start with 'transit/keys/'", keyType) + } + if parts[2] == "" { + return fmt.Errorf("key name cannot be empty for %s", keyType) + } + return nil + } + if err := validateHashiVaultKeyID(config.RootKeyID, "RootKeyID"); err != nil { + return err + } + if err := validateHashiVaultKeyID(config.IntermediateKeyID, "IntermediateKeyID"); err != nil { + return err + } + if err := validateHashiVaultKeyID(config.LeafKeyID, "LeafKeyID"); err != nil { + return err + } + default: return fmt.Errorf("unsupported KMS type: %s", config.Type) } diff --git a/pkg/certmaker/certmaker_test.go b/pkg/certmaker/certmaker_test.go index 9ee42709..a1140d8b 100644 --- a/pkg/certmaker/certmaker_test.go +++ b/pkg/certmaker/certmaker_test.go @@ -21,58 +21,90 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/x509" - "encoding/pem" + "crypto/x509/pkix" "fmt" + "io" + "math/big" "os" "path/filepath" "strings" "testing" + "time" - "go.step.sm/crypto/kms/apiv1" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -type mockKMSProvider struct { - keys map[string]crypto.Signer +// mockSignerVerifier implements signature.SignerVerifier for testing +type mockSignerVerifier struct { + key crypto.PrivateKey + err error + publicKeyFunc func() (crypto.PublicKey, error) } -func newMockKMSProvider() *mockKMSProvider { - keys := make(map[string]crypto.Signer) - for _, id := range []string{"root-key", "intermediate-key", "leaf-key"} { - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - panic(err) - } - keys[id] = priv +func (m *mockSignerVerifier) SignMessage(message io.Reader, _ ...signature.SignOption) ([]byte, error) { + if m.err != nil { + return nil, m.err + } + digest := make([]byte, 32) + if _, err := message.Read(digest); err != nil { + return nil, err + } + switch k := m.key.(type) { + case *ecdsa.PrivateKey: + return k.Sign(rand.Reader, digest, crypto.SHA256) + default: + return nil, fmt.Errorf("unsupported key type") } - return &mockKMSProvider{keys: keys} } -func (m *mockKMSProvider) CreateKey(*apiv1.CreateKeyRequest) (*apiv1.CreateKeyResponse, error) { - return nil, fmt.Errorf("not implemented") +func (m *mockSignerVerifier) VerifySignature(_, _ io.Reader, _ ...signature.VerifyOption) error { + return nil } -func (m *mockKMSProvider) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, error) { - key, ok := m.keys[req.SigningKey] - if !ok { - return nil, fmt.Errorf("key not found: %s", req.SigningKey) +func (m *mockSignerVerifier) PublicKey(_ ...signature.PublicKeyOption) (crypto.PublicKey, error) { + if m.publicKeyFunc != nil { + return m.publicKeyFunc() + } + if m.err != nil { + return nil, m.err + } + switch k := m.key.(type) { + case *ecdsa.PrivateKey: + return k.Public(), nil + default: + return nil, fmt.Errorf("unsupported key type") } - return key, nil } -func (m *mockKMSProvider) GetPublicKey(req *apiv1.GetPublicKeyRequest) (crypto.PublicKey, error) { - key, ok := m.keys[req.Name] - if !ok { - return nil, fmt.Errorf("key not found: %s", req.Name) - } - return key.Public(), nil +func (m *mockSignerVerifier) Close() error { + return nil } -func (m *mockKMSProvider) Close() error { +func (m *mockSignerVerifier) DefaultHashFunction() crypto.Hash { + return crypto.SHA256 +} + +func (m *mockSignerVerifier) Bytes() ([]byte, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockSignerVerifier) KeyID() (string, error) { + return "mock-key-id", nil +} + +func (m *mockSignerVerifier) Status() error { return nil } +// At package level +var ( + // Store the original function + originalInitKMS = InitKMS // Changed from initKMS to InitKMS +) + func TestValidateKMSConfig(t *testing.T) { tests := []struct { name string @@ -80,93 +112,32 @@ func TestValidateKMSConfig(t *testing.T) { wantError string }{ { - name: "empty KMS type", + name: "empty_KMS_type", config: KMSConfig{ - RootKeyID: "key-id", + RootKeyID: "test-key", }, wantError: "KMS type cannot be empty", }, { - name: "missing key IDs", + name: "missing_key_IDs", config: KMSConfig{ Type: "awskms", }, wantError: "at least one of RootKeyID or LeafKeyID must be specified", }, { - name: "AWS KMS missing region", + name: "AWS_KMS_missing_region", config: KMSConfig{ Type: "awskms", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", }, wantError: "region is required for AWS KMS", }, - { - name: "AWS KMS invalid root key ID", - config: KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "invalid-key-id", - }, - wantError: "awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'", - }, - { - name: "AWS KMS invalid intermediate key ID", - config: KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", - IntermediateKeyID: "invalid-key-id", - }, - wantError: "awskms IntermediateKeyID must start with 'arn:aws:kms:' or 'alias/'", - }, - { - name: "AWS KMS invalid leaf key ID", - config: KMSConfig{ - Type: "awskms", - Region: "us-west-2", - LeafKeyID: "invalid-key-id", - }, - wantError: "awskms LeafKeyID must start with 'arn:aws:kms:' or 'alias/'", - }, - { - name: "GCP_KMS_invalid_root_key_ID", - config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "invalid-key-id", - }, - wantError: "gcpkms RootKeyID must start with 'projects/'", - }, - { - name: "GCP_KMS_invalid_intermediate_key_ID", - config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", - IntermediateKeyID: "invalid-key-id", - }, - wantError: "gcpkms IntermediateKeyID must start with 'projects/'", - }, - { - name: "GCP_KMS_invalid_leaf_key_ID", - config: KMSConfig{ - Type: "gcpkms", - LeafKeyID: "invalid-key-id", - }, - wantError: "gcpkms LeafKeyID must start with 'projects/'", - }, - { - name: "GCP KMS missing required parts", - config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "projects/test-project", - }, - wantError: "gcpkms RootKeyID must contain '/locations/'", - }, { name: "Azure_KMS_missing_tenant_ID", config: KMSConfig{ Type: "azurekms", - RootKeyID: "azurekms:name=my-key;vault=my-vault", + RootKeyID: "azurekms:name=test-key;vault=test-vault", Options: map[string]string{}, }, wantError: "tenant-id is required for Azure KMS", @@ -175,516 +146,145 @@ func TestValidateKMSConfig(t *testing.T) { name: "Azure_KMS_missing_vault_parameter", config: KMSConfig{ Type: "azurekms", - RootKeyID: "azurekms:name=my-key", + RootKeyID: "azurekms:name=test-key", Options: map[string]string{ - "tenant-id": "tenant-id", + "tenant-id": "test-tenant", }, }, wantError: "azurekms RootKeyID must contain ';vault=' parameter", }, { - name: "unsupported KMS type", + name: "unsupported_KMS_type", config: KMSConfig{ - Type: "invalidkms", - RootKeyID: "key-id", + Type: "unsupported", + RootKeyID: "test-key", }, wantError: "unsupported KMS type", }, { - name: "aws_kms_invalid_arn_format", + name: "valid_AWS_KMS_config", config: KMSConfig{ Type: "awskms", Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:invalid", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", }, - wantError: "invalid AWS KMS ARN format for RootKeyID", }, { - name: "aws_kms_region_mismatch", + name: "valid_Azure_KMS_config", config: KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-east-1:123456789012:key/test-key", + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, }, - wantError: "region in ARN (us-east-1) does not match configured region (us-west-2)", }, { - name: "aws_kms_empty_alias", + name: "valid_GCP_KMS_config", config: KMSConfig{ - Type: "awskms", - Region: "us-west-2", - RootKeyID: "alias/", + Type: "gcpkms", + RootKeyID: "gcpkms://projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", }, - wantError: "alias name cannot be empty for RootKeyID", }, { - name: "azure_kms_empty_key_name", + name: "GCP_KMS_missing_cryptoKeyVersions", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=;vault=test-vault", - Options: map[string]string{ - "tenant-id": "test-tenant", - }, + Type: "gcpkms", + RootKeyID: "gcpkms://projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key", }, - wantError: "key name cannot be empty for RootKeyID", + wantError: "gcpkms RootKeyID must contain '/cryptoKeyVersions/'", }, { - name: "azure_kms_empty_vault_name", + name: "GCP_KMS_invalid_key_format", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=test-key;vault=", - Options: map[string]string{ - "tenant-id": "test-tenant", - }, + Type: "gcpkms", + RootKeyID: "invalid-key", }, - wantError: "vault name cannot be empty for RootKeyID", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateKMSConfig(tt.config) - if tt.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 TestCreateCertificates(t *testing.T) { - t.Run("TSA without intermediate", func(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) - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "keyUsage": ["digitalSignature"], - "basicConstraints": { - "isCA": false - }, - "extKeyUsage": ["timeStamping"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } - - config := KMSConfig{ - Type: "test", - RootKeyID: "root-key", - LeafKeyID: "leaf-key", - } - - outDir := filepath.Join(tmpDir, "out") - err = os.MkdirAll(outDir, 0755) - if err != nil { - t.Fatalf("Failed to create output directory: %v", err) - } - - kms := newMockKMSProvider() - err = CreateCertificates(kms, config, - rootTemplate, leafTemplate, - filepath.Join(outDir, "root.crt"), filepath.Join(outDir, "leaf.crt"), - "", "", "") - if err != nil { - t.Fatalf("Failed to create certificates: %v", err) - } - - verifyGeneratedCertificates(t, outDir) - }) - - t.Run("TSA with intermediate", func(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) - - rootTemplate := filepath.Join(tmpDir, "root.json") - err = os.WriteFile(rootTemplate, []byte(`{ - "subject": { - "commonName": "Test Root CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 1 - }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") - err = os.WriteFile(intermediateTemplate, []byte(`{ - "subject": { - "commonName": "Test Intermediate CA" - }, - "issuer": { - "commonName": "Test Root CA" - }, - "keyUsage": ["certSign", "crlSign"], - "basicConstraints": { - "isCA": true, - "maxPathLen": 0 - }, - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write intermediate template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": { - "commonName": "Test TSA" - }, - "issuer": { - "commonName": "Test Intermediate CA" - }, - "keyUsage": ["digitalSignature"], - "basicConstraints": { - "isCA": false - }, - "extKeyUsage": ["timeStamping"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } - - config := KMSConfig{ - Type: "test", - RootKeyID: "root-key", - IntermediateKeyID: "intermediate-key", - LeafKeyID: "leaf-key", - } - - outDir := filepath.Join(tmpDir, "out") - err = os.MkdirAll(outDir, 0755) - if err != nil { - t.Fatalf("Failed to create output directory: %v", err) - } - - kms := newMockKMSProvider() - err = CreateCertificates(kms, config, - rootTemplate, leafTemplate, - filepath.Join(outDir, "root.crt"), filepath.Join(outDir, "leaf.crt"), - "intermediate-key", intermediateTemplate, filepath.Join(outDir, "intermediate.crt")) - if err != nil { - t.Fatalf("Failed to create certificates: %v", err) - } - - verifyGeneratedCertificates(t, outDir) - }) - - t.Run("invalid root template path", func(t *testing.T) { - kms := newMockKMSProvider() - config := KMSConfig{ - Type: "test", - RootKeyID: "root-key", - LeafKeyID: "leaf-key", - } - - err := CreateCertificates(kms, config, - "/nonexistent/root.json", "/nonexistent/leaf.json", - "/nonexistent/root.crt", "/nonexistent/leaf.crt", - "", "", "") - if err == nil { - t.Error("Expected error but got none") - } - if !strings.Contains(err.Error(), "error reading template file") { - t.Errorf("Expected error containing 'error reading template file', got %v", err) - } - }) -} - -func verifyGeneratedCertificates(t *testing.T, outDir string) { - files := []string{ - "root.crt", - "leaf.crt", - } - - intermediateExists := false - intermediatePath := filepath.Join(outDir, "intermediate.crt") - if _, err := os.Stat(intermediatePath); err == nil { - intermediateExists = true - files = append(files, "intermediate.crt") - } - - for _, f := range files { - path := filepath.Join(outDir, f) - if _, err := os.Stat(path); os.IsNotExist(err) { - t.Errorf("Expected file %s does not exist", f) - } - } - - rootCertPath := filepath.Join(outDir, "root.crt") - rootCertBytes, err := os.ReadFile(rootCertPath) - if err != nil { - t.Fatalf("Failed to read root certificate: %v", err) - } - - rootBlock, _ := pem.Decode(rootCertBytes) - if rootBlock == nil { - t.Fatal("Failed to decode root certificate PEM") - } - - rootCert, err := x509.ParseCertificate(rootBlock.Bytes) - if err != nil { - t.Fatalf("Failed to parse root certificate: %v", err) - } - - if rootCert.Subject.CommonName != "Test Root CA" { - t.Errorf("Expected root CN %q, got %q", "Test Root CA", rootCert.Subject.CommonName) - } - - if !rootCert.IsCA { - t.Error("Expected root certificate to be CA") - } - - var intermediateCert *x509.Certificate - if intermediateExists { - intermediateCertBytes, err := os.ReadFile(intermediatePath) - if err != nil { - t.Fatalf("Failed to read intermediate certificate: %v", err) - } - - intermediateBlock, _ := pem.Decode(intermediateCertBytes) - if intermediateBlock == nil { - t.Fatal("Failed to decode intermediate certificate PEM") - } - - intermediateCert, err = x509.ParseCertificate(intermediateBlock.Bytes) - if err != nil { - t.Fatalf("Failed to parse intermediate certificate: %v", err) - } - - if intermediateCert.Subject.CommonName != "Test Intermediate CA" { - t.Errorf("Expected intermediate CN %q, got %q", "Test Intermediate CA", intermediateCert.Subject.CommonName) - } - - if !intermediateCert.IsCA { - t.Error("Expected intermediate certificate to be CA") - } - } - - leafCertPath := filepath.Join(outDir, "leaf.crt") - leafCertBytes, err := os.ReadFile(leafCertPath) - if err != nil { - t.Fatalf("Failed to read leaf certificate: %v", err) - } - - leafBlock, _ := pem.Decode(leafCertBytes) - if leafBlock == nil { - t.Fatal("Failed to decode leaf certificate PEM") - } - - leafCert, err := x509.ParseCertificate(leafBlock.Bytes) - if err != nil { - t.Fatalf("Failed to parse leaf certificate: %v", err) - } - - if leafCert.Subject.CommonName != "Test TSA" { - t.Errorf("Expected leaf CN %q, got %q", "Test TSA", leafCert.Subject.CommonName) - } - - if leafCert.IsCA { - t.Error("Expected leaf certificate not to be CA") - } - - roots := x509.NewCertPool() - roots.AddCert(rootCert) - - intermediates := x509.NewCertPool() - if intermediateCert != nil { - intermediates.AddCert(intermediateCert) - } - - opts := x509.VerifyOptions{ - Roots: roots, - Intermediates: intermediates, - KeyUsages: []x509.ExtKeyUsage{ - x509.ExtKeyUsageTimeStamping, + wantError: "gcpkms RootKeyID must start with 'projects/'", }, - } - - if _, err := leafCert.Verify(opts); err != nil { - t.Errorf("Failed to verify certificate chain: %v", err) - } -} - -func TestInitKMS(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "kms-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - privKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("Failed to generate private key: %v", err) - } - - privKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(privKey), - }) - - credsFile := filepath.Join(tmpDir, "test-credentials.json") - err = os.WriteFile(credsFile, []byte(fmt.Sprintf(`{ - "type": "service_account", - "project_id": "test-project", - "private_key_id": "test-key-id", - "private_key": %q, - "client_email": "test@test-project.iam.gserviceaccount.com", - "client_id": "123456789", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test@test-project.iam.gserviceaccount.com" - }`, string(privKeyPEM))), 0600) - if err != nil { - t.Fatalf("Failed to write credentials file: %v", err) - } - - ctx := context.Background() - tests := []struct { - name string - config KMSConfig - wantError bool - errMsg string - }{ { - name: "valid AWS KMS config", + name: "AWS_KMS_invalid_key_format", config: KMSConfig{ Type: "awskms", Region: "us-west-2", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", - LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", - Options: map[string]string{}, + RootKeyID: "invalid-key", }, - wantError: false, + wantError: "awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'", }, { - name: "valid GCP KMS config", + name: "Azure_KMS_invalid_key_format", config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", - LeafKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/leaf-key/cryptoKeyVersions/1", + Type: "azurekms", + RootKeyID: "invalid-key", Options: map[string]string{ - "credentials-file": credsFile, + "tenant-id": "test-tenant", }, }, - wantError: false, + wantError: "azurekms RootKeyID must start with 'azurekms:name='", }, { - name: "valid Azure KMS config", + name: "HashiVault_KMS_missing_options", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=test-key;vault=test-vault", - LeafKeyID: "azurekms:name=leaf-key;vault=test-vault", + Type: "hashivault", + RootKeyID: "transit/keys/my-key", + }, + wantError: "options map is required for HashiVault KMS", + }, + { + name: "HashiVault_KMS_missing_token", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/my-key", Options: map[string]string{ - "tenant-id": "test-tenant", + "address": "http://vault:8200", }, }, - wantError: false, + wantError: "token is required for HashiVault KMS", }, { - name: "AWS KMS missing region", + name: "HashiVault_KMS_missing_address", config: KMSConfig{ - Type: "awskms", - RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + Type: "hashivault", + RootKeyID: "transit/keys/my-key", + Options: map[string]string{ + "token": "test-token", + }, }, - wantError: true, - errMsg: "region is required for AWS KMS", + wantError: "address is required for HashiVault KMS", }, { - name: "GCP KMS invalid credentials", + name: "HashiVault_KMS_invalid_key_format", config: KMSConfig{ - Type: "gcpkms", - RootKeyID: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", + Type: "hashivault", + RootKeyID: "invalid-key", Options: map[string]string{ - "credentials-file": "/nonexistent/credentials.json", + "token": "test-token", + "address": "http://vault:8200", }, }, - wantError: true, - errMsg: "credentials file not found", + wantError: "hashivault RootKeyID must be in format: transit/keys/keyname", }, { - name: "Azure KMS missing tenant ID", + name: "valid_HashiVault_KMS_config", config: KMSConfig{ - Type: "azurekms", - RootKeyID: "azurekms:name=test-key;vault=test-vault", - Options: map[string]string{}, + Type: "hashivault", + RootKeyID: "transit/keys/my-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, }, - wantError: true, - errMsg: "tenant-id is required", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - km, err := InitKMS(ctx, tt.config) - if tt.wantError { - if err == nil { - t.Error("expected error but got nil") - } else if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("error %q should contain %q", err.Error(), tt.errMsg) - } - if km != nil { - t.Error("expected nil KMS but got non-nil") - } + err := ValidateKMSConfig(tt.config) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if km == nil { - t.Error("expected non-nil KMS but got nil") - } + require.NoError(t, err) } }) } @@ -693,81 +293,88 @@ func TestInitKMS(t *testing.T) { func TestValidateTemplatePath(t *testing.T) { tests := []struct { name string - path string setup func() string wantError string }{ { - name: "nonexistent file", - path: "/nonexistent/template.json", - wantError: "template not found", + name: "nonexistent_file", + setup: func() string { + return "/nonexistent/template.json" + }, + wantError: "no such file or directory", }, { - name: "wrong extension", - path: "template.txt", + name: "wrong_extension", setup: func() string { - f, err := os.CreateTemp("", "template.txt") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - return f.Name() + tmpFile, err := os.CreateTemp("", "template-*.txt") + require.NoError(t, err) + defer tmpFile.Close() + return tmpFile.Name() }, - wantError: "must have .json extension", + wantError: "template file must have .json extension", }, { - name: "valid JSON template", - path: "valid.json", + name: "valid_JSON_template", setup: func() string { - f, err := os.CreateTemp("", "template*.json") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - err = os.WriteFile(f.Name(), []byte(`{"key": "value"}`), 0600) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - return f.Name() + tmpFile, err := os.CreateTemp("", "template-*.json") + require.NoError(t, err) + defer tmpFile.Close() + return tmpFile.Name() }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path := tt.path - if tt.setup != nil { - path = tt.setup() - defer os.Remove(path) - } + path := tt.setup() + defer func() { + if _, err := os.Stat(path); err == nil { + os.Remove(path) + } + }() err := ValidateTemplatePath(path) if tt.wantError != "" { - if err == nil { - t.Errorf("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.wantError) { - t.Errorf("error %q should contain %q", err.Error(), tt.wantError) - } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } + require.NoError(t, err) } }) } } -func TestCreateCertificatesErrors(t *testing.T) { +func TestCreateCertificates(t *testing.T) { + // Save original and restore after test + defer func() { InitKMS = originalInitKMS }() // Changed from initKMS to InitKMS + + // Create a mock key + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + mockSV := &mockSignerVerifier{ + key: key, + publicKeyFunc: func() (crypto.PublicKey, error) { + return key.Public(), nil + }, + } + + // Replace initKMS with mock version + InitKMS = func(_ context.Context, _ KMSConfig) (signature.SignerVerifier, error) { + return mockSV, nil + } + tests := []struct { name string - setup func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) + setup func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) wantError string }{ { - name: "error creating intermediate signer", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + name: "successful_certificate_creation", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } + 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(`{ @@ -778,55 +385,92 @@ func TestCreateCertificatesErrors(t *testing.T) { "notBefore": "2024-01-01T00:00:00Z", "notAfter": "2025-01-01T00:00:00Z" }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") err = os.WriteFile(intermediateTemplate, []byte(`{ "subject": {"commonName": "Test Intermediate CA"}, - "issuer": {"commonName": "Test Root CA"}, "keyUsage": ["certSign", "crlSign"], "basicConstraints": {"isCA": true, "maxPathLen": 0}, "notBefore": "2024-01-01T00:00:00Z", "notAfter": "2025-01-01T00:00:00Z" }`), 0600) - if err != nil { - t.Fatalf("Failed to write intermediate template: %v", err) - } + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + }, + { + name: "invalid_template_path", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {"commonName": "Test TSA"}, - "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extKeyUsage": ["timeStamping"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) - config := KMSConfig{ - Type: "test", - RootKeyID: "root-key", - IntermediateKeyID: "nonexistent-key", - LeafKeyID: "leaf-key", - } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) - return tmpDir, config, newMockKMSProvider() + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} }, - wantError: "error creating intermediate signer", + wantError: "error parsing root template", }, { - name: "error creating leaf signer", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + name: "invalid_root_template_content", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } + require.NoError(t, err) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{invalid json`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing root template", + }, + { + name: "signer_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) rootTemplate := filepath.Join(tmpDir, "root.json") err = os.WriteFile(rootTemplate, []byte(`{ @@ -837,83 +481,65 @@ func TestCreateCertificatesErrors(t *testing.T) { "notBefore": "2024-01-01T00:00:00Z", "notAfter": "2025-01-01T00:00:00Z" }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } + require.NoError(t, err) - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {"commonName": "Test TSA"}, - "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extKeyUsage": ["timeStamping"], - "notBefore": "2024-01-01T00:00:00Z", - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) - config := KMSConfig{ - Type: "test", - RootKeyID: "root-key", - LeafKeyID: "nonexistent-key", - } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) - return tmpDir, config, newMockKMSProvider() + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key, err: fmt.Errorf("signer error")} }, - wantError: "error creating leaf signer", + wantError: "error getting root public key", }, { - name: "error creating root certificate", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + name: "invalid_intermediate_template", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } + require.NoError(t, err) rootTemplate := filepath.Join(tmpDir, "root.json") err = os.WriteFile(rootTemplate, []byte(`{ - "subject": {}, - "issuer": {}, - "notAfter": "2025-01-01T00:00:00Z" - }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } - - leafTemplate := filepath.Join(tmpDir, "leaf.json") - err = os.WriteFile(leafTemplate, []byte(`{ - "subject": {"commonName": "Test TSA"}, + "subject": {"commonName": "Test Root CA"}, "issuer": {"commonName": "Test Root CA"}, - "keyUsage": ["digitalSignature"], - "basicConstraints": {"isCA": false}, - "extKeyUsage": ["timeStamping"], + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, "notBefore": "2024-01-01T00:00:00Z", "notAfter": "2025-01-01T00:00:00Z" }`), 0600) - if err != nil { - t.Fatalf("Failed to write leaf template: %v", err) - } + require.NoError(t, err) - config := KMSConfig{ - Type: "test", - RootKeyID: "root-key", - LeafKeyID: "leaf-key", - } + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{invalid json`), 0600) + require.NoError(t, err) - return tmpDir, config, newMockKMSProvider() + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} }, - wantError: "error parsing root template: template validation error: notBefore time must be specified", + wantError: "error parsing intermediate template", }, { - name: "error writing certificates", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + name: "invalid_leaf_template", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } + require.NoError(t, err) rootTemplate := filepath.Join(tmpDir, "root.json") err = os.WriteFile(rootTemplate, []byte(`{ @@ -924,33 +550,68 @@ func TestCreateCertificatesErrors(t *testing.T) { "notBefore": "2024-01-01T00:00:00Z", "notAfter": "2025-01-01T00:00:00Z" }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{invalid json`), 0600) + require.NoError(t, err) outDir := filepath.Join(tmpDir, "out") - err = os.MkdirAll(outDir, 0444) - if err != nil { - t.Fatalf("Failed to create output directory: %v", err) - } + require.NoError(t, os.MkdirAll(outDir, 0755)) - config := KMSConfig{ - Type: "test", - RootKeyID: "root-key", - LeafKeyID: "leaf-key", - } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing leaf template", + }, + { + name: "root_cert_write_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) - return tmpDir, config, newMockKMSProvider() + // Create a directory where a file should be to cause a write error + rootCertDir := filepath.Join(tmpDir, "out", "root.crt") + require.NoError(t, os.MkdirAll(rootCertDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} }, wantError: "error writing root certificate", }, { - name: "error with nonexistent signer", - setup: func(t *testing.T) (string, KMSConfig, apiv1.KeyManager) { + name: "successful_certificate_creation_without_intermediate", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { tmpDir, err := os.MkdirTemp("", "cert-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } + 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(`{ @@ -961,56 +622,1298 @@ func TestCreateCertificatesErrors(t *testing.T) { "notBefore": "2024-01-01T00:00:00Z", "notAfter": "2025-01-01T00:00:00Z" }`), 0600) - if err != nil { - t.Fatalf("Failed to write root template: %v", err) - } + require.NoError(t, err) - config := KMSConfig{ - Type: "test", - RootKeyID: "nonexistent-key", - LeafKeyID: "leaf-key", - } + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + }, + { + name: "successful_certificate_creation_with_intermediate", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + // Create root template + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create intermediate template + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create leaf template + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} + }, + }, + { + name: "intermediate_cert_creation_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + // Create root template + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) - return tmpDir, config, newMockKMSProvider() + // Create invalid intermediate template + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/root-key", + IntermediateKeyID: "alias/intermediate-key", + LeafKeyID: "alias/leaf-key", + }, &mockSignerVerifier{key: key} }, - wantError: "error creating root signer", + wantError: "template validation error: CA certificate must have certSign key usage", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tmpDir, config, kms := tt.setup(t) + tmpDir, config, sv := tt.setup(t) defer os.RemoveAll(tmpDir) - outDir := filepath.Join(tmpDir, "out") - err := os.MkdirAll(outDir, 0755) - if err != nil { - t.Fatalf("Failed to create output directory: %v", err) - } - - var intermediateKeyID string - if tt.name == "error creating intermediate signer" { - intermediateKeyID = "nonexistent-key" + var intermediateKeyID, intermediateTemplate, intermediateCert string + if strings.Contains(tt.name, "intermediate") { + intermediateKeyID = config.IntermediateKeyID + intermediateTemplate = filepath.Join(tmpDir, "intermediate.json") + intermediateCert = filepath.Join(tmpDir, "out", "intermediate.crt") } - err = CreateCertificates(kms, config, + err := CreateCertificates(sv, config, filepath.Join(tmpDir, "root.json"), filepath.Join(tmpDir, "leaf.json"), - filepath.Join(outDir, "root.crt"), - filepath.Join(outDir, "leaf.crt"), + filepath.Join(tmpDir, "out", "root.crt"), + filepath.Join(tmpDir, "out", "leaf.crt"), intermediateKeyID, - filepath.Join(tmpDir, "intermediate.json"), - filepath.Join(outDir, "intermediate.crt")) + intermediateTemplate, + intermediateCert) 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) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + // Verify certificates were created + rootCertPath := filepath.Join(tmpDir, "out", "root.crt") + leafCertPath := filepath.Join(tmpDir, "out", "leaf.crt") + require.FileExists(t, rootCertPath) + require.FileExists(t, leafCertPath) + } + }) + } +} + +func TestInitKMS(t *testing.T) { + tests := []struct { + name string + config KMSConfig + shouldError bool + }{ + { + name: "invalid_config", + config: KMSConfig{ + Type: "invalid", + }, + shouldError: true, + }, + { + name: "aws_kms_invalid_key", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "invalid-key", + }, + shouldError: true, + }, + { + name: "azure_kms_missing_tenant", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + }, + shouldError: true, + }, + { + name: "gcp_kms_invalid_key", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "invalid-key", + }, + shouldError: true, + }, + { + name: "hashivault_kms_invalid_key", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "invalid-key", + }, + shouldError: true, + }, + { + name: "unsupported_kms_type", + config: KMSConfig{ + Type: "unsupported", + RootKeyID: "test-key", + }, + shouldError: true, + }, + { + name: "aws_kms_valid_config", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + }, + shouldError: true, + }, + { + name: "azure_kms_valid_config", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + shouldError: false, + }, + { + name: "gcp_kms_valid_config", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", + }, + shouldError: false, + }, + { + name: "hashivault_kms_valid_config", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/my-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, + }, + shouldError: true, + }, + { + name: "aws_kms_nil_signer", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + }, + shouldError: true, + }, + { + name: "aws_kms_with_endpoint", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/test-key", + }, + shouldError: false, + }, + { + name: "azure_kms_with_valid_format", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + shouldError: false, + }, + { + name: "azure_kms_with_name_vault_uri", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + shouldError: false, + }, + { + name: "gcp_kms_with_cryptoKeyVersions", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "projects/project-id/locations/global/keyRings/keyring-name/cryptoKeys/key-name/cryptoKeyVersions/1", + }, + shouldError: false, + }, + { + name: "hashivault_kms_with_transit_keys", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "transit/keys/test-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, + }, + shouldError: true, + }, + { + name: "aws_kms_with_alias", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "alias/test-key", + }, + shouldError: false, + }, + { + name: "aws_kms_with_arn", + config: KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + }, + shouldError: true, + }, + { + name: "azure_kms_with_vault_uri", + config: KMSConfig{ + Type: "azurekms", + RootKeyID: "azurekms:name=test-key;vault=test-vault", + Options: map[string]string{ + "tenant-id": "test-tenant", + }, + }, + shouldError: false, + }, + { + name: "gcp_kms_with_uri", + config: KMSConfig{ + Type: "gcpkms", + RootKeyID: "gcpkms://projects/test-project/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", + }, + shouldError: true, + }, + { + name: "hashivault_kms_with_uri", + config: KMSConfig{ + Type: "hashivault", + RootKeyID: "hashivault://transit/keys/test-key", + Options: map[string]string{ + "token": "test-token", + "address": "http://vault:8200", + }, + }, + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + _, err := InitKMS(ctx, tt.config) + if tt.shouldError { + require.Error(t, err) + } else { + require.NoError(t, err) } }) } } + +func TestCreateCertificatesWithoutIntermediate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": false}, + "extKeyUsage": ["CodeSigning"], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + config := KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/root-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + } + + err = CreateCertificates(&mockSignerVerifier{key: key}, config, + rootTemplate, + leafTemplate, + filepath.Join(outDir, "root.crt"), + filepath.Join(outDir, "leaf.crt"), + "", // No intermediate key ID + "", // No intermediate template + "") // No intermediate cert path + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to initialize AWS KMS") +} + +func TestCreateCertificatesLeafErrors(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{invalid json`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + config := KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/root-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + } + + err = CreateCertificates(&mockSignerVerifier{key: key}, config, + rootTemplate, + leafTemplate, + filepath.Join(outDir, "root.crt"), + filepath.Join(outDir, "leaf.crt"), + "", // No intermediate key ID + "", // No intermediate template + "") // No intermediate cert path + + require.Error(t, err) + assert.Contains(t, err.Error(), "error parsing leaf template") +} + +func TestCreateCertificatesWithErrors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) + wantError string + }{ + { + name: "root_cert_creation_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "invalid-time", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/root-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing root template: template validation error: invalid notBefore time format", + }, + { + name: "root_cert_sign_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms:///arn:aws:kms:us-west-2:123456789012:key/root-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + }, &mockSignerVerifier{key: key, err: fmt.Errorf("signing error")} + }, + wantError: "error getting root public key: signing error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, config, sv := tt.setup(t) + defer os.RemoveAll(tmpDir) + + err := CreateCertificates(sv, config, + filepath.Join(tmpDir, "root.json"), + filepath.Join(tmpDir, "leaf.json"), + filepath.Join(tmpDir, "out", "root.crt"), + filepath.Join(tmpDir, "out", "leaf.crt"), + "", + "", + "") + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func TestWriteCertificateToFileWithErrors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) (*x509.Certificate, string) + wantError string + }{ + { + name: "file_write_error", + setup: func(t *testing.T) (*x509.Certificate, string) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test CA", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 365), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + cert, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + parsedCert, err := x509.ParseCertificate(cert) + require.NoError(t, err) + + // Create a read-only directory to cause a write error + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + require.NoError(t, os.Chmod(tmpDir, 0500)) + certPath := filepath.Join(tmpDir, "cert.crt") + + return parsedCert, certPath + }, + wantError: "failed to create file", + }, + { + name: "invalid_cert_path", + setup: func(t *testing.T) (*x509.Certificate, string) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test CA", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 365), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + cert, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + parsedCert, err := x509.ParseCertificate(cert) + require.NoError(t, err) + + return parsedCert, "/nonexistent/directory/cert.crt" + }, + wantError: "failed to create file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cert, path := tt.setup(t) + if strings.HasPrefix(path, "/var") || strings.HasPrefix(path, "/tmp") { + defer os.RemoveAll(filepath.Dir(path)) + } + + err := WriteCertificateToFile(cert, path) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func TestValidateTemplateWithInvalidExtKeyUsage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + templateFile := filepath.Join(tmpDir, "template.json") + err = os.WriteFile(templateFile, []byte(`{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test TSA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": true}, + "extensions": [ + { + "id": "2.5.29.37", + "critical": true, + "value": "MCQwIgYDVR0lBBswGQYIKwYBBQUHAwgGDSsGAQQBgjcUAgICAf8=" + } + ], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + } + + template, err := ParseTemplate(templateFile, parent) + require.Error(t, err) + assert.Contains(t, err.Error(), "CA certificate must have certSign key usage") + assert.Nil(t, template) +} + +func TestCreateCertificatesWithInvalidIntermediateKey(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid root template + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create valid leaf template + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create valid intermediate template + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Test with invalid intermediate key ID format + err = CreateCertificates( + &mockSignerVerifier{key: key}, + KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms://test-endpoint/alias/test-key", + IntermediateKeyID: "invalid-intermediate-key", + LeafKeyID: "awskms://test-endpoint/alias/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "invalid-intermediate-key", + intermediateTemplate, + filepath.Join(tmpDir, "intermediate.crt"), + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error initializing intermediate KMS: invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") +} + +func TestCreateCertificatesWithInvalidLeafKey(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid root template + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create valid leaf template + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Test with invalid leaf key ID format + err = CreateCertificates( + &mockSignerVerifier{key: key}, + KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + LeafKeyID: "invalid-leaf-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error initializing leaf KMS") +} + +func TestCreateCertificatesWithInvalidRootCert(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create invalid root template (missing required fields) + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {}, + "issuer": {}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create valid leaf template + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + err = CreateCertificates( + &mockSignerVerifier{key: key}, + KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "subject.commonName cannot be empty") +} + +func TestCreateCertificatesWithInvalidCertPath(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid templates + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Create a directory where a file should be and make it read-only + invalidPath := filepath.Join(tmpDir, "invalid") + err = os.MkdirAll(invalidPath, 0444) // Changed permissions to read-only + require.NoError(t, err) + + err = CreateCertificates( + &mockSignerVerifier{key: key}, + KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(invalidPath, "root.crt"), + filepath.Join(invalidPath, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error writing root certificate") +} + +func TestWriteCertificateToFileWithPEMError(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a directory where a file should be to cause a write error + certPath := filepath.Join(tmpDir, "cert.pem") + err = os.MkdirAll(certPath, 0755) // Create a directory instead of a file + require.NoError(t, err) + + // Create a valid certificate + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(certBytes) + require.NoError(t, err) + + // Try to write to a path that is a directory, which should fail + err = WriteCertificateToFile(cert, certPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create file") +} + +func TestCreateCertificatesWithInvalidRootKey(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid templates + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Test with signing error + err = CreateCertificates( + &mockSignerVerifier{key: nil, err: fmt.Errorf("signing error")}, + KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error getting root public key: signing error") +} + +func TestCreateCertificatesWithInvalidLeafTemplate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create valid root template + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create invalid leaf template (missing TimeStamping extKeyUsage) + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + err = CreateCertificates( + &mockSignerVerifier{key: key}, + KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "awskms://test-endpoint/alias/test-key", + LeafKeyID: "awskms://test-endpoint/alias/test-key", + }, + rootTemplate, + leafTemplate, + filepath.Join(tmpDir, "root.crt"), + filepath.Join(tmpDir, "leaf.crt"), + "", + "", + "", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error initializing leaf KMS: invalid KMS configuration: awskms RootKeyID must start with 'arn:aws:kms:' or 'alias/'") +} + +func TestCreateCertificatesWithIntermediateErrors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) + wantError string + }{ + { + name: "intermediate_template_parse_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{invalid json`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", + IntermediateKeyID: "arn:aws:kms:us-west-2:123456789012:key/intermediate-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing intermediate template", + }, + { + name: "intermediate_cert_write_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + // Create a directory where the intermediate cert file should be + intermediateCertDir := filepath.Join(outDir, "intermediate.crt") + require.NoError(t, os.MkdirAll(intermediateCertDir, 0755)) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", + IntermediateKeyID: "arn:aws:kms:us-west-2:123456789012:key/intermediate-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error initializing intermediate KMS", + }, + { + name: "leaf_cert_with_intermediate_error", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "invalid-time", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", + IntermediateKeyID: "arn:aws:kms:us-west-2:123456789012:key/intermediate-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error initializing intermediate KMS", + }, + { + name: "invalid_intermediate_template_validation", + setup: func(t *testing.T) (string, KMSConfig, signature.SignerVerifier) { + tmpDir, err := os.MkdirTemp("", "cert-test-*") + require.NoError(t, err) + + outDir := filepath.Join(tmpDir, "out") + require.NoError(t, os.MkdirAll(outDir, 0755)) + + rootTemplate := filepath.Join(tmpDir, "root.json") + err = os.WriteFile(rootTemplate, []byte(`{ + "subject": {"commonName": "Test Root CA"}, + "issuer": {"commonName": "Test Root CA"}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {"isCA": true, "maxPathLen": 1}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + leafTemplate := filepath.Join(tmpDir, "leaf.json") + err = os.WriteFile(leafTemplate, []byte(`{ + "subject": {"commonName": "Test Leaf"}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["TimeStamping"], + "basicConstraints": {"isCA": false}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + intermediateTemplate := filepath.Join(tmpDir, "intermediate.json") + err = os.WriteFile(intermediateTemplate, []byte(`{ + "subject": {"commonName": "Test Intermediate CA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": true, "maxPathLen": 0}, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }`), 0600) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + return tmpDir, KMSConfig{ + Type: "awskms", + Region: "us-west-2", + RootKeyID: "arn:aws:kms:us-west-2:123456789012:key/root-key", + IntermediateKeyID: "arn:aws:kms:us-west-2:123456789012:key/intermediate-key", + LeafKeyID: "arn:aws:kms:us-west-2:123456789012:key/leaf-key", + }, &mockSignerVerifier{key: key} + }, + wantError: "error parsing intermediate template: template validation error: CA certificate must have certSign key usage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, config, sv := tt.setup(t) + defer os.RemoveAll(tmpDir) + + err := CreateCertificates(sv, config, + filepath.Join(tmpDir, "root.json"), + filepath.Join(tmpDir, "leaf.json"), + filepath.Join(tmpDir, "out", "root.crt"), + filepath.Join(tmpDir, "out", "leaf.crt"), + config.IntermediateKeyID, + filepath.Join(tmpDir, "intermediate.json"), + filepath.Join(tmpDir, "out", "intermediate.crt")) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} diff --git a/pkg/certmaker/template_test.go b/pkg/certmaker/template_test.go index 876b184b..075fb778 100644 --- a/pkg/certmaker/template_test.go +++ b/pkg/certmaker/template_test.go @@ -16,6 +16,9 @@ package certmaker import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/base64" @@ -23,6 +26,10 @@ import ( "path/filepath" "strings" "testing" + + "github.com/sigstore/sigstore/pkg/signature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParseTemplate(t *testing.T) { @@ -108,24 +115,14 @@ func TestParseTemplate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { tmpFile := filepath.Join(tmpDir, "template.json") err := os.WriteFile(tmpFile, []byte(tt.content), 0600) - if err != nil { - t.Fatalf("Failed to write template file: %v", err) - } + require.NoError(t, err) cert, err := ParseTemplate(tmpFile, 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()) - } - if cert != nil { - t.Error("Expected nil certificate when error occurs") - } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) } else { - if err != nil { - t.Errorf("Unexpected error: %v", err) - } + require.NoError(t, err) if cert == nil { t.Error("Expected non-nil certificate") } @@ -134,6 +131,43 @@ func TestParseTemplate(t *testing.T) { } } +func TestParseTemplateWithInvalidExtensions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cert-template-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + content := `{ + "subject": {"commonName": "Test TSA"}, + "issuer": {"commonName": "Test TSA"}, + "keyUsage": ["digitalSignature"], + "basicConstraints": {"isCA": false}, + "extensions": [ + { + "id": "2.5.29.37", + "critical": true, + "value": "invalid-base64" + } + ], + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }` + + tmpFile := filepath.Join(tmpDir, "template.json") + err = os.WriteFile(tmpFile, []byte(content), 0600) + require.NoError(t, err) + + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + } + + cert, err := ParseTemplate(tmpFile, parent) + require.Error(t, err) + assert.Contains(t, err.Error(), "error decoding extension value") + assert.Nil(t, cert) +} + func TestValidateTemplate(t *testing.T) { parent := &x509.Certificate{ Subject: pkix.Name{ @@ -256,3 +290,92 @@ func TestValidateTemplate(t *testing.T) { }) } } + +func TestValidateTemplateWithMockKMS(t *testing.T) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + mockSigner := &mockSignerVerifier{ + key: privKey, + } + + parent := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Parent CA", + }, + } + + tests := []struct { + name string + tmpl *CertificateTemplate + parent *x509.Certificate + signer signature.SignerVerifier + wantError string + }{ + { + name: "valid TSA template with mock KMS", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + Issuer: struct { + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "2024-01-01T00:00:00Z", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + Extensions: []struct { + ID string `json:"id"` + Critical bool `json:"critical"` + Value string `json:"value"` + }{ + { + ID: "2.5.29.37", + Critical: true, + Value: base64.StdEncoding.EncodeToString([]byte{0x30, 0x24, 0x30, 0x22, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x08}), + }, + }, + }, + parent: parent, + signer: mockSigner, + }, + { + name: "invalid TSA template with mock KMS", + tmpl: &CertificateTemplate{ + Subject: struct { + Country []string `json:"country,omitempty"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizationalUnit,omitempty"` + CommonName string `json:"commonName"` + }{ + CommonName: "Test TSA", + }, + NotBefore: "invalid", + NotAfter: "2025-01-01T00:00:00Z", + KeyUsage: []string{"digitalSignature"}, + }, + parent: parent, + signer: mockSigner, + wantError: "invalid notBefore time format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTemplate(tt.tmpl, tt.parent) + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + } + }) + } +}