From 276c83e919bc90441c2c107586f85fb01ab7bb76 Mon Sep 17 00:00:00 2001 From: Anitha Natarajan Date: Tue, 2 Jun 2026 23:14:25 +0530 Subject: [PATCH] feat: add OCI 1.1 Referrers API support with configurable distribution Signed-off-by: Anitha Natarajan Co-authored-by: Copilot --- docs/config.md | 3 + ...ct-distribution-format-referrers-schema.md | 179 ++++++++++++++++++ pkg/chains/signing.go | 11 ++ pkg/chains/signing/iface.go | 6 + pkg/chains/storage/oci/attestation.go | 114 +++++++++-- pkg/chains/storage/oci/legacy.go | 30 ++- pkg/chains/storage/oci/oci_test.go | 132 +++++++++++++ pkg/chains/storage/oci/options.go | 35 ++++ pkg/chains/storage/oci/simple.go | 93 +++++++-- pkg/chains/storage/oci/simple_test.go | 169 +++++++++++++++++ pkg/config/config.go | 46 ++++- pkg/config/config_test.go | 45 +++++ pkg/config/options.go | 7 + pkg/config/store_test.go | 6 + 14 files changed, 836 insertions(+), 40 deletions(-) create mode 100644 docs/oci-artifact-distribution-format-referrers-schema.md diff --git a/docs/config.md b/docs/config.md index a6badf7209..edd42fa052 100644 --- a/docs/config.md +++ b/docs/config.md @@ -69,6 +69,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.repository.insecure` | Whether to use insecure connection when connecting to the OCI repository | `true`, `false` | `false` | +| `storage.oci.distribution-method` | Controls how OCI signatures and attestations are attached to images in the registry, and implicitly the payload encoding: `legacy` uses tag-based storage with DSSE payloads, `referrers-api` uses the OCI 1.1 Referrers API with Sigstore protobuf-bundle attestations. See [OCI Artifact Distribution (Referrers)](oci-artifact-distribution-format-referrers-schema.md) for details. | `legacy`, `referrers-api` | `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` | | @@ -90,6 +91,8 @@ Supported keys include: > > **Recommendation**: Only use `storage.oci.repository.insecure: true` in development or test environments. For production deployments, always use secure HTTPS connections with valid TLS certificates (`storage.oci.repository.insecure: false`, which is the default). +For a full description of each format and registry compatibility see [OCI Artifact Distribution (Referrers)](oci-artifact-distribution-format-referrers-schema.md). + #### docstore You can read about the go-cloud docstore URI format [here](https://gocloud.dev/howto/docstore/). Tekton Chains supports the following docstore services: diff --git a/docs/oci-artifact-distribution-format-referrers-schema.md b/docs/oci-artifact-distribution-format-referrers-schema.md new file mode 100644 index 0000000000..913d9b82dc --- /dev/null +++ b/docs/oci-artifact-distribution-format-referrers-schema.md @@ -0,0 +1,179 @@ + + +# OCI Artifact Distribution: the Referrers Schema + +When Chains signs an image, it has to put the signature and the attestation +somewhere. Both are stored in the same registry as the image. This page explains +the two ways Chains can do that, and how to turn on the newer one. + +If you don't change anything, Chains uses the older tag-based layout and just +works. Read on if you want to use the OCI 1.1 Referrers API instead. + +## The problem with the old layout + +cosign, which Chains uses under the hood, has always stored a signature by +pushing an extra tag next to the image. For an image at digest `sha256:abc...`, +it creates tags like `sha256-abc....sig` and `sha256-abc....att`. + +This works on every registry, but it isn't ideal: + +- The registry fills up with extra tags that aren't real images. +- These tags are easy to confuse with actual image tags. +- No OCI standard describes this layout, so every tool has to special-case it. + +## What the Referrers schema is + +The [OCI 1.1 distribution spec](https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers) +added a standard way to attach one artifact to another. Instead of inventing a +tag, you push the signature or attestation as its own manifest that records the +image it belongs to in a `subject` field. The registry can then answer a simple +question: "what artifacts refer to this image?" + +This is the **Referrers schema**. The payoff is no extra tags, a clean registry, +and a standard that registries and policy tools already understand. + +## How cosign enables it + +cosign (and the `go-containerregistry` library it uses) already speaks the +Referrers schema. If the registry supports the Referrers API natively, cosign +uses it. If it doesn't, cosign falls back to the spec's **referrers tag schema**: +it keeps a single `sha256-` index tag and uses it to track referrers. + +Either way it is still "referrers mode" — the content is the same, no `.sig` or +`.att` tags are created, and `cosign verify` and `oras discover` both work. This +fallback is automatic and needs no configuration, so it covers registries that +don't yet have native support. + +## How Chains enables it + +Chains exposes this through a single config flag in the `chains-config` +ConfigMap: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: chains-config + namespace: tekton-chains +data: + storage.oci.distribution-method: "referrers-api" +``` + +| Value | What Chains does | +|---|---| +| `legacy` (default) | Old tag-based layout (`.sig` / `.att` tags), DSSE-encoded. Works everywhere. | +| `referrers-api` | OCI 1.1 Referrers schema. No extra tags, with automatic fallback on registries that lack native support. | + +That's the only knob. You pick *where* artifacts go, and Chains picks the right +encoding to match (explained next). + +> [!NOTE] +> This flag only takes effect when the OCI storage backend is in use — that is, +> when `artifacts.oci.storage`, `artifacts.taskrun.storage`, or +> `artifacts.pipelinerun.storage` includes `oci`. If you store signatures and +> attestations somewhere else (for example Tekton results, a docstore, or +> Grafeas), `storage.oci.distribution-method` has no effect. + +> [!TIP] +> The cosign bundled with Chains is new enough for everything described here, so +> you don't need to install anything or pass extra flags. + +## Why referrers + protobuf, and not referrers + DSSE + +There is one subtlety worth understanding: the *encoding* of the payload. + +- In **legacy** mode, the attestation is a **DSSE** envelope. This is what cosign + has always written and what existing tooling verifies. +- In **referrers** mode, Chains writes the attestation as a **Sigstore protobuf + bundle**. + +You might expect a third option — referrers with DSSE — but it's a dead end in +practice. cosign's verification for referrer-stored attestations expects the +protobuf bundle, not a DSSE envelope. A DSSE-over-referrers attestation can be +written, but `cosign verify-attestation` won't reliably verify it. The industry +is also moving toward the protobuf bundle as the standard format. + +So Chains keeps it simple: legacy means DSSE, referrers means protobuf. The two +are tied together on purpose — it avoids a confusing matrix of combinations, +some of which no tool can actually verify. If a genuine need for a separate +encoding option ever appears, it can be added later without changing this flag. + +Image **signatures** are not affected by this. In referrers mode they use +cosign's native signature manifest — the exact thing `cosign verify` looks for — +so signature verification works with no extra flags. + +## Verifying + +Verification is the same in both modes — point cosign at your key: + +```shell +# Verify a signature +cosign verify \ + --key k8s://tekton-chains/signing-secrets \ + @sha256: + +# Verify an attestation +cosign verify-attestation \ + --key k8s://tekton-chains/signing-secrets \ + --type slsaprovenance \ + @sha256: +``` + +To see what was stored, use [`oras`](https://oras.land/): + +```shell +oras discover @sha256: +``` + +## Things to keep in mind in referrers mode + +These are interoperability notes, not bugs in Chains. + +1. **`storage.oci.repository` is ignored.** A referrer has to live next to the + image it points at, so the override that redirects storage elsewhere doesn't + apply. Chains logs a warning and stores the referrer next to the image. The + override still works in `legacy` mode. + +2. **cosign's `--experimental-oci11` discovery may not find the attestation.** + Chains stores it as a protobuf bundle; that older discovery path filters on a + different type. The attestation is still there — `oras discover` shows it and + policy engines can use it — and `cosign verify` of the signature is + unaffected. + +3. **Some registries accept a write but don't return it on read.** If a registry + reports success but you can't read the referrer back, it isn't fully OCI 1.1 + compliant. Switch that registry to `legacy`. + +4. **`oras discover` may show a different `artifactType` per registry.** This is + display only; the stored content and verification don't change. + +5. **Concurrent writes can race on fallback registries.** Without the native API, + the index tag is updated with read-append-write, so simultaneous writes to the + same image can drop an entry. A registry with the native Referrers API avoids + this. + +## Registry compatibility + +cosign works with a wide range of registries, including AWS ECR, GCP Artifact +Registry, Docker Hub, Azure Container Registry, JFrog Artifactory, GitLab and +GitHub Container Registries, Harbor, and Quay. See the +[cosign registry support page](https://docs.sigstore.dev/cosign/system_config/registry_support/) +for the current list. + +| Registry | `legacy` | `referrers-api` | +|---|---|---| +| GCR, ECR, Artifact Registry, quay.io | ✓ | ✓ (native Referrers API) | +| GHCR (`ghcr.io`) | ✓ | ✓ (native API not exposed; cosign uses the tag-schema fallback) | +| Any other OCI registry | ✓ | ✓ (native API if available, otherwise tag-schema fallback) | + +## See also + +- [Chains configuration reference](config.md) — all `storage.oci.*` keys. +- [Signing](signing.md) — how signing keys and secrets are configured. +- [cosign registry support](https://docs.sigstore.dev/cosign/system_config/registry_support/) +- [OCI distribution spec — Listing Referrers](https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers) diff --git a/pkg/chains/signing.go b/pkg/chains/signing.go index 6676ef5ec5..e332642079 100644 --- a/pkg/chains/signing.go +++ b/pkg/chains/signing.go @@ -186,6 +186,16 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) } measureMetrics(ctx, metrics.SignedMessagesCount, o.Recorder) + // Attempt to extract the public key so storage backends that need it + // (e.g. protobuf-bundle OCI format) can use it without re-fetching. + // This is intentionally non-fatal: for the default legacy format the + // key is never used, so a transient KMS error here must not prevent + // signatures from being stored. + pubKey, pubKeyErr := signer.PublicKey() + if pubKeyErr != nil { + logger.Warnf("Could not extract public key from signer (will be unavailable to storage backends): %v", pubKeyErr) + } + // Now store those! for _, backend := range sets.List[string](signableType.StorageBackend(cfg)) { b, ok := o.Backends[backend] @@ -202,6 +212,7 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) 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..54679430cb 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,8 @@ 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 direct access to the key material + // (e.g. to create a cosign protobuf bundle without a certificate). + PublicKey crypto.PublicKey } diff --git a/pkg/chains/storage/oci/attestation.go b/pkg/chains/storage/oci/attestation.go index 9375e13605..e914d7a244 100644 --- a/pkg/chains/storage/oci/attestation.go +++ b/pkg/chains/storage/oci/attestation.go @@ -16,16 +16,23 @@ package oci import ( "context" + "crypto" + "crypto/x509" + "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 +47,8 @@ type AttestationStorer struct { repo *name.Repository // remoteOpts are additional remote options (i.e. auth) to use for client operations. remoteOpts []remote.Option + // distributionMethod specifies how artifacts are attached ("legacy" tag-based or "referrers-api"). + distributionMethod string } func NewAttestationStorer(opts ...AttestationStorerOption) (*AttestationStorer, error) { @@ -52,10 +61,8 @@ func NewAttestationStorer(opts ...AttestationStorerOption) (*AttestationStorer, return s, nil } -// Store saves the given statement. +// Store saves the given statement using the configured OCI storage format. func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[name.Digest, *intoto.Statement]) (*api.StoreResponse, error) { - logger := logging.FromContext(ctx) - repo := req.Artifact.Repository if s.repo != nil { repo = *s.repo @@ -65,20 +72,45 @@ func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[nam if errors.As(err, &entityNotFoundError) { se = ociremote.SignedUnknown(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...)) } else if err != nil { - return nil, errors.Wrap(err, "getting signed image") + return nil, errors.Wrap(err, "getting signed entity") + } + + switch s.distributionMethod { + case config.OCIDistributionReferrersAPI: + return s.storeReferrers(ctx, req, repo) + default: // OCIDistributionLegacy or empty + return s.storeLegacy(ctx, req, se, repo) + } +} + +// storeReferrers writes the attestation via the OCI 1.1 Referrers API using the +// Sigstore protobuf-bundle format. When the registry has no native Referrers API, +// cosign/go-containerregistry transparently uses the OCI referrers tag schema; +// either way no .att tags are created. +func (s *AttestationStorer) storeReferrers(ctx context.Context, req *api.StoreRequest[name.Digest, *intoto.Statement], repo name.Repository) (*api.StoreResponse, error) { + logger := logging.FromContext(ctx) + + if referrersRepoOverrideIgnored(repo, req.Artifact.Repository) { + logger.Warnf("storage.oci.repository override %q is ignored in referrers-api mode; OCI 1.1 referrers are stored alongside their subject image in %q", repo.String(), req.Artifact.Repository.String()) } - // Create the new attestation for this entity. + return s.storeWithProtobufBundle(ctx, req) +} + +// storeLegacy is the default tag-based attestation upload path. +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) + 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") } - // Check if an attestation with the same digest already exists. + // Skip upload if identical attestation already exists. newDigest, err := att.Digest() if err != nil { return nil, errors.Wrap(err, "getting new attestation digest") @@ -98,14 +130,74 @@ func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[nam 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 if err := ociremote.WriteAttestations(repo, newImage, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil { + return nil, errors.Wrap(err, "writing attestations") + } + logger.Infof("Successfully uploaded attestation using legacy format for %s", req.Artifact.String()) + return &api.StoreResponse{}, nil +} + +// storeWithProtobufBundle uploads attestations using cosign's protobuf bundle +// format over the OCI 1.1 Referrers API. +func (s *AttestationStorer) storeWithProtobufBundle(ctx context.Context, req *api.StoreRequest[name.Digest, *intoto.Statement]) (*api.StoreResponse, error) { + logger := logging.FromContext(ctx) + logger.Infof("Using protobuf bundle format for attestation storage (%s)", req.Artifact.String()) + + predicateType := req.Payload.PredicateType + if predicateType == "" { + return nil, errors.New("PredicateType is required for protobuf-bundle format") + } + + pubKey, err := resolvePubKey(req.Bundle.PublicKey, req.Bundle.Cert) + if err != nil { return nil, err } - logger.Infof("Successfully uploaded attestation for %s", req.Artifact.String()) + // req.Bundle.Signature is already a complete DSSE envelope (JSON) produced by + // the wrapped signer: its signature is computed over the DSSE PAE. MakeNewBundle + // expects exactly this envelope JSON as its `sig` argument - it extracts the + // PayloadType and the raw signature from it. Re-wrapping it in another envelope + // would place the whole envelope JSON into the inner sig field, producing a + // bundle whose signature does not verify ("Found: 0"). + var rekorEntry *models.LogEntryAnon + var timestampBytes []byte + var signerBytes []byte + if req.Bundle.Cert != nil { + signerBytes = req.Bundle.Cert + } + + bundleBytes, err := cbundle.MakeNewBundle(pubKey, rekorEntry, req.Bundle.Content, req.Bundle.Signature, signerBytes, timestampBytes) + if err != nil { + return nil, errors.Wrap(err, "creating protobuf bundle") + } + if err := ociremote.WriteAttestationNewBundleFormat(req.Artifact, bundleBytes, predicateType, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil { + return nil, errors.Wrap(err, "writing protobuf bundle attestation") + } + logger.Infof("Successfully uploaded attestation using protobuf bundle format for %s", req.Artifact.String()) return &api.StoreResponse{}, nil } + +// resolvePubKey returns the public key from the Bundle's explicit PublicKey field, +// or falls back to extracting it from the signer certificate bytes. +func resolvePubKey(explicit crypto.PublicKey, certPEM []byte) (crypto.PublicKey, error) { + if explicit != nil { + return explicit, nil + } + if len(certPEM) == 0 { + return nil, errors.New("no public key available: neither from signer nor from certificate") + } + block, _ := pem.Decode(certPEM) + var certBytes []byte + if block != nil { + certBytes = block.Bytes + } else { + certBytes = certPEM // assume DER + } + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, errors.Wrap(err, "parsing certificate for public key extraction") + } + return cert.PublicKey, nil +} diff --git a/pkg/chains/storage/oci/legacy.go b/pkg/chains/storage/oci/legacy.go index 6004ae6da7..b3c210becb 100644 --- a/pkg/chains/storage/oci/legacy.go +++ b/pkg/chains/storage/oci/legacy.go @@ -98,6 +98,16 @@ func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, ra return errors.Wrap(err, "unmarshal attestation") } + // Extract predicate type from the raw JSON payload because it may be cleared + // during proto unmarshal. + var rawStmt struct { + PredicateType string `json:"predicateType"` + } + if err := json.Unmarshal(rawPayload, &rawStmt); err != nil { + return errors.Wrap(err, "extracting predicate type from raw payload") + } + attestation.PredicateType = rawStmt.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. @@ -107,7 +117,7 @@ func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, ra return nil } - return b.uploadAttestation(ctx, &attestation, signature, storageOpts, remoteOpts...) + return b.uploadAttestation(ctx, &attestation, rawPayload, signature, storageOpts, remoteOpts...) } // Fallback in case unsupported payload format is used or the deprecated "tekton" format @@ -153,11 +163,13 @@ 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)) + store, err := NewSimpleStorerFromConfig( + WithTargetRepository(repo), + WithDistributionMethod(b.cfg.Storage.OCI.DistributionMethod), + ) 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, @@ -168,6 +180,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 @@ -175,7 +188,7 @@ func (b *Backend) uploadSignature(ctx context.Context, format simple.SimpleConta return nil } -func (b *Backend) uploadAttestation(ctx context.Context, attestation *intoto.Statement, signature string, storageOpts config.StorageOpts, remoteOpts ...remote.Option) error { +func (b *Backend) uploadAttestation(ctx context.Context, attestation *intoto.Statement, rawPayload []byte, signature string, storageOpts config.StorageOpts, remoteOpts ...remote.Option) error { logger := logging.FromContext(ctx) // upload an attestation for each subject logger.Info("Starting to upload attestations to OCI ...") @@ -193,21 +206,24 @@ 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)) + store, err := NewAttestationStorer( + WithTargetRepository(repo), + WithDistributionMethod(b.cfg.Storage.OCI.DistributionMethod), + ) 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, Payload: attestation, Bundle: &signing.Bundle{ - Content: nil, + Content: rawPayload, 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/oci_test.go b/pkg/chains/storage/oci/oci_test.go index 248ea0f10d..d31748ee58 100644 --- a/pkg/chains/storage/oci/oci_test.go +++ b/pkg/chains/storage/oci/oci_test.go @@ -31,6 +31,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/registry" "github.com/google/go-containerregistry/pkg/v1/remote" intoto "github.com/in-toto/attestation/go/v1" @@ -373,3 +374,134 @@ func generateSelfSignedCert() (tls.Certificate, error) { return cert, nil } + +// TestWithDistributionMethod_AttestationStorer verifies that WithDistributionMethod +// correctly sets the distributionMethod field on an AttestationStorer. +func TestWithDistributionMethod_AttestationStorer(t *testing.T) { + repo, err := name.NewRepository("example.com/test") + if err != nil { + t.Fatalf("name.NewRepository: %v", err) + } + for _, method := range []string{config.OCIDistributionLegacy, config.OCIDistributionReferrersAPI} { + t.Run(method, func(t *testing.T) { + storer, err := NewAttestationStorer( + WithTargetRepository(repo), + WithDistributionMethod(method), + ) + if err != nil { + t.Fatalf("NewAttestationStorer: %v", err) + } + if storer.distributionMethod != method { + t.Errorf("distributionMethod = %q, want %q", storer.distributionMethod, method) + } + }) + } +} + +// TestWithDistributionMethod_SimpleStorer verifies that WithDistributionMethod +// correctly sets the distributionMethod field on a SimpleStorer. +func TestWithDistributionMethod_SimpleStorer(t *testing.T) { + repo, err := name.NewRepository("example.com/test") + if err != nil { + t.Fatalf("name.NewRepository: %v", err) + } + for _, method := range []string{config.OCIDistributionLegacy, config.OCIDistributionReferrersAPI} { + t.Run(method, func(t *testing.T) { + storer, err := NewSimpleStorerFromConfig( + WithTargetRepository(repo), + WithDistributionMethod(method), + ) + if err != nil { + t.Fatalf("NewSimpleStorerFromConfig: %v", err) + } + if storer.distributionMethod != method { + t.Errorf("distributionMethod = %q, want %q", storer.distributionMethod, method) + } + }) + } +} + +// TestDefaultsAreEmpty verifies that omitting the option leaves distributionMethod +// empty (which the Store methods treat as legacy). +func TestDefaultsAreEmpty(t *testing.T) { + repo, err := name.NewRepository("example.com/test") + if err != nil { + t.Fatalf("name.NewRepository: %v", err) + } + + attestStorer, err := NewAttestationStorer(WithTargetRepository(repo)) + if err != nil { + t.Fatalf("NewAttestationStorer: %v", err) + } + if attestStorer.distributionMethod != "" { + t.Errorf("AttestationStorer.distributionMethod without option = %q, want empty", attestStorer.distributionMethod) + } + + simpleStorer, err := NewSimpleStorerFromConfig(WithTargetRepository(repo)) + if err != nil { + t.Fatalf("NewSimpleStorerFromConfig: %v", err) + } + if simpleStorer.distributionMethod != "" { + t.Errorf("SimpleStorer.distributionMethod without option = %q, want empty", simpleStorer.distributionMethod) + } +} + +// TestOCIBackend_DistributionMethodConfig verifies that the Backend struct properly +// exposes the single distribution-method OCI configuration. +func TestOCIBackend_DistributionMethodConfig(t *testing.T) { + for _, method := range []string{config.OCIDistributionLegacy, config.OCIDistributionReferrersAPI} { + t.Run(method, func(t *testing.T) { + backend := &Backend{ + cfg: config.Config{ + Storage: config.StorageConfigs{ + OCI: config.OCIStorageConfig{ + Repository: "example.com/repo", + DistributionMethod: method, + }, + }, + }, + } + if backend.cfg.Storage.OCI.DistributionMethod != method { + t.Errorf("DistributionMethod = %q, want %q", + backend.cfg.Storage.OCI.DistributionMethod, method) + } + }) + } +} + +// TestReferrersRepoOverrideIgnored verifies the helper that flags when a +// storage.oci.repository override cannot be honoured in referrers-api mode. +// Referrers are colocated with their subject image, so an override pointing at a +// different repository is reported as ignored, while an override that matches the +// artifact repository (the no-op case) is not. +func TestReferrersRepoOverrideIgnored(t *testing.T) { + artifact, err := name.NewRepository("registry.example.com/team/app") + if err != nil { + t.Fatalf("name.NewRepository: %v", err) + } + differentRepo, err := name.NewRepository("registry.example.com/team/signatures") + if err != nil { + t.Fatalf("name.NewRepository: %v", err) + } + differentRegistry, err := name.NewRepository("other.example.com/team/app") + if err != nil { + t.Fatalf("name.NewRepository: %v", err) + } + + tests := []struct { + name string + override name.Repository + want bool + }{ + {name: "same repository is not ignored", override: artifact, want: false}, + {name: "different repository in same registry is ignored", override: differentRepo, want: true}, + {name: "different registry is ignored", override: differentRegistry, want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := referrersRepoOverrideIgnored(tc.override, artifact); got != tc.want { + t.Errorf("referrersRepoOverrideIgnored(%q, %q) = %v, want %v", tc.override, artifact, got, tc.want) + } + }) + } +} diff --git a/pkg/chains/storage/oci/options.go b/pkg/chains/storage/oci/options.go index c905e7699c..6a1140eabd 100644 --- a/pkg/chains/storage/oci/options.go +++ b/pkg/chains/storage/oci/options.go @@ -52,3 +52,38 @@ func (o *targetRepoOption) applySimpleStorer(s *SimpleStorer) error { s.repo = &o.repo return nil } + +// WithDistributionMethod configures where and how artifacts are attached to images in the registry. +// +// Supported values are the OCIDistribution* constants in pkg/config: +// - OCIDistributionLegacy (default) – tag-based storage +// - OCIDistributionReferrersAPI – OCI 1.1 Referrers API +// +//nolint:ireturn // returning interface is the intended pattern here +func WithDistributionMethod(method string) Option { + return &distributionMethodOption{method: method} +} + +type distributionMethodOption struct { + method string +} + +func (o *distributionMethodOption) applyAttestationStorer(s *AttestationStorer) error { + s.distributionMethod = o.method + return nil +} + +func (o *distributionMethodOption) applySimpleStorer(s *SimpleStorer) error { + s.distributionMethod = o.method + return nil +} + +// referrersRepoOverrideIgnored reports whether a configured repository override +// would be silently dropped for an OCI 1.1 referrer write. Referrers must be +// colocated with their subject image (the referrer manifest references the +// subject by digest within the same repository), so a storage.oci.repository +// override cannot redirect them to a different repository. The override only +// applies to the legacy tag-based storage path. +func referrersRepoOverrideIgnored(override, artifactRepo name.Repository) bool { + return override.String() != artifactRepo.String() +} diff --git a/pkg/chains/storage/oci/simple.go b/pkg/chains/storage/oci/simple.go index 98e7c2495b..578f7b5244 100644 --- a/pkg/chains/storage/oci/simple.go +++ b/pkg/chains/storage/oci/simple.go @@ -21,11 +21,13 @@ import ( "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/pkg/errors" + "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/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 +38,8 @@ type SimpleStorer struct { repo *name.Repository // remoteOpts are additional remote options (i.e. auth) to use for client operations. remoteOpts []remote.Option + // distributionMethod specifies how artifacts are attached ("legacy" tag-based or "referrers-api"). + distributionMethod string } var ( @@ -53,37 +57,67 @@ 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") - se, err := ociremote.SignedEntity(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...)) var entityNotFoundError *ociremote.EntityNotFoundError if errors.As(err, &entityNotFoundError) { se = ociremote.SignedUnknown(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...)) } else if err != nil { - return nil, errors.Wrap(err, "getting signed image") + return nil, errors.Wrap(err, "getting signed entity") + } + + repo := req.Artifact.Repository + if s.repo != nil { + repo = *s.repo } + if s.distributionMethod == config.OCIDistributionReferrersAPI { + return s.storeReferrers(ctx, req, se, repo) + } + return s.storeLegacy(ctx, req, se, repo) +} + +// storeReferrers writes the signature via the OCI 1.1 Referrers API. When the +// registry has no native Referrers API, cosign/go-containerregistry transparently +// uses the OCI referrers tag schema; either way no .sig tags are created. +func (s *SimpleStorer) storeReferrers(ctx context.Context, req *api.StoreRequest[name.Digest, simple.SimpleContainerImage], se oci.SignedEntity, repo name.Repository) (*api.StoreResponse, error) { + logger := logging.FromContext(ctx).With("image", req.Artifact.String()) + + if referrersRepoOverrideIgnored(repo, req.Artifact.Repository) { + logger.Warnf("storage.oci.repository override %q is ignored in referrers-api mode; OCI 1.1 referrers are stored alongside their subject image in %q", repo.String(), req.Artifact.Repository.String()) + } + + // Image signatures are always stored via cosign's native signature referrer. + // A cosign image signature is a plain signature over the simplesigning payload, + // not a DSSE envelope (whose signature must be over the DSSE PAE), so it cannot + // be represented as a verifiable DSSE-envelope bundle. This mirrors upstream + // cosign, where image signatures use the standard signature format while + // attestations may use the protobuf bundle format. + return s.storeWithReferrersAPI(ctx, req, se) +} + +// storeLegacy is the default tag-based signature upload path. +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).With("image", req.Artifact.String()) + 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") } - // Check if a signature with the same payload digest already exists. + // Skip upload if an identical signature already exists. newDigest, err := sig.Digest() if err != nil { return nil, errors.Wrap(err, "getting new signature digest") } if existingSigs, err := se.Signatures(); err != nil { - logger.Debugf("Could not fetch existing signatures for %s, skipping dedup check: %v", req.Artifact.String(), err) + logger.Debugf("Could not fetch existing signatures, skipping dedup check: %v", err) } else if layers, err := existingSigs.Get(); err != nil { - logger.Debugf("Could not get signature layers for %s, skipping dedup check: %v", req.Artifact.String(), err) + logger.Debugf("Could not get signature layers, skipping dedup check: %v", err) } else { for _, l := range layers { if d, err := l.Digest(); err == nil && d == newDigest { @@ -93,20 +127,43 @@ func (s *SimpleStorer) Store(ctx context.Context, req *api.StoreRequest[name.Dig } } - // 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") + } + if err := ociremote.WriteSignatures(repo, newSE, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil { + return nil, errors.Wrap(err, "writing signatures") } + logger.Info("Successfully uploaded signature using legacy format") + return &api.StoreResponse{}, nil +} - repo := req.Artifact.Repository - if s.repo != nil { - repo = *s.repo +// storeWithReferrersAPI uploads signatures using the OCI 1.1 Referrers API. +func (s *SimpleStorer) storeWithReferrersAPI(ctx context.Context, req *api.StoreRequest[name.Digest, simple.SimpleContainerImage], se oci.SignedEntity) (*api.StoreResponse, error) { + logger := logging.FromContext(ctx).With("image", req.Artifact.String()) + logger.Info("Using OCI 1.1 referrers API for signature storage") + + sigOpts := []static.Option{} + if req.Bundle.Cert != nil { + sigOpts = append(sigOpts, static.WithCertChain(req.Bundle.Cert, req.Bundle.Chain)) } - // Publish the signatures associated with this entity - if err := ociremote.WriteSignatures(repo, newSE, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil { - return nil, err + 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") + } + // WriteSignaturesExperimentalOCI publishes the signature as an OCI 1.1 referrer + // using cosign's signature-native writer. It sets config.mediaType to + // application/vnd.dev.cosign.artifact.sig.v1+json, populates the subject, and + // writes SimpleSigning layers — exactly the manifest shape that `cosign verify` + // reverse-discovers in referrers mode. + if err := ociremote.WriteSignaturesExperimentalOCI(req.Artifact, newSE, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil { + return nil, errors.Wrap(err, "writing signature referrer") } - logger.Info("Successfully uploaded signature") + logger.Info("Successfully uploaded signature using referrers API") return &api.StoreResponse{}, nil } diff --git a/pkg/chains/storage/oci/simple_test.go b/pkg/chains/storage/oci/simple_test.go index 1db9e90fd2..626def92e8 100644 --- a/pkg/chains/storage/oci/simple_test.go +++ b/pkg/chains/storage/oci/simple_test.go @@ -15,6 +15,7 @@ package oci import ( + "encoding/json" "fmt" "net/http/httptest" "strings" @@ -22,6 +23,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/random" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/types" @@ -29,6 +31,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" ) @@ -233,3 +236,169 @@ func TestSimpleStorer_Store_DistinctNotDeduped(t *testing.T) { t.Errorf("expected 2 distinct signature layers, got %d", got) } } + +// sigArtifactType is the artifactType cosign assigns to signature referrers +// (application/vnd.dev.cosign.artifact.sig.v1+json). `cosign verify` filters on +// exactly this value when discovering signatures in OCI 1.1 referrers mode. +const sigArtifactType = "application/vnd.dev.cosign.artifact.sig.v1+json" + +// TestSimpleStorer_Store_ReferrersAPI verifies that the referrers-api distribution +// path writes a cosign-native signature manifest: config.mediaType set to the +// signature artifactType and a populated subject pointing back at the image. This +// is the manifest shape `cosign verify` reverse-discovers, so it guards against +// regressing back to the low-level WriteReferrer encoding. +func TestSimpleStorer_Store_ReferrersAPI(t *testing.T) { + s := httptest.NewServer(registry.New(registry.WithReferrersSupport(true))) + defer s.Close() + registryName := strings.TrimPrefix(s.URL, "http://") + + img, err := random.Image(1024, 2) + if err != nil { + t.Fatalf("failed to create random image: %s", err) + } + imgDigest, err := img.Digest() + if err != nil { + t.Fatalf("failed to get image digest: %v", err) + } + ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, imgDigest)) + if err != nil { + t.Fatalf("failed to parse digest: %v", err) + } + if err := remote.Write(ref, img); err != nil { + t.Fatalf("failed to write image to mock registry: %v", err) + } + + storer, err := NewSimpleStorerFromConfig( + WithTargetRepository(ref.Repository), + WithDistributionMethod(config.OCIDistributionReferrersAPI), + ) + if err != nil { + t.Fatalf("failed to create storer: %v", err) + } + + ctx := logtesting.TestContextWithLogger(t) + if _, err := storer.Store(ctx, &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{ + Artifact: ref, + Payload: simple.NewSimpleStruct(ref), + Bundle: &signing.Bundle{Content: []byte("payload"), Signature: []byte("sig1")}, + }); err != nil { + t.Fatalf("error during Store(): %s", err) + } + + // No legacy .sig tag should have been created in referrers mode. + tags, err := remote.List(ref.Repository) + if err != nil { + t.Fatalf("failed to list tags: %v", err) + } + for _, tag := range tags { + if strings.HasSuffix(tag, ".sig") { + t.Errorf("unexpected legacy signature tag %q created in referrers mode", tag) + } + } + + // Discover the signature via the OCI 1.1 Referrers API, filtered by the cosign + // signature artifactType. + idx, err := ociremote.Referrers(ref, sigArtifactType) + if err != nil { + t.Fatalf("failed to list referrers: %v", err) + } + if len(idx.Manifests) == 0 { + t.Fatalf("expected at least one signature referrer, got none") + } + + // Fetch the referrer manifest and assert its cosign-native shape. + refDesc := idx.Manifests[0] + referrerRef, err := name.NewDigest(fmt.Sprintf("%s@%s", ref.Repository.Name(), refDesc.Digest)) + if err != nil { + t.Fatalf("failed to build referrer digest ref: %v", err) + } + got, err := remote.Get(referrerRef) + if err != nil { + t.Fatalf("failed to fetch referrer manifest: %v", err) + } + var m v1.Manifest + if err := json.Unmarshal(got.Manifest, &m); err != nil { + t.Fatalf("failed to unmarshal referrer manifest: %v", err) + } + + if string(m.Config.MediaType) != sigArtifactType { + t.Errorf("config.mediaType = %q, want %q", m.Config.MediaType, sigArtifactType) + } + if m.Subject == nil { + t.Fatalf("referrer manifest has nil subject, want subject pointing at the image") + } + if m.Subject.Digest.String() != imgDigest.String() { + t.Errorf("subject.digest = %q, want image digest %q", m.Subject.Digest, imgDigest) + } + if len(m.Layers) == 0 { + t.Errorf("expected SimpleSigning signature layers, got none") + } +} + +// TestSimpleStorer_Store_ReferrersAPI_RepoOverrideIgnored verifies that a +// storage.oci.repository override is not honoured in referrers-api mode: the +// signature referrer is written alongside the subject image (its own repository), +// not the override repository, because OCI 1.1 referrers must be colocated with +// their subject. This guards the documented behaviour raised in PR review. +func TestSimpleStorer_Store_ReferrersAPI_RepoOverrideIgnored(t *testing.T) { + s := httptest.NewServer(registry.New(registry.WithReferrersSupport(true))) + defer s.Close() + registryName := strings.TrimPrefix(s.URL, "http://") + + img, err := random.Image(1024, 2) + if err != nil { + t.Fatalf("failed to create random image: %s", err) + } + imgDigest, err := img.Digest() + if err != nil { + t.Fatalf("failed to get image digest: %v", err) + } + ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, imgDigest)) + if err != nil { + t.Fatalf("failed to parse digest: %v", err) + } + if err := remote.Write(ref, img); err != nil { + t.Fatalf("failed to write image to mock registry: %v", err) + } + + // Configure a target repository override that differs from the artifact's repo. + overrideRepo, err := name.NewRepository(fmt.Sprintf("%s/test/override", registryName)) + if err != nil { + t.Fatalf("failed to parse override repo: %v", err) + } + + storer, err := NewSimpleStorerFromConfig( + WithTargetRepository(overrideRepo), + WithDistributionMethod(config.OCIDistributionReferrersAPI), + ) + if err != nil { + t.Fatalf("failed to create storer: %v", err) + } + + ctx := logtesting.TestContextWithLogger(t) + if _, err := storer.Store(ctx, &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{ + Artifact: ref, + Payload: simple.NewSimpleStruct(ref), + Bundle: &signing.Bundle{Content: []byte("payload"), Signature: []byte("sig1")}, + }); err != nil { + t.Fatalf("error during Store(): %s", err) + } + + // The referrer must be discoverable against the artifact's own repository. + idx, err := ociremote.Referrers(ref, sigArtifactType) + if err != nil { + t.Fatalf("failed to list referrers at artifact repo: %v", err) + } + if len(idx.Manifests) == 0 { + t.Fatalf("expected signature referrer at artifact repo %q, got none", ref.Repository.Name()) + } + + // The override repository must NOT have received the referrer. + overrideDigest, err := name.NewDigest(fmt.Sprintf("%s@%s", overrideRepo.Name(), imgDigest)) + if err != nil { + t.Fatalf("failed to build override digest ref: %v", err) + } + if overrideIdx, err := ociremote.Referrers(overrideDigest, sigArtifactType); err == nil && len(overrideIdx.Manifests) > 0 { + t.Errorf("override repo %q unexpectedly received %d referrer(s); override must be ignored in referrers mode", overrideRepo.Name(), len(overrideIdx.Manifests)) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index c045d6b25b..e698093330 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -118,6 +118,11 @@ type GCSStorageConfig struct { type OCIStorageConfig struct { Repository string Insecure bool + // DistributionMethod controls where and how artifacts are attached to images in the + // registry, and implicitly the payload encoding: + // - "legacy" (default): tag-based storage with DSSE-encoded payloads. + // - "referrers-api": OCI 1.1 Referrers API with Sigstore protobuf-bundle attestations. + DistributionMethod string } type TektonStorageConfig struct { @@ -179,6 +184,7 @@ const ( gcsBucketKey = "storage.gcs.bucket" ociRepositoryKey = "storage.oci.repository" ociRepositoryInsecureKey = "storage.oci.repository.insecure" + ociDistributionMethodKey = "storage.oci.distribution-method" docDBUrlKey = "storage.docdb.url" docDBMongoServerURLKey = "storage.docdb.mongo-server-url" docDBMongoServerURLDirKey = "storage.docdb.mongo-server-url-dir" @@ -227,6 +233,13 @@ const ( buildTypeKey = "builddefinition.buildtype" ChainsConfig = "chains-config" + + // OCIDistributionLegacy is the default tag-based distribution method (full backward compatibility). + // It stores DSSE-encoded payloads under .sig/.att tags. + OCIDistributionLegacy = "legacy" + // OCIDistributionReferrersAPI uses the OCI 1.1 Referrers API, reducing tag proliferation. + // Attestations are stored as Sigstore protobuf bundles. + OCIDistributionReferrersAPI = "referrers-api" ) func (artifact *Artifact) Enabled() bool { @@ -237,6 +250,20 @@ func (artifact *Artifact) Enabled() bool { return !(artifact.StorageBackend.Len() == 1 && artifact.StorageBackend.Has("")) } +// validateOCIDistributionMethod returns an error if the provided distribution method is not +// one of the supported values. +func validateOCIDistributionMethod(method string) error { + if method == "" { + return nil // empty defaults to legacy + } + switch method { + case OCIDistributionLegacy, OCIDistributionReferrersAPI: + return nil + } + return fmt.Errorf("invalid storage.oci.distribution-method %q: must be one of %q, %q", + method, OCIDistributionLegacy, OCIDistributionReferrersAPI) +} + func defaultConfig() *Config { return &Config{ Artifacts: ArtifactConfigs{ @@ -267,10 +294,11 @@ func defaultConfig() *Config { TUFMirrorURL: tuf.DefaultRemoteRoot, }, }, - Storage: StorageConfigs{ - Grafeas: GrafeasConfig{ - NoteHint: "This attestation note was generated by Tekton Chains", - }, + Storage: StorageConfigs{OCI: OCIStorageConfig{ + DistributionMethod: OCIDistributionLegacy, + }, Grafeas: GrafeasConfig{ + NoteHint: "This attestation note was generated by Tekton Chains", + }, }, Builder: BuilderConfig{ ID: "https://tekton.dev/chains/v2", @@ -314,6 +342,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(ociDistributionMethodKey, &cfg.Storage.OCI.DistributionMethod), asString(docDBUrlKey, &cfg.Storage.DocDB.URL), asString(docDBMongoServerURLKey, &cfg.Storage.DocDB.MongoServerURL), asString(docDBMongoServerURLDirKey, &cfg.Storage.DocDB.MongoServerURLDir), @@ -355,6 +384,15 @@ func NewConfigFromMap(data map[string]string) (*Config, error) { return nil, fmt.Errorf("failed to parse data: %w", err) } + // Default to legacy if no distribution method was resolved. + if cfg.Storage.OCI.DistributionMethod == "" { + cfg.Storage.OCI.DistributionMethod = OCIDistributionLegacy + } + + if err := validateOCIDistributionMethod(cfg.Storage.OCI.DistributionMethod); err != nil { + return nil, err + } + return cfg, nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index fa54ec2e41..70fa2d354d 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -85,3 +85,48 @@ func TestArtifact_Enabled(t *testing.T) { }) } } + +func TestOCIDistributionMethodDefault(t *testing.T) { + cfg, err := NewConfigFromMap(map[string]string{}) + if err != nil { + t.Fatalf("NewConfigFromMap() error: %v", err) + } + if cfg.Storage.OCI.DistributionMethod != OCIDistributionLegacy { + t.Errorf("default DistributionMethod = %q, want %q", cfg.Storage.OCI.DistributionMethod, OCIDistributionLegacy) + } +} + +func TestOCIDistributionMethodExplicit(t *testing.T) { + for _, method := range []string{OCIDistributionLegacy, OCIDistributionReferrersAPI} { + t.Run(method, func(t *testing.T) { + cfg, err := NewConfigFromMap(map[string]string{ociDistributionMethodKey: method}) + if err != nil { + t.Fatalf("NewConfigFromMap() error: %v", err) + } + if cfg.Storage.OCI.DistributionMethod != method { + t.Errorf("DistributionMethod = %q, want %q", cfg.Storage.OCI.DistributionMethod, method) + } + }) + } +} + +func TestOCIDistributionMethodInvalid(t *testing.T) { + _, err := NewConfigFromMap(map[string]string{ociDistributionMethodKey: "unknown-method"}) + if err == nil { + t.Error("expected error for invalid distribution method, got nil") + } +} + +func TestValidateOCIDistributionMethod(t *testing.T) { + if err := validateOCIDistributionMethod(""); err != nil { + t.Errorf("empty string: unexpected error: %v", err) + } + for _, valid := range []string{OCIDistributionLegacy, OCIDistributionReferrersAPI} { + if err := validateOCIDistributionMethod(valid); err != nil { + t.Errorf("valid %q: unexpected error: %v", valid, err) + } + } + if err := validateOCIDistributionMethod("bad-method"); err == nil { + t.Error("expected error for invalid method, got nil") + } +} diff --git a/pkg/config/options.go b/pkg/config/options.go index 8460db6f9a..86c4629cba 100644 --- a/pkg/config/options.go +++ b/pkg/config/options.go @@ -16,6 +16,8 @@ limitations under the License. package config +import "crypto" + // 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 @@ -45,6 +47,11 @@ type StorageOpts struct { // https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md Chain string + // PublicKey is the public key used to create the signature. + // Extracted from the signer and available for storage backends that need it + // (e.g. to create a cosign protobuf bundle). + PublicKey crypto.PublicKey + // PayloadFormat is the format to store payload in. PayloadFormat PayloadType } diff --git a/pkg/config/store_test.go b/pkg/config/store_test.go index b08553ea03..87b561c552 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{ + DistributionMethod: OCIDistributionLegacy, + }, 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{ + DistributionMethod: OCIDistributionLegacy, + }, Grafeas: GrafeasConfig{ NoteHint: "a test message", },