From 512c64bb007f138fa5dcbcbb93fd9da5c94fb35e Mon Sep 17 00:00:00 2001 From: Luiz Carvalho Date: Wed, 2 Jul 2025 12:41:39 -0400 Subject: [PATCH] Handle signing and attesting blobs Chains fetches existing information about the container images it signs and attests. This works well when the image reference refers to an Image Index or an Image Manifest since those are served from the same endpoint in a container registry. However, when fetching information about a blob (layer) a different endpoint is needed. Cosign handles this behavior by making this step optional: https://github.com/sigstore/cosign/blob/c86498055d0c4ea2f39076064aa094db12f85f6a/cmd/cosign/cli/sign/sign.go#L181-L186 Thus it is possible to sign/attest a blob with the cosign CLI. This commit implements the same logic to Chains so it can also sign/attest blobs. Signed-off-by: Luiz Carvalho --- pkg/chains/storage/oci/attestation.go | 5 +- pkg/chains/storage/oci/attestation_test.go | 111 +++++++++++++++++ pkg/chains/storage/oci/simple.go | 5 +- pkg/chains/storage/oci/simple_test.go | 110 +++++++++++++++++ .../go-containerregistry/pkg/v1/random/doc.go | 16 +++ .../pkg/v1/random/image.go | 116 ++++++++++++++++++ .../pkg/v1/random/index.go | 111 +++++++++++++++++ .../pkg/v1/random/options.go | 60 +++++++++ vendor/modules.txt | 1 + 9 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 pkg/chains/storage/oci/attestation_test.go create mode 100644 pkg/chains/storage/oci/simple_test.go create mode 100644 vendor/github.com/google/go-containerregistry/pkg/v1/random/doc.go create mode 100644 vendor/github.com/google/go-containerregistry/pkg/v1/random/image.go create mode 100644 vendor/github.com/google/go-containerregistry/pkg/v1/random/index.go create mode 100644 vendor/github.com/google/go-containerregistry/pkg/v1/random/options.go diff --git a/pkg/chains/storage/oci/attestation.go b/pkg/chains/storage/oci/attestation.go index 5856c6d0fe..2d2099058d 100644 --- a/pkg/chains/storage/oci/attestation.go +++ b/pkg/chains/storage/oci/attestation.go @@ -61,7 +61,10 @@ func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[nam repo = *s.repo } se, err := ociremote.SignedEntity(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...)) - if err != nil { + var entityNotFoundError *ociremote.EntityNotFoundError + if errors.As(err, &entityNotFoundError) { + se = ociremote.SignedUnknown(req.Artifact) + } else if err != nil { return nil, errors.Wrap(err, "getting signed image") } diff --git a/pkg/chains/storage/oci/attestation_test.go b/pkg/chains/storage/oci/attestation_test.go new file mode 100644 index 0000000000..659b787ecd --- /dev/null +++ b/pkg/chains/storage/oci/attestation_test.go @@ -0,0 +1,111 @@ +// Copyright 2025 The Tekton 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 oci + +import ( + "fmt" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + intoto "github.com/in-toto/attestation/go/v1" + "github.com/tektoncd/chains/pkg/chains/signing" + "github.com/tektoncd/chains/pkg/chains/storage/api" + logtesting "knative.dev/pkg/logging/testing" +) + +func TestAttestationStorer_Store(t *testing.T) { + tests := []struct { + name string + writeToRegistry func(*testing.T, string) name.Digest + wantErr error + }{ + { + name: "image manifest", + writeToRegistry: func(t *testing.T, registryName string) name.Digest { + t.Helper() + img, err := random.Image(1024, 2) + if err != nil { + t.Fatalf("failed to create random image: %s", err) + } + imgDigest, err := img.Digest() + if err != nil { + t.Fatalf("failed to get image digest: %v", err) + } + ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, imgDigest)) + if err != nil { + t.Fatalf("failed to parse digest: %v", err) + } + if err := remote.Write(ref, img); err != nil { + t.Fatalf("failed to write image to mock registry: %v", err) + } + return ref + }, + }, + { + name: "image layer", + writeToRegistry: func(t *testing.T, registryName string) name.Digest { + t.Helper() + layer, err := random.Layer(1024, types.OCILayer) + if err != nil { + t.Fatalf("failed to create random layer: %v", err) + } + layerDigest, err := layer.Digest() + if err != nil { + t.Fatalf("failed to get layer digest: %v", err) + } + ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, layerDigest)) + if err != nil { + t.Fatalf("failed to parse digest: %v", err) + } + if err := remote.WriteLayer(ref.Repository, layer); err != nil { + t.Fatalf("failed to write layer to mock registry: %v", err) + } + return ref + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := httptest.NewServer(registry.New()) + defer s.Close() + registryName := strings.TrimPrefix(s.URL, "http://") + + ref := tt.writeToRegistry(t, registryName) + + storer, err := NewAttestationStorer(WithTargetRepository(ref.Repository)) + if err != nil { + t.Fatalf("failed to create storer: %v", err) + } + + ctx := logtesting.TestContextWithLogger(t) + _, err = storer.Store(ctx, &api.StoreRequest[name.Digest, *intoto.Statement]{ + Artifact: ref, + Payload: &intoto.Statement{}, + Bundle: &signing.Bundle{}, + }) + + if err != nil { + t.Fatalf("error during Store(): %s", err) + } + }) + } +} diff --git a/pkg/chains/storage/oci/simple.go b/pkg/chains/storage/oci/simple.go index 8cb3c8668f..30952ac7dc 100644 --- a/pkg/chains/storage/oci/simple.go +++ b/pkg/chains/storage/oci/simple.go @@ -57,7 +57,10 @@ func (s *SimpleStorer) Store(ctx context.Context, req *api.StoreRequest[name.Dig logger.Info("Uploading signature") se, err := ociremote.SignedEntity(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...)) - if err != nil { + var entityNotFoundError *ociremote.EntityNotFoundError + if errors.As(err, &entityNotFoundError) { + se = ociremote.SignedUnknown(req.Artifact) + } else if err != nil { return nil, errors.Wrap(err, "getting signed image") } diff --git a/pkg/chains/storage/oci/simple_test.go b/pkg/chains/storage/oci/simple_test.go new file mode 100644 index 0000000000..5e6b3e4479 --- /dev/null +++ b/pkg/chains/storage/oci/simple_test.go @@ -0,0 +1,110 @@ +// Copyright 2025 The Tekton 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 oci + +import ( + "fmt" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/tektoncd/chains/pkg/chains/formats/simple" + "github.com/tektoncd/chains/pkg/chains/signing" + "github.com/tektoncd/chains/pkg/chains/storage/api" + logtesting "knative.dev/pkg/logging/testing" +) + +func TestSimpleStorer_Store(t *testing.T) { + tests := []struct { + name string + writeToRegistry func(*testing.T, string) name.Digest + }{ + { + name: "image manifest", + writeToRegistry: func(t *testing.T, registryName string) name.Digest { + t.Helper() + img, err := random.Image(1024, 2) + if err != nil { + t.Fatalf("failed to create random image: %s", err) + } + imgDigest, err := img.Digest() + if err != nil { + t.Fatalf("failed to get image digest: %v", err) + } + ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, imgDigest)) + if err != nil { + t.Fatalf("failed to parse digest: %v", err) + } + if err := remote.Write(ref, img); err != nil { + t.Fatalf("failed to write image to mock registry: %v", err) + } + return ref + }, + }, + { + name: "image layer", + writeToRegistry: func(t *testing.T, registryName string) name.Digest { + t.Helper() + layer, err := random.Layer(1024, types.OCILayer) + if err != nil { + t.Fatalf("failed to create random layer: %s", err) + } + layerDigest, err := layer.Digest() + if err != nil { + t.Fatalf("failed to get layer digest: %v", err) + } + ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, layerDigest)) + if err != nil { + t.Fatalf("failed to parse digest: %v", err) + } + if err := remote.WriteLayer(ref.Repository, layer); err != nil { + t.Fatalf("failed to write layer to mock registry: %v", err) + } + return ref + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := httptest.NewServer(registry.New()) + defer s.Close() + registryName := strings.TrimPrefix(s.URL, "http://") + + ref := tt.writeToRegistry(t, registryName) + + storer, err := NewSimpleStorerFromConfig(WithTargetRepository(ref.Repository)) + if err != nil { + t.Fatalf("failed to create storer: %v", err) + } + + ctx := logtesting.TestContextWithLogger(t) + _, err = storer.Store(ctx, &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{ + Artifact: ref, + Payload: simple.NewSimpleStruct(ref), + Bundle: &signing.Bundle{}, + }) + + if err != nil { + t.Fatalf("error during Store(): %s", err) + } + }) + } +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/v1/random/doc.go b/vendor/github.com/google/go-containerregistry/pkg/v1/random/doc.go new file mode 100644 index 0000000000..d3712767d2 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/v1/random/doc.go @@ -0,0 +1,16 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// 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 random provides a facility for synthesizing pseudo-random images. +package random diff --git a/vendor/github.com/google/go-containerregistry/pkg/v1/random/image.go b/vendor/github.com/google/go-containerregistry/pkg/v1/random/image.go new file mode 100644 index 0000000000..6121aa715c --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/v1/random/image.go @@ -0,0 +1,116 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// 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 random + +import ( + "archive/tar" + "bytes" + "crypto" + "encoding/hex" + "fmt" + "io" + "math/rand" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +// uncompressedLayer implements partial.UncompressedLayer from raw bytes. +type uncompressedLayer struct { + diffID v1.Hash + mediaType types.MediaType + content []byte +} + +// DiffID implements partial.UncompressedLayer +func (ul *uncompressedLayer) DiffID() (v1.Hash, error) { + return ul.diffID, nil +} + +// Uncompressed implements partial.UncompressedLayer +func (ul *uncompressedLayer) Uncompressed() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewBuffer(ul.content)), nil +} + +// MediaType returns the media type of the layer +func (ul *uncompressedLayer) MediaType() (types.MediaType, error) { + return ul.mediaType, nil +} + +var _ partial.UncompressedLayer = (*uncompressedLayer)(nil) + +// Image returns a pseudo-randomly generated Image. +func Image(byteSize, layers int64, options ...Option) (v1.Image, error) { + adds := make([]mutate.Addendum, 0, 5) + for i := int64(0); i < layers; i++ { + layer, err := Layer(byteSize, types.DockerLayer, options...) + if err != nil { + return nil, err + } + adds = append(adds, mutate.Addendum{ + Layer: layer, + History: v1.History{ + Author: "random.Image", + Comment: fmt.Sprintf("this is a random history %d of %d", i, layers), + CreatedBy: "random", + }, + }) + } + + return mutate.Append(empty.Image, adds...) +} + +// Layer returns a layer with pseudo-randomly generated content. +func Layer(byteSize int64, mt types.MediaType, options ...Option) (v1.Layer, error) { + o := getOptions(options) + rng := rand.New(o.source) //nolint:gosec + + fileName := fmt.Sprintf("random_file_%d.txt", rng.Int()) + + // Hash the contents as we write it out to the buffer. + var b bytes.Buffer + hasher := crypto.SHA256.New() + mw := io.MultiWriter(&b, hasher) + + // Write a single file with a random name and random contents. + tw := tar.NewWriter(mw) + if err := tw.WriteHeader(&tar.Header{ + Name: fileName, + Size: byteSize, + Typeflag: tar.TypeReg, + }); err != nil { + return nil, err + } + if _, err := io.CopyN(tw, rng, byteSize); err != nil { + return nil, err + } + if err := tw.Close(); err != nil { + return nil, err + } + + h := v1.Hash{ + Algorithm: "sha256", + Hex: hex.EncodeToString(hasher.Sum(make([]byte, 0, hasher.Size()))), + } + + return partial.UncompressedToLayer(&uncompressedLayer{ + diffID: h, + mediaType: mt, + content: b.Bytes(), + }) +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/v1/random/index.go b/vendor/github.com/google/go-containerregistry/pkg/v1/random/index.go new file mode 100644 index 0000000000..4368bddff3 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/v1/random/index.go @@ -0,0 +1,111 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// 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 random + +import ( + "bytes" + "encoding/json" + "fmt" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +type randomIndex struct { + images map[v1.Hash]v1.Image + manifest *v1.IndexManifest +} + +// Index returns a pseudo-randomly generated ImageIndex with count images, each +// having the given number of layers of size byteSize. +func Index(byteSize, layers, count int64, options ...Option) (v1.ImageIndex, error) { + manifest := v1.IndexManifest{ + SchemaVersion: 2, + MediaType: types.OCIImageIndex, + Manifests: []v1.Descriptor{}, + } + + images := make(map[v1.Hash]v1.Image) + for i := int64(0); i < count; i++ { + img, err := Image(byteSize, layers, options...) + if err != nil { + return nil, err + } + + rawManifest, err := img.RawManifest() + if err != nil { + return nil, err + } + digest, size, err := v1.SHA256(bytes.NewReader(rawManifest)) + if err != nil { + return nil, err + } + mediaType, err := img.MediaType() + if err != nil { + return nil, err + } + + manifest.Manifests = append(manifest.Manifests, v1.Descriptor{ + Digest: digest, + Size: size, + MediaType: mediaType, + }) + + images[digest] = img + } + + return &randomIndex{ + images: images, + manifest: &manifest, + }, nil +} + +func (i *randomIndex) MediaType() (types.MediaType, error) { + return i.manifest.MediaType, nil +} + +func (i *randomIndex) Digest() (v1.Hash, error) { + return partial.Digest(i) +} + +func (i *randomIndex) Size() (int64, error) { + return partial.Size(i) +} + +func (i *randomIndex) IndexManifest() (*v1.IndexManifest, error) { + return i.manifest, nil +} + +func (i *randomIndex) RawManifest() ([]byte, error) { + m, err := i.IndexManifest() + if err != nil { + return nil, err + } + return json.Marshal(m) +} + +func (i *randomIndex) Image(h v1.Hash) (v1.Image, error) { + if img, ok := i.images[h]; ok { + return img, nil + } + + return nil, fmt.Errorf("image not found: %v", h) +} + +func (i *randomIndex) ImageIndex(h v1.Hash) (v1.ImageIndex, error) { + // This is a single level index (for now?). + return nil, fmt.Errorf("image not found: %v", h) +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/v1/random/options.go b/vendor/github.com/google/go-containerregistry/pkg/v1/random/options.go new file mode 100644 index 0000000000..af1d2f9695 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/v1/random/options.go @@ -0,0 +1,60 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// 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 random + +import "math/rand" + +// Option is an optional parameter to the random functions +type Option func(opts *options) + +type options struct { + source rand.Source + + // TODO opens the door to add this in the future + // algorithm digest.Algorithm +} + +func getOptions(opts []Option) *options { + // get a random seed + + // TODO in go 1.20 this is fine (it will be random) + seed := rand.Int63() //nolint:gosec + /* + // in prior go versions this needs to come from crypto/rand + var b [8]byte + _, err := crypto_rand.Read(b[:]) + if err != nil { + panic("cryptographically secure random number generator is not working") + } + seed := int64(binary.LittleEndian.Int64(b[:])) + */ + + // defaults + o := &options{ + source: rand.NewSource(seed), + } + + for _, opt := range opts { + opt(o) + } + return o +} + +// WithSource sets the random number generator source +func WithSource(source rand.Source) Option { + return func(opts *options) { + opts.source = source + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index ad50f53486..5b4f5296ab 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1197,6 +1197,7 @@ github.com/google/go-containerregistry/pkg/v1/layout github.com/google/go-containerregistry/pkg/v1/match github.com/google/go-containerregistry/pkg/v1/mutate github.com/google/go-containerregistry/pkg/v1/partial +github.com/google/go-containerregistry/pkg/v1/random github.com/google/go-containerregistry/pkg/v1/remote github.com/google/go-containerregistry/pkg/v1/remote/transport github.com/google/go-containerregistry/pkg/v1/static