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 }