From 1b32257fa21c3f821a465441ec2b2a69fe0ade22 Mon Sep 17 00:00:00 2001 From: ab-ghosh Date: Fri, 3 Apr 2026 14:25:05 +0530 Subject: [PATCH] feat: embed Rekor bundle in OCI attestation layer annotations When transparency.enabled=true, the Rekor bundle (SignedEntryTimestamp, integratedTime, logIndex, logID) is now embedded as a dev.sigstore.cosign/bundle annotation on the OCI attestation layer. This enables offline signature verification of attestations signed by Tekton Chains using keyless (Fulcio) signing. Previously, the Rekor upload happened after storage, so the bundle was not available when writing attestations to OCI registries. The upload is now performed before storage so the bundle can be passed through to the OCI backend. Fixes #1598 Signed-off-by: ab-ghosh --- docs/sigstore.md | 6 + pkg/chains/signing.go | 46 ++++-- pkg/chains/signing/iface.go | 5 +- pkg/chains/signing_test.go | 158 +++++++++++++++++- pkg/chains/storage/oci/attestation.go | 3 + pkg/chains/storage/oci/attestation_test.go | 182 +++++++++++++++++++++ pkg/chains/storage/oci/legacy.go | 9 +- pkg/config/options.go | 7 + 8 files changed, 392 insertions(+), 24 deletions(-) diff --git a/docs/sigstore.md b/docs/sigstore.md index a57e0ca4ba..3435c938eb 100644 --- a/docs/sigstore.md +++ b/docs/sigstore.md @@ -35,6 +35,12 @@ Right now, Chains default to storing entries in the public Rekor instance kubectl patch configmap chains-config -n tekton-chains -p='{"data":{"transparency.url": ""}}' ``` +### Offline Verification with Rekor Bundle + +When transparency is enabled and the OCI storage backend is used, Chains automatically embeds the Rekor bundle (`dev.sigstore.cosign/bundle`) in the OCI attestation layer annotations. This enables offline verification of transparency log entries without querying the Rekor server. + +No additional configuration is required — the bundle is embedded automatically when `transparency.enabled` is set to `true` and attestations are stored in an OCI registry. + ## Keyless Signing Mode Chains also supports a keyless signing mode with diff --git a/pkg/chains/signing.go b/pkg/chains/signing.go index 6676ef5ec5..7afd9d4ae1 100644 --- a/pkg/chains/signing.go +++ b/pkg/chains/signing.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/go-multierror" intoto "github.com/in-toto/attestation/go/v1" + cbundle "github.com/sigstore/cosign/v2/pkg/cosign/bundle" "github.com/tektoncd/chains/pkg/artifacts" "github.com/tektoncd/chains/pkg/chains/annotations" "github.com/tektoncd/chains/pkg/chains/formats" @@ -186,6 +187,32 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) } measureMetrics(ctx, metrics.SignedMessagesCount, o.Recorder) + // Upload to Rekor before storage so the bundle is available for OCI attestation annotations. + var rekorBundle *cbundle.RekorBundle + if shouldUploadTlog(cfg, tektonObj) { + rekorClient, err := getRekor(cfg.Transparency.URL) + if err != nil { + return err + } + + entry, err := rekorClient.UploadTlog(ctx, signer, signature, rawPayload, signer.Cert(), string(payloadFormat)) + if err != nil { + logger.Warnf("error uploading entry to tlog: %v", err) + o.recordError(ctx, signableType, metrics.TlogError) + merr = multierror.Append(merr, err) + } else { + logger.Infof("Uploaded entry to %s with index %d", cfg.Transparency.URL, *entry.LogIndex) + extraAnnotations[annotations.ChainsTransparencyAnnotation] = fmt.Sprintf("%s/api/v1/log/entries?logIndex=%d", cfg.Transparency.URL, *entry.LogIndex) + rekorBundle = cbundle.EntryToBundle(entry) + if rekorBundle != nil { + logger.Infof("Resolved Rekor bundle for offline verification (logIndex: %d)", rekorBundle.Payload.LogIndex) + } else { + logger.Warn("Rekor entry missing verification data, skipping bundle for offline verification") + } + measureMetrics(ctx, metrics.PayloadUploadedCount, o.Recorder) + } + } + // Now store those! for _, backend := range sets.List[string](signableType.StorageBackend(cfg)) { b, ok := o.Backends[backend] @@ -203,6 +230,7 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) Cert: signer.Cert(), Chain: signer.Chain(), PayloadFormat: payloadFormat, + RekorBundle: rekorBundle, } if err := b.StorePayload(ctx, tektonObj, rawPayload, string(signature), storageOpts); err != nil { logger.Error(err) @@ -213,24 +241,6 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) } } - if shouldUploadTlog(cfg, tektonObj) { - rekorClient, err := getRekor(cfg.Transparency.URL) - if err != nil { - return err - } - - entry, err := rekorClient.UploadTlog(ctx, signer, signature, rawPayload, signer.Cert(), string(payloadFormat)) - if err != nil { - logger.Warnf("error uploading entry to tlog: %v", err) - o.recordError(ctx, signableType, metrics.TlogError) - merr = multierror.Append(merr, err) - } else { - logger.Infof("Uploaded entry to %s with index %d", cfg.Transparency.URL, *entry.LogIndex) - extraAnnotations[annotations.ChainsTransparencyAnnotation] = fmt.Sprintf("%s/api/v1/log/entries?logIndex=%d", cfg.Transparency.URL, *entry.LogIndex) - measureMetrics(ctx, metrics.PayloadUploadedCount, o.Recorder) - } - } - } if merr.ErrorOrNil() != nil { if retryErr := annotations.HandleRetry(ctx, tektonObj, o.Pipelineclientset, extraAnnotations); retryErr != nil { diff --git a/pkg/chains/signing/iface.go b/pkg/chains/signing/iface.go index b64fcbabb3..fdd2178ed3 100644 --- a/pkg/chains/signing/iface.go +++ b/pkg/chains/signing/iface.go @@ -14,6 +14,7 @@ limitations under the License. package signing import ( + "github.com/sigstore/cosign/v2/pkg/cosign/bundle" "github.com/sigstore/sigstore/pkg/signature" ) @@ -39,6 +40,8 @@ type Bundle struct { Signature []byte // Cert is an optional PEM encoded x509 certificate, if one was used for signing. Cert []byte - // Cert is an optional PEM encoded x509 certificate chain, if one was used for signing. + // Chain is an optional PEM encoded x509 certificate chain, if one was used for signing. Chain []byte + // RekorBundle is an optional Rekor transparency log bundle, populated when transparency is enabled. + RekorBundle *bundle.RekorBundle } diff --git a/pkg/chains/signing_test.go b/pkg/chains/signing_test.go index 5add59b728..eb6462f591 100644 --- a/pkg/chains/signing_test.go +++ b/pkg/chains/signing_test.go @@ -334,6 +334,152 @@ func TestSigner_Transparency(t *testing.T) { } } +func TestSigner_TransparencyRekorBundle(t *testing.T) { + // Verify that when transparency is enabled, the Rekor bundle is passed through to storage opts. + tests := []struct { + name string + cfg *config.Config + getNewObject func(string) objects.TektonObject + }{ + { + name: "taskrun with transparency enabled", + cfg: &config.Config{ + Artifacts: config.ArtifactConfigs{ + TaskRuns: config.Artifact{ + Format: "slsa/v1", + StorageBackend: sets.New[string]("mock"), + Signer: "x509", + }, + }, + Transparency: config.TransparencyConfig{ + Enabled: true, + URL: "https://rekor.example.com", + }, + }, + getNewObject: func(name string) objects.TektonObject { + return objects.NewTaskRunObjectV1(&v1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + }) + }, + }, + { + name: "pipelinerun with transparency enabled", + cfg: &config.Config{ + Artifacts: config.ArtifactConfigs{ + PipelineRuns: config.Artifact{ + Format: "slsa/v1", + StorageBackend: sets.New[string]("mock"), + Signer: "x509", + }, + }, + Transparency: config.TransparencyConfig{ + Enabled: true, + URL: "https://rekor.example.com", + }, + }, + getNewObject: func(name string) objects.TektonObject { + return objects.NewPipelineRunObjectV1(&v1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + }) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rekor := &mockRekor{} + backend := &mockBackend{backendType: "mock"} + cleanup := setupMocks(rekor) + defer cleanup() + + ctx, _ := rtesting.SetupFakeContext(t) + ps := fakepipelineclient.Get(ctx) + ctx = config.ToContext(ctx, tt.cfg.DeepCopy()) + + os := &ObjectSigner{ + Backends: fakeAllBackends([]*mockBackend{backend}), + SecretPath: "./signing/x509/testdata/", + Pipelineclientset: ps, + } + + obj := tt.getNewObject("test-rekor-bundle") + tekton.CreateObject(t, ctx, ps, obj) + + if err := os.Sign(ctx, obj); err != nil { + t.Fatalf("Signer.Sign() error = %v", err) + } + + if len(rekor.entries) != 1 { + t.Fatalf("expected 1 transparency log entry, got %d", len(rekor.entries)) + } + + if backend.storedOpts.RekorBundle == nil { + t.Fatal("expected RekorBundle to be set in StorageOpts, got nil") + } + + if string(backend.storedOpts.RekorBundle.SignedEntryTimestamp) != "signed-entry-timestamp" { + t.Errorf("unexpected SignedEntryTimestamp: %s", string(backend.storedOpts.RekorBundle.SignedEntryTimestamp)) + } + + if backend.storedOpts.RekorBundle.Payload.IntegratedTime != 1234567890 { + t.Errorf("unexpected IntegratedTime: %d", backend.storedOpts.RekorBundle.Payload.IntegratedTime) + } + + if backend.storedOpts.RekorBundle.Payload.LogID != "test-log-id" { + t.Errorf("unexpected LogID: %s", backend.storedOpts.RekorBundle.Payload.LogID) + } + }) + } +} + +func TestSigner_NoRekorBundleWithoutTransparency(t *testing.T) { + // Verify that RekorBundle is nil when transparency is disabled. + backend := &mockBackend{backendType: "mock"} + rekor := &mockRekor{} + cleanup := setupMocks(rekor) + defer cleanup() + + cfg := &config.Config{ + Artifacts: config.ArtifactConfigs{ + TaskRuns: config.Artifact{ + Format: "slsa/v1", + StorageBackend: sets.New[string]("mock"), + Signer: "x509", + }, + }, + Transparency: config.TransparencyConfig{ + Enabled: false, + }, + } + + ctx, _ := rtesting.SetupFakeContext(t) + ps := fakepipelineclient.Get(ctx) + ctx = config.ToContext(ctx, cfg.DeepCopy()) + + os := &ObjectSigner{ + Backends: fakeAllBackends([]*mockBackend{backend}), + SecretPath: "./signing/x509/testdata/", + Pipelineclientset: ps, + } + + obj := objects.NewTaskRunObjectV1(&v1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{Name: "test-no-rekor-bundle"}, + }) + tekton.CreateObject(t, ctx, ps, obj) + + if err := os.Sign(ctx, obj); err != nil { + t.Fatalf("Signer.Sign() error = %v", err) + } + + if len(rekor.entries) != 0 { + t.Fatalf("expected no transparency log entries, got %d", len(rekor.entries)) + } + + if backend.storedOpts.RekorBundle != nil { + t.Error("expected RekorBundle to be nil when transparency is disabled") + } +} + func TestSigningObjects(t *testing.T) { tests := []struct { name string @@ -573,13 +719,22 @@ type mockRekor struct { func (r *mockRekor) UploadTlog(ctx context.Context, signer signing.Signer, signature, rawPayload []byte, cert, payloadFormat string) (*models.LogEntryAnon, error) { r.entries = append(r.entries, signature) index := int64(len(r.entries) - 1) + logID := "test-log-id" + integratedTime := int64(1234567890) return &models.LogEntryAnon{ - LogIndex: &index, + LogIndex: &index, + LogID: &logID, + IntegratedTime: &integratedTime, + Body: "dGVzdC1ib2R5", // base64("test-body") + Verification: &models.LogEntryAnonVerification{ + SignedEntryTimestamp: []byte("signed-entry-timestamp"), + }, }, nil } type mockBackend struct { storedPayload []byte + storedOpts config.StorageOpts shouldErr bool backendType string } @@ -590,6 +745,7 @@ func (b *mockBackend) StorePayload(ctx context.Context, _ objects.TektonObject, return errors.New("mock error storing") } b.storedPayload = rawPayload + b.storedOpts = opts return nil } diff --git a/pkg/chains/storage/oci/attestation.go b/pkg/chains/storage/oci/attestation.go index dbaeec25d6..3dd8a9ff2b 100644 --- a/pkg/chains/storage/oci/attestation.go +++ b/pkg/chains/storage/oci/attestation.go @@ -73,6 +73,9 @@ func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[nam if req.Bundle.Cert != nil { attOpts = append(attOpts, static.WithCertChain(req.Bundle.Cert, req.Bundle.Chain)) } + if req.Bundle.RekorBundle != nil { + attOpts = append(attOpts, static.WithBundle(req.Bundle.RekorBundle)) + } att, err := static.NewAttestation(req.Bundle.Signature, attOpts...) if err != nil { return nil, err diff --git a/pkg/chains/storage/oci/attestation_test.go b/pkg/chains/storage/oci/attestation_test.go index 659b787ecd..8c57cb1ad3 100644 --- a/pkg/chains/storage/oci/attestation_test.go +++ b/pkg/chains/storage/oci/attestation_test.go @@ -15,6 +15,7 @@ package oci import ( + "encoding/json" "fmt" "net/http/httptest" "strings" @@ -26,6 +27,9 @@ import ( "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/sigstore/cosign/v2/pkg/cosign/bundle" + ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" + "github.com/sigstore/cosign/v2/pkg/oci/static" "github.com/tektoncd/chains/pkg/chains/signing" "github.com/tektoncd/chains/pkg/chains/storage/api" logtesting "knative.dev/pkg/logging/testing" @@ -109,3 +113,181 @@ func TestAttestationStorer_Store(t *testing.T) { }) } } + +func TestAttestationStorer_StoreWithRekorBundle(t *testing.T) { + s := httptest.NewServer(registry.New()) + defer s.Close() + registryName := strings.TrimPrefix(s.URL, "http://") + + // Push a random image to the registry. + 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) + } + + rekorBundle := &bundle.RekorBundle{ + SignedEntryTimestamp: []byte("test-signed-entry-timestamp"), + Payload: bundle.RekorPayload{ + Body: "dGVzdC1ib2R5", + IntegratedTime: 1234567890, + LogIndex: 0, + LogID: "test-log-id", + }, + } + + 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{ + Cert: []byte("test-cert"), + Chain: []byte("test-chain"), + RekorBundle: rekorBundle, + }, + }) + if err != nil { + t.Fatalf("error during Store(): %s", err) + } + + // Verify the attestation was stored with the Rekor bundle annotation. + se, err := ociremote.SignedEntity(ref) + if err != nil { + t.Fatalf("failed to get signed entity: %v", err) + } + + atts, err := se.Attestations() + if err != nil { + t.Fatalf("failed to get attestations: %v", err) + } + + sigs, err := atts.Get() + if err != nil { + t.Fatalf("failed to get signatures: %v", err) + } + + if len(sigs) != 1 { + t.Fatalf("expected 1 attestation, got %d", len(sigs)) + } + + annotations, err := sigs[0].Annotations() + if err != nil { + t.Fatalf("failed to get annotations: %v", err) + } + + bundleAnnotation, ok := annotations[static.BundleAnnotationKey] + if !ok { + t.Fatal("expected dev.sigstore.cosign/bundle annotation to be present") + } + + // Verify the bundle content can be deserialized back. + var gotBundle bundle.RekorBundle + if err := json.Unmarshal([]byte(bundleAnnotation), &gotBundle); err != nil { + t.Fatalf("failed to unmarshal bundle annotation: %v", err) + } + + if string(gotBundle.SignedEntryTimestamp) != "test-signed-entry-timestamp" { + t.Errorf("unexpected SignedEntryTimestamp: got %s", string(gotBundle.SignedEntryTimestamp)) + } + + if gotBundle.Payload.IntegratedTime != 1234567890 { + t.Errorf("unexpected IntegratedTime: got %d, want 1234567890", gotBundle.Payload.IntegratedTime) + } + + if gotBundle.Payload.LogID != "test-log-id" { + t.Errorf("unexpected LogID: got %s, want test-log-id", gotBundle.Payload.LogID) + } + + // Also verify cert and chain annotations are present. + if _, ok := annotations[static.CertificateAnnotationKey]; !ok { + t.Error("expected dev.sigstore.cosign/certificate annotation to be present") + } + + if _, ok := annotations[static.ChainAnnotationKey]; !ok { + t.Error("expected dev.sigstore.cosign/chain annotation to be present") + } +} + +func TestAttestationStorer_StoreWithoutRekorBundle(t *testing.T) { + s := httptest.NewServer(registry.New()) + defer s.Close() + registryName := strings.TrimPrefix(s.URL, "http://") + + 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) + } + + 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{ + Cert: []byte("test-cert"), + Chain: []byte("test-chain"), + }, + }) + if err != nil { + t.Fatalf("error during Store(): %s", err) + } + + // Verify no bundle annotation is present. + se, err := ociremote.SignedEntity(ref) + if err != nil { + t.Fatalf("failed to get signed entity: %v", err) + } + + atts, err := se.Attestations() + if err != nil { + t.Fatalf("failed to get attestations: %v", err) + } + + sigs, err := atts.Get() + if err != nil { + t.Fatalf("failed to get signatures: %v", err) + } + + if len(sigs) != 1 { + t.Fatalf("expected 1 attestation, got %d", len(sigs)) + } + + annotations, err := sigs[0].Annotations() + if err != nil { + t.Fatalf("failed to get annotations: %v", err) + } + + if _, ok := annotations[static.BundleAnnotationKey]; ok { + t.Error("expected dev.sigstore.cosign/bundle annotation to NOT be present when RekorBundle is nil") + } +} diff --git a/pkg/chains/storage/oci/legacy.go b/pkg/chains/storage/oci/legacy.go index 6004ae6da7..4614bed578 100644 --- a/pkg/chains/storage/oci/legacy.go +++ b/pkg/chains/storage/oci/legacy.go @@ -204,10 +204,11 @@ func (b *Backend) uploadAttestation(ctx context.Context, attestation *intoto.Sta Artifact: ref, Payload: attestation, Bundle: &signing.Bundle{ - Content: nil, - Signature: []byte(signature), - Cert: []byte(storageOpts.Cert), - Chain: []byte(storageOpts.Chain), + Content: nil, + Signature: []byte(signature), + Cert: []byte(storageOpts.Cert), + Chain: []byte(storageOpts.Chain), + RekorBundle: storageOpts.RekorBundle, }, }); err != nil { return err diff --git a/pkg/config/options.go b/pkg/config/options.go index 8460db6f9a..361a60fc4f 100644 --- a/pkg/config/options.go +++ b/pkg/config/options.go @@ -16,6 +16,10 @@ limitations under the License. package config +import ( + "github.com/sigstore/cosign/v2/pkg/cosign/bundle" +) + // PayloadType specifies the format to store payload in. // - For OCI artifact, Chains only supports `simplesigning` format. https://www.redhat.com/en/blog/container-image-signing // - For Tekton artifacts, Chains supports `tekton` and `in-toto` format. https://slsa.dev/provenance/v0.2 @@ -47,4 +51,7 @@ type StorageOpts struct { // PayloadFormat is the format to store payload in. PayloadFormat PayloadType + + // RekorBundle is an optional Rekor transparency log bundle for offline verification. + RekorBundle *bundle.RekorBundle }