diff --git a/docs/config.md b/docs/config.md
index b985399182..5bfe5c4608 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -66,6 +66,7 @@ Supported keys include:
|:-------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `storage.gcs.bucket` | The GCS bucket for storage | | |
| `storage.oci.repository` | The OCI repo to store OCI signatures and attestation in | If left undefined _and_ one of `artifacts.{oci,taskrun}.storage` includes `oci` storage, attestations will be stored alongside the stored OCI artifact itself. ([example on GCP](../images/attestations-in-artifact-registry.png)) Defining this value results in the OCI bundle stored in the designated location _instead of_ alongside the image. See [cosign documentation](https://github.com/sigstore/cosign#specifying-registry) for additional information. | |
+| `storage.oci.format` | Storage format for OCI signatures and attestations. Controls both the storage mechanism and serialization format used for storing cryptographic artifacts. | `legacy` - Tag-based storage with DSSE format for full backward compatibility
`referrers-api` - OCI 1.1 referrers API with DSSE format for reduced tag proliferation
`protobuf-bundle` - OCI 1.1 referrers API with protobuf bundle format for experimental cosign features | `legacy` |
| `storage.docdb.url` | The go-cloud URI reference to a docstore collection | `firestore://projects/[PROJECT]/databases/(default)/documents/[COLLECTION]?name_field=name` | |
| `storage.docdb.mongo-server-url` (optional) | The value of MONGO_SERVER_URL env var with the MongoDB connection URI | Example: `mongodb://[USER]:[PASSWORD]@[HOST]:[PORT]/[DATABASE]` | |
| `storage.docdb.mongo-server-url-dir` (optional) | The path of the directory that contains the file named MONGO_SERVER_URL that stores the value of MONGO_SERVER_URL env var | If the file `/mnt/mongo-creds-secret/MONGO_SERVER_URL` has the value of MONGO_SERVER_URL, then set `storage.docdb.mongo-server-url-dir: /mnt/mongo-creds-secret` | |
@@ -75,6 +76,77 @@ Supported keys include:
| `storage.grafeas.notehint` (optional) | This field is used to set the [human_readable_name](https://github.com/grafeas/grafeas/blob/cd23d4dc1bef740d6d6d90d5007db5c9a2431c41/proto/v1/attestation.proto#L49) field in the Grafeas ATTESTATION note. If it is not provided, the default `This attestation note was generated by Tekton Chains` will be used. | | |
| `storage.archivista.url` | The URL endpoint for the Archivista service. | A valid HTTPS URL pointing to your Archivista instance (e.g. `https://archivista.testifysec.io`). | None |
+#### OCI Storage Formats
+
+The `storage.oci.format` configuration supports three distinct storage formats, each designed for different use cases:
+
+##### Legacy Format (`legacy`)
+- **Storage Mechanism**: Tag-based storage using `:sha256-.sig` and `:sha256-.att` tags
+- **Serialization Format**: DSSE (Dead Simple Signing Envelope) format
+- **Compatibility**: Full backward compatibility with existing tooling and deployments
+- **Registry Impact**: Creates additional tags in the registry for each signature and attestation
+- **Use Case**: Production deployments requiring maximum compatibility
+
+**Example Configuration:**
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: chains-config
+ namespace: tekton-chains
+data:
+ storage.oci.format: "legacy"
+```
+
+##### Referrers API Format (`referrers-api`)
+- **Storage Mechanism**: OCI 1.1 Referrers API for artifact relationships
+- **Serialization Format**: DSSE (Dead Simple Signing Envelope) format - same as legacy
+- **Compatibility**: Compatible with OCI 1.1 registries and DSSE-aware tooling
+- **Registry Impact**: Uses referrers API, significantly reducing tag proliferation
+- **Use Case**: Modern OCI 1.1 registries where tag reduction is desired while maintaining DSSE compatibility
+
+**Example Configuration:**
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: chains-config
+ namespace: tekton-chains
+data:
+ storage.oci.format: "referrers-api"
+```
+
+##### Protobuf Bundle Format (`protobuf-bundle`)
+- **Storage Mechanism**: OCI 1.1 Referrers API for artifact relationships
+- **Serialization Format**: Protobuf bundle format for experimental cosign features
+- **Compatibility**: Requires cosign experimental features and latest tooling
+- **Registry Impact**: Uses referrers API with experimental serialization
+- **Use Case**: Testing new cosign features and experimental workflows
+
+**Example Configuration:**
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: chains-config
+ namespace: tekton-chains
+data:
+ storage.oci.format: "protobuf-bundle"
+```
+
+**Important Notes:**
+- The default format is `legacy` to ensure backward compatibility
+- Referrers API formats (`referrers-api` and `protobuf-bundle`) require OCI 1.1 compatible registries
+- Format changes affect both signatures and attestations
+- The `protobuf-bundle` format is experimental and may change in future releases
+
+**Migration from Deprecated Configuration:**
+The deprecated `storage.oci.referrers-api` boolean configuration is automatically migrated:
+- `storage.oci.referrers-api: false` → `storage.oci.format: "legacy"`
+- `storage.oci.referrers-api: true` → `storage.oci.format: "protobuf-bundle"`
+
+See the [OCI Format Migration Guide](oci-format-migration.md) for detailed migration instructions.
+
#### docstore
You can read about the go-cloud docstore URI format [here](https://gocloud.dev/howto/docstore/). Tekton Chains supports the following docstore services:
@@ -189,4 +261,4 @@ To restrict the controller to the dev and test namespaces, you would start the c
```shell
--namespace=dev,test
```
-In this example, the controller will only monitor resources (pipelinesruns and taskruns) within the dev and test namespaces.
+In this example, the controller will only monitor resources (pipelinesruns and taskruns) within the dev and test namespaces.
\ No newline at end of file
diff --git a/pkg/chains/signing.go b/pkg/chains/signing.go
index 170ecf4b85..a948e44540 100644
--- a/pkg/chains/signing.go
+++ b/pkg/chains/signing.go
@@ -196,11 +196,21 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject)
continue
}
+ // Extract public key from signer for storage backends that need it
+ pubKey, err := signer.PublicKey()
+ if err != nil {
+ logger.Errorf("Failed to extract public key from signer: %v", err)
+ o.recordError(ctx, signableType.Type(), metrics.SigningError)
+ merr = multierror.Append(merr, err)
+ continue
+ }
+
storageOpts := config.StorageOpts{
ShortKey: signableType.ShortKey(obj),
FullKey: signableType.FullKey(obj),
Cert: signer.Cert(),
Chain: signer.Chain(),
+ PublicKey: pubKey,
PayloadFormat: payloadFormat,
}
if err := b.StorePayload(ctx, tektonObj, rawPayload, string(signature), storageOpts); err != nil {
diff --git a/pkg/chains/signing/iface.go b/pkg/chains/signing/iface.go
index b64fcbabb3..3a871828b7 100644
--- a/pkg/chains/signing/iface.go
+++ b/pkg/chains/signing/iface.go
@@ -14,6 +14,8 @@ limitations under the License.
package signing
import (
+ "crypto"
+
"github.com/sigstore/sigstore/pkg/signature"
)
@@ -41,4 +43,6 @@ type Bundle struct {
Cert []byte
// Cert is an optional PEM encoded x509 certificate chain, if one was used for signing.
Chain []byte
+ // PublicKey is the public key from the signer, available for storage backends that need it.
+ PublicKey crypto.PublicKey
}
diff --git a/pkg/chains/signing_test.go b/pkg/chains/signing_test.go
index 687953afe2..e026f1d936 100644
--- a/pkg/chains/signing_test.go
+++ b/pkg/chains/signing_test.go
@@ -16,15 +16,20 @@ package chains
import (
"bytes"
"context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
"encoding/json"
"errors"
"fmt"
+ "io"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
intoto "github.com/in-toto/attestation/go/v1"
"github.com/sigstore/rekor/pkg/generated/models"
+ "github.com/sigstore/sigstore/pkg/signature"
"github.com/tektoncd/chains/pkg/chains/objects"
"github.com/tektoncd/chains/pkg/chains/signing"
"github.com/tektoncd/chains/pkg/chains/storage"
@@ -543,3 +548,201 @@ func (b *mockBackend) RetrievePayloads(ctx context.Context, _ objects.TektonObje
func (b *mockBackend) RetrieveSignatures(ctx context.Context, _ objects.TektonObject, opts config.StorageOpts) (map[string][]string, error) {
return nil, fmt.Errorf("not implemented")
}
+
+// Additional tests for protobuf bundle fix
+
+func TestBundle_PublicKey(t *testing.T) {
+ // Test Bundle struct with PublicKey field
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed to generate RSA key: %v", err)
+ }
+ publicKey := &privateKey.PublicKey
+
+ tests := []struct {
+ name string
+ bundle signing.Bundle
+ want crypto.PublicKey
+ }{
+ {
+ name: "Bundle with PublicKey set",
+ bundle: signing.Bundle{
+ Content: []byte("test-content"),
+ Signature: []byte("test-signature"),
+ Cert: []byte("test-cert"),
+ Chain: []byte("test-chain"),
+ PublicKey: publicKey,
+ },
+ want: publicKey,
+ },
+ {
+ name: "Bundle with nil PublicKey",
+ bundle: signing.Bundle{
+ Content: []byte("test-content"),
+ Signature: []byte("test-signature"),
+ Cert: []byte("test-cert"),
+ Chain: []byte("test-chain"),
+ PublicKey: nil,
+ },
+ want: nil,
+ },
+ {
+ name: "Empty Bundle",
+ bundle: signing.Bundle{},
+ want: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.bundle.PublicKey != tt.want {
+ t.Errorf("Bundle.PublicKey = %v, want %v", tt.bundle.PublicKey, tt.want)
+ }
+
+ // Test that PublicKey field can be set and retrieved
+ if tt.want != nil {
+ // Verify the key is the expected type
+ if _, ok := tt.bundle.PublicKey.(*rsa.PublicKey); !ok {
+ t.Errorf("Expected PublicKey to be *rsa.PublicKey, got %T", tt.bundle.PublicKey)
+ }
+
+ // Verify the key matches our test key
+ rsaKey, ok := tt.bundle.PublicKey.(*rsa.PublicKey)
+ if !ok {
+ t.Fatalf("PublicKey is not *rsa.PublicKey")
+ }
+ expectedRSAKey, ok := tt.want.(*rsa.PublicKey)
+ if !ok {
+ t.Fatalf("Expected key is not *rsa.PublicKey")
+ }
+
+ if rsaKey.N.Cmp(expectedRSAKey.N) != 0 || rsaKey.E != expectedRSAKey.E {
+ t.Errorf("PublicKey does not match expected key")
+ }
+ }
+ })
+ }
+}
+
+// Mock signer for testing public key extraction
+type mockSignerWithPublicKey struct {
+ publicKey crypto.PublicKey
+ cert string
+ chain string
+ shouldErr bool
+ errOnPubKey bool
+}
+
+func (m *mockSignerWithPublicKey) SignMessage(msg io.Reader, opts ...signature.SignOption) ([]byte, error) {
+ if m.shouldErr {
+ return nil, errors.New("mock signing error")
+ }
+ return []byte("mock-signature"), nil
+}
+
+func (m *mockSignerWithPublicKey) VerifySignature(signature, message io.Reader, opts ...signature.VerifyOption) error {
+ return nil
+}
+
+func (m *mockSignerWithPublicKey) PublicKey(opts ...signature.PublicKeyOption) (crypto.PublicKey, error) {
+ if m.errOnPubKey {
+ return nil, errors.New("mock public key error")
+ }
+ return m.publicKey, nil
+}
+
+func (m *mockSignerWithPublicKey) Type() string {
+ return "mock"
+}
+
+func (m *mockSignerWithPublicKey) Cert() string {
+ return m.cert
+}
+
+func (m *mockSignerWithPublicKey) Chain() string {
+ return m.chain
+}
+
+func TestSigner_PublicKeyExtraction(t *testing.T) {
+ // Test that the signing loop includes public key extraction logic
+ // This test verifies the behavior exists but doesn't require complex mocking
+
+ // Test that we can create a StorageOpts with public key
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed to generate RSA key: %v", err)
+ }
+ publicKey := &privateKey.PublicKey
+
+ // Test that StorageOpts can hold public key correctly
+ opts := config.StorageOpts{
+ FullKey: "test-full",
+ ShortKey: "test-short",
+ Cert: "test-cert",
+ Chain: "test-chain",
+ PublicKey: publicKey,
+ PayloadFormat: "in-toto",
+ }
+
+ if opts.PublicKey != publicKey {
+ t.Error("StorageOpts should preserve public key")
+ }
+
+ // Test that mockBackendWithCapture can capture StorageOpts correctly
+ var capturedOpts config.StorageOpts
+ backend := &mockBackendWithCapture{
+ backendType: "test",
+ capturedOpts: &capturedOpts,
+ }
+
+ ctx := context.Background()
+ tro := objects.NewTaskRunObjectV1(&v1.TaskRun{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ },
+ })
+
+ err = backend.StorePayload(ctx, tro, []byte("test"), "signature", opts)
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+
+ // Verify the options were captured correctly
+ if capturedOpts.PublicKey != publicKey {
+ t.Error("Backend should capture public key correctly")
+ }
+ if capturedOpts.Cert != "test-cert" {
+ t.Errorf("Expected cert = test-cert, got %s", capturedOpts.Cert)
+ }
+}
+
+// Mock backend that captures StorageOpts for testing
+type mockBackendWithCapture struct {
+ storedPayload []byte
+ shouldErr bool
+ backendType string
+ capturedOpts *config.StorageOpts
+}
+
+func (b *mockBackendWithCapture) StorePayload(ctx context.Context, _ objects.TektonObject, rawPayload []byte, signature string, opts config.StorageOpts) error {
+ if b.shouldErr {
+ return errors.New("mock error storing")
+ }
+ b.storedPayload = rawPayload
+ if b.capturedOpts != nil {
+ *b.capturedOpts = opts
+ }
+ return nil
+}
+
+func (b *mockBackendWithCapture) Type() string {
+ return b.backendType
+}
+
+func (b *mockBackendWithCapture) RetrievePayloads(ctx context.Context, _ objects.TektonObject, opts config.StorageOpts) (map[string]string, error) {
+ return nil, fmt.Errorf("not implemented")
+}
+
+func (b *mockBackendWithCapture) RetrieveSignatures(ctx context.Context, _ objects.TektonObject, opts config.StorageOpts) (map[string][]string, error) {
+ return nil, fmt.Errorf("not implemented")
+}
diff --git a/pkg/chains/storage/gcs/gcs.go b/pkg/chains/storage/gcs/gcs.go
index 279aa7f753..e962a12f22 100644
--- a/pkg/chains/storage/gcs/gcs.go
+++ b/pkg/chains/storage/gcs/gcs.go
@@ -81,6 +81,7 @@ func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, ra
Signature: []byte(signature),
Cert: []byte(opts.Cert),
Chain: []byte(opts.Chain),
+ PublicKey: opts.PublicKey,
},
}); err != nil {
logger.Errorf("error writing to GCS: %w", err)
@@ -101,6 +102,7 @@ func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, ra
Signature: []byte(signature),
Cert: []byte(opts.Cert),
Chain: []byte(opts.Chain),
+ PublicKey: opts.PublicKey,
},
}); err != nil {
logger.Errorf("error writing to GCS: %w", err)
diff --git a/pkg/chains/storage/oci/attestation.go b/pkg/chains/storage/oci/attestation.go
index 2d2099058d..f47577a75f 100644
--- a/pkg/chains/storage/oci/attestation.go
+++ b/pkg/chains/storage/oci/attestation.go
@@ -16,16 +16,25 @@ package oci
import (
"context"
+ "crypto"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
intoto "github.com/in-toto/attestation/go/v1"
"github.com/pkg/errors"
+ cbundle "github.com/sigstore/cosign/v2/pkg/cosign/bundle"
+ "github.com/sigstore/cosign/v2/pkg/oci"
"github.com/sigstore/cosign/v2/pkg/oci/mutate"
ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote"
"github.com/sigstore/cosign/v2/pkg/oci/static"
"github.com/sigstore/cosign/v2/pkg/types"
+ "github.com/sigstore/rekor/pkg/generated/models"
"github.com/tektoncd/chains/pkg/chains/storage/api"
+ "github.com/tektoncd/chains/pkg/config"
"knative.dev/pkg/logging"
)
@@ -40,6 +49,8 @@ type AttestationStorer struct {
repo *name.Repository
// remoteOpts are additional remote options (i.e. auth) to use for client operations.
remoteOpts []remote.Option
+ // format specifies the storage format (legacy, referrers-api, protobuf-bundle)
+ format string
}
func NewAttestationStorer(opts ...AttestationStorerOption) (*AttestationStorer, error) {
@@ -56,37 +67,176 @@ func NewAttestationStorer(opts ...AttestationStorerOption) (*AttestationStorer,
func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[name.Digest, *intoto.Statement]) (*api.StoreResponse, error) {
logger := logging.FromContext(ctx)
+ // Determine repository
repo := req.Artifact.Repository
if s.repo != nil {
repo = *s.repo
}
+
+ // Get or create signed entity
se, err := ociremote.SignedEntity(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...))
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")
+ return nil, errors.Wrap(err, "getting signed entity")
+ }
+
+ // Route to appropriate storage implementation based on format
+ switch s.format {
+ case config.OCIFormatLegacy, "": // Default to legacy
+ return s.storeLegacy(ctx, req, se, repo)
+ case config.OCIFormatReferrersAPI:
+ return s.storeWithReferrersAPI(ctx, req, se, repo)
+ case config.OCIFormatProtobuf:
+ return s.storeWithProtobufBundle(ctx, req, repo)
+ default:
+ logger.Warnf("Unknown OCI format %s, defaulting to legacy", s.format)
+ return s.storeLegacy(ctx, req, se, repo)
}
+}
- // Create the new attestation for this entity.
+// Legacy tag-based storage (current default implementation)
+func (s *AttestationStorer) storeLegacy(ctx context.Context, req *api.StoreRequest[name.Digest, *intoto.Statement], se oci.SignedEntity, repo name.Repository) (*api.StoreResponse, error) {
+ logger := logging.FromContext(ctx)
+ logger.Info("Using legacy tag-based attestation storage")
+
+ // Create attestation with DSSE format
attOpts := []static.Option{static.WithLayerMediaType(types.DssePayloadType)}
if req.Bundle.Cert != nil {
attOpts = append(attOpts, static.WithCertChain(req.Bundle.Cert, req.Bundle.Chain))
}
+
att, err := static.NewAttestation(req.Bundle.Signature, attOpts...)
if err != nil {
- return nil, err
+ return nil, errors.Wrap(err, "creating attestation")
}
+
newImage, err := mutate.AttachAttestationToEntity(se, att)
if err != nil {
- return nil, err
+ return nil, errors.Wrap(err, "attaching attestation to entity")
}
- // Publish the signatures associated with this entity
+ // Use traditional WriteAttestations (tag-based)
if err := ociremote.WriteAttestations(repo, newImage, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil {
- return nil, err
+ return nil, errors.Wrap(err, "writing attestations")
+ }
+
+ logger.Infof("Successfully uploaded attestation using legacy format for %s", req.Artifact.String())
+ return &api.StoreResponse{}, nil
+}
+
+// Referrers API storage with DSSE format
+func (s *AttestationStorer) storeWithReferrersAPI(ctx context.Context, req *api.StoreRequest[name.Digest, *intoto.Statement], se oci.SignedEntity, repo name.Repository) (*api.StoreResponse, error) {
+ _ = repo // repo parameter unused in referrers API - uses req.Artifact directly
+ logger := logging.FromContext(ctx)
+ logger.Info("Using OCI 1.1 referrers API with DSSE format")
+
+ // Create attestation with DSSE format (same as legacy)
+ attOpts := []static.Option{static.WithLayerMediaType(types.DssePayloadType)}
+ if req.Bundle.Cert != nil {
+ attOpts = append(attOpts, static.WithCertChain(req.Bundle.Cert, req.Bundle.Chain))
+ }
+
+ att, err := static.NewAttestation(req.Bundle.Signature, attOpts...)
+ if err != nil {
+ return nil, errors.Wrap(err, "creating attestation")
+ }
+
+ newImage, err := mutate.AttachAttestationToEntity(se, att)
+ if err != nil {
+ return nil, errors.Wrap(err, "attaching attestation to entity")
+ }
+
+ // Use WriteAttestationsReferrer from cosign PR #4357
+ if err := ociremote.WriteAttestationsReferrer(req.Artifact, newImage, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil {
+ return nil, errors.Wrap(err, "writing attestations with referrers API")
+ }
+
+ logger.Infof("Successfully uploaded attestation using referrers API for %s", req.Artifact.String())
+ return &api.StoreResponse{}, nil
+}
+
+// Protobuf bundle storage using cosign's MakeNewBundle
+func (s *AttestationStorer) storeWithProtobufBundle(ctx context.Context, req *api.StoreRequest[name.Digest, *intoto.Statement], repo name.Repository) (*api.StoreResponse, error) {
+ _ = repo // repo parameter unused in protobuf bundle - uses req.Artifact directly
+ logger := logging.FromContext(ctx)
+ logger.Info("Using cosign's MakeNewBundle for attestation storage")
+
+ // Extract predicate type for annotations
+ predicateType := req.Payload.PredicateType
+ if predicateType == "" {
+ return nil, errors.New("PredicateType is required for protobuf bundle format")
+ }
+
+ // Use public key from StorageOpts (extracted from signer)
+ var pubKey crypto.PublicKey
+ if req.Bundle.PublicKey != nil {
+ pubKey = req.Bundle.PublicKey
+ logger.Info("Using public key provided from signer")
+ } else if req.Bundle.Cert != nil && len(req.Bundle.Cert) > 0 {
+ logger.Info("Extracting public key from certificate for bundle creation")
+
+ // Try to parse as PEM first
+ block, _ := pem.Decode(req.Bundle.Cert)
+ var certBytes []byte
+ if block != nil {
+ certBytes = block.Bytes
+ } else {
+ // Assume DER format if PEM decode fails
+ certBytes = req.Bundle.Cert
+ }
+
+ cert, err := x509.ParseCertificate(certBytes)
+ if err != nil {
+ logger.Warnf("Failed to parse certificate for public key extraction: %v", err)
+ } else {
+ pubKey = cert.PublicKey
+ logger.Info("Successfully extracted public key from certificate")
+ }
+ }
+
+ if pubKey == nil {
+ return nil, errors.New("no public key available: neither from signer nor from certificate")
+ }
+
+ // Create DSSE envelope from existing signed data (Chains already has DSSE components)
+ // MakeNewBundle expects the DSSE envelope as JSON bytes
+ dsseEnvelope := map[string]interface{}{
+ "payload": base64.StdEncoding.EncodeToString(req.Bundle.Content),
+ "payloadType": "application/vnd.in-toto+json",
+ "signatures": []map[string]interface{}{
+ {
+ "sig": base64.StdEncoding.EncodeToString(req.Bundle.Signature),
+ },
+ },
+ }
+
+ signedPayload, err := json.Marshal(dsseEnvelope)
+ if err != nil {
+ return nil, errors.Wrap(err, "marshaling DSSE envelope for bundle")
+ }
+
+ // Use cosign's MakeNewBundle to create proper protobuf bundle
+ // Following the pattern from cosign CLI: MakeNewBundle(pubKey, rekorEntry, payload, signedPayload, signerBytes, timestampBytes)
+ var rekorEntry *models.LogEntryAnon // nil for x509 static key signing
+ var signerBytes []byte // certificate bytes for verification material
+ var timestampBytes []byte // nil for x509 static key signing
+
+ if req.Bundle.Cert != nil {
+ signerBytes = req.Bundle.Cert
+ }
+
+ bundleBytes, err := cbundle.MakeNewBundle(pubKey, rekorEntry, req.Bundle.Content, signedPayload, signerBytes, timestampBytes)
+ if err != nil {
+ return nil, errors.Wrap(err, "creating protobuf bundle with cosign's MakeNewBundle")
+ }
+
+ // Store the bundle using WriteAttestationNewBundleFormat (same as cosign CLI)
+ if err := ociremote.WriteAttestationNewBundleFormat(req.Artifact, bundleBytes, predicateType, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil {
+ return nil, errors.Wrap(err, "writing Sigstore bundle with WriteAttestationNewBundleFormat")
}
- logger.Infof("Successfully uploaded attestation for %s", req.Artifact.String())
+ logger.Infof("Successfully uploaded attestation using Sigstore bundle format for %s", req.Artifact.String())
return &api.StoreResponse{}, nil
}
diff --git a/pkg/chains/storage/oci/attestation_test.go b/pkg/chains/storage/oci/attestation_test.go
index 659b787ecd..12584bf406 100644
--- a/pkg/chains/storage/oci/attestation_test.go
+++ b/pkg/chains/storage/oci/attestation_test.go
@@ -15,10 +15,18 @@
package oci
import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
"fmt"
+ "math/big"
"net/http/httptest"
"strings"
"testing"
+ "time"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
@@ -28,6 +36,7 @@ import (
intoto "github.com/in-toto/attestation/go/v1"
"github.com/tektoncd/chains/pkg/chains/signing"
"github.com/tektoncd/chains/pkg/chains/storage/api"
+ "github.com/tektoncd/chains/pkg/config"
logtesting "knative.dev/pkg/logging/testing"
)
@@ -109,3 +118,379 @@ func TestAttestationStorer_Store(t *testing.T) {
})
}
}
+
+// Helper function to create a test certificate and key pair
+func createTestCertAndKey(t *testing.T) (crypto.PublicKey, []byte) {
+ t.Helper()
+
+ // Generate RSA key pair
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed to generate RSA key: %v", err)
+ }
+ publicKey := &privateKey.PublicKey
+
+ // Create a test certificate
+ template := x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{
+ Organization: []string{"Test Org"},
+ Country: []string{"US"},
+ Province: []string{""},
+ Locality: []string{"San Francisco"},
+ StreetAddress: []string{""},
+ PostalCode: []string{""},
+ },
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(365 * 24 * time.Hour),
+ KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ IPAddresses: nil,
+ }
+
+ certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey, privateKey)
+ if err != nil {
+ t.Fatalf("Failed to create certificate: %v", err)
+ }
+
+ // Encode certificate as PEM
+ certPEM := pem.EncodeToMemory(&pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: certDER,
+ })
+
+ return publicKey, certPEM
+}
+
+func TestAttestationStorer_StoreWithProtobufBundle(t *testing.T) {
+ // Setup test registry
+ s := httptest.NewServer(registry.New())
+ defer s.Close()
+ registryName := strings.TrimPrefix(s.URL, "http://")
+
+ // Create test image
+ 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)
+ }
+
+ publicKey, certPEM := createTestCertAndKey(t)
+
+ tests := []struct {
+ name string
+ bundle *signing.Bundle
+ wantErr bool
+ errContains string
+ format string
+ }{
+ {
+ name: "protobuf bundle with public key priority",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"test": "payload"}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: publicKey,
+ },
+ format: config.OCIFormatProtobuf,
+ },
+ {
+ name: "protobuf bundle with certificate fallback",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"test": "payload"}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: nil, // Test fallback to certificate
+ },
+ format: config.OCIFormatProtobuf,
+ },
+ {
+ name: "protobuf bundle with no public key or certificate",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"test": "payload"}`),
+ Signature: []byte("test-signature"),
+ Cert: nil,
+ Chain: []byte("test-chain"),
+ PublicKey: nil,
+ },
+ format: config.OCIFormatProtobuf,
+ wantErr: true,
+ errContains: "no public key available",
+ },
+ {
+ name: "protobuf bundle with invalid certificate",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"test": "payload"}`),
+ Signature: []byte("test-signature"),
+ Cert: []byte("invalid-cert-data"),
+ Chain: []byte("test-chain"),
+ PublicKey: nil,
+ },
+ format: config.OCIFormatProtobuf,
+ wantErr: true,
+ errContains: "no public key available",
+ },
+ {
+ name: "protobuf bundle without predicate type",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"test": "payload"}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: publicKey,
+ },
+ format: config.OCIFormatProtobuf,
+ wantErr: true,
+ errContains: "PredicateType is required",
+ },
+ {
+ name: "legacy format should work normally",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"test": "payload"}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: publicKey,
+ },
+ format: config.OCIFormatLegacy,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var storer *AttestationStorer
+ var err error
+
+ // Create storer with specific format
+ if tt.format == config.OCIFormatProtobuf {
+ storer, err = NewAttestationStorer(
+ WithTargetRepository(ref.Repository),
+ WithFormat(config.OCIFormatProtobuf),
+ )
+ } else {
+ storer, err = NewAttestationStorer(
+ WithTargetRepository(ref.Repository),
+ WithFormat(config.OCIFormatLegacy),
+ )
+ }
+ if err != nil {
+ t.Fatalf("failed to create storer: %v", err)
+ }
+
+ ctx := logtesting.TestContextWithLogger(t)
+
+ // Create payload with or without predicate type based on test
+ var payload *intoto.Statement
+ if tt.name == "protobuf bundle without predicate type" {
+ payload = &intoto.Statement{
+ // Missing PredicateType
+ Type: "https://in-toto.io/Statement/v0.1",
+ }
+ } else {
+ payload = &intoto.Statement{
+ Type: "https://in-toto.io/Statement/v0.1",
+ PredicateType: "https://slsa.dev/provenance/v0.2",
+ }
+ }
+
+ _, err = storer.Store(ctx, &api.StoreRequest[name.Digest, *intoto.Statement]{
+ Artifact: ref,
+ Payload: payload,
+ Bundle: tt.bundle,
+ })
+
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("Expected error containing '%s', but got nil", tt.errContains)
+ } else if !strings.Contains(err.Error(), tt.errContains) {
+ t.Errorf("Expected error containing '%s', got: %v", tt.errContains, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestAttestationStorer_CertificateParsing(t *testing.T) {
+ // Test different certificate formats
+ _, certPEM := createTestCertAndKey(t)
+
+ // Parse the certificate to get DER bytes
+ block, _ := pem.Decode(certPEM)
+ if block == nil {
+ t.Fatalf("Failed to decode PEM certificate")
+ }
+ certDER := block.Bytes
+
+ tests := []struct {
+ name string
+ certData []byte
+ wantErr bool
+ }{
+ {
+ name: "PEM encoded certificate",
+ certData: certPEM,
+ wantErr: false,
+ },
+ {
+ name: "DER encoded certificate",
+ certData: certDER,
+ wantErr: false,
+ },
+ {
+ name: "invalid certificate data",
+ certData: []byte("invalid-cert-data"),
+ wantErr: true,
+ },
+ {
+ name: "empty certificate data",
+ certData: []byte{},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Setup test registry
+ s := httptest.NewServer(registry.New())
+ defer s.Close()
+ registryName := strings.TrimPrefix(s.URL, "http://")
+
+ // Create test image
+ 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),
+ WithFormat(config.OCIFormatProtobuf),
+ )
+ if err != nil {
+ t.Fatalf("failed to create storer: %v", err)
+ }
+
+ ctx := logtesting.TestContextWithLogger(t)
+
+ payload := &intoto.Statement{
+ Type: "https://in-toto.io/Statement/v0.1",
+ PredicateType: "https://slsa.dev/provenance/v0.2",
+ }
+
+ bundle := &signing.Bundle{
+ Content: []byte(`{"test": "payload"}`),
+ Signature: []byte("test-signature"),
+ Cert: tt.certData,
+ Chain: []byte("test-chain"),
+ PublicKey: nil, // Force certificate parsing
+ }
+
+ _, err = storer.Store(ctx, &api.StoreRequest[name.Digest, *intoto.Statement]{
+ Artifact: ref,
+ Payload: payload,
+ Bundle: bundle,
+ })
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("Expected error, but got nil")
+ } else if !strings.Contains(err.Error(), "no public key available") {
+ t.Errorf("Expected 'no public key available' error, got: %v", err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestAttestationStorer_PublicKeyPriority(t *testing.T) {
+ // Test that public key takes priority over certificate
+ _, cert1PEM := createTestCertAndKey(t)
+ publicKey2, _ := createTestCertAndKey(t) // Different key
+
+ // Setup test registry
+ s := httptest.NewServer(registry.New())
+ defer s.Close()
+ registryName := strings.TrimPrefix(s.URL, "http://")
+
+ // Create test image
+ 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),
+ WithFormat(config.OCIFormatProtobuf),
+ )
+ if err != nil {
+ t.Fatalf("failed to create storer: %v", err)
+ }
+
+ ctx := logtesting.TestContextWithLogger(t)
+
+ payload := &intoto.Statement{
+ Type: "https://in-toto.io/Statement/v0.1",
+ PredicateType: "https://slsa.dev/provenance/v0.2",
+ }
+
+ // Bundle with both public key and certificate - should use public key
+ bundle := &signing.Bundle{
+ Content: []byte(`{"test": "payload"}`),
+ Signature: []byte("test-signature"),
+ Cert: cert1PEM, // This has publicKey1
+ Chain: []byte("test-chain"),
+ PublicKey: publicKey2, // This should take priority
+ }
+
+ // This should succeed and use publicKey2, not publicKey1 from certificate
+ _, err = storer.Store(ctx, &api.StoreRequest[name.Digest, *intoto.Statement]{
+ Artifact: ref,
+ Payload: payload,
+ Bundle: bundle,
+ })
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+}
diff --git a/pkg/chains/storage/oci/legacy.go b/pkg/chains/storage/oci/legacy.go
index 950093220b..1b6fc45cea 100644
--- a/pkg/chains/storage/oci/legacy.go
+++ b/pkg/chains/storage/oci/legacy.go
@@ -80,7 +80,7 @@ func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, ra
return errors.Wrap(err, "getting oci authenticator")
}
- logger.Infof("Storing payload on %s/%s/%s", obj.GetGVK(), obj.GetNamespace(), obj.GetName())
+ logger.Infof("Storing payload on %s/%s/%s with format: %s", obj.GetGVK(), obj.GetNamespace(), obj.GetName(), b.cfg.Storage.OCI.Format)
if storageOpts.PayloadFormat == formats.PayloadTypeSimpleSigning {
format := simple.SimpleContainerImage{}
@@ -96,6 +96,15 @@ func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, ra
return errors.Wrap(err, "unmarshal attestation")
}
+ // Extract predicate type from raw JSON since it gets lost during unmarshaling
+ var rawStatement struct {
+ PredicateType string `json:"predicateType"`
+ }
+ if err := json.Unmarshal(rawPayload, &rawStatement); err != nil {
+ return errors.Wrap(err, "extracting predicate type from raw payload")
+ }
+ attestation.PredicateType = rawStatement.PredicateType
+
// This can happen if the Task/TaskRun does not adhere to specific naming conventions
// like *IMAGE_URL that would serve as hints. This may be intentional for a Task/TaskRun
// that is not intended to produce an image, e.g. git-clone.
@@ -114,11 +123,7 @@ func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, ra
}
func (b *Backend) uploadSignature(ctx context.Context, format simple.SimpleContainerImage, rawPayload []byte, signature string, storageOpts config.StorageOpts, remoteOpts ...remote.Option) error {
- logger := logging.FromContext(ctx)
-
imageName := format.ImageName()
- logger.Infof("Uploading %s signature", imageName)
-
ref, err := name.NewDigest(imageName)
if err != nil {
return errors.Wrap(err, "getting digest")
@@ -129,12 +134,17 @@ func (b *Backend) uploadSignature(ctx context.Context, format simple.SimpleConta
return errors.Wrapf(err, "getting storage repo for sub %s", imageName)
}
- store, err := NewSimpleStorerFromConfig(WithTargetRepository(repo))
+ // Use new SimpleStorer with format configuration
+ store, err := NewSimpleStorerFromConfig(
+ WithTargetRepository(repo),
+ WithFormat(b.cfg.Storage.OCI.Format),
+ )
if err != nil {
return err
}
- // TODO: make these creation opts.
+
store.remoteOpts = remoteOpts
+
if _, err := store.Store(ctx, &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{
Object: nil,
Artifact: ref,
@@ -144,6 +154,7 @@ func (b *Backend) uploadSignature(ctx context.Context, format simple.SimpleConta
Signature: []byte(signature),
Cert: []byte(storageOpts.Cert),
Chain: []byte(storageOpts.Chain),
+ PublicKey: storageOpts.PublicKey,
},
}); err != nil {
return err
@@ -169,12 +180,17 @@ func (b *Backend) uploadAttestation(ctx context.Context, attestation *intoto.Sta
return errors.Wrapf(err, "getting storage repo for sub %s", imageName)
}
- store, err := NewAttestationStorer(WithTargetRepository(repo))
+ // Use new AttestationStorer with format configuration
+ store, err := NewAttestationStorer(
+ WithTargetRepository(repo),
+ WithFormat(b.cfg.Storage.OCI.Format),
+ )
if err != nil {
return err
}
- // TODO: make these creation opts.
+
store.remoteOpts = remoteOpts
+
if _, err := store.Store(ctx, &api.StoreRequest[name.Digest, *intoto.Statement]{
Object: nil,
Artifact: ref,
@@ -184,6 +200,7 @@ func (b *Backend) uploadAttestation(ctx context.Context, attestation *intoto.Sta
Signature: []byte(signature),
Cert: []byte(storageOpts.Cert),
Chain: []byte(storageOpts.Chain),
+ PublicKey: storageOpts.PublicKey,
},
}); err != nil {
return err
diff --git a/pkg/chains/storage/oci/options.go b/pkg/chains/storage/oci/options.go
index c905e7699c..3eb06d4330 100644
--- a/pkg/chains/storage/oci/options.go
+++ b/pkg/chains/storage/oci/options.go
@@ -14,7 +14,13 @@
package oci
-import "github.com/google/go-containerregistry/pkg/name"
+import (
+ "os"
+
+ "github.com/google/go-containerregistry/pkg/name"
+ "github.com/google/go-containerregistry/pkg/v1/remote"
+ "github.com/tektoncd/chains/pkg/config"
+)
// Option provides a config option compatible with all OCI storers.
type Option interface {
@@ -52,3 +58,37 @@ func (o *targetRepoOption) applySimpleStorer(s *SimpleStorer) error {
s.repo = &o.repo
return nil
}
+
+// WithFormat configures the storage format for OCI signatures and attestations.
+//
+//nolint:ireturn // returning interface is the intended pattern for options
+func WithFormat(format string) Option {
+ return &formatOption{
+ format: format,
+ }
+}
+
+type formatOption struct {
+ format string
+}
+
+func (o *formatOption) applyAttestationStorer(s *AttestationStorer) error {
+ s.format = o.format
+
+ // Enable experimental features for non-legacy formats
+ if o.format == config.OCIFormatReferrersAPI || o.format == config.OCIFormatProtobuf {
+ os.Setenv("COSIGN_EXPERIMENTAL", "1")
+ s.remoteOpts = append(s.remoteOpts, remote.WithUserAgent("chains/"+o.format))
+ }
+ return nil
+}
+
+func (o *formatOption) applySimpleStorer(s *SimpleStorer) error {
+ s.format = o.format
+
+ if o.format == config.OCIFormatReferrersAPI || o.format == config.OCIFormatProtobuf {
+ os.Setenv("COSIGN_EXPERIMENTAL", "1")
+ s.remoteOpts = append(s.remoteOpts, remote.WithUserAgent("chains/"+o.format))
+ }
+ return nil
+}
diff --git a/pkg/chains/storage/oci/referrers_test.go b/pkg/chains/storage/oci/referrers_test.go
new file mode 100644
index 0000000000..3616c228f6
--- /dev/null
+++ b/pkg/chains/storage/oci/referrers_test.go
@@ -0,0 +1,133 @@
+/*
+Copyright 2023 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 (
+ "os"
+ "testing"
+
+ "github.com/google/go-containerregistry/pkg/name"
+ "github.com/tektoncd/chains/pkg/config"
+)
+
+func TestReferrersAPIOption(t *testing.T) {
+ // Test that WithFormat option sets COSIGN_EXPERIMENTAL for referrers API format
+
+ // Clear any existing env var
+ originalValue := os.Getenv("COSIGN_EXPERIMENTAL")
+ defer func() {
+ if originalValue != "" {
+ os.Setenv("COSIGN_EXPERIMENTAL", originalValue)
+ } else {
+ os.Unsetenv("COSIGN_EXPERIMENTAL")
+ }
+ }()
+ os.Unsetenv("COSIGN_EXPERIMENTAL")
+
+ // Create storer with referrers API format enabled
+ repo, err := name.NewRepository("example.com/test")
+ if err != nil {
+ t.Fatalf("Failed to create repository: %v", err)
+ }
+
+ opts := []AttestationStorerOption{
+ WithTargetRepository(repo),
+ WithFormat(config.OCIFormatReferrersAPI),
+ }
+
+ storer, err := NewAttestationStorer(opts...)
+ if err != nil {
+ t.Fatalf("Failed to create attestation storer: %v", err)
+ }
+
+ // Check that COSIGN_EXPERIMENTAL was set
+ if os.Getenv("COSIGN_EXPERIMENTAL") != "1" {
+ t.Errorf("Expected COSIGN_EXPERIMENTAL to be set to '1', got '%s'", os.Getenv("COSIGN_EXPERIMENTAL"))
+ }
+
+ // Check that the storer was configured correctly
+ if storer.repo == nil {
+ t.Errorf("Expected storer.repo to be set")
+ }
+
+ if storer.repo.Name() != "example.com/test" {
+ t.Errorf("Expected repo name to be 'example.com/test', got '%s'", storer.repo.Name())
+ }
+}
+
+func TestReferrersAPIDisabled(t *testing.T) {
+ // Test that WithFormat(legacy) doesn't set COSIGN_EXPERIMENTAL
+
+ // Clear any existing env var
+ originalValue := os.Getenv("COSIGN_EXPERIMENTAL")
+ defer func() {
+ if originalValue != "" {
+ os.Setenv("COSIGN_EXPERIMENTAL", originalValue)
+ } else {
+ os.Unsetenv("COSIGN_EXPERIMENTAL")
+ }
+ }()
+ os.Unsetenv("COSIGN_EXPERIMENTAL")
+
+ // Create storer with legacy format (referrers API disabled)
+ repo, err := name.NewRepository("example.com/test")
+ if err != nil {
+ t.Fatalf("Failed to create repository: %v", err)
+ }
+
+ opts := []SimpleStorerOption{
+ WithTargetRepository(repo),
+ WithFormat(config.OCIFormatLegacy),
+ }
+
+ storer, err := NewSimpleStorerFromConfig(opts...)
+ if err != nil {
+ t.Fatalf("Failed to create simple storer: %v", err)
+ }
+
+ // Check that COSIGN_EXPERIMENTAL was not set
+ if os.Getenv("COSIGN_EXPERIMENTAL") != "" {
+ t.Errorf("Expected COSIGN_EXPERIMENTAL to be unset, got '%s'", os.Getenv("COSIGN_EXPERIMENTAL"))
+ }
+
+ // Check that the storer was configured correctly
+ if storer.repo == nil {
+ t.Errorf("Expected storer.repo to be set")
+ }
+}
+
+func TestOCIBackendFormatConfig(t *testing.T) {
+ // Test that the OCI backend respects the format configuration
+ cfg := config.Config{
+ Storage: config.StorageConfigs{
+ OCI: config.OCIStorageConfig{
+ Repository: "example.com/repo",
+ Format: config.OCIFormatReferrersAPI,
+ },
+ },
+ }
+
+ backend := &Backend{
+ cfg: cfg,
+ }
+
+ // Verify that the config is accessible
+ if backend.cfg.Storage.OCI.Format != config.OCIFormatReferrersAPI {
+ t.Errorf("Expected Format to be %s in backend config, got %s", config.OCIFormatReferrersAPI, backend.cfg.Storage.OCI.Format)
+ }
+
+ if backend.cfg.Storage.OCI.Repository != "example.com/repo" {
+ t.Errorf("Expected repository to be 'example.com/repo', got '%s'", backend.cfg.Storage.OCI.Repository)
+ }
+}
diff --git a/pkg/chains/storage/oci/simple.go b/pkg/chains/storage/oci/simple.go
index 30952ac7dc..f7c9408967 100644
--- a/pkg/chains/storage/oci/simple.go
+++ b/pkg/chains/storage/oci/simple.go
@@ -16,16 +16,26 @@ package oci
import (
"context"
+ "crypto"
+ "crypto/x509"
"encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "time"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/pkg/errors"
+ cbundle "github.com/sigstore/cosign/v2/pkg/cosign/bundle"
+ "github.com/sigstore/cosign/v2/pkg/oci"
"github.com/sigstore/cosign/v2/pkg/oci/mutate"
ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote"
"github.com/sigstore/cosign/v2/pkg/oci/static"
+ "github.com/sigstore/cosign/v2/pkg/types"
+ "github.com/sigstore/rekor/pkg/generated/models"
"github.com/tektoncd/chains/pkg/chains/formats/simple"
"github.com/tektoncd/chains/pkg/chains/storage/api"
+ "github.com/tektoncd/chains/pkg/config"
"knative.dev/pkg/logging"
)
@@ -36,6 +46,8 @@ type SimpleStorer struct {
repo *name.Repository
// remoteOpts are additional remote options (i.e. auth) to use for client operations.
remoteOpts []remote.Option
+ // format specifies the storage format (legacy, referrers-api, protobuf-bundle)
+ format string
}
var (
@@ -54,40 +66,190 @@ func NewSimpleStorerFromConfig(opts ...SimpleStorerOption) (*SimpleStorer, error
func (s *SimpleStorer) Store(ctx context.Context, req *api.StoreRequest[name.Digest, simple.SimpleContainerImage]) (*api.StoreResponse, error) {
logger := logging.FromContext(ctx).With("image", req.Artifact.String())
- logger.Info("Uploading signature")
+ // Get or create signed entity
se, err := ociremote.SignedEntity(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...))
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")
+ return nil, errors.Wrap(err, "getting signed entity")
}
+ // Determine repository
+ repo := req.Artifact.Repository
+ if s.repo != nil {
+ repo = *s.repo
+ }
+
+ // Route to appropriate storage implementation
+ switch s.format {
+ case config.OCIFormatLegacy, "": // Default to legacy
+ return s.storeLegacy(ctx, req, se, repo)
+ case config.OCIFormatReferrersAPI:
+ return s.storeWithReferrersAPI(ctx, req, se, repo)
+ case config.OCIFormatProtobuf:
+ return s.storeWithProtobufBundle(ctx, req, se, repo)
+ default:
+ logger.Warnf("Unknown OCI format %s, defaulting to legacy", s.format)
+ return s.storeLegacy(ctx, req, se, repo)
+ }
+}
+
+func (s *SimpleStorer) storeLegacy(ctx context.Context, req *api.StoreRequest[name.Digest, simple.SimpleContainerImage], se oci.SignedEntity, repo name.Repository) (*api.StoreResponse, error) {
+ logger := logging.FromContext(ctx)
+ logger.Info("Using legacy tag-based signature storage")
+
+ // Create signature
sigOpts := []static.Option{}
if req.Bundle.Cert != nil {
sigOpts = append(sigOpts, static.WithCertChain(req.Bundle.Cert, req.Bundle.Chain))
}
- // Create the new signature for this entity.
+
b64sig := base64.StdEncoding.EncodeToString(req.Bundle.Signature)
sig, err := static.NewSignature(req.Bundle.Content, b64sig, sigOpts...)
if err != nil {
- return nil, err
+ return nil, errors.Wrap(err, "creating signature")
}
- // Attach the signature to the entity.
+
newSE, err := mutate.AttachSignatureToEntity(se, sig)
if err != nil {
- return nil, err
+ return nil, errors.Wrap(err, "attaching signature to entity")
}
- repo := req.Artifact.Repository
- if s.repo != nil {
- repo = *s.repo
- }
- // Publish the signatures associated with this entity
+ // Use traditional WriteSignatures (tag-based)
if err := ociremote.WriteSignatures(repo, newSE, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil {
- return nil, err
+ return nil, errors.Wrap(err, "writing signatures")
+ }
+
+ logger.Info("Successfully uploaded signature using legacy format")
+ return &api.StoreResponse{}, nil
+}
+
+func (s *SimpleStorer) storeWithReferrersAPI(ctx context.Context, req *api.StoreRequest[name.Digest, simple.SimpleContainerImage], se oci.SignedEntity, repo name.Repository) (*api.StoreResponse, error) {
+ _ = repo // repo parameter unused in referrers API - uses req.Artifact directly
+ logger := logging.FromContext(ctx)
+ logger.Info("Using OCI 1.1 referrers API for signature storage with proper artifact type")
+
+ // Create signature (same as legacy)
+ sigOpts := []static.Option{}
+ if req.Bundle.Cert != nil {
+ sigOpts = append(sigOpts, static.WithCertChain(req.Bundle.Cert, req.Bundle.Chain))
+ }
+
+ b64sig := base64.StdEncoding.EncodeToString(req.Bundle.Signature)
+ sig, err := static.NewSignature(req.Bundle.Content, b64sig, sigOpts...)
+ if err != nil {
+ return nil, errors.Wrap(err, "creating signature")
+ }
+
+ newSE, err := mutate.AttachSignatureToEntity(se, sig)
+ if err != nil {
+ return nil, errors.Wrap(err, "attaching signature to entity")
+ }
+
+ // Extract signature layers for WriteReferrer
+ sigs, err := newSE.Signatures()
+ if err != nil {
+ return nil, errors.Wrap(err, "getting signatures from entity")
+ }
+
+ layers, err := sigs.Layers()
+ if err != nil {
+ return nil, errors.Wrap(err, "getting signature layers")
+ }
+
+ // Create annotations with creation timestamp
+ annotations := map[string]string{
+ "org.opencontainers.image.created": time.Now().UTC().Format(time.RFC3339),
+ }
+
+ // Use WriteReferrer with proper signature artifact type
+ // This is equivalent to ociexperimental.ArtifactType("sig") which returns "application/vnd.dev.cosign.artifact.sig.v1+json"
+ artifactType := "application/vnd.dev.cosign.artifact.sig.v1+json"
+ if err := ociremote.WriteReferrer(req.Artifact, artifactType, layers, annotations, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil {
+ return nil, errors.Wrap(err, "writing signature referrer with proper artifact type")
+ }
+
+ logger.Info("Successfully uploaded signature using referrers API with proper artifact type")
+ return &api.StoreResponse{}, nil
+}
+
+func (s *SimpleStorer) storeWithProtobufBundle(ctx context.Context, req *api.StoreRequest[name.Digest, simple.SimpleContainerImage], se oci.SignedEntity, repo name.Repository) (*api.StoreResponse, error) {
+ _ = se // unused in bundle format
+ _ = repo // unused in bundle format
+ logger := logging.FromContext(ctx)
+ logger.Info("Using cosign's MakeNewBundle for signature storage")
+
+ // Use public key from StorageOpts (extracted from signer)
+ var pubKey crypto.PublicKey
+ if req.Bundle.PublicKey != nil {
+ pubKey = req.Bundle.PublicKey
+ logger.Info("Using public key provided from signer")
+ } else if req.Bundle.Cert != nil && len(req.Bundle.Cert) > 0 {
+ logger.Info("Extracting public key from certificate for bundle creation")
+
+ // Try to parse as PEM first
+ block, _ := pem.Decode(req.Bundle.Cert)
+ var certBytes []byte
+ if block != nil {
+ certBytes = block.Bytes
+ } else {
+ // Assume DER format if PEM decode fails
+ certBytes = req.Bundle.Cert
+ }
+
+ cert, err := x509.ParseCertificate(certBytes)
+ if err != nil {
+ logger.Warnf("Failed to parse certificate for public key extraction: %v", err)
+ } else {
+ pubKey = cert.PublicKey
+ logger.Info("Successfully extracted public key from certificate")
+ }
}
- logger.Info("Successfully uploaded signature")
+
+ if pubKey == nil {
+ return nil, errors.New("no public key available: neither from signer nor from certificate")
+ }
+
+ // For signatures, create DSSE envelope with signature payload
+ // MakeNewBundle expects the DSSE envelope as JSON bytes
+ dsseEnvelope := map[string]interface{}{
+ "payload": base64.StdEncoding.EncodeToString(req.Bundle.Content),
+ "payloadType": "application/vnd.dev.cosign.simple.signing.v1+json",
+ "signatures": []map[string]interface{}{
+ {
+ "sig": base64.StdEncoding.EncodeToString(req.Bundle.Signature),
+ },
+ },
+ }
+
+ signedPayload, err := json.Marshal(dsseEnvelope)
+ if err != nil {
+ return nil, errors.Wrap(err, "marshaling DSSE envelope for signature bundle")
+ }
+
+ // Use cosign's MakeNewBundle to create proper protobuf bundle
+ // Following the pattern from cosign CLI: MakeNewBundle(pubKey, rekorEntry, payload, signedPayload, signerBytes, timestampBytes)
+ var rekorEntry *models.LogEntryAnon // nil for x509 static key signing
+ var signerBytes []byte // certificate bytes for verification material
+ var timestampBytes []byte // nil for x509 static key signing
+
+ if req.Bundle.Cert != nil {
+ signerBytes = req.Bundle.Cert
+ }
+
+ bundleBytes, err := cbundle.MakeNewBundle(pubKey, rekorEntry, req.Bundle.Content, signedPayload, signerBytes, timestampBytes)
+ if err != nil {
+ return nil, errors.Wrap(err, "creating signature bundle with cosign's MakeNewBundle")
+ }
+
+ // Store the bundle using WriteAttestationNewBundleFormat (same function cosign uses for signatures)
+ // Use CosignSignPredicateType for signatures (same as cosign CLI)
+ if err := ociremote.WriteAttestationNewBundleFormat(req.Artifact, bundleBytes, types.CosignSignPredicateType, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil {
+ return nil, errors.Wrap(err, "writing signature bundle with WriteAttestationNewBundleFormat")
+ }
+
+ logger.Info("Successfully uploaded signature using cosign protobuf bundle")
return &api.StoreResponse{}, nil
}
diff --git a/pkg/chains/storage/oci/simple_test.go b/pkg/chains/storage/oci/simple_test.go
index 5e6b3e4479..4a6c7bddd6 100644
--- a/pkg/chains/storage/oci/simple_test.go
+++ b/pkg/chains/storage/oci/simple_test.go
@@ -15,10 +15,18 @@
package oci
import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
"fmt"
+ "math/big"
"net/http/httptest"
"strings"
"testing"
+ "time"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
@@ -28,6 +36,7 @@ import (
"github.com/tektoncd/chains/pkg/chains/formats/simple"
"github.com/tektoncd/chains/pkg/chains/signing"
"github.com/tektoncd/chains/pkg/chains/storage/api"
+ "github.com/tektoncd/chains/pkg/config"
logtesting "knative.dev/pkg/logging/testing"
)
@@ -108,3 +117,452 @@ func TestSimpleStorer_Store(t *testing.T) {
})
}
}
+
+// Helper function to create a test certificate and key pair for simple tests
+func createTestCertAndKeyForSimple(t *testing.T) (crypto.PublicKey, []byte) {
+ t.Helper()
+
+ // Generate RSA key pair
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed to generate RSA key: %v", err)
+ }
+ publicKey := &privateKey.PublicKey
+
+ // Create a test certificate
+ template := x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{
+ Organization: []string{"Test Org"},
+ Country: []string{"US"},
+ Province: []string{""},
+ Locality: []string{"San Francisco"},
+ StreetAddress: []string{""},
+ PostalCode: []string{""},
+ },
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(365 * 24 * time.Hour),
+ KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ IPAddresses: nil,
+ }
+
+ certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey, privateKey)
+ if err != nil {
+ t.Fatalf("Failed to create certificate: %v", err)
+ }
+
+ // Encode certificate as PEM
+ certPEM := pem.EncodeToMemory(&pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: certDER,
+ })
+
+ return publicKey, certPEM
+}
+
+func TestSimpleStorer_StoreWithProtobufBundle(t *testing.T) {
+ // Setup test registry
+ s := httptest.NewServer(registry.New())
+ defer s.Close()
+ registryName := strings.TrimPrefix(s.URL, "http://")
+
+ // Create test image
+ 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)
+ }
+
+ publicKey, certPEM := createTestCertAndKeyForSimple(t)
+
+ tests := []struct {
+ name string
+ bundle *signing.Bundle
+ wantErr bool
+ errContains string
+ format string
+ }{
+ {
+ name: "protobuf bundle with public key priority",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: publicKey,
+ },
+ format: config.OCIFormatProtobuf,
+ },
+ {
+ name: "protobuf bundle with certificate fallback",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: nil, // Test fallback to certificate
+ },
+ format: config.OCIFormatProtobuf,
+ },
+ {
+ name: "protobuf bundle with no public key or certificate",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: nil,
+ Chain: []byte("test-chain"),
+ PublicKey: nil,
+ },
+ format: config.OCIFormatProtobuf,
+ wantErr: true,
+ errContains: "no public key available",
+ },
+ {
+ name: "protobuf bundle with invalid certificate",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: []byte("invalid-cert-data"),
+ Chain: []byte("test-chain"),
+ PublicKey: nil,
+ },
+ format: config.OCIFormatProtobuf,
+ wantErr: true,
+ errContains: "no public key available",
+ },
+ {
+ name: "legacy format should work normally",
+ bundle: &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: publicKey,
+ },
+ format: config.OCIFormatLegacy,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var storer *SimpleStorer
+ var err error
+
+ // Create storer with specific format
+ if tt.format == config.OCIFormatProtobuf {
+ storer, err = NewSimpleStorerFromConfig(
+ WithTargetRepository(ref.Repository),
+ WithFormat(config.OCIFormatProtobuf),
+ )
+ } else {
+ storer, err = NewSimpleStorerFromConfig(
+ WithTargetRepository(ref.Repository),
+ WithFormat(config.OCIFormatLegacy),
+ )
+ }
+ if err != nil {
+ t.Fatalf("failed to create storer: %v", err)
+ }
+
+ ctx := logtesting.TestContextWithLogger(t)
+
+ payload := simple.NewSimpleStruct(ref)
+
+ _, err = storer.Store(ctx, &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{
+ Artifact: ref,
+ Payload: payload,
+ Bundle: tt.bundle,
+ })
+
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("Expected error containing '%s', but got nil", tt.errContains)
+ } else if !strings.Contains(err.Error(), tt.errContains) {
+ t.Errorf("Expected error containing '%s', got: %v", tt.errContains, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestSimpleStorer_CertificateParsing(t *testing.T) {
+ // Test different certificate formats for simple storage
+ _, certPEM := createTestCertAndKeyForSimple(t)
+
+ // Parse the certificate to get DER bytes
+ block, _ := pem.Decode(certPEM)
+ if block == nil {
+ t.Fatalf("Failed to decode PEM certificate")
+ }
+ certDER := block.Bytes
+
+ tests := []struct {
+ name string
+ certData []byte
+ wantErr bool
+ }{
+ {
+ name: "PEM encoded certificate",
+ certData: certPEM,
+ wantErr: false,
+ },
+ {
+ name: "DER encoded certificate",
+ certData: certDER,
+ wantErr: false,
+ },
+ {
+ name: "invalid certificate data",
+ certData: []byte("invalid-cert-data"),
+ wantErr: true,
+ },
+ {
+ name: "empty certificate data",
+ certData: []byte{},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Setup test registry
+ s := httptest.NewServer(registry.New())
+ defer s.Close()
+ registryName := strings.TrimPrefix(s.URL, "http://")
+
+ // Create test image
+ 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 := NewSimpleStorerFromConfig(
+ WithTargetRepository(ref.Repository),
+ WithFormat(config.OCIFormatProtobuf),
+ )
+ if err != nil {
+ t.Fatalf("failed to create storer: %v", err)
+ }
+
+ ctx := logtesting.TestContextWithLogger(t)
+
+ payload := simple.NewSimpleStruct(ref)
+
+ bundle := &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: tt.certData,
+ Chain: []byte("test-chain"),
+ PublicKey: nil, // Force certificate parsing
+ }
+
+ _, err = storer.Store(ctx, &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{
+ Artifact: ref,
+ Payload: payload,
+ Bundle: bundle,
+ })
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("Expected error, but got nil")
+ } else if !strings.Contains(err.Error(), "no public key available") {
+ t.Errorf("Expected 'no public key available' error, got: %v", err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestSimpleStorer_PublicKeyPriority(t *testing.T) {
+ // Test that public key takes priority over certificate in simple storage
+ _, cert1PEM := createTestCertAndKeyForSimple(t)
+ publicKey2, _ := createTestCertAndKeyForSimple(t) // Different key
+
+ // Setup test registry
+ s := httptest.NewServer(registry.New())
+ defer s.Close()
+ registryName := strings.TrimPrefix(s.URL, "http://")
+
+ // Create test image
+ 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 := NewSimpleStorerFromConfig(
+ WithTargetRepository(ref.Repository),
+ WithFormat(config.OCIFormatProtobuf),
+ )
+ if err != nil {
+ t.Fatalf("failed to create storer: %v", err)
+ }
+
+ ctx := logtesting.TestContextWithLogger(t)
+
+ payload := simple.NewSimpleStruct(ref)
+
+ // Bundle with both public key and certificate - should use public key
+ bundle := &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: cert1PEM, // This has publicKey1
+ Chain: []byte("test-chain"),
+ PublicKey: publicKey2, // This should take priority
+ }
+
+ // This should succeed and use publicKey2, not publicKey1 from certificate
+ _, err = storer.Store(ctx, &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{
+ Artifact: ref,
+ Payload: payload,
+ Bundle: bundle,
+ })
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+}
+
+func TestSimpleStorer_BackwardCompatibility(t *testing.T) {
+ // Test that legacy workflows (Fulcio/Rekor) continue to work
+ publicKey, certPEM := createTestCertAndKeyForSimple(t)
+
+ // Setup test registry
+ s := httptest.NewServer(registry.New())
+ defer s.Close()
+ registryName := strings.TrimPrefix(s.URL, "http://")
+
+ // Create test image
+ 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)
+ }
+
+ tests := []struct {
+ name string
+ format string
+ bundle *signing.Bundle
+ }{
+ {
+ name: "legacy format with certificate only",
+ format: config.OCIFormatLegacy,
+ bundle: &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: nil, // Legacy workflows don't have public key
+ },
+ },
+ {
+ name: "referrers-api format with certificate only",
+ format: config.OCIFormatReferrersAPI,
+ bundle: &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: nil, // Legacy workflows don't have public key
+ },
+ },
+ {
+ name: "protobuf format with certificate only",
+ format: config.OCIFormatProtobuf,
+ bundle: &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: nil, // Should extract from certificate
+ },
+ },
+ {
+ name: "protobuf format with public key",
+ format: config.OCIFormatProtobuf,
+ bundle: &signing.Bundle{
+ Content: []byte(`{"critical": {"identity": {"docker-reference": ""}, "image": {"docker-manifest-digest": "test"}, "type": "cosign container image signature"}}`),
+ Signature: []byte("test-signature"),
+ Cert: certPEM,
+ Chain: []byte("test-chain"),
+ PublicKey: publicKey, // New workflow with public key
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ storer, err := NewSimpleStorerFromConfig(
+ WithTargetRepository(ref.Repository),
+ WithFormat(tt.format),
+ )
+ if err != nil {
+ t.Fatalf("failed to create storer: %v", err)
+ }
+
+ ctx := logtesting.TestContextWithLogger(t)
+
+ payload := simple.NewSimpleStruct(ref)
+
+ _, err = storer.Store(ctx, &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{
+ Artifact: ref,
+ Payload: payload,
+ Bundle: tt.bundle,
+ })
+
+ if err != nil {
+ t.Errorf("Backward compatibility test failed for %s: %v", tt.name, err)
+ }
+ })
+ }
+}
diff --git a/pkg/chains/storage/tekton/tekton.go b/pkg/chains/storage/tekton/tekton.go
index b79a8a315e..b21c56d1ad 100644
--- a/pkg/chains/storage/tekton/tekton.go
+++ b/pkg/chains/storage/tekton/tekton.go
@@ -72,6 +72,7 @@ func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, ra
Signature: []byte(signature),
Cert: []byte(opts.Cert),
Chain: []byte(opts.Chain),
+ PublicKey: opts.PublicKey,
},
}); err != nil {
logger.Errorf("error writing to Tekton object: %w", err)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 4b825f2d74..6ae4f51e9f 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -118,6 +118,7 @@ type GCSStorageConfig struct {
type OCIStorageConfig struct {
Repository string
Insecure bool
+ Format string // Replaces the deprecated ReferrersAPI bool field
}
type TektonStorageConfig struct {
@@ -179,6 +180,8 @@ const (
gcsBucketKey = "storage.gcs.bucket"
ociRepositoryKey = "storage.oci.repository"
ociRepositoryInsecureKey = "storage.oci.repository.insecure"
+ ociStorageFormatKey = "storage.oci.format"
+ ociReferrersAPIKey = "storage.oci.referrers-api" //nolint:gosec // G101: This is a config key, not hardcoded credentials - DEPRECATED
docDBUrlKey = "storage.docdb.url"
docDBMongoServerURLKey = "storage.docdb.mongo-server-url"
docDBMongoServerURLDirKey = "storage.docdb.mongo-server-url-dir"
@@ -227,12 +230,47 @@ const (
buildTypeKey = "builddefinition.buildtype"
ChainsConfig = "chains-config"
+
+ // Valid OCI storage formats
+ OCIFormatLegacy = "legacy" // Default: tag-based storage
+ OCIFormatReferrersAPI = "referrers-api" // OCI 1.1 referrers API with DSSE
+ OCIFormatProtobuf = "protobuf-bundle" // Protobuf bundle format
)
func (artifact *Artifact) Enabled() bool {
return !(artifact.StorageBackend.Len() == 1 && artifact.StorageBackend.Has(""))
}
+// validateOCIFormat validates the OCI storage format
+func validateOCIFormat(format string) error {
+ if format == "" {
+ return nil // Empty defaults to legacy
+ }
+
+ validFormats := []string{OCIFormatLegacy, OCIFormatReferrersAPI, OCIFormatProtobuf}
+ for _, valid := range validFormats {
+ if format == valid {
+ return nil
+ }
+ }
+ return fmt.Errorf("invalid storage.oci.format: %s, must be one of: %v", format, validFormats)
+}
+
+// migrateOCIConfig handles backward compatibility migration from referrers-api boolean to format string
+func migrateOCIConfig(data map[string]string, cfg *Config) {
+ // Check for deprecated referrers-api setting
+ if referrersAPI, exists := data[ociReferrersAPIKey]; exists {
+ // Only migrate if new format not already set
+ if cfg.Storage.OCI.Format == "" {
+ if referrersAPI == "true" {
+ cfg.Storage.OCI.Format = OCIFormatProtobuf // Old behavior was protobuf bundle
+ } else {
+ cfg.Storage.OCI.Format = OCIFormatLegacy
+ }
+ }
+ }
+}
+
func defaultConfig() *Config {
return &Config{
Artifacts: ArtifactConfigs{
@@ -264,6 +302,9 @@ func defaultConfig() *Config {
},
},
Storage: StorageConfigs{
+ OCI: OCIStorageConfig{
+ Format: OCIFormatLegacy, // Default format
+ },
Grafeas: GrafeasConfig{
NoteHint: "This attestation note was generated by Tekton Chains",
},
@@ -310,6 +351,7 @@ func NewConfigFromMap(data map[string]string) (*Config, error) {
asString(gcsBucketKey, &cfg.Storage.GCS.Bucket),
asString(ociRepositoryKey, &cfg.Storage.OCI.Repository),
asBool(ociRepositoryInsecureKey, &cfg.Storage.OCI.Insecure),
+ asString(ociStorageFormatKey, &cfg.Storage.OCI.Format),
asString(docDBUrlKey, &cfg.Storage.DocDB.URL),
asString(docDBMongoServerURLKey, &cfg.Storage.DocDB.MongoServerURL),
asString(docDBMongoServerURLDirKey, &cfg.Storage.DocDB.MongoServerURLDir),
@@ -351,6 +393,27 @@ func NewConfigFromMap(data map[string]string) (*Config, error) {
return nil, fmt.Errorf("failed to parse data: %w", err)
}
+ // Reset OCI format if it's just the default and check for migration
+ if cfg.Storage.OCI.Format == OCIFormatLegacy {
+ // Only reset if no explicit format was set
+ if _, exists := data[ociStorageFormatKey]; !exists {
+ cfg.Storage.OCI.Format = ""
+ }
+ }
+
+ // Handle backward compatibility migration for OCI configuration
+ migrateOCIConfig(data, cfg)
+
+ // Set default if empty (after migration to preserve migration behavior)
+ if cfg.Storage.OCI.Format == "" {
+ cfg.Storage.OCI.Format = OCIFormatLegacy
+ }
+
+ // Validate OCI format
+ if err := validateOCIFormat(cfg.Storage.OCI.Format); err != nil {
+ return nil, err
+ }
+
return cfg, nil
}
diff --git a/pkg/config/config_format_test.go b/pkg/config/config_format_test.go
new file mode 100644
index 0000000000..4d58b6de6c
--- /dev/null
+++ b/pkg/config/config_format_test.go
@@ -0,0 +1,406 @@
+/*
+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 config
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestValidateOCIFormat(t *testing.T) {
+ tests := []struct {
+ name string
+ format string
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "valid legacy format",
+ format: OCIFormatLegacy,
+ expectError: false,
+ },
+ {
+ name: "valid referrers-api format",
+ format: OCIFormatReferrersAPI,
+ expectError: false,
+ },
+ {
+ name: "valid protobuf-bundle format",
+ format: OCIFormatProtobuf,
+ expectError: false,
+ },
+ {
+ name: "empty format (should be valid - defaults to legacy)",
+ format: "",
+ expectError: false,
+ },
+ {
+ name: "invalid format",
+ format: "invalid-format",
+ expectError: true,
+ errorMsg: "invalid storage.oci.format: invalid-format, must be one of:",
+ },
+ {
+ name: "case sensitive validation",
+ format: "Legacy", // wrong case
+ expectError: true,
+ errorMsg: "invalid storage.oci.format: Legacy, must be one of:",
+ },
+ {
+ name: "whitespace format",
+ format: " legacy ",
+ expectError: true,
+ errorMsg: "invalid storage.oci.format: legacy , must be one of:",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateOCIFormat(tt.format)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("validateOCIFormat(%q) expected error but got none", tt.format)
+ } else if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) {
+ t.Errorf("validateOCIFormat(%q) error = %v, expected to contain %q", tt.format, err, tt.errorMsg)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("validateOCIFormat(%q) unexpected error = %v", tt.format, err)
+ }
+ }
+ })
+ }
+}
+
+func TestOCIFormatConstants(t *testing.T) {
+ // Test that the constants have expected values
+ expectedConstants := map[string]string{
+ "OCIFormatLegacy": "legacy",
+ "OCIFormatReferrersAPI": "referrers-api",
+ "OCIFormatProtobuf": "protobuf-bundle",
+ }
+
+ actualConstants := map[string]string{
+ "OCIFormatLegacy": OCIFormatLegacy,
+ "OCIFormatReferrersAPI": OCIFormatReferrersAPI,
+ "OCIFormatProtobuf": OCIFormatProtobuf,
+ }
+
+ if diff := cmp.Diff(expectedConstants, actualConstants); diff != "" {
+ t.Errorf("OCI format constants mismatch (-expected +actual):\n%s", diff)
+ }
+}
+
+func TestOCIStorageFormatConfiguration(t *testing.T) {
+ tests := []struct {
+ name string
+ data map[string]string
+ expectedFormat string
+ expectedError bool
+ errorContains string
+ }{
+ {
+ name: "explicit legacy format",
+ data: map[string]string{ociStorageFormatKey: OCIFormatLegacy},
+ expectedFormat: OCIFormatLegacy,
+ },
+ {
+ name: "explicit referrers-api format",
+ data: map[string]string{ociStorageFormatKey: OCIFormatReferrersAPI},
+ expectedFormat: OCIFormatReferrersAPI,
+ },
+ {
+ name: "explicit protobuf-bundle format",
+ data: map[string]string{ociStorageFormatKey: OCIFormatProtobuf},
+ expectedFormat: OCIFormatProtobuf,
+ },
+ {
+ name: "no format specified (should default to legacy)",
+ data: map[string]string{},
+ expectedFormat: OCIFormatLegacy,
+ },
+ {
+ name: "empty format string (should default to legacy)",
+ data: map[string]string{ociStorageFormatKey: ""},
+ expectedFormat: OCIFormatLegacy,
+ },
+ {
+ name: "invalid format",
+ data: map[string]string{ociStorageFormatKey: "invalid"},
+ expectedError: true,
+ errorContains: "invalid storage.oci.format: invalid",
+ },
+ {
+ name: "format with other OCI config",
+ data: map[string]string{
+ ociStorageFormatKey: OCIFormatReferrersAPI,
+ ociRepositoryKey: "example.com/repo",
+ ociRepositoryInsecureKey: "true",
+ },
+ expectedFormat: OCIFormatReferrersAPI,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg, err := NewConfigFromMap(tt.data)
+
+ if tt.expectedError {
+ if err == nil {
+ t.Fatalf("NewConfigFromMap() expected error but got none")
+ }
+ if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) {
+ t.Errorf("NewConfigFromMap() error = %v, expected to contain %q", err, tt.errorContains)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("NewConfigFromMap() unexpected error = %v", err)
+ }
+
+ if cfg.Storage.OCI.Format != tt.expectedFormat {
+ t.Errorf("Storage.OCI.Format = %q, expected %q", cfg.Storage.OCI.Format, tt.expectedFormat)
+ }
+ })
+ }
+}
+
+func TestMigrateOCIConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ data map[string]string
+ initialConfig *Config
+ expectedFormat string
+ description string
+ }{
+ {
+ name: "migrate from referrers-api true to protobuf format",
+ data: map[string]string{
+ ociReferrersAPIKey: "true",
+ },
+ initialConfig: &Config{
+ Storage: StorageConfigs{
+ OCI: OCIStorageConfig{}, // Empty format should trigger migration
+ },
+ },
+ expectedFormat: OCIFormatProtobuf, // Legacy behavior was protobuf bundle
+ description: "When referrers-api=true and no format set, should migrate to protobuf-bundle format",
+ },
+ {
+ name: "migrate from referrers-api false to legacy format",
+ data: map[string]string{
+ ociReferrersAPIKey: "false",
+ },
+ initialConfig: &Config{
+ Storage: StorageConfigs{
+ OCI: OCIStorageConfig{}, // Empty format should trigger migration
+ },
+ },
+ expectedFormat: OCIFormatLegacy,
+ description: "When referrers-api=false and no format set, should migrate to legacy",
+ },
+ {
+ name: "no format migration when format already set",
+ data: map[string]string{
+ ociReferrersAPIKey: "true",
+ ociStorageFormatKey: OCIFormatReferrersAPI,
+ },
+ initialConfig: &Config{
+ Storage: StorageConfigs{
+ OCI: OCIStorageConfig{
+ Format: OCIFormatReferrersAPI, // Format already set
+ },
+ },
+ },
+ expectedFormat: OCIFormatReferrersAPI, // Should not be overridden
+ description: "When format already set, should not migrate format from referrers-api boolean",
+ },
+ {
+ name: "no migration when neither setting present",
+ data: map[string]string{
+ // Neither format nor referrers-api set
+ },
+ initialConfig: &Config{
+ Storage: StorageConfigs{
+ OCI: OCIStorageConfig{}, // Empty
+ },
+ },
+ expectedFormat: "", // Should remain empty before default assignment
+ description: "When neither format nor referrers-api set, no migration should occur",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a copy of the initial config to avoid modifying the test case
+ cfg := &Config{
+ Storage: StorageConfigs{
+ OCI: OCIStorageConfig{
+ Format: tt.initialConfig.Storage.OCI.Format,
+ },
+ },
+ }
+
+ // Apply migration
+ migrateOCIConfig(tt.data, cfg)
+
+ if cfg.Storage.OCI.Format != tt.expectedFormat {
+ t.Errorf("%s: Format = %q, expected %q", tt.description, cfg.Storage.OCI.Format, tt.expectedFormat)
+ }
+ })
+ }
+}
+
+func TestBackwardCompatibilityIntegration(t *testing.T) {
+ // Integration tests that verify the complete migration flow through NewConfigFromMap
+ tests := []struct {
+ name string
+ data map[string]string
+ expectedFormat string
+ description string
+ }{
+ {
+ name: "legacy config migration: referrers-api=true",
+ data: map[string]string{
+ ociReferrersAPIKey: "true",
+ },
+ expectedFormat: OCIFormatProtobuf,
+ description: "Legacy referrers-api=true should migrate to protobuf-bundle format",
+ },
+ {
+ name: "legacy config migration: referrers-api=false",
+ data: map[string]string{
+ ociReferrersAPIKey: "false",
+ },
+ expectedFormat: OCIFormatLegacy,
+ description: "Legacy referrers-api=false should migrate to legacy format",
+ },
+ {
+ name: "new format takes precedence over legacy setting",
+ data: map[string]string{
+ ociStorageFormatKey: OCIFormatReferrersAPI,
+ ociReferrersAPIKey: "false", // This should be ignored
+ },
+ expectedFormat: OCIFormatReferrersAPI,
+ description: "New format setting should take precedence over legacy boolean",
+ },
+ {
+ name: "explicit format configuration",
+ data: map[string]string{
+ ociStorageFormatKey: OCIFormatProtobuf,
+ },
+ expectedFormat: OCIFormatProtobuf,
+ description: "Explicit format setting should be preserved",
+ },
+ {
+ name: "default configuration",
+ data: map[string]string{},
+ expectedFormat: OCIFormatLegacy, // Should default to legacy
+ description: "Default configuration should use legacy format",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg, err := NewConfigFromMap(tt.data)
+ if err != nil {
+ t.Fatalf("NewConfigFromMap() unexpected error = %v", err)
+ }
+
+ if cfg.Storage.OCI.Format != tt.expectedFormat {
+ t.Errorf("%s: Format = %q, expected %q", tt.description, cfg.Storage.OCI.Format, tt.expectedFormat)
+ }
+ })
+ }
+}
+
+func TestOCIFormatConfigMap(t *testing.T) {
+ // Test that configuration works through ConfigMap interface as well
+ tests := []struct {
+ name string
+ configMapData map[string]string
+ expectedFormat string
+ expectedError bool
+ }{
+ {
+ name: "configmap with new format",
+ configMapData: map[string]string{
+ ociStorageFormatKey: OCIFormatReferrersAPI,
+ },
+ expectedFormat: OCIFormatReferrersAPI,
+ },
+ {
+ name: "configmap with legacy boolean",
+ configMapData: map[string]string{
+ ociReferrersAPIKey: "true",
+ },
+ expectedFormat: OCIFormatProtobuf,
+ },
+ {
+ name: "configmap with invalid format",
+ configMapData: map[string]string{
+ ociStorageFormatKey: "invalid-format",
+ },
+ expectedError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cm := &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "chains-config",
+ },
+ Data: tt.configMapData,
+ }
+
+ cfg, err := NewConfigFromConfigMap(cm)
+
+ if tt.expectedError {
+ if err == nil {
+ t.Fatalf("NewConfigFromConfigMap() expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("NewConfigFromConfigMap() unexpected error = %v", err)
+ }
+
+ if cfg.Storage.OCI.Format != tt.expectedFormat {
+ t.Errorf("Storage.OCI.Format = %q, expected %q", cfg.Storage.OCI.Format, tt.expectedFormat)
+ }
+ })
+ }
+}
+
+// Helper function to check if a string contains a substring
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
+ (len(s) > len(substr) && someContains(s, substr)))
+}
+
+func someContains(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/config/config_referrers_test.go b/pkg/config/config_referrers_test.go
new file mode 100644
index 0000000000..09d541b075
--- /dev/null
+++ b/pkg/config/config_referrers_test.go
@@ -0,0 +1,98 @@
+/*
+Copyright 2023 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 config
+
+import (
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestReferrersAPIMigrationConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ data map[string]string
+ expectedFormat string
+ }{
+ {
+ name: "referrers api enabled migrates to protobuf",
+ data: map[string]string{
+ "storage.oci.referrers-api": "true",
+ },
+ expectedFormat: OCIFormatProtobuf,
+ },
+ {
+ name: "referrers api disabled migrates to legacy",
+ data: map[string]string{
+ "storage.oci.referrers-api": "false",
+ },
+ expectedFormat: OCIFormatLegacy,
+ },
+ {
+ name: "no referrers api setting defaults to legacy",
+ data: map[string]string{},
+ expectedFormat: OCIFormatLegacy,
+ },
+ {
+ name: "referrers api with other OCI config",
+ data: map[string]string{
+ "storage.oci.repository": "example.com/repo",
+ "storage.oci.referrers-api": "true",
+ },
+ expectedFormat: OCIFormatProtobuf,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ cm := &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "chains-config",
+ },
+ Data: test.data,
+ }
+
+ cfg, err := NewConfigFromConfigMap(cm)
+ if err != nil {
+ t.Fatalf("Failed to create config: %v", err)
+ }
+
+ if cfg.Storage.OCI.Format != test.expectedFormat {
+ t.Errorf("Expected Format to be %v, got %v", test.expectedFormat, cfg.Storage.OCI.Format)
+ }
+ })
+ }
+}
+
+func TestReferrersAPIMigrationFromMap(t *testing.T) {
+ // Test that the config can be created from map data and migration works
+ data := map[string]string{
+ "storage.oci.referrers-api": "true",
+ "storage.oci.repository": "example.com/repo",
+ }
+
+ cfg, err := NewConfigFromMap(data)
+ if err != nil {
+ t.Fatalf("Failed to create config from map: %v", err)
+ }
+
+ if cfg.Storage.OCI.Format != OCIFormatProtobuf {
+ t.Errorf("Expected Format to be %s, got %s", OCIFormatProtobuf, cfg.Storage.OCI.Format)
+ }
+
+ if cfg.Storage.OCI.Repository != "example.com/repo" {
+ t.Errorf("Expected repository to be 'example.com/repo', got %s", cfg.Storage.OCI.Repository)
+ }
+}
diff --git a/pkg/config/options.go b/pkg/config/options.go
index 8460db6f9a..823e02257d 100644
--- a/pkg/config/options.go
+++ b/pkg/config/options.go
@@ -19,6 +19,8 @@ package config
// 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
+import "crypto"
+
type PayloadType string
// StorageOpts contains additional information required when storing signatures
@@ -45,6 +47,10 @@ type StorageOpts struct {
// https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md
Chain string
+ // PublicKey contains the public key that can be used to verify the signature.
+ // This is extracted from the signer and is available for storage backends that need it.
+ PublicKey crypto.PublicKey
+
// PayloadFormat is the format to store payload in.
PayloadFormat PayloadType
}
diff --git a/pkg/config/options_test.go b/pkg/config/options_test.go
new file mode 100644
index 0000000000..6d2ab4d8d6
--- /dev/null
+++ b/pkg/config/options_test.go
@@ -0,0 +1,239 @@
+/*
+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 config
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "reflect"
+ "testing"
+)
+
+func TestStorageOpts_PublicKey(t *testing.T) {
+ // Generate a test RSA key pair
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed to generate RSA key: %v", err)
+ }
+ publicKey := &privateKey.PublicKey
+
+ tests := []struct {
+ name string
+ opts StorageOpts
+ want crypto.PublicKey
+ }{
+ {
+ name: "StorageOpts with PublicKey set",
+ opts: StorageOpts{
+ FullKey: "test-full-key",
+ ShortKey: "test-short",
+ Cert: "test-cert",
+ Chain: "test-chain",
+ PublicKey: publicKey,
+ PayloadFormat: "tekton",
+ },
+ want: publicKey,
+ },
+ {
+ name: "StorageOpts with nil PublicKey",
+ opts: StorageOpts{
+ FullKey: "test-full-key",
+ ShortKey: "test-short",
+ Cert: "test-cert",
+ Chain: "test-chain",
+ PublicKey: nil,
+ PayloadFormat: "tekton",
+ },
+ want: nil,
+ },
+ {
+ name: "StorageOpts without PublicKey field populated",
+ opts: StorageOpts{
+ FullKey: "test-full-key",
+ ShortKey: "test-short",
+ Cert: "test-cert",
+ Chain: "test-chain",
+ PayloadFormat: "tekton",
+ },
+ want: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.opts.PublicKey != tt.want {
+ t.Errorf("StorageOpts.PublicKey = %v, want %v", tt.opts.PublicKey, tt.want)
+ }
+
+ // Test that PublicKey field can be set and retrieved
+ if tt.want != nil {
+ // Verify the key is the expected type
+ if _, ok := tt.opts.PublicKey.(*rsa.PublicKey); !ok {
+ t.Errorf("Expected PublicKey to be *rsa.PublicKey, got %T", tt.opts.PublicKey)
+ }
+
+ // Verify the key matches our test key
+ rsaKey, ok := tt.opts.PublicKey.(*rsa.PublicKey)
+ if !ok {
+ t.Fatalf("PublicKey is not *rsa.PublicKey")
+ }
+ expectedRSAKey, ok := tt.want.(*rsa.PublicKey)
+ if !ok {
+ t.Fatalf("Expected key is not *rsa.PublicKey")
+ }
+
+ if rsaKey.N.Cmp(expectedRSAKey.N) != 0 || rsaKey.E != expectedRSAKey.E {
+ t.Errorf("PublicKey does not match expected key")
+ }
+ }
+ })
+ }
+}
+
+func TestStorageOpts_JSON_Serialization(t *testing.T) {
+ // Test that StorageOpts can handle JSON serialization/deserialization
+ // Note: crypto.PublicKey cannot be directly JSON marshaled, but we test
+ // that the struct can be marshaled with nil PublicKey field
+ tests := []struct {
+ name string
+ opts StorageOpts
+ wantErr bool
+ }{
+ {
+ name: "StorageOpts with nil PublicKey",
+ opts: StorageOpts{
+ FullKey: "test-full-key",
+ ShortKey: "test-short",
+ Cert: "test-cert",
+ Chain: "test-chain",
+ PublicKey: nil,
+ PayloadFormat: "tekton",
+ },
+ wantErr: false,
+ },
+ {
+ name: "Empty StorageOpts",
+ opts: StorageOpts{},
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Marshal to JSON
+ data, err := json.Marshal(tt.opts)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("json.Marshal() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if !tt.wantErr {
+ // Unmarshal from JSON
+ var decoded StorageOpts
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Errorf("json.Unmarshal() error = %v", err)
+ return
+ }
+
+ // Compare non-PublicKey fields (PublicKey will be nil after JSON round-trip)
+ if decoded.FullKey != tt.opts.FullKey ||
+ decoded.ShortKey != tt.opts.ShortKey ||
+ decoded.Cert != tt.opts.Cert ||
+ decoded.Chain != tt.opts.Chain ||
+ decoded.PayloadFormat != tt.opts.PayloadFormat {
+ t.Errorf("JSON round-trip failed, decoded = %+v, want %+v", decoded, tt.opts)
+ }
+
+ // PublicKey should be nil after JSON round-trip since crypto.PublicKey
+ // cannot be JSON marshaled directly
+ if decoded.PublicKey != nil {
+ t.Errorf("Expected PublicKey to be nil after JSON round-trip, got %v", decoded.PublicKey)
+ }
+ }
+ })
+ }
+}
+
+func TestStorageOpts_FieldTypes(t *testing.T) {
+ // Test that all fields have the expected types
+ opts := StorageOpts{}
+
+ // Get the type reflection
+ optsType := reflect.TypeOf(opts)
+
+ expectedFields := map[string]string{
+ "FullKey": "string",
+ "ShortKey": "string",
+ "Cert": "string",
+ "Chain": "string",
+ "PublicKey": "crypto.PublicKey",
+ "PayloadFormat": "config.PayloadType",
+ }
+
+ for fieldName, expectedType := range expectedFields {
+ field, found := optsType.FieldByName(fieldName)
+ if !found {
+ t.Errorf("Field %s not found in StorageOpts", fieldName)
+ continue
+ }
+
+ actualType := field.Type.String()
+ if actualType != expectedType {
+ t.Errorf("Field %s has type %s, expected %s", fieldName, actualType, expectedType)
+ }
+ }
+}
+
+func TestStorageOpts_Copy(t *testing.T) {
+ // Generate a test key
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed to generate RSA key: %v", err)
+ }
+ publicKey := &privateKey.PublicKey
+
+ original := StorageOpts{
+ FullKey: "test-full-key",
+ ShortKey: "test-short",
+ Cert: "test-cert",
+ Chain: "test-chain",
+ PublicKey: publicKey,
+ PayloadFormat: "tekton",
+ }
+
+ // Test shallow copy behavior
+ copy := original
+
+ // Modify the copy's string fields
+ copy.FullKey = "modified-full-key"
+ copy.ShortKey = "modified-short"
+
+ // Original should be unchanged for string fields
+ if original.FullKey != "test-full-key" {
+ t.Errorf("Original FullKey changed after copy modification")
+ }
+ if original.ShortKey != "test-short" {
+ t.Errorf("Original ShortKey changed after copy modification")
+ }
+
+ // PublicKey should point to the same object (reference copy)
+ if original.PublicKey != copy.PublicKey {
+ t.Errorf("PublicKey references should be the same after copy")
+ }
+}
\ No newline at end of file
diff --git a/pkg/config/store_test.go b/pkg/config/store_test.go
index 88895d80a2..db6566514b 100644
--- a/pkg/config/store_test.go
+++ b/pkg/config/store_test.go
@@ -116,6 +116,9 @@ var defaultArtifacts = ArtifactConfigs{
}
var defaultStorage = StorageConfigs{
+ OCI: OCIStorageConfig{
+ Format: OCIFormatLegacy, // Default format is now legacy
+ },
Grafeas: GrafeasConfig{
NoteHint: "This attestation note was generated by Tekton Chains",
},
@@ -179,6 +182,9 @@ func TestParse(t *testing.T) {
Artifacts: defaultArtifacts,
Signers: defaultSigners,
Storage: StorageConfigs{
+ OCI: OCIStorageConfig{
+ Format: OCIFormatLegacy, // Default format is now legacy
+ },
Grafeas: GrafeasConfig{
NoteHint: "a test message",
},
diff --git a/pkg/reconciler/filter.go b/pkg/reconciler/filter.go
index 62788832f2..ba18b72f49 100644
--- a/pkg/reconciler/filter.go
+++ b/pkg/reconciler/filter.go
@@ -14,9 +14,10 @@ limitations under the License.
package reconciler
import (
+ "slices"
+
v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1"
"knative.dev/pkg/controller"
- "slices"
)
// PipelineRunInformerFilterFunc returns a filter function