From 00ba37951503e95a1becaada35b6f052d6d5a8b3 Mon Sep 17 00:00:00 2001 From: Jake Sanders Date: Sun, 16 May 2021 23:35:55 -0700 Subject: [PATCH] add `-r` option for recursively signing multi-arch images Signed-off-by: Jake Sanders --- cmd/cosign/cli/sign.go | 196 ++++++++++++++++++++++++------------ cmd/cosign/cli/sign_test.go | 2 +- pkg/cosign/fetch.go | 8 +- pkg/cosign/upload.go | 23 +++-- test/e2e_test.go | 20 ++-- test/e2e_test_secrets.sh | 15 ++- 6 files changed, 176 insertions(+), 88 deletions(-) diff --git a/cmd/cosign/cli/sign.go b/cmd/cosign/cli/sign.go index a183ec7877a..878dd310d47 100644 --- a/cmd/cosign/cli/sign.go +++ b/cmd/cosign/cli/sign.go @@ -75,12 +75,13 @@ func Sign() *ffcli.Command { sk = flagset.Bool("sk", false, "whether to use a hardware security key") payloadPath = flagset.String("payload", "", "path to a payload file to use rather than generating one.") force = flagset.Bool("f", false, "skip warnings and confirmations") + recursive = flagset.Bool("r", false, "if a multi-arch image is specified, additionally sign each discrete image") annotations = annotationsMap{} ) flagset.Var(&annotations, "a", "extra key=value pairs to sign") return &ffcli.Command{ Name: "sign", - ShortUsage: "cosign sign -key | [-payload ] [-a key=value] [-upload=true|false] [-f] ", + ShortUsage: "cosign sign -key | [-payload ] [-a key=value] [-upload=true|false] [-f] [-r] ", ShortHelp: `Sign the supplied container image.`, LongHelp: `Sign the supplied container image. @@ -91,6 +92,9 @@ EXAMPLES # sign a container image with a local key pair file cosign sign -key cosign.key + # sign a multi-arch container image AND all referenced, discrete images + cosign sign -key cosign.key -r + # sign a container image and add annotations cosign sign -key cosign.key -a key1=value1 -a key2=value2 @@ -113,7 +117,7 @@ EXAMPLES Sk: *sk, } for _, img := range args { - if err := SignCmd(ctx, so, img, *upload, *payloadPath, *force); err != nil { + if err := SignCmd(ctx, so, img, *upload, *payloadPath, *force, *recursive); err != nil { return errors.Wrapf(err, "signing %s", img) } } @@ -129,8 +133,43 @@ type SignOpts struct { Pf cosign.PassFunc } +func getTransitiveImages(rootIndex *remote.Descriptor, repo name.Repository, opts ...remote.Option) ([]name.Digest, error) { + var imgs []name.Digest + + indexDescs := []*remote.Descriptor{rootIndex} + + for len(indexDescs) > 0 { + indexDesc := indexDescs[len(indexDescs)-1] + indexDescs = indexDescs[:len(indexDescs)-1] + + idx, err := indexDesc.ImageIndex() + if err != nil { + return nil, err + } + idxManifest, err := idx.IndexManifest() + if err != nil { + return nil, err + } + for _, manifest := range idxManifest.Manifests { + if manifest.MediaType.IsIndex() { + nextIndexName := repo.Digest(manifest.Digest.String()) + indexDesc, err := remote.Get(nextIndexName, opts...) + if err != nil { + return nil, errors.Wrap(err, "getting recursive image index") + } + indexDescs = append(indexDescs, indexDesc) + + } + childImg := repo.Digest(manifest.Digest.String()) + imgs = append(imgs, childImg) + } + } + + return imgs, nil +} + func SignCmd(ctx context.Context, so SignOpts, - imageRef string, upload bool, payloadPath string, force bool) error { + imageRef string, upload bool, payloadPath string, force bool, recursive bool) error { // A key file or token is required unless we're in experimental mode! if cosign.Experimental() { @@ -153,21 +192,18 @@ func SignCmd(ctx context.Context, so SignOpts, if err != nil { return errors.Wrap(err, "getting remote image") } + repo := ref.Context() img := repo.Digest(get.Digest.String()) - // The payload can be specified via a flag to skip generation. - var payload []byte - if payloadPath != "" { - fmt.Fprintln(os.Stderr, "Using payload from:", payloadPath) - payload, err = ioutil.ReadFile(filepath.Clean(payloadPath)) - } else { - payload, err = (&sigPayload.Cosign{ - Image: img, - Annotations: so.Annotations, - }).MarshalJSON() - } - if err != nil { - return errors.Wrap(err, "payload") + + toSign := []name.Digest{img} + + if recursive && get.MediaType.IsIndex() { + imgs, err := getTransitiveImages(get, repo, remoteAuth) + if err != nil { + return err + } + toSign = append(toSign, imgs...) } var signer signature.Signer @@ -198,71 +234,101 @@ func SignCmd(ctx context.Context, so SignOpts, cert, chain = k.Cert, k.Chain } - sig, _, err := signer.Sign(ctx, payload) - if err != nil { - return errors.Wrap(err, "signing") - } - - if !upload { - fmt.Println(base64.StdEncoding.EncodeToString(sig)) - return nil - } - - // sha256:... -> sha256-... - dstRef, err := cosign.DestinationRef(ref, get) - if err != nil { - return err - } - fmt.Fprintln(os.Stderr, "Pushing signature to:", dstRef.String()) - uo := cosign.UploadOpts{ - Cert: cert, - Chain: chain, - DupeDetector: dupeDetector, - RemoteOpts: []remote.Option{remoteAuth}, - } - - if !cosign.Experimental() { - _, err := cosign.Upload(ctx, sig, payload, dstRef, uo) - return err - } - // Check if the image is public (no auth in Get) - if !force { + uploadTLog := cosign.Experimental() + if uploadTLog && !force { if _, err := remote.Get(ref); err != nil { fmt.Print("warning: uploading to the public transparency log for a private image, please confirm [Y/N]: ") - var response string - if _, err := fmt.Scanln(&response); err != nil { + + var tlogConfirmResponse string + if _, err := fmt.Scanln(&tlogConfirmResponse); err != nil { return err } - if response != "Y" { + if tlogConfirmResponse != "Y" { fmt.Println("not uploading to transparency log") - return nil + uploadTLog = false } } } - // Upload the cert or the public key, depending on what we have var rekorBytes []byte - if cert != "" { - rekorBytes = []byte(cert) - } else { - pemBytes, err := cosign.PublicKeyPem(ctx, signer) - if err != nil { - return nil + if uploadTLog { + // Upload the cert or the public key, depending on what we have + if cert != "" { + rekorBytes = []byte(cert) + } else { + pemBytes, err := cosign.PublicKeyPem(ctx, signer) + if err != nil { + return err + } + rekorBytes = pemBytes } - rekorBytes = pemBytes } - entry, err := cosign.UploadTLog(sig, payload, rekorBytes) - if err != nil { - return err + + var staticPayload []byte + if payloadPath != "" { + fmt.Fprintln(os.Stderr, "Using payload from:", payloadPath) + staticPayload, err = ioutil.ReadFile(filepath.Clean(payloadPath)) + if err != nil { + return errors.Wrap(err, "payload from file") + } } - fmt.Println("tlog entry created with index: ", *entry.LogIndex) - uo.Bundle = bundle(entry) - uo.AdditionalAnnotations = annotations(entry) - if _, err = cosign.Upload(ctx, sig, payload, dstRef, uo); err != nil { - return errors.Wrap(err, "uploading") + for len(toSign) > 0 { + img := toSign[0] + toSign = toSign[1:] + // The payload can be specified via a flag to skip generation. + payload := staticPayload + if len(payload) == 0 { + payload, err = (&sigPayload.Cosign{ + Image: img, + Annotations: so.Annotations, + }).MarshalJSON() + if err != nil { + return errors.Wrap(err, "payload") + } + } + + sig, _, err := signer.Sign(ctx, payload) + if err != nil { + return errors.Wrap(err, "signing") + } + + if !upload { + fmt.Println(base64.StdEncoding.EncodeToString(sig)) + continue + } + + // sha256:... -> sha256-... + sigRef, err := cosign.SignaturesRef(img) + if err != nil { + return err + } + + uo := cosign.UploadOpts{ + Cert: cert, + Chain: chain, + DupeDetector: dupeDetector, + RemoteOpts: []remote.Option{remoteAuth}, + } + + if uploadTLog { + entry, err := cosign.UploadTLog(sig, payload, rekorBytes) + if err != nil { + return err + } + fmt.Println("tlog entry created with index: ", *entry.LogIndex) + + uo.Bundle = bundle(entry) + uo.AdditionalAnnotations = annotations(entry) + } + + fmt.Fprintln(os.Stderr, "Pushing signature to:", sigRef.String()) + if _, err = cosign.Upload(ctx, sig, payload, sigRef, uo); err != nil { + return errors.Wrap(err, "uploading") + } } + return nil } diff --git a/cmd/cosign/cli/sign_test.go b/cmd/cosign/cli/sign_test.go index 77ee36e43b9..b1a4b24bd7e 100644 --- a/cmd/cosign/cli/sign_test.go +++ b/cmd/cosign/cli/sign_test.go @@ -34,7 +34,7 @@ func TestSignCmdLocalKeyAndSk(t *testing.T) { Sk: true, }, } { - err := SignCmd(ctx, so, "", false, "", false) + err := SignCmd(ctx, so, "", false, "", false, false) if (errors.Is(err, &KeyParseError{}) == false) { t.Fatal("expected KeyParseError") } diff --git a/pkg/cosign/fetch.go b/pkg/cosign/fetch.go index 581844dedff..4a40e3231b1 100644 --- a/pkg/cosign/fetch.go +++ b/pkg/cosign/fetch.go @@ -51,10 +51,12 @@ type SignedPayload struct { // } func Munge(desc v1.Descriptor) string { + return signatureImageTagForDigest(desc.Digest.String()) +} + +func signatureImageTagForDigest(digest string) string { // sha256:... -> sha256-... - munged := strings.ReplaceAll(desc.Digest.String(), ":", "-") - munged += ".sig" - return munged + return strings.ReplaceAll(digest, ":", "-") + ".sig" } func FetchSignatures(ctx context.Context, ref name.Reference) ([]SignedPayload, *v1.Descriptor, error) { diff --git a/pkg/cosign/upload.go b/pkg/cosign/upload.go index 4c7a1ecdc9f..69a154731e1 100644 --- a/pkg/cosign/upload.go +++ b/pkg/cosign/upload.go @@ -55,15 +55,15 @@ func DockerMediaTypes() bool { return false } -func DestinationRef(ref name.Reference, img *remote.Descriptor) (name.Reference, error) { - dstTag := ref.Context().Tag(Munge(img.Descriptor)) +func substituteRepo(img name.Reference) (name.Reference, error) { wantRepo := os.Getenv(repoEnv) if wantRepo == "" { - return dstTag, nil + return img, nil } + reg := img.Context().RegistryStr() // strip registry from image - oldImage := strings.TrimPrefix(dstTag.Name(), dstTag.RegistryStr()) - newSubrepo := strings.TrimPrefix(wantRepo, dstTag.RegistryStr()) + oldImage := strings.TrimPrefix(img.Name(), reg) + newSubrepo := strings.TrimPrefix(wantRepo, reg) // replace old subrepo with new one subRepo := strings.Split(oldImage, "/") @@ -74,13 +74,22 @@ func DestinationRef(ref name.Reference, img *remote.Descriptor) (name.Reference, } newRepo := strings.Join(subRepo, "/") // add the tag back in if we lost it - if !strings.Contains(newRepo, ":") { + if dstTag, isTag := img.(name.Tag); isTag && !strings.Contains(newRepo, ":") { newRepo = newRepo + ":" + dstTag.TagStr() } - subbed := dstTag.RegistryStr() + newRepo + subbed := reg + newRepo return name.ParseReference(subbed) } +func SignaturesRef(signed name.Digest) (name.Reference, error) { + return substituteRepo(signed.Context().Tag(signatureImageTagForDigest(signed.DigestStr()))) +} + +func DestinationRef(ref name.Reference, img *remote.Descriptor) (name.Reference, error) { + dstTag := ref.Context().Tag(Munge(img.Descriptor)) + return substituteRepo(dstTag) +} + // Upload will upload the signature, public key and payload to the tlog func UploadTLog(signature, payload []byte, pemBytes []byte) (*models.LogEntryAnon, error) { rekorClient, err := app.GetRekorClient(TlogServer()) diff --git a/test/e2e_test.go b/test/e2e_test.go index f02e712162e..06cec1d7f4d 100644 --- a/test/e2e_test.go +++ b/test/e2e_test.go @@ -80,7 +80,7 @@ func TestSignVerify(t *testing.T) { // Now sign the image so := cli.SignOpts{KeyRef: privKeyPath, Pf: passFunc} - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // Now verify and download should work! must(verify(pubKeyPath, imgName, true, nil), t) @@ -91,7 +91,7 @@ func TestSignVerify(t *testing.T) { // Sign the image with an annotation so.Annotations = map[string]interface{}{"foo": "bar"} - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // It should match this time. must(verify(pubKeyPath, imgName, true, map[string]interface{}{"foo": "bar"}), t) @@ -115,7 +115,7 @@ func TestSignVerifyClean(t *testing.T) { // Now sign the image so := cli.SignOpts{KeyRef: privKeyPath, Pf: passFunc} - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // Now verify and download should work! must(verify(pubKeyPath, imgName, true, nil), t) @@ -153,7 +153,7 @@ func TestBundle(t *testing.T) { } // Sign the image - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // Make sure verify works must(verify(pubKeyPath, imgName, true, nil), t) @@ -182,14 +182,14 @@ func TestDuplicateSign(t *testing.T) { // Now sign the image so := cli.SignOpts{KeyRef: privKeyPath, Pf: passFunc} - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // Now verify and download should work! must(verify(pubKeyPath, imgName, true, nil), t) must(cli.DownloadCmd(ctx, imgName), t) // Signing again should work just fine... - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // but a duplicate signature should not be a uploaded signatures, _, err := cosign.FetchSignatures(ctx, ref) if err != nil { @@ -244,14 +244,14 @@ func TestMultipleSignatures(t *testing.T) { // Now sign the image with one key so := cli.SignOpts{KeyRef: priv1, Pf: passFunc} - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // Now verify should work with that one, but not the other must(verify(pub1, imgName, true, nil), t) mustErr(verify(pub2, imgName, true, nil), t) // Now sign with the other key too so.KeyRef = priv2 - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // Now verify should work with both must(verify(pub1, imgName, true, nil), t) @@ -453,7 +453,7 @@ func TestTlog(t *testing.T) { KeyRef: privKeyPath, Pf: passFunc, } - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // Now verify should work! must(verify(pubKeyPath, imgName, true, nil), t) @@ -465,7 +465,7 @@ func TestTlog(t *testing.T) { mustErr(verify(pubKeyPath, imgName, true, nil), t) // Sign again with the tlog env var on - must(cli.SignCmd(ctx, so, imgName, true, "", false), t) + must(cli.SignCmd(ctx, so, imgName, true, "", false, false), t) // And now verify works! must(verify(pubKeyPath, imgName, true, nil), t) } diff --git a/test/e2e_test_secrets.sh b/test/e2e_test_secrets.sh index 05596e61ba8..7b9f7e69259 100755 --- a/test/e2e_test_secrets.sh +++ b/test/e2e_test_secrets.sh @@ -32,14 +32,16 @@ export COSIGN_PASSWORD=$pass img="us-central1-docker.pkg.dev/projectsigstore/cosign-ci/test" img2="us-central1-docker.pkg.dev/projectsigstore/cosign-ci/test-2" legacy_img="us-central1-docker.pkg.dev/projectsigstore/cosign-ci/legacy-test" -img_copy="${img}/copy" for image in $img $img2 $legacy_img do (crane delete $(./cosign triangulate $image)) || true crane cp busybox $image done +img_copy="${img}/copy" crane ls $img_copy | while read tag ; do crane delete "${img_copy}:${tag}" ; done - +multiarch_img="us-central1-docker.pkg.dev/projectsigstore/cosign-ci/multiarch-test" +crane ls $multiarch_img | while read tag ; do crane delete "${multiarch_img}:${tag}" ; done +crane cp gcr.io/distroless/base $multiarch_img ## sign/verify ./cosign sign -key cosign.key $img @@ -49,6 +51,15 @@ crane ls $img_copy | while read tag ; do crane delete "${img_copy}:${tag}" ; don ./cosign copy $img $img_copy ./cosign verify -key cosign.pub $img_copy +# sign recursively +./cosign sign -key cosign.key -r $multiarch_img +./cosign verify -key cosign.pub $multiarch_img # verify image index +for arch in "linux/amd64" "linux/arm64" "linux/s390x" +do + # verify sigs on discrete images + ./cosign verify -key cosign.pub "${multiarch_img}@$(crane digest --platform=$arch ${multiarch_img})" +done + ## confirm use of OCI media type in signature image crane manifest $(./cosign triangulate $img) | grep -q "application/vnd.oci.image.config.v1+json"