From 0bd02a0e6ad66b346683bc21592c44edc25ea2c5 Mon Sep 17 00:00:00 2001 From: Matt Moore Date: Sun, 7 May 2023 11:28:05 -0700 Subject: [PATCH] Feature: Allow cosign to sign/attest digests before they are uploaded. :gift: This feature allows `cosign` to sign and attest a digest that doesn't exist yet, to support scenarios such as signing an image prior to pushing it. This adapts the ideas from the two prior approaches, which were closed by stale-bot. Fixes: https://github.com/sigstore/cosign/issues/1905 /kind feature Signed-off-by: Matt Moore --- cmd/cosign/cli/attest/attest.go | 7 +- cmd/cosign/cli/sign/sign.go | 4 +- pkg/oci/mutate/mutate.go | 111 +++++++++++++++++++++++++++++++- pkg/oci/mutate/mutate_test.go | 27 ++++++-- pkg/oci/remote/remote.go | 6 +- pkg/oci/remote/unknown.go | 60 +++++++++++++++++ pkg/oci/remote/unknown_test.go | 101 +++++++++++++++++++++++++++++ test/e2e_test_secrets.sh | 16 +++++ 8 files changed, 316 insertions(+), 16 deletions(-) create mode 100644 pkg/oci/remote/unknown.go create mode 100644 pkg/oci/remote/unknown_test.go diff --git a/cmd/cosign/cli/attest/attest.go b/cmd/cosign/cli/attest/attest.go index 2a0aab6beea..83595bc3284 100644 --- a/cmd/cosign/cli/attest/attest.go +++ b/cmd/cosign/cli/attest/attest.go @@ -214,10 +214,9 @@ func (c *AttestCommand) Exec(ctx context.Context, imageRef string) error { return err } - se, err := ociremote.SignedEntity(digest, ociremoteOpts...) - if err != nil { - return err - } + // We don't actually need to access the remote entity to attach things to it + // so we use a placeholder here. + se := ociremote.SignedUnknown(digest) signOpts := []mutate.SignOption{ mutate.WithDupeDetector(dd), diff --git a/cmd/cosign/cli/sign/sign.go b/cmd/cosign/cli/sign/sign.go index a09bc339e7a..c262c9aea59 100644 --- a/cmd/cosign/cli/sign/sign.go +++ b/cmd/cosign/cli/sign/sign.go @@ -179,7 +179,9 @@ func SignCmd(ro *options.RootOptions, ko options.KeyOpts, signOpts options.SignO if digest, ok := ref.(name.Digest); ok && !signOpts.Recursive { se, err := ociremote.SignedEntity(ref, opts...) - if err != nil { + if err == ociremote.ErrEntityNotFound { + se = ociremote.SignedUnknown(digest) + } else if err != nil { return fmt.Errorf("accessing image: %w", err) } err = signDigest(ctx, digest, staticPayload, ko, signOpts, annotations, dd, sv, se) diff --git a/pkg/oci/mutate/mutate.go b/pkg/oci/mutate/mutate.go index ce667750ed1..32d37be576f 100644 --- a/pkg/oci/mutate/mutate.go +++ b/pkg/oci/mutate/mutate.go @@ -135,7 +135,7 @@ func AttachSignatureToEntity(se oci.SignedEntity, sig oci.Signature, opts ...Sig case oci.SignedImageIndex: return AttachSignatureToImageIndex(obj, sig, opts...) default: - return nil, fmt.Errorf("unsupported type: %T", se) + return AttachSignatureToUnknown(obj, sig, opts...) } } @@ -147,7 +147,7 @@ func AttachAttestationToEntity(se oci.SignedEntity, att oci.Signature, opts ...S case oci.SignedImageIndex: return AttachAttestationToImageIndex(obj, att, opts...) default: - return nil, fmt.Errorf("unsupported type: %T", se) + return AttachAttestationToUnknown(obj, att, opts...) } } @@ -159,7 +159,7 @@ func AttachFileToEntity(se oci.SignedEntity, name string, f oci.File, opts ...Si case oci.SignedImageIndex: return AttachFileToImageIndex(obj, name, f, opts...) default: - return nil, fmt.Errorf("unsupported type: %T", se) + return AttachFileToUnknown(obj, name, f, opts...) } } @@ -348,3 +348,108 @@ func (sii *signedImageIndex) Attachment(attName string) (oci.File, error) { } return nil, fmt.Errorf("attachment %q not found", attName) } + +// AttachSignatureToUnknown attaches the provided signature to the provided image. +func AttachSignatureToUnknown(se oci.SignedEntity, sig oci.Signature, opts ...SignOption) (oci.SignedEntity, error) { + return &signedUnknown{ + SignedEntity: se, + sig: sig, + attachments: make(map[string]oci.File), + so: makeSignOpts(opts...), + }, nil +} + +// AttachAttestationToUnknown attaches the provided attestation to the provided image. +func AttachAttestationToUnknown(se oci.SignedEntity, att oci.Signature, opts ...SignOption) (oci.SignedEntity, error) { + return &signedUnknown{ + SignedEntity: se, + att: att, + attachments: make(map[string]oci.File), + so: makeSignOpts(opts...), + }, nil +} + +// AttachFileToUnknown attaches the provided file to the provided image. +func AttachFileToUnknown(se oci.SignedEntity, name string, f oci.File, opts ...SignOption) (oci.SignedEntity, error) { + return &signedUnknown{ + SignedEntity: se, + attachments: map[string]oci.File{ + name: f, + }, + so: makeSignOpts(opts...), + }, nil +} + +type signedUnknown struct { + oci.SignedEntity + sig oci.Signature + att oci.Signature + so *signOpts + attachments map[string]oci.File +} + +type digestable interface { + Digest() (v1.Hash, error) +} + +// Digest is generally implemented by oci.SignedEntity implementations. +func (si *signedUnknown) Digest() (v1.Hash, error) { + d, ok := si.SignedEntity.(digestable) + if !ok { + return v1.Hash{}, fmt.Errorf("underlying signed entity not digestable: %T", si.SignedEntity) + } + return d.Digest() +} + +// Signatures implements oci.SignedEntity +func (si *signedUnknown) Signatures() (oci.Signatures, error) { + base, err := si.SignedEntity.Signatures() + if err != nil { + return nil, err + } else if si.sig == nil { + return base, nil + } + if si.so.dd != nil { + if existing, err := si.so.dd.Find(base, si.sig); err != nil { + return nil, err + } else if existing != nil { + // Just return base if the signature is redundant + return base, nil + } + } + return AppendSignatures(base, si.sig) +} + +// Attestations implements oci.SignedEntity +func (si *signedUnknown) Attestations() (oci.Signatures, error) { + base, err := si.SignedEntity.Attestations() + if err != nil { + return nil, err + } else if si.att == nil { + return base, nil + } + if si.so.dd != nil { + if existing, err := si.so.dd.Find(base, si.att); err != nil { + return nil, err + } else if existing != nil { + // Just return base if the signature is redundant + return base, nil + } + } + if si.so.ro != nil { + replace, err := si.so.ro.Replace(base, si.att) + if err != nil { + return nil, err + } + return ReplaceSignatures(replace) + } + return AppendSignatures(base, si.att) +} + +// Attachment implements oci.SignedEntity +func (si *signedUnknown) Attachment(attName string) (oci.File, error) { + if f, ok := si.attachments[attName]; ok { + return f, nil + } + return nil, fmt.Errorf("attachment %q not found", attName) +} diff --git a/pkg/oci/mutate/mutate_test.go b/pkg/oci/mutate/mutate_test.go index 089d5d41e74..4a3152683b5 100644 --- a/pkg/oci/mutate/mutate_test.go +++ b/pkg/oci/mutate/mutate_test.go @@ -163,8 +163,21 @@ func TestSignEntity(t *testing.T) { } sii := signed.ImageIndex(ii) + // Create an explicitly unknown implementation of oci.SignedEntity, which we + // feed through the table tests below. + want := make([]byte, 300) + rand.Read(want) + orig, err := static.NewFile(want) + if err != nil { + t.Fatalf("static.NewFile() = %v", err) + } + sunk, err := AttachFileToUnknown(sii, "sbom", orig) + if err != nil { + t.Fatalf("AttachFileToUnknown() = %v", err) + } + t.Run("attach SBOMs", func(t *testing.T) { - for _, se := range []oci.SignedEntity{si, sii} { + for _, se := range []oci.SignedEntity{si, sii, sunk} { want := make([]byte, 300) rand.Read(want) @@ -197,7 +210,7 @@ func TestSignEntity(t *testing.T) { }) t.Run("without duplicate detector (signature)", func(t *testing.T) { - for _, se := range []oci.SignedEntity{si, sii} { + for _, se := range []oci.SignedEntity{si, sii, sunk} { orig, err := static.NewSignature(nil, "") if err != nil { t.Fatalf("static.NewSignature() = %v", err) @@ -232,7 +245,7 @@ func TestSignEntity(t *testing.T) { }) t.Run("without duplicate detector (attestation)", func(t *testing.T) { - for _, se := range []oci.SignedEntity{si, sii} { + for _, se := range []oci.SignedEntity{si, sii, sunk} { orig, err := static.NewAttestation([]byte("payload")) if err != nil { t.Fatalf("static.NewAttestation() = %v", err) @@ -267,7 +280,7 @@ func TestSignEntity(t *testing.T) { }) t.Run("with duplicate detector (signature)", func(t *testing.T) { - for _, se := range []oci.SignedEntity{si, sii} { + for _, se := range []oci.SignedEntity{si, sii, sunk} { orig, err := static.NewSignature(nil, "") if err != nil { t.Fatalf("static.NewSignature() = %v", err) @@ -306,7 +319,7 @@ func TestSignEntity(t *testing.T) { }) t.Run("with duplicate detector (attestation)", func(t *testing.T) { - for _, se := range []oci.SignedEntity{si, sii} { + for _, se := range []oci.SignedEntity{si, sii, sunk} { orig, err := static.NewAttestation([]byte("blah")) if err != nil { t.Fatalf("static.NewAttestation() = %v", err) @@ -345,7 +358,7 @@ func TestSignEntity(t *testing.T) { }) t.Run("with erroring duplicate detector (signature)", func(t *testing.T) { - for _, se := range []oci.SignedEntity{si, sii} { + for _, se := range []oci.SignedEntity{si, sii, sunk} { orig, err := static.NewSignature(nil, "") if err != nil { t.Fatalf("static.NewSignature() = %v", err) @@ -379,7 +392,7 @@ func TestSignEntity(t *testing.T) { }) t.Run("with erroring duplicate detector (attestation)", func(t *testing.T) { - for _, se := range []oci.SignedEntity{si, sii} { + for _, se := range []oci.SignedEntity{si, sii, sunk} { orig, err := static.NewAttestation([]byte("blah")) if err != nil { t.Fatalf("static.NewAttestation() = %v", err) diff --git a/pkg/oci/remote/remote.go b/pkg/oci/remote/remote.go index 01b48f5dc39..54105a5d29d 100644 --- a/pkg/oci/remote/remote.go +++ b/pkg/oci/remote/remote.go @@ -36,6 +36,10 @@ var ( remoteIndex = remote.Index remoteGet = remote.Get remoteWrite = remote.Write + + // ErrEntityNotFound is the error that SignedEntity returns when the + // provided ref does not exist. + ErrEntityNotFound = errors.New("entity not found in registry") ) // SignedEntity provides access to a remote reference, and its signatures. @@ -46,7 +50,7 @@ func SignedEntity(ref name.Reference, options ...Option) (oci.SignedEntity, erro got, err := remoteGet(ref, o.ROpt...) var te *transport.Error if errors.As(err, &te) && te.StatusCode == http.StatusNotFound { - return nil, errors.New("entity not found in registry") + return nil, ErrEntityNotFound } else if err != nil { return nil, err } diff --git a/pkg/oci/remote/unknown.go b/pkg/oci/remote/unknown.go new file mode 100644 index 00000000000..ca76d613c34 --- /dev/null +++ b/pkg/oci/remote/unknown.go @@ -0,0 +1,60 @@ +// +// 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 remote + +import ( + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/sigstore/cosign/v2/pkg/oci" +) + +// SignedUnknown provides access to signed metadata without directly accessing +// the underlying entity. This can be used to access signature metadata for +// digests that have not been published (yet). +func SignedUnknown(digest name.Digest, options ...Option) oci.SignedEntity { + o := makeOptions(digest.Context(), options...) + return &unknown{ + digest: digest, + opt: o, + } +} + +type unknown struct { + digest name.Digest + opt *options +} + +var _ oci.SignedEntity = (*unknown)(nil) + +// Digest implements digestable +func (i *unknown) Digest() (v1.Hash, error) { + return v1.NewHash(i.digest.DigestStr()) +} + +// Signatures implements oci.SignedEntity +func (i *unknown) Signatures() (oci.Signatures, error) { + return signatures(i, i.opt) +} + +// Attestations implements oci.SignedEntity +func (i *unknown) Attestations() (oci.Signatures, error) { + return attestations(i, i.opt) +} + +// Attachment implements oci.SignedEntity +func (i *unknown) Attachment(name string) (oci.File, error) { + return attachment(i, name, i.opt) +} diff --git a/pkg/oci/remote/unknown_test.go b/pkg/oci/remote/unknown_test.go new file mode 100644 index 00000000000..7274e642631 --- /dev/null +++ b/pkg/oci/remote/unknown_test.go @@ -0,0 +1,101 @@ +// +// 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 remote + +import ( + "testing" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +func TestSignedUnknown(t *testing.T) { + ri := remote.Image + t.Cleanup(func() { + remoteImage = ri + }) + wantLayers := int64(7) + + remoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + // Only called for signature images + return random.Image(300 /* byteSize */, wantLayers) + } + + // :nonroot as of 2023/05/07 + digest, err := name.NewDigest("gcr.io/distroless/static@sha256:9ecc53c269509f63c69a266168e4a687c7eb8c0cfd753bd8bfcaa4f58a90876f") + if err != nil { + t.Fatalf("ParseRef() = %v", err) + } + si := SignedUnknown(digest) + + sigs, err := si.Signatures() + if err != nil { + t.Fatalf("Signatures() = %v", err) + } + + if sl, err := sigs.Get(); err != nil { + t.Errorf("Get() = %v", err) + } else if got := int64(len(sl)); got != wantLayers { + t.Errorf("len(Get()) = %d, wanted %d", got, wantLayers) + } + + atts, err := si.Attestations() + if err != nil { + t.Fatalf("Signatures() = %v", err) + } + + if al, err := atts.Get(); err != nil { + t.Errorf("Get() = %v", err) + } else if got := int64(len(al)); got != wantLayers { + t.Errorf("len(Get()) = %d, wanted %d", got, wantLayers) + } +} + +func TestSignedUnknownWithAttachment(t *testing.T) { + ri := remote.Image + t.Cleanup(func() { + remoteImage = ri + }) + wantLayers := int64(1) // File must have a single layer + + remoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + // Only called for signature images + return random.Image(300 /* byteSize */, wantLayers) + } + + // :nonroot as of 2023/05/07 + digest, err := name.NewDigest("gcr.io/distroless/static@sha256:9ecc53c269509f63c69a266168e4a687c7eb8c0cfd753bd8bfcaa4f58a90876f") + if err != nil { + t.Fatalf("ParseRef() = %v", err) + } + si := SignedUnknown(digest) + + file, err := si.Attachment("sbom") + if err != nil { + t.Fatalf("Signatures() = %v", err) + } + + payload, err := file.Payload() + if err != nil { + t.Errorf("Payload() = %v", err) + } + // We check greater than because it's wrapped in a tarball with `random.Layer` + if len(payload) < 300 { + t.Errorf("Payload() = %d bytes, wanted %d", len(payload), 300) + } +} diff --git a/test/e2e_test_secrets.sh b/test/e2e_test_secrets.sh index 176a226a4c1..f5cd317d57b 100755 --- a/test/e2e_test_secrets.sh +++ b/test/e2e_test_secrets.sh @@ -36,6 +36,7 @@ signing_key=cosign.key verification_key=cosign.pub img="${TEST_INSTANCE_REPO}/test" img2="${TEST_INSTANCE_REPO}/test-2" +img3="${TEST_INSTANCE_REPO}/test-3" legacy_img="${TEST_INSTANCE_REPO}/legacy-test" for image in $img $img2 $legacy_img do @@ -73,6 +74,21 @@ do ./cosign verify --key ${verification_key} "${multiarch_img}@$(crane digest --platform=$arch ${multiarch_img})" done +# sign/attest an image that doesn't exist (yet) in the registry +# This digest was generated with the following command and +# does not exist anywhere AFAIK: +# head -10 /dev/urandom | sha256sum | cut -d' ' -f 1 +# We don't just run this here because the macos leg doesn't +# have sha256sum +./cosign sign --key ${signing_key} "$img3@sha256:17b14220441083f55dfa21e1deb3720457d3c2d571219801d629b43c53b99627" +PREDICATE_FILE=$(mktemp) +cat > "${PREDICATE_FILE}" <