diff --git a/cmd/cosign/cli/sign/sign.go b/cmd/cosign/cli/sign/sign.go index 2be762b7bf4..9a68f18f60d 100644 --- a/cmd/cosign/cli/sign/sign.go +++ b/cmd/cosign/cli/sign/sign.go @@ -42,6 +42,7 @@ import ( "github.com/sigstore/cosign/pkg/cosign/pivkey" cremote "github.com/sigstore/cosign/pkg/cosign/remote" "github.com/sigstore/cosign/pkg/oci" + ociempty "github.com/sigstore/cosign/pkg/oci/empty" "github.com/sigstore/cosign/pkg/oci/mutate" ociremote "github.com/sigstore/cosign/pkg/oci/remote" "github.com/sigstore/cosign/pkg/oci/static" @@ -168,6 +169,18 @@ func SignCmd(ctx context.Context, ko KeyOpts, regOpts options.RegistryOptions, a return fmt.Errorf("unable to resolve attachment %s for image %s", attachment, inputImg) } + if digest, ok := ref.(name.Digest); ok && !recursive { + se, err := ociempty.SignedImage(ref) + if err != nil { + return err + } + err = signDigest(ctx, digest, staticPayload, ko, regOpts, annotations, upload, force, dd, sv, se) + if err != nil { + return err + } + continue + } + se, err := ociremote.SignedEntity(ref, opts...) if err != nil { return err @@ -181,74 +194,85 @@ func SignCmd(ctx context.Context, ko KeyOpts, regOpts options.RegistryOptions, a } digest := ref.Context().Digest(d.String()) - // The payload can be specified via a flag to skip generation. - payload := staticPayload - if len(payload) == 0 { - payload, err = (&sigPayload.Cosign{ - Image: digest, - Annotations: annotations, - }).MarshalJSON() - if err != nil { - return errors.Wrap(err, "payload") - } - } - - signature, err := sv.SignMessage(bytes.NewReader(payload), signatureoptions.WithContext(ctx)) + err = signDigest(ctx, digest, staticPayload, ko, regOpts, annotations, upload, force, dd, sv, se) if err != nil { - return errors.Wrap(err, "signing") - } - b64sig := base64.StdEncoding.EncodeToString(signature) - - if !upload { - fmt.Println(b64sig) - return ErrDone + return err } + return ErrDone + }); err != nil { + return err + } + } - opts := []static.Option{} - if sv.Cert != nil { - opts = append(opts, static.WithCertChain(sv.Cert, sv.Chain)) - } + return nil +} - // Check whether we should be uploading to the transparency log - if uploadTLog, err := ShouldUploadToTlog(digest, force, ko.RekorURL); err != nil { - return err - } else if uploadTLog { - bundle, err := UploadToTlog(ctx, sv, ko.RekorURL, func(r *client.Rekor, b []byte) (*models.LogEntryAnon, error) { - return cosign.TLogUpload(r, signature, payload, b) - }) - if err != nil { - return err - } - opts = append(opts, static.WithBundle(bundle)) - } +func signDigest(ctx context.Context, digest name.Digest, payload []byte, ko KeyOpts, + regOpts options.RegistryOptions, annotations map[string]interface{}, upload bool, force bool, + dd mutate.DupeDetector, sv *CertSignVerifier, se oci.SignedEntity) error { + var err error + // The payload can be passed to skip generation. + if len(payload) == 0 { + payload, err = (&sigPayload.Cosign{ + Image: digest, + Annotations: annotations, + }).MarshalJSON() + if err != nil { + return errors.Wrap(err, "payload") + } + } - // Create the new signature for this entity. - sig, err := static.NewSignature(payload, b64sig, opts...) - if err != nil { - return err - } + signature, err := sv.SignMessage(bytes.NewReader(payload), signatureoptions.WithContext(ctx)) + if err != nil { + return errors.Wrap(err, "signing") + } + b64sig := base64.StdEncoding.EncodeToString(signature) - // Attach the signature to the entity. - newSE, err := mutate.AttachSignatureToEntity(se, sig, mutate.WithDupeDetector(dd)) - if err != nil { - return err - } + if !upload { + fmt.Println(b64sig) + return nil + } - walkOpts, err := regOpts.ClientOpts(ctx) - if err != nil { - return errors.Wrap(err, "constructing client options") - } + opts := []static.Option{} + if sv.Cert != nil { + opts = append(opts, static.WithCertChain(sv.Cert, sv.Chain)) + } - // Publish the signatures associated with this entity - if err := ociremote.WriteSignatures(digest.Repository, newSE, walkOpts...); err != nil { - return err - } - return ErrDone - }); err != nil { + // Check whether we should be uploading to the transparency log + if uploadTLog, err := ShouldUploadToTlog(digest, force, ko.RekorURL); err != nil { + return err + } else if uploadTLog { + bundle, err := UploadToTlog(ctx, sv, ko.RekorURL, func(r *client.Rekor, b []byte) (*models.LogEntryAnon, error) { + return cosign.TLogUpload(r, signature, payload, b) + }) + if err != nil { return err } + opts = append(opts, static.WithBundle(bundle)) } + // Create the new signature for this entity. + sig, err := static.NewSignature(payload, b64sig, opts...) + if err != nil { + return err + } + + // Attach the signature to the entity. + newSE, err := mutate.AttachSignatureToEntity(se, sig, mutate.WithDupeDetector(dd)) + if err != nil { + return err + } + + // Publish the signatures associated with this entity + walkOpts, err := regOpts.ClientOpts(ctx) + if err != nil { + return errors.Wrap(err, "constructing client options") + } + + // Publish the signatures associated with this entity + if err := ociremote.WriteSignatures(digest.Repository, newSE, walkOpts...); err != nil { + return err + } return nil } diff --git a/pkg/oci/empty/signed.go b/pkg/oci/empty/signed.go new file mode 100644 index 00000000000..ebeac1a1d19 --- /dev/null +++ b/pkg/oci/empty/signed.go @@ -0,0 +1,65 @@ +// +// Copyright 2021 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 empty + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/sigstore/cosign/pkg/oci" +) + +type signedImage struct { + v1.Image + digest v1.Hash + signature oci.Signatures + attestations oci.Signatures +} + +func (se *signedImage) Signatures() (oci.Signatures, error) { + return se.signature, nil +} + +func (se *signedImage) Attestations() (oci.Signatures, error) { + return se.attestations, nil +} + +func (se *signedImage) Digest() (v1.Hash, error) { + if se.digest.Hex == "" { + return v1.Hash{}, fmt.Errorf("digest not available") + } + return se.digest, nil +} + +func SignedImage(ref name.Reference) (oci.SignedImage, error) { + var err error + d := v1.Hash{} + base := empty.Image + if digest, ok := ref.(name.Digest); ok { + d, err = v1.NewHash(digest.DigestStr()) + if err != nil { + return nil, err + } + } + return &signedImage{ + Image: base, + digest: d, + signature: Signatures(), + attestations: Signatures(), + }, nil +} diff --git a/pkg/oci/empty/signed_test.go b/pkg/oci/empty/signed_test.go new file mode 100644 index 00000000000..691a1df2972 --- /dev/null +++ b/pkg/oci/empty/signed_test.go @@ -0,0 +1,68 @@ +// +// Copyright 2021 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 empty + +import ( + "testing" + + "github.com/google/go-containerregistry/pkg/name" +) + +func TestSignedImage(t *testing.T) { + tests := []struct { + ref string + digestStr string + digestErr string + }{ + { + ref: "hello-world:latest", + digestErr: "digest not available", + }, + { + ref: "hello-world@sha256:e1c082e3d3c45cccac829840a25941e679c25d438cc8412c2fa221cf1a824e6a", + digestStr: "sha256:e1c082e3d3c45cccac829840a25941e679c25d438cc8412c2fa221cf1a824e6a", + }, + } + for _, test := range tests { + ref, err := name.ParseReference(test.ref) + if err != nil { + t.Errorf("failed to parse ref \"%s\": %v", test.ref, err) + continue + } + se, err := SignedImage(ref) + if err != nil { + t.Errorf("failed to create signed image for \"%s\": %v", test.ref, err) + continue + } + d, err := se.Digest() + if (err == nil && test.digestErr != "") || + (err != nil && test.digestErr == "") || + (err != nil && test.digestErr != "" && err.Error() != test.digestErr) { + t.Errorf("digest error mismatch for \"%s\": expected %s, saw %v", test.ref, test.digestErr, err) + } + if test.digestStr != "" && d.String() != test.digestStr { + t.Errorf("digest mismatch for \"%s\": expected %s, saw %s", test.ref, test.digestStr, d.String()) + } + _, err = se.Signatures() + if err != nil { + t.Errorf("failed to get signatures for %s: %v", test.ref, err) + } + _, err = se.Attestations() + if err != nil { + t.Errorf("failed to get attestations for %s: %v", test.ref, err) + } + } +}