Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/sigstore.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<YOUR 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
Expand Down
46 changes: 28 additions & 18 deletions pkg/chains/signing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Comment on lines +194 to +196
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be a hard return. What if getRekor() fails (maybe due to bad rekor URL config or anything) and this might prevent all attestation storage. WDYT?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this hard return is pre-existing, IMO if getRekor fails, it's a config error, should not be treated as a transient failure


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]
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion pkg/chains/signing/iface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
}
158 changes: 157 additions & 1 deletion pkg/chains/signing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions pkg/chains/storage/oci/attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading