diff --git a/cmd/tuf/server/main.go b/cmd/tuf/server/main.go index edc1dbf6f..e5e4b1990 100644 --- a/cmd/tuf/server/main.go +++ b/cmd/tuf/server/main.go @@ -50,8 +50,10 @@ var ( // repository - Compressed repo, which has been tar/gzipped. secretName = flag.String("rootsecret", "tuf-root", "Name of the secret to create for the initial root file") // Name of the "secret" where we create one entry per key JSON definition as generated by TUF, e.g. "root.json", "timestamp.json", ... - keysSecretName = flag.String("keyssecret", "", "Name of the secret to create for generated keys (keys won't be stored unless this is provided)") - noK8s = flag.Bool("no-k8s", false, "Run in a non-k8s environment") + keysSecretName = flag.String("keyssecret", "", "Name of the secret to create for generated keys (keys won't be stored unless this is provided)") + noK8s = flag.Bool("no-k8s", false, "Run in a non-k8s environment") + metadataTargets = flag.Bool("metadata-targets", true, "Serve individual targets with custom Sigstore metadata") + trustedRoot = flag.Bool("trusted-root", true, "Generate and serve trusted_root.json") ) func getNamespaceAndClientset(noK8s bool) (string, *kubernetes.Clientset, error) { @@ -126,7 +128,7 @@ func initTUFRepo(ctx context.Context, certsDir, targetDir, repoSecretName, keysS } // Create a new TUF root with the listed artifacts. - local, dir, err := repo.CreateRepo(ctx, files) + local, dir, err := repo.CreateRepoWithOptions(ctx, files, repo.CreateRepoOptions{AddMetadataTargets: *metadataTargets, AddTrustedRoot: *trustedRoot}) if err != nil { return fmt.Errorf("failed to create repo: %v", err) } diff --git a/go.mod b/go.mod index b02534fd2..494de4eb1 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/sigstore/fulcio v1.6.2 github.com/sigstore/rekor v1.3.6 github.com/sigstore/sigstore v1.8.8 + github.com/sigstore/sigstore-go v0.6.1-0.20240821212051-2198ac32dd94 github.com/sigstore/timestamp-authority v1.2.2 github.com/theupdateframework/go-tuf v0.7.0 github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 @@ -251,6 +252,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect github.com/thales-e-security/pool v0.0.2 // indirect + github.com/theupdateframework/go-tuf/v2 v2.0.0 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect github.com/transparency-dev/merkle v0.0.2 // indirect @@ -270,7 +272,7 @@ require ( go.step.sm/crypto v0.51.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect - golang.org/x/mod v0.19.0 // indirect + golang.org/x/mod v0.20.0 // indirect golang.org/x/oauth2 v0.22.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.23.0 // indirect diff --git a/go.sum b/go.sum index 28b5ff7ac..0a3033ee9 100644 --- a/go.sum +++ b/go.sum @@ -1197,6 +1197,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/in-toto/attestation v1.1.0 h1:oRWzfmZPDSctChD0VaQV7MJrywKOzyNrtpENQFq//2Q= +github.com/in-toto/attestation v1.1.0/go.mod h1:DB59ytd3z7cIHgXxwpSX2SABrU6WJUKg/grpdgHVgVs= github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU= github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -1464,8 +1466,8 @@ github.com/sigstore/rekor v1.3.6 h1:QvpMMJVWAp69a3CHzdrLelqEqpTM3ByQRt5B5Kspbi8= github.com/sigstore/rekor v1.3.6/go.mod h1:JDTSNNMdQ/PxdsS49DJkJ+pRJCO/83nbR5p3aZQteXc= github.com/sigstore/sigstore v1.8.8 h1:B6ZQPBKK7Z7tO3bjLNnlCMG+H66tO4E/+qAphX8T/hg= github.com/sigstore/sigstore v1.8.8/go.mod h1:GW0GgJSCTBJY3fUOuGDHeFWcD++c4G8Y9K015pwcpDI= -github.com/sigstore/sigstore-go v0.5.1 h1:5IhKvtjlQBeLnjKkzMELNG4tIBf+xXQkDzhLV77+/8Y= -github.com/sigstore/sigstore-go v0.5.1/go.mod h1:TuOfV7THHqiDaUHuJ5+QN23RP/YoKmsbwJpY+aaYPN0= +github.com/sigstore/sigstore-go v0.6.1-0.20240821212051-2198ac32dd94 h1:MoT4su5n2fVgwoXWPpXeHCvtY48BkxcONsySq1rHMiw= +github.com/sigstore/sigstore-go v0.6.1-0.20240821212051-2198ac32dd94/go.mod h1:+RyopI/FJDE6z5WVs2sQ2nkc+zsxxByDmbp8a4HoxbA= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.8 h1:2zHmUvaYCwV6LVeTo+OAkTm8ykOGzA9uFlAjwDPAUWM= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.8/go.mod h1:OEhheBplZinUsm7W9BupafztVZV3ldkAxEHbpAeC0Pk= github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.8 h1:RKk4Z+qMaLORUdT7zntwMqKiYAej1VQlCswg0S7xNSY= @@ -1743,8 +1745,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= -golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go index c866dc64a..619274c20 100644 --- a/pkg/repo/repo.go +++ b/pkg/repo/repo.go @@ -18,7 +18,15 @@ import ( "archive/tar" "compress/gzip" "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/hex" "encoding/json" + "encoding/pem" "errors" "fmt" "io" @@ -26,13 +34,28 @@ import ( "os" "path" "path/filepath" + "sort" "strings" "time" + "github.com/sigstore/sigstore-go/pkg/root" "github.com/theupdateframework/go-tuf" "knative.dev/pkg/logging" ) +const ( + FulcioTarget = "Fulcio" + RekorTarget = "Rekor" + CTFETarget = "CTFE" + TSATarget = "TSA" + UnknownTarget = "Unknown" +) + +type CreateRepoOptions struct { + AddMetadataTargets bool + AddTrustedRoot bool +} + // TargetWithMetadata describes a TUF target with the given Name, Bytes, and // CustomMetadata type TargetWithMetadata struct { @@ -110,7 +133,7 @@ func CreateRepoWithMetadata(ctx context.Context, targets []TargetWithMetadata) ( return local, dir, nil } -// CreateRepo creates and initializes a TUF repo for Sigstore by adding +// CreateRepoWithOptions creates and initializes a TUF repo for Sigstore by adding // keys to bytes. keys are typically for a basic setup like: // "fulcio_v1.crt.pem" - Fulcio root cert in PEM format // "ctfe.pub" - CTLog public key in PEM format @@ -127,36 +150,264 @@ func CreateRepoWithMetadata(ctx context.Context, targets []TargetWithMetadata) ( // - `rekor` = it will get Usage set to `Rekor` // - `tsa` = it will get Usage set to `tsa`. // - Anything else will get set to `Unknown` -func CreateRepo(ctx context.Context, files map[string][]byte) (tuf.LocalStore, string, error) { - targets := make([]TargetWithMetadata, 0, len(files)) +// +// The targets will be added individually to the TUF repo if CreateRepoOptions.AddMetadataTargets +// is set to true. The trusted_root.json file will be added if CreateRepoOptions.AddTrustedRoot +// is set to true. At least one of these has to be true. +func CreateRepoWithOptions(ctx context.Context, files map[string][]byte, options CreateRepoOptions) (tuf.LocalStore, string, error) { + if !options.AddMetadataTargets && !options.AddTrustedRoot { + return nil, "", errors.New("failed to create TUF repo: At least one of metadataTargets, trustedRoot must be true") + } + + metadataTargets := make([]TargetWithMetadata, 0, len(files)) for name, bytes := range files { - var usage string - switch { - case strings.Contains(name, "fulcio"): - usage = "Fulcio" - case strings.Contains(name, "ctfe"): - usage = "CTFE" - case strings.Contains(name, "rekor"): - usage = "Rekor" - case strings.Contains(name, "tsa"): - usage = "TSA" - default: - usage = "Unknown" - } - scmActive, err := json.Marshal(&sigstoreCustomMetadata{Sigstore: CustomMetadata{Usage: usage, Status: "Active"}}) + scmActive, err := json.Marshal(&sigstoreCustomMetadata{Sigstore: CustomMetadata{Usage: getTargetUsage(name), Status: "Active"}}) if err != nil { return nil, "", fmt.Errorf("failed to marshal custom metadata for %s: %w", name, err) } - targets = append(targets, TargetWithMetadata{ + metadataTargets = append(metadataTargets, TargetWithMetadata{ Name: name, Bytes: bytes, CustomMetadata: scmActive, }) } + targets := make([]TargetWithMetadata, 0, len(files)+1) + if options.AddMetadataTargets { + targets = append(targets, metadataTargets...) + } + if options.AddTrustedRoot { + trustedRootTarget, err := constructTrustedRoot(metadataTargets) + if err != nil { + return nil, "", fmt.Errorf("failed to construct trust root: %w", err) + } + targets = append(targets, *trustedRootTarget) + } + return CreateRepoWithMetadata(ctx, targets) } +// CreateRepo calls CreateRepoWithOptions, while setting: +// * CreateRepoOptions.AddMetadataTargets: true +// * CreateRepoOptions.AddTrustedRoot: false +func CreateRepo(ctx context.Context, files map[string][]byte) (tuf.LocalStore, string, error) { + return CreateRepoWithOptions(ctx, files, CreateRepoOptions{AddMetadataTargets: true, AddTrustedRoot: false}) +} + +func constructTrustedRoot(targets []TargetWithMetadata) (*TargetWithMetadata, error) { + var fulcioRoot, tsaLeaf, tsaRoot []byte + var fulcioIntermed, tsaIntermed [][]byte + rekorKeys := map[string]*root.TransparencyLog{} + ctlogKeys := map[string]*root.TransparencyLog{} + now := time.Now() + + // we sort the targets by Name, this results in intermediary certs being sorted correctly, + // as long as there is less than 10, which is ok to assume for the purposes of this code + sort.Slice(targets, func(i, j int) bool { + return targets[i].Name < targets[j].Name + }) + + for _, target := range targets { + // NOTE: in the below switch, we are able to process whole certificate chains, but we also support + // if they're passed in as individual certificates, already split in individual targets + switch getTargetUsage(target.Name) { + case FulcioTarget: + switch { + // no leaf for Fulcio certificate, the leaf is the code signing cert + case strings.Contains(target.Name, "intermediate"): + fulcioIntermed = append(fulcioIntermed, target.Bytes) + default: + fulcioRoot = target.Bytes + } + case TSATarget: + switch { + case strings.Contains(target.Name, "leaf"): + tsaLeaf = target.Bytes + case strings.Contains(target.Name, "intermediate"): + tsaIntermed = append(tsaIntermed, target.Bytes) + default: + tsaRoot = target.Bytes + } + case RekorTarget: + tlinstance, id, err := pubkeyToTransparencyLogInstance(target.Bytes, now) + if err != nil { + return nil, fmt.Errorf("failed to parse rekor key: %w", err) + } + rekorKeys[id] = tlinstance + case CTFETarget: + tlinstance, id, err := pubkeyToTransparencyLogInstance(target.Bytes, now) + if err != nil { + return nil, fmt.Errorf("failed to parse ctlog key: %w", err) + } + ctlogKeys[id] = tlinstance + } + } + + fulcioChainPem := concatCertChain([]byte{}, fulcioIntermed, fulcioRoot) + fulcioAuthorities := []root.CertificateAuthority{} + if len(fulcioChainPem) > 0 { + fulcioAuthority, err := certChainToCertificateAuthority(fulcioChainPem) + if err != nil { + return nil, fmt.Errorf("failed to parse cert chain for Fulcio: %w", err) + } + fulcioAuthorities = append(fulcioAuthorities, *fulcioAuthority) + } + + tsaChainPem := concatCertChain(tsaLeaf, tsaIntermed, tsaRoot) + tsaAuthorities := []root.CertificateAuthority{} + if len(tsaChainPem) > 0 { + tsaAuthority, err := certChainToCertificateAuthority(tsaChainPem) + if err != nil { + return nil, fmt.Errorf("failed to parse cert chain for TSA: %w", err) + } + tsaAuthorities = append(tsaAuthorities, *tsaAuthority) + } + + tr, err := root.NewTrustedRoot( + root.TrustedRootMediaType01, + fulcioAuthorities, + ctlogKeys, + tsaAuthorities, + rekorKeys, + ) + if err != nil { + return nil, fmt.Errorf("failed to create TrustedRoot: %w", err) + } + serialized, err := json.Marshal(tr) + if err != nil { + return nil, fmt.Errorf("failed to serialize TrustedRoot to JSON: %w", err) + } + + return &TargetWithMetadata{ + Name: "trusted_root.json", + Bytes: serialized, + }, nil +} + +func pubkeyToTransparencyLogInstance(keyBytes []byte, tm time.Time) (*root.TransparencyLog, string, error) { + logID := sha256.Sum256(keyBytes) + der, _ := pem.Decode(keyBytes) + key, keyDetails, err := getKeyWithDetails(der.Bytes) + if err != nil { + return nil, "", err + } + + return &root.TransparencyLog{ + BaseURL: "", + ID: logID[:], + ValidityPeriodStart: tm, + HashFunc: crypto.SHA256, // we can't get this from the keyBytes, assume SHA256 + PublicKey: key, + SignatureHashFunc: keyDetails, + }, hex.EncodeToString(logID[:]), nil +} + +func getKeyWithDetails(key []byte) (crypto.PublicKey, crypto.Hash, error) { + var k any + var hashFunc crypto.Hash + var err1, err2 error + + k, err1 = x509.ParsePKCS1PublicKey(key) + if err1 != nil { + k, err2 = x509.ParsePKIXPublicKey(key) + if err2 != nil { + return 0, 0, fmt.Errorf("can't parse public key with PKCS1 or PKIX: %w, %w", err1, err2) + } + } + + switch v := k.(type) { + case *ecdsa.PublicKey: + switch v.Curve { + case elliptic.P256(): + hashFunc = crypto.SHA256 + case elliptic.P384(): + hashFunc = crypto.SHA384 + case elliptic.P521(): + hashFunc = crypto.SHA512 + default: + return 0, 0, fmt.Errorf("unsupported elliptic curve %T", v.Curve) + } + case *rsa.PublicKey: + switch v.Size() * 8 { + case 2048, 3072, 4096: + hashFunc = crypto.SHA256 + default: + return 0, 0, fmt.Errorf("unsupported public modulus %d", v.Size()) + } + default: + return 0, 0, errors.New("unknown public key type") + } + + return k, hashFunc, nil +} + +func certChainToCertificateAuthority(certChainPem []byte) (*root.CertificateAuthority, error) { + var cert *x509.Certificate + var err error + rest := certChainPem + certChain := []*x509.Certificate{} + + // skip potential whitespace at end of file (8 is kinda random, but seems to work fine) + for len(rest) > 8 { + var derCert *pem.Block + derCert, rest = pem.Decode(rest) + if derCert == nil { + return nil, fmt.Errorf("input is left, but it is not a certificate: %+v", rest) + } + cert, err = x509.ParseCertificate(derCert.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + certChain = append(certChain, cert) + } + if len(certChain) == 0 { + return nil, fmt.Errorf("no certificates found in input") + } + + ca := root.CertificateAuthority{} + + for i, cert := range certChain { + switch { + case i == 0 && !cert.IsCA: + ca.Leaf = cert + case i < len(certChain)-1: + ca.Intermediates = append(ca.Intermediates, cert) + case i == len(certChain)-1: + ca.Root = cert + } + } + + ca.ValidityPeriodStart = certChain[0].NotBefore + ca.ValidityPeriodEnd = certChain[0].NotAfter + + return &ca, nil +} + +func concatCertChain(leaf []byte, intermediate [][]byte, root []byte) []byte { + var result []byte + if len(leaf) > 0 { + // for Fulcio, the leaf will always be empty, don't necessarily append an empty newline + result = append(result, leaf...) + result = append(result, byte('\n')) + } + for _, intermed := range intermediate { + result = append(result, intermed...) + result = append(result, byte('\n')) + } + result = append(result, root...) + return result +} + +func getTargetUsage(name string) string { + for _, knownTargetType := range []string{FulcioTarget, RekorTarget, CTFETarget, TSATarget} { + if strings.Contains(name, strings.ToLower(knownTargetType)) { + return knownTargetType + } + } + + return UnknownTarget +} + func writeStagedTarget(dir, path string, data []byte) error { path = filepath.Join(dir, "staged", "targets", path) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {