Skip to content

Commit ee8dec2

Browse files
committed
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 <abghosh@redhat.com>
1 parent 49d3db7 commit ee8dec2

7 files changed

Lines changed: 385 additions & 23 deletions

File tree

pkg/chains/signing.go

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121

2222
"github.com/hashicorp/go-multierror"
2323
intoto "github.com/in-toto/attestation/go/v1"
24+
cbundle "github.com/sigstore/cosign/v2/pkg/cosign/bundle"
2425
"github.com/tektoncd/chains/pkg/artifacts"
2526
"github.com/tektoncd/chains/pkg/chains/annotations"
2627
"github.com/tektoncd/chains/pkg/chains/formats"
@@ -186,6 +187,32 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject)
186187
}
187188
measureMetrics(ctx, metrics.SignedMessagesCount, o.Recorder)
188189

190+
// Upload to Rekor before storage so the bundle is available for OCI attestation annotations.
191+
var rekorBundle *cbundle.RekorBundle
192+
if shouldUploadTlog(cfg, tektonObj) {
193+
rekorClient, err := getRekor(cfg.Transparency.URL)
194+
if err != nil {
195+
return err
196+
}
197+
198+
entry, err := rekorClient.UploadTlog(ctx, signer, signature, rawPayload, signer.Cert(), string(payloadFormat))
199+
if err != nil {
200+
logger.Warnf("error uploading entry to tlog: %v", err)
201+
o.recordError(ctx, signableType, metrics.TlogError)
202+
merr = multierror.Append(merr, err)
203+
} else {
204+
logger.Infof("Uploaded entry to %s with index %d", cfg.Transparency.URL, *entry.LogIndex)
205+
extraAnnotations[annotations.ChainsTransparencyAnnotation] = fmt.Sprintf("%s/api/v1/log/entries?logIndex=%d", cfg.Transparency.URL, *entry.LogIndex)
206+
rekorBundle = cbundle.EntryToBundle(entry)
207+
if rekorBundle != nil {
208+
logger.Infof("Resolved Rekor bundle for offline verification (logIndex: %d)", rekorBundle.Payload.LogIndex)
209+
} else {
210+
logger.Warn("Rekor entry missing verification data, skipping bundle for offline verification")
211+
}
212+
measureMetrics(ctx, metrics.PayloadUploadedCount, o.Recorder)
213+
}
214+
}
215+
189216
// Now store those!
190217
for _, backend := range sets.List[string](signableType.StorageBackend(cfg)) {
191218
b, ok := o.Backends[backend]
@@ -203,6 +230,7 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject)
203230
Cert: signer.Cert(),
204231
Chain: signer.Chain(),
205232
PayloadFormat: payloadFormat,
233+
RekorBundle: rekorBundle,
206234
}
207235
if err := b.StorePayload(ctx, tektonObj, rawPayload, string(signature), storageOpts); err != nil {
208236
logger.Error(err)
@@ -213,24 +241,6 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject)
213241
}
214242
}
215243

216-
if shouldUploadTlog(cfg, tektonObj) {
217-
rekorClient, err := getRekor(cfg.Transparency.URL)
218-
if err != nil {
219-
return err
220-
}
221-
222-
entry, err := rekorClient.UploadTlog(ctx, signer, signature, rawPayload, signer.Cert(), string(payloadFormat))
223-
if err != nil {
224-
logger.Warnf("error uploading entry to tlog: %v", err)
225-
o.recordError(ctx, signableType, metrics.TlogError)
226-
merr = multierror.Append(merr, err)
227-
} else {
228-
logger.Infof("Uploaded entry to %s with index %d", cfg.Transparency.URL, *entry.LogIndex)
229-
extraAnnotations[annotations.ChainsTransparencyAnnotation] = fmt.Sprintf("%s/api/v1/log/entries?logIndex=%d", cfg.Transparency.URL, *entry.LogIndex)
230-
measureMetrics(ctx, metrics.PayloadUploadedCount, o.Recorder)
231-
}
232-
}
233-
234244
}
235245
if merr.ErrorOrNil() != nil {
236246
if retryErr := annotations.HandleRetry(ctx, tektonObj, o.Pipelineclientset, extraAnnotations); retryErr != nil {

pkg/chains/signing/iface.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ limitations under the License.
1414
package signing
1515

1616
import (
17+
"github.com/sigstore/cosign/v2/pkg/cosign/bundle"
1718
"github.com/sigstore/sigstore/pkg/signature"
1819
)
1920

@@ -41,4 +42,6 @@ type Bundle struct {
4142
Cert []byte
4243
// Cert is an optional PEM encoded x509 certificate chain, if one was used for signing.
4344
Chain []byte
45+
// RekorBundle is an optional Rekor transparency log bundle, populated when transparency is enabled.
46+
RekorBundle *bundle.RekorBundle
4447
}

pkg/chains/signing_test.go

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,152 @@ func TestSigner_Transparency(t *testing.T) {
334334
}
335335
}
336336

337+
func TestSigner_TransparencyRekorBundle(t *testing.T) {
338+
// Verify that when transparency is enabled, the Rekor bundle is passed through to storage opts.
339+
tests := []struct {
340+
name string
341+
cfg *config.Config
342+
getNewObject func(string) objects.TektonObject
343+
}{
344+
{
345+
name: "taskrun with transparency enabled",
346+
cfg: &config.Config{
347+
Artifacts: config.ArtifactConfigs{
348+
TaskRuns: config.Artifact{
349+
Format: "slsa/v1",
350+
StorageBackend: sets.New[string]("mock"),
351+
Signer: "x509",
352+
},
353+
},
354+
Transparency: config.TransparencyConfig{
355+
Enabled: true,
356+
URL: "https://rekor.example.com",
357+
},
358+
},
359+
getNewObject: func(name string) objects.TektonObject {
360+
return objects.NewTaskRunObjectV1(&v1.TaskRun{
361+
ObjectMeta: metav1.ObjectMeta{Name: name},
362+
})
363+
},
364+
},
365+
{
366+
name: "pipelinerun with transparency enabled",
367+
cfg: &config.Config{
368+
Artifacts: config.ArtifactConfigs{
369+
PipelineRuns: config.Artifact{
370+
Format: "slsa/v1",
371+
StorageBackend: sets.New[string]("mock"),
372+
Signer: "x509",
373+
},
374+
},
375+
Transparency: config.TransparencyConfig{
376+
Enabled: true,
377+
URL: "https://rekor.example.com",
378+
},
379+
},
380+
getNewObject: func(name string) objects.TektonObject {
381+
return objects.NewPipelineRunObjectV1(&v1.PipelineRun{
382+
ObjectMeta: metav1.ObjectMeta{Name: name},
383+
})
384+
},
385+
},
386+
}
387+
388+
for _, tt := range tests {
389+
t.Run(tt.name, func(t *testing.T) {
390+
rekor := &mockRekor{}
391+
backend := &mockBackend{backendType: "mock"}
392+
cleanup := setupMocks(rekor)
393+
defer cleanup()
394+
395+
ctx, _ := rtesting.SetupFakeContext(t)
396+
ps := fakepipelineclient.Get(ctx)
397+
ctx = config.ToContext(ctx, tt.cfg.DeepCopy())
398+
399+
os := &ObjectSigner{
400+
Backends: fakeAllBackends([]*mockBackend{backend}),
401+
SecretPath: "./signing/x509/testdata/",
402+
Pipelineclientset: ps,
403+
}
404+
405+
obj := tt.getNewObject("test-rekor-bundle")
406+
tekton.CreateObject(t, ctx, ps, obj)
407+
408+
if err := os.Sign(ctx, obj); err != nil {
409+
t.Fatalf("Signer.Sign() error = %v", err)
410+
}
411+
412+
if len(rekor.entries) != 1 {
413+
t.Fatalf("expected 1 transparency log entry, got %d", len(rekor.entries))
414+
}
415+
416+
if backend.storedOpts.RekorBundle == nil {
417+
t.Fatal("expected RekorBundle to be set in StorageOpts, got nil")
418+
}
419+
420+
if string(backend.storedOpts.RekorBundle.SignedEntryTimestamp) != "signed-entry-timestamp" {
421+
t.Errorf("unexpected SignedEntryTimestamp: %s", string(backend.storedOpts.RekorBundle.SignedEntryTimestamp))
422+
}
423+
424+
if backend.storedOpts.RekorBundle.Payload.IntegratedTime != 1234567890 {
425+
t.Errorf("unexpected IntegratedTime: %d", backend.storedOpts.RekorBundle.Payload.IntegratedTime)
426+
}
427+
428+
if backend.storedOpts.RekorBundle.Payload.LogID != "test-log-id" {
429+
t.Errorf("unexpected LogID: %s", backend.storedOpts.RekorBundle.Payload.LogID)
430+
}
431+
})
432+
}
433+
}
434+
435+
func TestSigner_NoRekorBundleWithoutTransparency(t *testing.T) {
436+
// Verify that RekorBundle is nil when transparency is disabled.
437+
backend := &mockBackend{backendType: "mock"}
438+
rekor := &mockRekor{}
439+
cleanup := setupMocks(rekor)
440+
defer cleanup()
441+
442+
cfg := &config.Config{
443+
Artifacts: config.ArtifactConfigs{
444+
TaskRuns: config.Artifact{
445+
Format: "slsa/v1",
446+
StorageBackend: sets.New[string]("mock"),
447+
Signer: "x509",
448+
},
449+
},
450+
Transparency: config.TransparencyConfig{
451+
Enabled: false,
452+
},
453+
}
454+
455+
ctx, _ := rtesting.SetupFakeContext(t)
456+
ps := fakepipelineclient.Get(ctx)
457+
ctx = config.ToContext(ctx, cfg.DeepCopy())
458+
459+
os := &ObjectSigner{
460+
Backends: fakeAllBackends([]*mockBackend{backend}),
461+
SecretPath: "./signing/x509/testdata/",
462+
Pipelineclientset: ps,
463+
}
464+
465+
obj := objects.NewTaskRunObjectV1(&v1.TaskRun{
466+
ObjectMeta: metav1.ObjectMeta{Name: "test-no-rekor-bundle"},
467+
})
468+
tekton.CreateObject(t, ctx, ps, obj)
469+
470+
if err := os.Sign(ctx, obj); err != nil {
471+
t.Fatalf("Signer.Sign() error = %v", err)
472+
}
473+
474+
if len(rekor.entries) != 0 {
475+
t.Fatalf("expected no transparency log entries, got %d", len(rekor.entries))
476+
}
477+
478+
if backend.storedOpts.RekorBundle != nil {
479+
t.Error("expected RekorBundle to be nil when transparency is disabled")
480+
}
481+
}
482+
337483
func TestSigningObjects(t *testing.T) {
338484
tests := []struct {
339485
name string
@@ -573,13 +719,22 @@ type mockRekor struct {
573719
func (r *mockRekor) UploadTlog(ctx context.Context, signer signing.Signer, signature, rawPayload []byte, cert, payloadFormat string) (*models.LogEntryAnon, error) {
574720
r.entries = append(r.entries, signature)
575721
index := int64(len(r.entries) - 1)
722+
logID := "test-log-id"
723+
integratedTime := int64(1234567890)
576724
return &models.LogEntryAnon{
577-
LogIndex: &index,
725+
LogIndex: &index,
726+
LogID: &logID,
727+
IntegratedTime: &integratedTime,
728+
Body: "dGVzdC1ib2R5", // base64("test-body")
729+
Verification: &models.LogEntryAnonVerification{
730+
SignedEntryTimestamp: []byte("signed-entry-timestamp"),
731+
},
578732
}, nil
579733
}
580734

581735
type mockBackend struct {
582736
storedPayload []byte
737+
storedOpts config.StorageOpts
583738
shouldErr bool
584739
backendType string
585740
}
@@ -590,6 +745,7 @@ func (b *mockBackend) StorePayload(ctx context.Context, _ objects.TektonObject,
590745
return errors.New("mock error storing")
591746
}
592747
b.storedPayload = rawPayload
748+
b.storedOpts = opts
593749
return nil
594750
}
595751

pkg/chains/storage/oci/attestation.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[nam
7373
if req.Bundle.Cert != nil {
7474
attOpts = append(attOpts, static.WithCertChain(req.Bundle.Cert, req.Bundle.Chain))
7575
}
76+
if req.Bundle.RekorBundle != nil {
77+
attOpts = append(attOpts, static.WithBundle(req.Bundle.RekorBundle))
78+
}
7679
att, err := static.NewAttestation(req.Bundle.Signature, attOpts...)
7780
if err != nil {
7881
return nil, err

0 commit comments

Comments
 (0)