diff --git a/go.mod b/go.mod index 318ba5804..1a83d3fc6 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 github.com/spiffe/go-spiffe/v2 v2.4.0 + github.com/stretchr/testify v1.9.0 github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 github.com/tink-crypto/tink-go/v2 v2.2.0 @@ -54,13 +55,19 @@ require ( 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.2 // indirect + dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 // indirect 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/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/PaesslerAG/gval v1.0.0 // indirect github.com/aws/aws-sdk-go v1.55.5 // indirect github.com/aws/aws-sdk-go-v2 v1.32.4 // indirect @@ -82,6 +89,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chainguard-dev/clog v1.5.1-0.20240811185937-4c523ae4593f // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -104,6 +112,7 @@ require ( github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/vault/api v1.15.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jellydator/ttlcache/v3 v3.3.0 // indirect github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect @@ -111,19 +120,23 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/miekg/pkcs11 v1.1.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect github.com/segmentio/ksuid v1.0.4 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect diff --git a/go.sum b/go.sum index 4607d5e17..ec0425b92 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,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= @@ -29,6 +31,10 @@ 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.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= @@ -38,6 +44,12 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/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/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= @@ -143,6 +155,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.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= @@ -210,6 +224,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= @@ -251,12 +267,16 @@ github.com/miekg/pkcs11 v1.0.3-0.20190429190417-a667d056470f/go.mod h1:XsNlhZGX7 github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= 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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -305,6 +325,8 @@ 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/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= +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= diff --git a/pkg/certmaker/certmaker.go b/pkg/certmaker/certmaker.go new file mode 100644 index 000000000..3e022edce --- /dev/null +++ b/pkg/certmaker/certmaker.go @@ -0,0 +1,376 @@ +// 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 Sigstore services. +// It supports creating root and intermediate certificates for both Fulcio and +// Timestamp Authority using various KMS providers (AWS, GCP, Azure). +package certmaker + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "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" + "go.uber.org/zap" +) + +var ( + logger *zap.Logger + version string + + rootCmd = &cobra.Command{ + Use: "sigstore-certificate-maker", + Short: "Create certificate chains for Sigstore services", + Long: `A tool for creating root and intermediate certificates for Fulcio and Timestamp Authority`, + Version: version, + } + + createCmd = &cobra.Command{ + Use: "create", + Short: "Create certificate chain", + RunE: runCreate, + } + + // Flag variables + 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": "sigstore-certificate-maker"}, + "encoderConfig": { + "messageKey": "message", + "levelKey": "level", + "levelEncoder": "lowercase", + "timeKey": "timestamp", + "timeEncoder": "iso8601" + } + }`) +) + +func init() { + logger = initLogger() + + // Add create command + rootCmd.AddCommand(createCmd) + + // Add flags to create command + 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", "root-template.json", "Path to root certificate template") + createCmd.Flags().StringVar(&intermTemplatePath, "intermediate-template", "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 + kmsConfig := 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 provider-specific options + switch kmsConfig.Type { + case "cloudkms": + if credsFile := getConfigValue(kmsCredsFile, "KMS_CREDENTIALS_FILE"); credsFile != "" { + kmsConfig.Options["credentials-file"] = credsFile + } + case "azurekms": + if vaultName := getConfigValue(kmsVaultName, "KMS_VAULT_NAME"); vaultName != "" { + kmsConfig.Options["vault-name"] = vaultName + } + if tenantID := getConfigValue(kmsTenantID, "KMS_TENANT_ID"); tenantID != "" { + kmsConfig.Options["tenant-id"] = tenantID + } + } + + ctx := context.Background() + km, err := initKMS(ctx, kmsConfig) + if err != nil { + return fmt.Errorf("failed to initialize KMS: %w", err) + } + + // Validate template paths + if err := validateTemplatePath(rootTemplatePath); err != nil { + return fmt.Errorf("root template error: %w", err) + } + if err := validateTemplatePath(intermTemplatePath); err != nil { + return fmt.Errorf("intermediate template error: %w", err) + } + + return createCertificates(km, kmsConfig, rootTemplatePath, intermTemplatePath, rootCertPath, intermCertPath) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + logger.Fatal("Command failed", zap.Error(err)) + } +} + +// initKMS creates and configures a KeyManager based on the provided KMS configuration +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) + } +} + +// KMSConfig holds the configuration for a Key Management Service provider. +// It supports AWS KMS, Google Cloud KMS, and Azure Key Vault. +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 +} + +// 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. The certificates are written to the specified output paths +// and the chain is verified before returning. +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) + } + + logger.Info("Certificates created successfully", + zap.String("root_cert", rootCert.Subject.CommonName), + zap.String("intermediate_cert", intermediateCert.Subject.CommonName), + zap.Bool("root_is_ca", rootCert.IsCA), + zap.Bool("intermediate_is_ca", intermediateCert.IsCA), + zap.Int("root_path_len", rootCert.MaxPathLen), + zap.String("key_usage", fmt.Sprintf("%v", rootCert.KeyUsage)), + zap.String("ext_key_usage", fmt.Sprintf("%v", rootCert.ExtKeyUsage))) + + 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 +} + +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()) +} + +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 000000000..f3dedba38 --- /dev/null +++ b/pkg/certmaker/certmaker_test.go @@ -0,0 +1,287 @@ +// 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" + ], + "extKeyUsage": [ + "CodeSigning" + ], + "basicConstraints": { + "isCA": true, + "maxPathLen": 0 + }, + "notBefore": "2024-01-01T00:00:00Z", + "notAfter": "2025-01-01T00:00:00Z" + }` + + err = os.WriteFile(tmpFile.Name(), []byte(templateContent), 0600) + 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) { + 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) +} + +// 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 000000000..a4b941ef7 --- /dev/null +++ b/pkg/certmaker/template.go @@ -0,0 +1,167 @@ +// Package certmaker provides template parsing and certificate generation functionality +// for creating Fulcio X.509 certificates from JSON templates. It supports both root and +// intermediate certificate creation with configurable properties including key usage, +// extended key usage, and basic constraints. Certificates are specifically configured +// for code signing capabilities as required by the Fulcio CA. +package certmaker + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "fmt" + "math/big" + "os" + "time" +) + +// CertificateTemplate defines the JSON structure for Fulcio certificate templates. +// It supports both root and intermediate CA certificates with code signing capabilities. +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") + } + } + + // Fulcio-specific validation for code signing + hasCodeSigning := false + for _, usage := range tmpl.ExtKeyUsage { + if usage == "codeSign" { + hasCodeSigning = true + break + } + } + if !hasCodeSigning && !tmpl.BasicConstraints.IsCA { + return fmt.Errorf("Fulcio leaf certificates must have codeSign extended 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 "codeSign": + cert.ExtKeyUsage = append(cert.ExtKeyUsage, x509.ExtKeyUsageCodeSigning) + } + } +} diff --git a/fulcio-intermediate-template.json b/pkg/certmaker/templates/intermediate-template.json similarity index 100% rename from fulcio-intermediate-template.json rename to pkg/certmaker/templates/intermediate-template.json diff --git a/fulcio-root-template.json b/pkg/certmaker/templates/root-template.json similarity index 100% rename from fulcio-root-template.json rename to pkg/certmaker/templates/root-template.json