From 18342c4280715795495f8ecb5414bf8a777b4e1f Mon Sep 17 00:00:00 2001 From: Anitha Natarajan Date: Thu, 23 Jul 2026 12:30:33 +0530 Subject: [PATCH] feat(storage): add OCI 1.1 Referrers API support with sigstore-bundle format Adds a new storage.oci.encoding-format config key that controls both the serialization format and distribution mechanism for OCI artifacts: storage.oci.encoding-format: dsse (default) | sigstore-bundle - dsse: unchanged tag-based storage with DSSE envelopes (default) - sigstore-bundle: Sigstore protobuf bundle via OCI 1.1 Referrers API, eliminating sha256-*.sig / *.att tag proliferation in the registry Both signatures and attestations are stored as application/vnd.dev.sigstore.bundle.v0.3+json referrer manifests, distinguished by the dev.sigstore.bundle.predicateType annotation. Signatures use CosignSignPredicateType; attestations use the in-toto predicate type. This matches the manifest layout produced by cosign 3.x `cosign sign` and `cosign attest`. Also fixes a bug where the raw Rekor transparency log entry was never forwarded to MakeNewBundle in the sigstore-bundle attestation path. The entry was converted to a RekorBundle in signing.go and then discarded; the OCI attestation storer always passed a nil rekorEntry to MakeNewBundle, so bundles shipped without a tlog entry and would be rejected by cosign verify-attestation (which checks for one by default since v2). Fixed by carrying *models.LogEntryAnon through StorageOpts and signing.Bundle to the storer. Bumps cosign from v2.6.3 to v2.6.4 to include: - PR #4997: correct artifactType in OCI 1.1 signature referrer manifests - PR #4996: cosign download attestation now returns bundle-format referrers alongside legacy .att tag attestations Signed-off-by: Anitha Natarajan Assisted-by: Claude Sonnet 4.6 (via GitHub Copilot) --- config/100-deployment.yaml | 4 + docs/config.md | 3 + docs/oci-encoding-format.md | 274 ++++++++++++++++++ go.mod | 4 +- go.sum | 4 +- pkg/chains/rekor.go | 2 +- pkg/chains/signing.go | 17 ++ pkg/chains/signing/iface.go | 11 + pkg/chains/storage/oci/attestation.go | 160 +++++++++- pkg/chains/storage/oci/attestation_test.go | 260 ++++++++++++++++- pkg/chains/storage/oci/legacy.go | 39 ++- pkg/chains/storage/oci/oci_test.go | 265 +++++++++++++++++ pkg/chains/storage/oci/options.go | 35 +++ pkg/chains/storage/oci/simple.go | 187 ++++++++++-- pkg/chains/storage/oci/simple_test.go | 224 ++++++++++++++ pkg/config/config.go | 21 +- pkg/config/config_test.go | 31 ++ pkg/config/options.go | 13 + pkg/config/store_test.go | 6 + test/oci_sigstore_bundle_e2e_test.go | 199 +++++++++++++ .../sigstore/cosign/v2/pkg/cosign/fetch.go | 4 +- .../cosign/v2/pkg/oci/remote/write.go | 13 +- vendor/modules.txt | 2 +- 23 files changed, 1720 insertions(+), 58 deletions(-) create mode 100644 docs/oci-encoding-format.md create mode 100644 test/oci_sigstore_bundle_e2e_test.go diff --git a/config/100-deployment.yaml b/config/100-deployment.yaml index 2b6cc022bb..6495efc1bd 100644 --- a/config/100-deployment.yaml +++ b/config/100-deployment.yaml @@ -50,6 +50,10 @@ metadata: # artifacts.oci.signer: x509 # transparency.enabled: false # transparency.url: https://rekor.sigstore.dev +# storage.oci.encoding-format controls the on-disk format used by the OCI storage backend. +# "dsse" – (default) DSSE envelope stored as .sig/.att tags (backward-compatible) +# "sigstore-bundle" – Sigstore protobuf bundle (v0.3) stored via OCI 1.1 Referrers API +# storage.oci.encoding-format: "dsse" --- apiVersion: apps/v1 kind: Deployment diff --git a/docs/config.md b/docs/config.md index 5049471415..d2601afffb 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.encoding-format` | Controls the payload encoding for OCI artifact storage and implicitly the storage layout: `dsse` (default) uses DSSE-encoded payloads stored under .sig/.att tags, `sigstore-bundle` uses the Sigstore protobuf-bundle format stored via the OCI 1.1 Referrers API. See [OCI Storage Encoding Format](oci-encoding-format.md) for details. | `dsse`, `sigstore-bundle` | `dsse` | | `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 Storage Encoding Format](oci-encoding-format.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-encoding-format.md b/docs/oci-encoding-format.md new file mode 100644 index 0000000000..92ca150737 --- /dev/null +++ b/docs/oci-encoding-format.md @@ -0,0 +1,274 @@ + + +# OCI Storage Encoding Format + +The `storage.oci.encoding-format` key controls how Chains serializes OCI +signatures and attestations when using the OCI storage backend. Setting this +key also determines the storage layout, because the two are tightly coupled — +see [Why they are coupled](#why-the-encoding-and-layout-are-coupled). + +If you don't change anything, Chains keeps the existing `dsse` behavior and +everything just works. Read on if you want to opt into `sigstore-bundle`, or to +understand the difference. + +## Encoding formats + +The *encoding* is the serialization format of the attestation payload itself. + +### DSSE (default) + +[Dead Simple Signing Envelope](https://github.com/secure-systems-lab/dsse) is +the format cosign has used since its early versions. An attestation is a JSON +object with a base64-encoded payload and one or more signatures computed over +the DSSE Pre-Authentication Encoding (PAE) of the content. + +DSSE is widely supported and is what all existing tooling — older +`cosign verify-attestation`, most policy engines — expects by default. + +### Sigstore protobuf bundle + +The [Sigstore bundle specification](https://github.com/sigstore/protobuf-specs) +defines a Protocol Buffer message that combines the payload, signature, signing +certificate, and optional transparency log entry into a single self-contained +artifact. This is the default format for cosign v3+ (it was available in +v2.4–v2.6 behind the `--new-bundle-format` flag). + +Because the bundle includes the certificate and tlog entry inline, it supports +offline verification without out-of-band trust anchor lookups. + +## How the encoding determines the storage layout + +The encoding choice also determines *where* signatures and attestations are +stored in the registry. The supported combinations are: + +| `encoding-format` | Attestation encoding | Storage layout | +|---|---|---| +| `dsse` (default) | DSSE JSON envelope | Tag-based: `.sig` / `.att` tags pushed alongside the image | +| `sigstore-bundle` | Sigstore protobuf bundle | OCI 1.1 Referrers API: referrer manifests with a `subject` field | + +### Tag-based layout (`dsse`) + +cosign stores signatures and attestations as extra tags next to the image: +`sha256-.sig` and `sha256-.att`. Every OCI-compliant registry +supports this, but the tags have drawbacks: + +- Tags are meant to name top-level artifacts. Using them for signatures and + attestations puts metadata in the same namespace as real images. +- The `.att` tag holds *all* attestations in a single manifest whose digest + changes with each write, so there is no stable, individually addressable + reference to any single attestation. +- No OCI standard describes this layout, so every tool must special-case it. + +### OCI 1.1 Referrers API (`sigstore-bundle`) + +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. Each signature or +attestation is pushed as its own manifest with a `subject` field pointing to +the image it belongs to. No extra tags are created, and any OCI-compliant tool +can discover the relationship via the Referrers API. + +> [!NOTE] +> On registries without native Referrers API support, +> `go-containerregistry` (the library Chains uses) automatically falls back to +> the spec's referrers tag schema — a single `sha256-` index tag +> listing all referrers. The protobuf bundle encoding is unchanged; only the +> discovery mechanism differs. No configuration is needed. + +## Why the encoding and layout are coupled + +The encoding and storage layout are intentionally coupled into a single knob. +`dsse` always uses tag-based storage; `sigstore-bundle` always uses the OCI 1.1 +Referrers API. There is no mix-and-match. + +Image **signatures** and attestations are both stored as Sigstore protobuf +bundle referrers in `sigstore-bundle` mode. Both signatures and attestations use +a `DsseEnvelope` bundle: for signatures the DSSE envelope wraps a +SimpleSigning payload (matching what `cosign sign` produces in v3.x), while for +attestations it wraps the in-toto statement. Using `DsseEnvelope` for signatures +is required because `WriteAttestationNewBundleFormat` always sets the +`dev.sigstore.bundle.content: dsse-envelope` annotation — a `MessageSignature` +bundle would make that annotation inconsistent and cause `cosign verify` to +fail. Both carry the +`dev.sigstore.bundle.predicateType` annotation to identify their content: +`"https://sigstore.dev/cosign/sign/v1"` for signatures and the in-toto +predicate type (e.g. `"https://slsa.dev/provenance/v0.2"`) for attestations. + +## Configuring the encoding format + +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.encoding-format: "sigstore-bundle" +``` + +| Value | Encoding | Storage layout | +|---|---|---| +| `dsse` (default) | DSSE JSON envelope | `.sig` / `.att` tags | +| `sigstore-bundle` | Sigstore protobuf bundle | OCI 1.1 Referrers API (with automatic fallback to referrers tag schema on older registries) | + +> [!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 (docstore, mongo, Grafeas, or other supported +> backends), `storage.oci.encoding-format` has no effect. + +> [!TIP] +> Chains vendors the Sigstore libraries it needs, so `sigstore-bundle` mode +> works out of the box — there is nothing extra to install. You only need a +> separate `cosign` or `oras` CLI if you want to verify or inspect stored +> artifacts yourself, as shown below. + +## 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 \ + --insecure-ignore-tlog=true \ + @sha256: + +# Verify an attestation +cosign verify-attestation \ + --key k8s://tekton-chains/signing-secrets \ + --type slsaprovenance \ + --insecure-ignore-tlog=true \ + @sha256: +``` + +> [!NOTE] +> In `sigstore-bundle` mode, `cosign verify --key` returns **both** the +> signature bundle and the SLSA attestation bundle. Check the `"type"` field in +> each result to distinguish them: +> - `"https://sigstore.dev/cosign/sign/v1"` → image signature +> - `"https://slsa.dev/provenance/v0.2"` → SLSA provenance attestation + +> [!IMPORTANT] +> Starting with **cosign v2.0**, and continuing through the v3.x series, +> `cosign verify` and `cosign verify-attestation` check for transparency-log +> (Rekor) inclusion **by default**. Whether that check can pass depends on +> Chains' transparency setting: +> +> - **Transparency disabled** (`transparency.enabled: "false"`): Chains does not +> record signatures in a transparency log, so there are no Rekor entries and +> the default cosign check fails with an error such as: +> +> ```text +> Error: no matching signatures: ... not enough verified log entries from +> transparency log: 0 < 1 +> ``` +> +> This is not a signature problem. Add `--insecure-ignore-tlog=true` to the +> commands above to verify against the public key alone. +> +> - **Transparency enabled** (`transparency.enabled: "true"`): signatures are +> recorded in Rekor, so the default tlog check passes and no extra flag is +> needed. + +To see what was stored, use [`oras`](https://oras.land/): + +```shell +oras discover @sha256: +``` + +In `sigstore-bundle` mode you will see two referrers, both with +`application/vnd.dev.sigstore.bundle.v0.3+json` as their `artifactType`. +`oras` displays the artifactType directly for both: + +```text +@sha256: +├── application/vnd.dev.sigstore.bundle.v0.3+json ← signature +│ └── sha256: +└── application/vnd.dev.sigstore.bundle.v0.3+json ← SLSA attestation + └── sha256: +``` + +To distinguish them, inspect the `dev.sigstore.bundle.predicateType` annotation +on each manifest: + +```shell +oras manifest fetch @sha256: | jq '.annotations["dev.sigstore.bundle.predicateType"]' +# → "https://sigstore.dev/cosign/sign/v1" (signature) + +oras manifest fetch @sha256: | jq '.annotations["dev.sigstore.bundle.predicateType"]' +# → "https://slsa.dev/provenance/v0.2" (SLSA attestation) +``` + +In `dsse` mode, `oras discover` returns no referrers because the signature and +attestations are stored as ordinary tags (`.sig` / `.att`) rather than referrer +manifests. + +## Things to keep in mind in `sigstore-bundle` mode + +These are interoperability notes, not bugs in Chains. + +1. **`storage.oci.repository` is ignored.** This setting normally redirects where + OCI signatures and attestations are stored, letting you keep them in a + different repository from the image. A referrer, by contrast, must live in the + same repository as the image it points at, because the referrer manifest + references its subject by digest within that repository. In `sigstore-bundle` + mode Chains logs a warning and stores the referrer next to the image. The + override still works in `dsse` mode. + +2. **Older cosign discovery paths may not surface the attestation.** Chains + stores attestations as a protobuf bundle, which is the default for current + cosign versions. Older cosign releases that default to the tag-based layout + discover attestations by a different type and may not list it. The attestation + is still present — `oras discover` shows it and policy engines can consume 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 `dsse`. + +4. **Both signature and attestation have the same `artifactType`.** Both are + stored as `application/vnd.dev.sigstore.bundle.v0.3+json` referrers. + Use the `dev.sigstore.bundle.predicateType` annotation to distinguish them: + `"https://sigstore.dev/cosign/sign/v1"` for signatures and the in-toto + predicate type for attestations. `cosign verify` and `cosign verify-attestation` + do this automatically. + +5. **Kyverno ClusterPolicy users must migrate to ImageValidatingPolicy.** + `ClusterPolicy` discovers signatures via the `.sig` tag, which no longer + exists in `sigstore-bundle` mode. Switch to `ImageValidatingPolicy`, which + understands the OCI 1.1 Referrers API and the bundle predicateType annotation. + +6. **Concurrent writes can race on the tag-schema fallback.** On registries + without a native Referrers API, the index tag is updated with a + read-append-write cycle, so simultaneous writes to the same image can drop an + entry. Registries with the native Referrers API are not affected. This does + not apply to `dsse` mode. + +## Registry compatibility + +cosign works with a wide range of registries, including Amazon ECR, Azure +Container Registry, Docker Hub, GitHub Container Registry, GitLab Container +Registry, Google Artifact Registry, Harbor, JFrog Artifactory, and Quay. See the +[cosign registry support page](https://docs.sigstore.dev/cosign/system_config/registry_support/) +for the current list. + +Both `dsse` and `sigstore-bundle` work against any OCI-compliant registry. +In `sigstore-bundle` mode, registries with a native Referrers API use it directly; +the rest fall back automatically to the referrers tag schema, as described above. +You do not need to know a registry's level of OCI 1.1 support in advance. + +## 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/go.mod b/go.mod index 9dd681e172..8bd0635dd0 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,8 @@ require ( github.com/opencontainers/go-digest v1.0.0 github.com/pkg/errors v0.9.1 github.com/secure-systems-lab/go-securesystemslib v0.11.0 - github.com/sigstore/cosign/v2 v2.6.3 + github.com/sigstore/cosign/v2 v2.6.4 + github.com/sigstore/protobuf-specs v0.5.1 github.com/sigstore/rekor v1.5.2 github.com/sigstore/sigstore v1.10.8 github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 @@ -384,7 +385,6 @@ require ( github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sigstore/fulcio v1.8.7 // indirect - github.com/sigstore/protobuf-specs v0.5.1 // indirect github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect github.com/sigstore/sigstore-go v1.1.4 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index ebc263e5a1..3411f6a8b8 100644 --- a/go.sum +++ b/go.sum @@ -1145,8 +1145,8 @@ github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxr github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk= github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= -github.com/sigstore/cosign/v2 v2.6.3 h1:1W+rZWz0zkTfqmTmYBOQS/Jt97NKz1OCzxTV2YAp1+s= -github.com/sigstore/cosign/v2 v2.6.3/go.mod h1:g+P/LgYyJkC85WGGDho7yySl3C6xTJzzpLm21ZV+E6s= +github.com/sigstore/cosign/v2 v2.6.4 h1:DiyWP1/SVM+K8gXLIyn22FSFdEhw1e4dzdZKfRq5qOw= +github.com/sigstore/cosign/v2 v2.6.4/go.mod h1:g+P/LgYyJkC85WGGDho7yySl3C6xTJzzpLm21ZV+E6s= github.com/sigstore/fulcio v1.8.7 h1:d7QJQxukgXarqPCT9uDNeyzt1Bbt/biw58/VfOnwI/o= github.com/sigstore/fulcio v1.8.7/go.mod h1:7fX+QyigZxRGKTgHvTfOfxwwniCDxK02PVloHy5/vg4= github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= diff --git a/pkg/chains/rekor.go b/pkg/chains/rekor.go index 301aca87e7..50f5b66112 100644 --- a/pkg/chains/rekor.go +++ b/pkg/chains/rekor.go @@ -48,7 +48,7 @@ func (r *rekor) UploadTlog(ctx context.Context, signer signing.Signer, signature return nil, errors.Wrap(err, "public key or cert") } if _, ok := formats.IntotoAttestationSet[config.PayloadType(payloadFormat)]; ok { - return cosign.TLogUploadInTotoAttestation(ctx, r.c, signature, pkoc) + return cosign.TLogUploadDSSEEnvelope(ctx, r.c, signature, pkoc) } h := sha256.New() diff --git a/pkg/chains/signing.go b/pkg/chains/signing.go index 647f1adeae..922d89717c 100644 --- a/pkg/chains/signing.go +++ b/pkg/chains/signing.go @@ -22,6 +22,7 @@ import ( "github.com/hashicorp/go-multierror" intoto "github.com/in-toto/attestation/go/v1" cbundle "github.com/sigstore/cosign/v2/pkg/cosign/bundle" + "github.com/sigstore/rekor/pkg/generated/models" "github.com/tektoncd/chains/pkg/artifacts" "github.com/tektoncd/chains/pkg/chains/annotations" "github.com/tektoncd/chains/pkg/chains/formats" @@ -203,6 +204,7 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) // On upload failure, storage proceeds but the bundle annotation will be absent — // consumers that rely on the bundle for offline verification will get an attestation without it. var rekorBundle *cbundle.RekorBundle + var storageEntry *models.LogEntryAnon if tlogClient != nil { entry, err := tlogClient.UploadTlog(ctx, signer, signature, rawPayload, signer.Cert(), string(payloadFormat)) if err != nil { @@ -218,10 +220,23 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) } else { logger.Warn("Rekor entry missing verification data, skipping bundle for offline verification") } + // Preserve the raw entry so storage backends building a Sigstore protobuf + // bundle (OCI sigstore-bundle mode) can embed the tlog entry inline. + storageEntry = entry measureMetrics(ctx, metrics.PayloadUploadedCount, 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] @@ -238,8 +253,10 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) FullKey: signableType.FullKey(obj), Cert: signer.Cert(), Chain: signer.Chain(), + PublicKey: pubKey, PayloadFormat: payloadFormat, RekorBundle: rekorBundle, + RekorEntry: storageEntry, } if err := b.StorePayload(ctx, tektonObj, rawPayload, string(signature), storageOpts); err != nil { logger.Error(err) diff --git a/pkg/chains/signing/iface.go b/pkg/chains/signing/iface.go index fdd2178ed3..dc0bf30b9d 100644 --- a/pkg/chains/signing/iface.go +++ b/pkg/chains/signing/iface.go @@ -14,7 +14,10 @@ limitations under the License. package signing import ( + "crypto" + "github.com/sigstore/cosign/v2/pkg/cosign/bundle" + "github.com/sigstore/rekor/pkg/generated/models" "github.com/sigstore/sigstore/pkg/signature" ) @@ -44,4 +47,12 @@ type Bundle struct { Chain []byte // RekorBundle is an optional Rekor transparency log bundle, populated when transparency is enabled. RekorBundle *bundle.RekorBundle + // RekorEntry is the raw Rekor transparency log entry, populated when transparency is enabled. + // Storage backends that build a Sigstore protobuf bundle (e.g. OCI sigstore-bundle mode) need + // the raw entry to embed tlog data inline; the converted RekorBundle alone is insufficient. + RekorEntry *models.LogEntryAnon + // 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 b7088aa4cb..ee27627b6e 100644 --- a/pkg/chains/storage/oci/attestation.go +++ b/pkg/chains/storage/oci/attestation.go @@ -16,19 +16,32 @@ package oci import ( "context" + "crypto" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" "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/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/tektoncd/chains/pkg/chains/storage/api" + "github.com/tektoncd/chains/pkg/config" "knative.dev/pkg/logging" ) +// bundleArtifactType is the OCI artifactType that WriteAttestationNewBundleFormat +// sets on attestation referrer manifests. Used in tests to assert layer mediaType; +// the dedup logic uses an empty filter when listing referrers (see storeWithProtobufBundle). +const bundleArtifactType = "application/vnd.dev.sigstore.bundle.v0.3+json" + var ( _ api.Storer[name.Digest, *intoto.Statement] = &AttestationStorer{} ) @@ -40,6 +53,8 @@ type AttestationStorer struct { repo *name.Repository // remoteOpts are additional remote options (i.e. auth) to use for client operations. remoteOpts []remote.Option + // encodingFormat specifies the payload encoding ("dsse" tag-based or "sigstore-bundle" referrers). + encodingFormat string } func NewAttestationStorer(opts ...AttestationStorerOption) (*AttestationStorer, error) { @@ -52,23 +67,46 @@ 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 } + + switch s.encodingFormat { + case config.OCIEncodingFormatSigstoreBundle: + return s.storeReferrers(ctx, req, repo) + default: // OCIEncodingFormatDSSE or empty + return s.storeLegacy(ctx, req, 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 sigstore-bundle mode; OCI 1.1 referrers are stored alongside their subject image in %q", repo.String(), req.Artifact.Repository.String()) + } + + 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], repo name.Repository) (*api.StoreResponse, error) { 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") } + logger := logging.FromContext(ctx) - // Create the new attestation for this entity. attOpts := []static.Option{static.WithLayerMediaType(types.DssePayloadType)} if req.Bundle.Cert != nil { attOpts = append(attOpts, static.WithCertChain(req.Bundle.Cert, req.Bundle.Chain)) @@ -78,10 +116,10 @@ func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[nam } 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") @@ -101,14 +139,114 @@ 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 timestampBytes []byte + var signerBytes []byte + if req.Bundle.Cert != nil { + signerBytes = req.Bundle.Cert + } + + bundleBytes, err := cbundle.MakeNewBundle(pubKey, req.Bundle.RekorEntry, req.Bundle.Content, req.Bundle.Signature, signerBytes, timestampBytes) + if err != nil { + return nil, errors.Wrap(err, "creating protobuf bundle") + } + + // Dedup scan: O(referrers × layers) serial registry calls. + // Acceptable for typical bundle counts (1–3 per artifact); the scan + // short-circuits on the first digest match. Optimize to parallel + // fetches if referrer counts grow large in practice. + // + // Dedup: skip if an identical bundle layer already exists as a referrer. + // static.NewLayer (used by WriteAttestationNewBundleFormat) stores bytes + // uncompressed, so sha256(bundleBytes) == the stored layer's Digest. + bundleHash := sha256.Sum256(bundleBytes) + newLayerDigest := v1.Hash{Algorithm: "sha256", Hex: hex.EncodeToString(bundleHash[:])} + // Use empty artifactType filter to list ALL referrers; the mock registry (and some real + // registries) derive the descriptor's ArtifactType from config.MediaType rather than + // the manifest's top-level artifactType field, so filtering by bundleArtifactType would + // return 0 results even when an identical bundle already exists. Dedup is based on the + // layer content digest, so fetching all referrers is safe and correct. + if idx, listErr := ociremote.Referrers(req.Artifact, "", ociremote.WithRemoteOptions(s.remoteOpts...)); listErr != nil { + logger.Debugf("Could not list referrers for dedup check, will attempt write: %v", listErr) + } else { + for _, desc := range idx.Manifests { + refRef, nameErr := name.NewDigest(req.Artifact.Repository.Name() + "@" + desc.Digest.String()) + if nameErr != nil { + continue + } + refImg, imgErr := remote.Image(refRef, s.remoteOpts...) + if imgErr != nil { + continue + } + layers, layerErr := refImg.Layers() + if layerErr != nil { + continue + } + for _, l := range layers { + if d, dErr := l.Digest(); dErr == nil && d == newLayerDigest { + logger.Infof("Identical attestation bundle with layer digest %s already exists as a referrer, skipping", newLayerDigest) + return &api.StoreResponse{}, nil + } + } + } + } + + 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/attestation_test.go b/pkg/chains/storage/oci/attestation_test.go index a7ee5ab12b..87e356a8e6 100644 --- a/pkg/chains/storage/oci/attestation_test.go +++ b/pkg/chains/storage/oci/attestation_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Tekton Authors +// Copyright 2026 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. @@ -15,8 +15,13 @@ package oci import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" + "io" "net/http/httptest" "strings" "testing" @@ -32,6 +37,7 @@ import ( "github.com/sigstore/cosign/v2/pkg/oci/static" "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" ) @@ -415,3 +421,255 @@ func TestAttestationStorer_Store_DistinctNotDeduped(t *testing.T) { t.Errorf("expected 2 distinct attestation layers, got %d", got) } } + +// testDSSEEnvelope builds a minimal DSSE envelope JSON accepted by cbundle.MakeNewBundle. +// The payload and signature values are arbitrary; this is used only to exercise the +// storage path, not to produce a cryptographically valid attestation. +func testDSSEEnvelope(t *testing.T, payload []byte) []byte { + t.Helper() + type sig struct { + Sig string `json:"sig"` + } + type envelope struct { + Payload string `json:"payload"` + PayloadType string `json:"payloadType"` + Signatures []sig `json:"signatures"` + } + env := envelope{ + Payload: base64.StdEncoding.EncodeToString(payload), + PayloadType: "application/vnd.in-toto+json", + Signatures: []sig{{Sig: base64.StdEncoding.EncodeToString([]byte("test-sig-bytes"))}}, + } + b, err := json.Marshal(env) + if err != nil { + t.Fatalf("failed to marshal test DSSE envelope: %v", err) + } + return b +} + +// TestAttestationStorer_Store_SigstoreBundle verifies that the sigstore-bundle path +// writes the attestation as a protobuf bundle referrer: the bundle artifactType is +// set, a subject pointing at the image is present, no legacy .att tag is created, +// and the referrer is discoverable via the OCI 1.1 Referrers API. +func TestAttestationStorer_Store_SigstoreBundle(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) + } + + // Generate a test key pair; PublicKey is required by storeWithProtobufBundle. + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate test key: %v", err) + } + + payload := []byte(`{"_type":"https://in-toto.io/Statement/v0.1"}`) + dsseEnv := testDSSEEnvelope(t, payload) + + storer, err := NewAttestationStorer( + WithTargetRepository(ref.Repository), + WithEncodingFormat(config.OCIEncodingFormatSigstoreBundle), + ) + if err != nil { + t.Fatalf("failed to create storer: %v", err) + } + + ctx := logtesting.TestContextWithLogger(t) + if _, err := storer.Store(ctx, &api.StoreRequest[name.Digest, *intoto.Statement]{ + Artifact: ref, + Payload: &intoto.Statement{PredicateType: "https://slsa.dev/provenance/v0.2"}, + Bundle: &signing.Bundle{ + Content: payload, + Signature: dsseEnv, + PublicKey: privKey.Public(), + }, + }); err != nil { + t.Fatalf("Store() returned unexpected error: %v", err) + } + + // No legacy .att tag should exist in sigstore-bundle 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, ".att") { + t.Errorf("unexpected legacy attestation tag %q created in sigstore-bundle mode", tag) + } + } + + // The attestation must be discoverable as a referrer. + // Note: we use empty filter because the mock registry derives ArtifactType from + // config.MediaType ("application/vnd.oci.empty.v1+json") rather than the manifest's + // top-level artifactType field; real OCI 1.1 registries handle this correctly. + idx, err := ociremote.Referrers(ref, "") + if err != nil { + t.Fatalf("failed to list referrers: %v", err) + } + if len(idx.Manifests) == 0 { + t.Fatalf("expected at least one attestation referrer, got none") + } + + // Fetch the referrer manifest and verify its layer mediaType. + desc := idx.Manifests[0] + refRef, err := name.NewDigest(ref.Repository.Name() + "@" + desc.Digest.String()) + if err != nil { + t.Fatalf("failed to build referrer digest ref: %v", err) + } + refImg, err := remote.Image(refRef) + if err != nil { + t.Fatalf("failed to fetch referrer image: %v", err) + } + refLayers, err := refImg.Layers() + if err != nil { + t.Fatalf("failed to get referrer layers: %v", err) + } + if len(refLayers) == 0 { + t.Fatalf("expected bundle layer in referrer manifest, got none") + } + layerMT, err := refLayers[0].MediaType() + if err != nil { + t.Fatalf("failed to get layer media type: %v", err) + } + if string(layerMT) != bundleArtifactType { + t.Errorf("layer mediaType = %q, want %q", layerMT, bundleArtifactType) + } + + // Assert the referrer manifest subject points at the image digest. + manifest, err := refImg.Manifest() + if err != nil { + t.Fatalf("failed to get referrer manifest: %v", err) + } + if manifest.Subject == nil { + t.Fatal("referrer manifest has nil subject, want subject pointing at the image") + } + if manifest.Subject.Digest.String() != imgDigest.String() { + t.Errorf("subject.digest = %q, want image digest %q", manifest.Subject.Digest, imgDigest) + } + + // Assert the layer content is valid sigstore bundle JSON containing a dsseEnvelope. + rc, err := refLayers[0].Compressed() + if err != nil { + t.Fatalf("failed to open layer: %v", err) + } + defer rc.Close() + bundleBytes, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("failed to read bundle layer: %v", err) + } + var bundleJSON struct { + DsseEnvelope map[string]interface{} `json:"dsseEnvelope"` + } + if err := json.Unmarshal(bundleBytes, &bundleJSON); err != nil { + t.Fatalf("bundle layer is not valid JSON: %v", err) + } + if bundleJSON.DsseEnvelope == nil { + t.Error("bundle layer JSON missing 'dsseEnvelope' key") + } +} + +// TestAttestationStorer_Store_SigstoreBundle_Dedup verifies that storing the same +// attestation bundle twice in sigstore-bundle mode results in a single referrer. +func TestAttestationStorer_Store_SigstoreBundle_Dedup(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) + } + + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate test key: %v", err) + } + + payload := []byte(`{"_type":"https://in-toto.io/Statement/v0.1"}`) + dsseEnv := testDSSEEnvelope(t, payload) + + storer, err := NewAttestationStorer( + WithTargetRepository(ref.Repository), + WithEncodingFormat(config.OCIEncodingFormatSigstoreBundle), + ) + if err != nil { + t.Fatalf("failed to create storer: %v", err) + } + + ctx := logtesting.TestContextWithLogger(t) + req := &api.StoreRequest[name.Digest, *intoto.Statement]{ + Artifact: ref, + Payload: &intoto.Statement{PredicateType: "https://slsa.dev/provenance/v0.2"}, + Bundle: &signing.Bundle{ + Content: payload, + Signature: dsseEnv, + PublicKey: privKey.Public(), + }, + } + + // Store the same attestation twice. + if _, err := storer.Store(ctx, req); err != nil { + t.Fatalf("first Store() failed: %v", err) + } + if _, err := storer.Store(ctx, req); err != nil { + t.Fatalf("second Store() failed: %v", err) + } + + // Exactly one referrer must exist — no duplicates. + // Note: we use empty filter; see TestAttestationStorer_Store_SigstoreBundle for rationale. + idx, err := ociremote.Referrers(ref, "") + if err != nil { + t.Fatalf("failed to list referrers: %v", err) + } + if got := len(idx.Manifests); got != 1 { + t.Errorf("expected 1 attestation referrer after dedup, got %d", got) + } +} + +// TestResolvePubKey_ExplicitWinsOverCert verifies that when both an explicit PublicKey +// and a certificate are provided, resolvePubKey returns the explicit key without +// even parsing the certificate (white-box: the function short-circuits on non-nil explicit). +func TestResolvePubKey_ExplicitWinsOverCert(t *testing.T) { + explicitKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate explicit key: %v", err) + } + + // certPEM is intentionally not a valid certificate — the function must not reach + // cert-parsing code when explicit is non-nil. + fakeCertPEM := []byte("-----BEGIN CERTIFICATE-----\nnot-a-real-cert\n-----END CERTIFICATE-----") + + got, err := resolvePubKey(explicitKey.Public(), fakeCertPEM) + if err != nil { + t.Fatalf("resolvePubKey() returned unexpected error: %v", err) + } + if got != explicitKey.Public() { + t.Errorf("resolvePubKey() returned cert-derived key, want explicit key") + } +} diff --git a/pkg/chains/storage/oci/legacy.go b/pkg/chains/storage/oci/legacy.go index 7098730544..540cbaa094 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), + WithEncodingFormat(b.cfg.Storage.OCI.EncodingFormat), + ) 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,22 +206,26 @@ 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), + WithEncodingFormat(b.cfg.Storage.OCI.EncodingFormat), + ) 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, RekorBundle: storageOpts.RekorBundle, + RekorEntry: storageOpts.RekorEntry, }, }); err != nil { return err @@ -222,6 +239,10 @@ func (b *Backend) Type() string { } func (b *Backend) RetrieveSignatures(ctx context.Context, obj objects.TektonObject, opts config.StorageOpts) (map[string][]string, error) { + if b.cfg.Storage.OCI.EncodingFormat == config.OCIEncodingFormatSigstoreBundle { + return nil, fmt.Errorf("RetrieveSignatures is not supported in sigstore-bundle encoding mode; " + + "use the OCI 1.1 Referrers API (e.g. 'oras discover' or 'cosign download') to list referrers for the artifact digest") + } images, err := b.RetrieveArtifact(ctx, obj, opts) if err != nil { return nil, err @@ -250,6 +271,10 @@ func (b *Backend) RetrieveSignatures(ctx context.Context, obj objects.TektonObje } func (b *Backend) RetrievePayloads(ctx context.Context, obj objects.TektonObject, opts config.StorageOpts) (map[string]string, error) { + if b.cfg.Storage.OCI.EncodingFormat == config.OCIEncodingFormatSigstoreBundle { + return nil, fmt.Errorf("RetrievePayloads is not supported in sigstore-bundle encoding mode; " + + "use the OCI 1.1 Referrers API (e.g. 'oras discover' or 'cosign download attestation') to list referrers for the artifact digest") + } var err error images, err := b.RetrieveArtifact(ctx, obj, opts) if err != nil { diff --git a/pkg/chains/storage/oci/oci_test.go b/pkg/chains/storage/oci/oci_test.go index 248ea0f10d..39be9ee756 100644 --- a/pkg/chains/storage/oci/oci_test.go +++ b/pkg/chains/storage/oci/oci_test.go @@ -15,9 +15,14 @@ package oci import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" + "encoding/base64" "encoding/json" "fmt" + "io" "net/http/httptest" "net/url" "strings" @@ -28,9 +33,11 @@ import ( "github.com/tektoncd/chains/pkg/chains/formats/simple" "github.com/tektoncd/chains/pkg/chains/objects" "github.com/tektoncd/chains/pkg/config" + "google.golang.org/protobuf/encoding/protojson" "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" @@ -38,6 +45,7 @@ import ( "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" slsa "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" "github.com/sigstore/sigstore/pkg/signature/payload" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" remotetest "github.com/tektoncd/pipeline/test" @@ -373,3 +381,260 @@ func generateSelfSignedCert() (tls.Certificate, error) { return cert, nil } + +// TestWithEncodingFormat_AttestationStorer verifies that WithEncodingFormat +// correctly sets the encodingFormat field on an AttestationStorer. +func TestWithEncodingFormat_AttestationStorer(t *testing.T) { + repo, err := name.NewRepository("example.com/test") + if err != nil { + t.Fatalf("name.NewRepository: %v", err) + } + for _, format := range []string{config.OCIEncodingFormatDSSE, config.OCIEncodingFormatSigstoreBundle} { + t.Run(format, func(t *testing.T) { + storer, err := NewAttestationStorer( + WithTargetRepository(repo), + WithEncodingFormat(format), + ) + if err != nil { + t.Fatalf("NewAttestationStorer: %v", err) + } + if storer.encodingFormat != format { + t.Errorf("encodingFormat = %q, want %q", storer.encodingFormat, format) + } + }) + } +} + +// TestWithEncodingFormat_SimpleStorer verifies that WithEncodingFormat +// correctly sets the encodingFormat field on a SimpleStorer. +func TestWithEncodingFormat_SimpleStorer(t *testing.T) { + repo, err := name.NewRepository("example.com/test") + if err != nil { + t.Fatalf("name.NewRepository: %v", err) + } + for _, format := range []string{config.OCIEncodingFormatDSSE, config.OCIEncodingFormatSigstoreBundle} { + t.Run(format, func(t *testing.T) { + storer, err := NewSimpleStorerFromConfig( + WithTargetRepository(repo), + WithEncodingFormat(format), + ) + if err != nil { + t.Fatalf("NewSimpleStorerFromConfig: %v", err) + } + if storer.encodingFormat != format { + t.Errorf("encodingFormat = %q, want %q", storer.encodingFormat, format) + } + }) + } +} + +// TestDefaultsAreEmpty verifies that omitting the option leaves encodingFormat +// empty (which the Store methods treat as dsse). +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.encodingFormat != "" { + t.Errorf("AttestationStorer.encodingFormat without option = %q, want empty", attestStorer.encodingFormat) + } + + simpleStorer, err := NewSimpleStorerFromConfig(WithTargetRepository(repo)) + if err != nil { + t.Fatalf("NewSimpleStorerFromConfig: %v", err) + } + if simpleStorer.encodingFormat != "" { + t.Errorf("SimpleStorer.encodingFormat without option = %q, want empty", simpleStorer.encodingFormat) + } +} + +// TestOCIBackend_EncodingFormatConfig verifies that the Backend struct properly +// exposes the encoding-format OCI configuration. +func TestOCIBackend_EncodingFormatConfig(t *testing.T) { + for _, format := range []string{config.OCIEncodingFormatDSSE, config.OCIEncodingFormatSigstoreBundle} { + t.Run(format, func(t *testing.T) { + backend := &Backend{ + cfg: config.Config{ + Storage: config.StorageConfigs{ + OCI: config.OCIStorageConfig{ + Repository: "example.com/repo", + EncodingFormat: format, + }, + }, + }, + } + if backend.cfg.Storage.OCI.EncodingFormat != format { + t.Errorf("EncodingFormat = %q, want %q", + backend.cfg.Storage.OCI.EncodingFormat, format) + } + }) + } +} + +// TestReferrersRepoOverrideIgnored verifies the helper that flags when a +// storage.oci.repository override cannot be honoured in sigstore-bundle 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) + } + }) + } +} + +// TestBackend_StorePayload_SigstoreBundle_BundlePayloadPreserved is a regression +// test for uploadAttestation omitting Content from signing.Bundle when constructing +// the sigstore-bundle call. If Content is nil, cbundle.MakeNewBundle produces a +// protobuf bundle whose dsseEnvelope.payload field is absent, making the attestation +// unverifiable. The fix: uploadAttestation must set Content: rawPayload in Bundle. +func TestBackend_StorePayload_SigstoreBundle_BundlePayloadPreserved(t *testing.T) { + s := httptest.NewServer(registry.New(registry.WithReferrersSupport(true))) + defer s.Close() + u, _ := url.Parse(s.URL) + + imgRefStr, err := remotetest.CreateImage(u.Host+"/test/attestation-img", tr) + if err != nil { + t.Fatalf("CreateImage: %v", err) + } + imgRef, err := name.NewDigest(imgRefStr) + if err != nil { + t.Fatalf("name.NewDigest: %v", err) + } + // DigestStr() = "sha256:HEX" — split into algo and hex for the in-toto subject. + digestParts := strings.SplitN(imgRef.DigestStr(), ":", 2) + algo, digestHex := digestParts[0], digestParts[1] + + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("ecdsa.GenerateKey: %v", err) + } + + // Build an in-toto statement pointing at the image; rawPayload is the content that + // must appear in the stored bundle's dsseEnvelope.payload field. + // Use protojson.Marshal (not encoding/json) so field names are camelCase + // (e.g. "predicateType"), matching what uploadAttestation in legacy.go expects. + stmt := &intoto.Statement{ + Type: in_toto.StatementInTotoV01, + PredicateType: slsa.PredicateSLSAProvenance, + Subject: []*intoto.ResourceDescriptor{{ + Name: imgRef.Repository.String(), + Digest: common.DigestSet{algo: digestHex}, + }}, + Predicate: &structpb.Struct{}, + } + rawPayload, err := protojson.Marshal(stmt) + if err != nil { + t.Fatalf("protojson.Marshal(statement): %v", err) + } + + // dsseEnv is the DSSE envelope JSON that StorePayload receives as its signature + // arg. MakeNewBundle extracts the inner signature bytes from it; the Content field + // (rawPayload) is what fills dsseEnvelope.payload in the output bundle. + dsseEnv := testDSSEEnvelope(t, rawPayload) + + b := &Backend{ + cfg: config.Config{ + Storage: config.StorageConfigs{ + OCI: config.OCIStorageConfig{ + EncodingFormat: config.OCIEncodingFormatSigstoreBundle, + }, + }, + }, + getAuthenticator: func(context.Context, objects.TektonObject, kubernetes.Interface) (remote.Option, error) { + return remote.WithAuthFromKeychain(authn.DefaultKeychain), nil + }, + } + + ctx := logtesting.TestContextWithLogger(t) + if err := b.StorePayload(ctx, objects.NewTaskRunObjectV1(tr), rawPayload, string(dsseEnv), config.StorageOpts{ + PayloadFormat: formats.PayloadTypeSlsav1, + PublicKey: privKey.Public(), + }); err != nil { + t.Fatalf("StorePayload: %v", err) + } + + // Discover the referrer written by uploadAttestation. + idx, err := ociremote.Referrers(imgRef, "") + if err != nil { + t.Fatalf("ociremote.Referrers: %v", err) + } + if len(idx.Manifests) == 0 { + t.Fatalf("expected at least one referrer, got none; bundle was not stored") + } + + refRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imgRef.Repository.Name(), idx.Manifests[0].Digest)) + if err != nil { + t.Fatalf("referrer digest ref: %v", err) + } + refImg, err := remote.Image(refRef) + if err != nil { + t.Fatalf("remote.Image(referrer): %v", err) + } + layers, err := refImg.Layers() + if err != nil || len(layers) == 0 { + t.Fatalf("referrer has no layers: %v", err) + } + rc, err := layers[0].Compressed() + if err != nil { + t.Fatalf("layer.Compressed: %v", err) + } + defer rc.Close() + bundleBytes, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("io.ReadAll(bundle layer): %v", err) + } + + // The protobuf bundle is serialised by protojson: bytes fields are standard base64. + // Assert dsseEnvelope.payload is present and round-trips back to the original payload. + var bundle struct { + DsseEnvelope struct { + Payload string `json:"payload"` + } `json:"dsseEnvelope"` + } + if err := json.Unmarshal(bundleBytes, &bundle); err != nil { + t.Fatalf("unmarshal bundle JSON: %v", err) + } + if bundle.DsseEnvelope.Payload == "" { + t.Fatal("bundle.dsseEnvelope.payload is empty: " + + "uploadAttestation must set Content: rawPayload in signing.Bundle; " + + "if nil, cbundle.MakeNewBundle omits the payload key from the bundle JSON") + } + got, err := base64.StdEncoding.DecodeString(bundle.DsseEnvelope.Payload) + if err != nil { + t.Fatalf("base64-decode bundle.dsseEnvelope.payload: %v", err) + } + if string(got) != string(rawPayload) { + t.Errorf("bundle.dsseEnvelope.payload decoded to %q, want rawPayload %q", got, rawPayload) + } +} diff --git a/pkg/chains/storage/oci/options.go b/pkg/chains/storage/oci/options.go index c905e7699c..3391775f7f 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 } + +// WithEncodingFormat configures the payload encoding for OCI artifact storage. +// +// Supported values are the OCIEncodingFormat* constants in pkg/config: +// - OCIEncodingFormatDSSE (default) – DSSE envelope, tag-based storage +// - OCIEncodingFormatSigstoreBundle – Sigstore protobuf bundle, OCI 1.1 Referrers API +// +//nolint:ireturn // returning interface is the intended pattern here +func WithEncodingFormat(format string) Option { + return &encodingFormatOption{format: format} +} + +type encodingFormatOption struct { + format string +} + +func (o *encodingFormatOption) applyAttestationStorer(s *AttestationStorer) error { + s.encodingFormat = o.format + return nil +} + +func (o *encodingFormatOption) applySimpleStorer(s *SimpleStorer) error { + s.encodingFormat = o.format + 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..4ee7be9913 100644 --- a/pkg/chains/storage/oci/simple.go +++ b/pkg/chains/storage/oci/simple.go @@ -16,16 +16,29 @@ package oci import ( "context" + "crypto/sha256" + "crypto/x509" "encoding/base64" + "encoding/hex" + "encoding/pem" "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" "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" + protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" + protodsse "github.com/sigstore/protobuf-specs/gen/pb-go/dsse" + "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" + "google.golang.org/protobuf/encoding/protojson" "knative.dev/pkg/logging" ) @@ -36,6 +49,8 @@ type SimpleStorer struct { repo *name.Repository // remoteOpts are additional remote options (i.e. auth) to use for client operations. remoteOpts []remote.Option + // encodingFormat specifies the payload encoding ("dsse" tag-based or "sigstore-bundle" referrers). + encodingFormat string } var ( @@ -53,37 +68,66 @@ 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") + repo := req.Artifact.Repository + if s.repo != nil { + repo = *s.repo + } + + if s.encodingFormat == config.OCIEncodingFormatSigstoreBundle { + return s.storeReferrers(ctx, req, repo) + } + // Legacy path requires the signed entity; propagate non-404 errors so + // TLS/auth failures surface immediately rather than at WriteSignatures. 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") } + return s.storeLegacy(ctx, req, se, repo) +} + +// storeReferrers writes the signature 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 .sig tags are created. The signature bundle carries +// CosignSignPredicateType so `cosign verify` can distinguish it from SLSA +// attestation bundles stored by the same API. +func (s *SimpleStorer) storeReferrers(ctx context.Context, req *api.StoreRequest[name.Digest, simple.SimpleContainerImage], 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 sigstore-bundle mode; OCI 1.1 referrers are stored alongside their subject image in %q", repo.String(), req.Artifact.Repository.String()) + } + + return s.storeWithSigstoreBundle(ctx, req) +} + +// 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 +137,129 @@ 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 +// storeWithSigstoreBundle uploads the image signature as a Sigstore protobuf +// bundle (v0.3) referrer. The bundle uses a DsseEnvelope wrapping the +// SimpleSigning payload — the same format cosign 3.x `cosign sign` produces. +// WriteAttestationNewBundleFormat hardcodes the "dev.sigstore.bundle.content": +// "dsse-envelope" annotation, so the bundle content must be a DsseEnvelope for +// cosign verify to accept it. The predicateType annotation is set to +// CosignSignPredicateType so cosign can distinguish signature bundles from SLSA +// attestation bundles stored alongside them. +func (s *SimpleStorer) storeWithSigstoreBundle(ctx context.Context, req *api.StoreRequest[name.Digest, simple.SimpleContainerImage]) (*api.StoreResponse, error) { + logger := logging.FromContext(ctx).With("image", req.Artifact.String()) + logger.Info("Using sigstore bundle format for signature storage") + + bundleBytes, err := makeSigBundleBytes(req.Bundle.PublicKey, req.Bundle.Cert, req.Bundle.Content, req.Bundle.Signature, req.Bundle.RekorEntry) + if err != nil { + return nil, errors.Wrap(err, "creating signature bundle") } - // Publish the signatures associated with this entity - if err := ociremote.WriteSignatures(repo, newSE, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil { - return nil, err + + // Dedup scan: O(referrers × layers) serial registry calls. + // Acceptable for typical bundle counts (1–3 per artifact); the scan + // short-circuits on the first digest match. Optimize to parallel + // fetches if referrer counts grow large in practice. + // + // Dedup: skip if an identical bundle layer already exists as a referrer. + // static.NewLayer (used by WriteAttestationNewBundleFormat) stores bytes + // uncompressed, so sha256(bundleBytes) == the stored layer's Digest. + bundleHash := sha256.Sum256(bundleBytes) + newLayerDigest := v1.Hash{Algorithm: "sha256", Hex: hex.EncodeToString(bundleHash[:])} + // Use empty artifactType filter to list ALL referrers; the mock registry (and some real + // registries) derive the descriptor's ArtifactType from config.MediaType rather than + // the manifest's top-level artifactType field, so filtering by bundleArtifactType would + // return 0 results even when an identical bundle already exists. Dedup is based on the + // layer content digest, so fetching all referrers is safe and correct. + if idx, listErr := ociremote.Referrers(req.Artifact, "", ociremote.WithRemoteOptions(s.remoteOpts...)); listErr != nil { + logger.Debugf("Could not list referrers for dedup check, will attempt write: %v", listErr) + } else { + for _, desc := range idx.Manifests { + refRef, nameErr := name.NewDigest(req.Artifact.Repository.Name() + "@" + desc.Digest.String()) + if nameErr != nil { + continue + } + refImg, imgErr := remote.Image(refRef, s.remoteOpts...) + if imgErr != nil { + continue + } + layers, layerErr := refImg.Layers() + if layerErr != nil { + continue + } + for _, l := range layers { + if d, dErr := l.Digest(); dErr == nil && d == newLayerDigest { + logger.Infof("Identical signature bundle with layer digest %s already exists as a referrer, skipping", newLayerDigest) + return &api.StoreResponse{}, nil + } + } + } } - logger.Info("Successfully uploaded signature") + + if err := ociremote.WriteAttestationNewBundleFormat(req.Artifact, bundleBytes, types.CosignSignPredicateType, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil { + return nil, errors.Wrap(err, "writing signature bundle referrer") + } + logger.Info("Successfully uploaded signature using sigstore bundle format") return &api.StoreResponse{}, nil } + +// makeSigBundleBytes constructs a Sigstore protobuf bundle (v0.3) JSON for an +// OCI image signature. The format exactly matches cosign 3.x `cosign sign`: +// the bundle content is a DsseEnvelope with payloadType set to +// SimpleSigningMediaType. WriteAttestationNewBundleFormat hardcodes the +// "dev.sigstore.bundle.content": "dsse-envelope" annotation, so the bundle +// content MUST be a DsseEnvelope — using MessageSignature here would make the +// annotation inconsistent and cause cosign verify to fail. +// +// We build the bundle directly (rather than calling MakeNewBundle) to avoid a +// nil-pubkey crash in the test path: MakeNewBundle calls x509.MarshalPKIXPublicKey +// unconditionally when no cert is provided, which panics with a nil key. +func makeSigBundleBytes(pubKey interface{}, certPEM []byte, payload []byte, rawSig []byte, rekorEntry *models.LogEntryAnon) ([]byte, error) { + var hint string + var rawCert []byte + + if len(certPEM) > 0 { + block, _ := pem.Decode(certPEM) + der := certPEM + if block != nil { + der = block.Bytes + } + if cert, err := x509.ParseCertificate(der); err == nil { + rawCert = cert.Raw + } + } + if rawCert == nil && pubKey != nil { + if pkixKey, err := x509.MarshalPKIXPublicKey(pubKey); err == nil { + h := sha256.Sum256(pkixKey) + hint = base64.StdEncoding.EncodeToString(h[:]) + } + } + + bundle, err := cbundle.MakeProtobufBundle(hint, rawCert, rekorEntry, nil) + if err != nil { + return nil, errors.Wrap(err, "creating protobuf bundle") + } + + bundle.Content = &protobundle.Bundle_DsseEnvelope{ + DsseEnvelope: &protodsse.Envelope{ + Payload: payload, + PayloadType: types.SimpleSigningMediaType, + Signatures: []*protodsse.Signature{{Sig: rawSig}}, + }, + } + + out, err := protojson.Marshal(bundle) + if err != nil { + return nil, errors.Wrap(err, "marshaling bundle") + } + return out, nil +} diff --git a/pkg/chains/storage/oci/simple_test.go b/pkg/chains/storage/oci/simple_test.go index 1db9e90fd2..372e8ebb56 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,13 +23,16 @@ 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" ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" + cosigntypes "github.com/sigstore/cosign/v2/pkg/types" "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 +237,223 @@ func TestSimpleStorer_Store_DistinctNotDeduped(t *testing.T) { t.Errorf("expected 2 distinct signature layers, got %d", got) } } + +// TestSimpleStorer_Store_SigstoreBundle verifies that the sigstore-bundle encoding +// path writes a Sigstore protobuf bundle referrer: artifactType set to +// bundleArtifactType, dev.sigstore.bundle.predicateType annotation set to +// CosignSignPredicateType, and subject pointing back at the image. +func TestSimpleStorer_Store_SigstoreBundle(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), + WithEncodingFormat(config.OCIEncodingFormatSigstoreBundle), + ) + 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 sigstore-bundle 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 sigstore-bundle mode", tag) + } + } + + // Discover the signature via the OCI 1.1 Referrers API. + // Note: use empty filter because the mock registry derives ArtifactType from + // config.MediaType ("application/vnd.oci.empty.v1+json") rather than the manifest's + // top-level artifactType field; real OCI 1.1 registries handle this correctly. + idx, err := ociremote.Referrers(ref, "") + 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 bundle 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 got := m.Annotations["dev.sigstore.bundle.predicateType"]; got != cosigntypes.CosignSignPredicateType { + t.Errorf("dev.sigstore.bundle.predicateType = %q, want %q", got, cosigntypes.CosignSignPredicateType) + } + 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 bundle signature layer, got none") + } +} + +// TestSimpleStorer_Store_SigstoreBundle_RepoOverrideIgnored verifies that a +// storage.oci.repository override is not honoured in sigstore-bundle 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_SigstoreBundle_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), + WithEncodingFormat(config.OCIEncodingFormatSigstoreBundle), + ) + 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. + // Note: use empty filter — same reasoning as TestSimpleStorer_Store_SigstoreBundle. + idx, err := ociremote.Referrers(ref, "") + 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, ""); err == nil && len(overrideIdx.Manifests) > 0 { + t.Errorf("override repo %q unexpectedly received %d referrer(s); override must be ignored in sigstore-bundle mode", overrideRepo.Name(), len(overrideIdx.Manifests)) + } +} + +// TestSimpleStorer_Store_SigstoreBundle_Dedup verifies that storing the same +// signature twice in sigstore-bundle mode results in a single referrer, not two. +func TestSimpleStorer_Store_SigstoreBundle_Dedup(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), + WithEncodingFormat(config.OCIEncodingFormatSigstoreBundle), + ) + if err != nil { + t.Fatalf("failed to create storer: %v", err) + } + + ctx := logtesting.TestContextWithLogger(t) + req := &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{ + Artifact: ref, + Payload: simple.NewSimpleStruct(ref), + Bundle: &signing.Bundle{Content: []byte("payload"), Signature: []byte("sig1")}, + } + + // Store the same signature twice. + if _, err := storer.Store(ctx, req); err != nil { + t.Fatalf("first Store() failed: %s", err) + } + if _, err := storer.Store(ctx, req); err != nil { + t.Fatalf("second Store() failed: %s", err) + } + + // Exactly one referrer must exist — no duplicates. + // Note: use empty filter — same reasoning as TestSimpleStorer_Store_SigstoreBundle. + idx, err := ociremote.Referrers(ref, "") + if err != nil { + t.Fatalf("failed to list referrers: %v", err) + } + if got := len(idx.Manifests); got != 1 { + t.Errorf("expected 1 signature referrer after dedup, got %d", got) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 5ad4c7239c..2bc2f31836 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -119,6 +119,10 @@ type GCSStorageConfig struct { type OCIStorageConfig struct { Repository string Insecure bool + // EncodingFormat controls the payload encoding for OCI artifacts and implicitly the storage layout: + // - "dsse" (default): DSSE-encoded payloads stored under .sig/.att tags. + // - "sigstore-bundle": Sigstore protobuf-bundle stored via OCI 1.1 Referrers API. + EncodingFormat string } type TektonStorageConfig struct { @@ -180,6 +184,7 @@ const ( gcsBucketKey = "storage.gcs.bucket" ociRepositoryKey = "storage.oci.repository" ociRepositoryInsecureKey = "storage.oci.repository.insecure" + ociEncodingFormatKey = "storage.oci.encoding-format" docDBUrlKey = "storage.docdb.url" docDBMongoServerURLKey = "storage.docdb.mongo-server-url" docDBMongoServerURLDirKey = "storage.docdb.mongo-server-url-dir" @@ -229,6 +234,12 @@ const ( buildTypeKey = "builddefinition.buildtype" ChainsConfig = "chains-config" + + // OCIEncodingFormatDSSE is the default encoding: DSSE envelope stored under .sig/.att tags. + OCIEncodingFormatDSSE = "dsse" + // OCIEncodingFormatSigstoreBundle uses the Sigstore protobuf-bundle format stored + // via the OCI 1.1 Referrers API, reducing tag proliferation. + OCIEncodingFormatSigstoreBundle = "sigstore-bundle" ) func (artifact *Artifact) Enabled() bool { @@ -269,10 +280,11 @@ func defaultConfig() *Config { TUFMirrorURL: tuf.DefaultRemoteRoot, }, }, - Storage: StorageConfigs{ - Grafeas: GrafeasConfig{ - NoteHint: "This attestation note was generated by Tekton Chains", - }, + Storage: StorageConfigs{OCI: OCIStorageConfig{ + EncodingFormat: OCIEncodingFormatDSSE, + }, Grafeas: GrafeasConfig{ + NoteHint: "This attestation note was generated by Tekton Chains", + }, }, Builder: BuilderConfig{ ID: "https://tekton.dev/chains/v2", @@ -316,6 +328,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(ociEncodingFormatKey, &cfg.Storage.OCI.EncodingFormat, OCIEncodingFormatDSSE, OCIEncodingFormatSigstoreBundle), asString(docDBUrlKey, &cfg.Storage.DocDB.URL), asString(docDBMongoServerURLKey, &cfg.Storage.DocDB.MongoServerURL), asString(docDBMongoServerURLDirKey, &cfg.Storage.DocDB.MongoServerURLDir), diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 3b07a3e23b..e5c197121e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -106,3 +106,34 @@ func TestNewConfigFromMap_KMSAuthOIDC(t *testing.T) { t.Errorf("OIDC.TokenPath = %q, want %q", cfg.Signers.KMS.Auth.OIDC.TokenPath, "/var/run/secrets/tokens/vault-token") } } + +func TestOCIEncodingFormatDefault(t *testing.T) { + cfg, err := NewConfigFromMap(map[string]string{}) + if err != nil { + t.Fatalf("NewConfigFromMap() error: %v", err) + } + if cfg.Storage.OCI.EncodingFormat != OCIEncodingFormatDSSE { + t.Errorf("default EncodingFormat = %q, want %q", cfg.Storage.OCI.EncodingFormat, OCIEncodingFormatDSSE) + } +} + +func TestOCIEncodingFormatExplicit(t *testing.T) { + for _, format := range []string{OCIEncodingFormatDSSE, OCIEncodingFormatSigstoreBundle} { + t.Run(format, func(t *testing.T) { + cfg, err := NewConfigFromMap(map[string]string{ociEncodingFormatKey: format}) + if err != nil { + t.Fatalf("NewConfigFromMap() error: %v", err) + } + if cfg.Storage.OCI.EncodingFormat != format { + t.Errorf("EncodingFormat = %q, want %q", cfg.Storage.OCI.EncodingFormat, format) + } + }) + } +} + +func TestOCIEncodingFormatInvalid(t *testing.T) { + _, err := NewConfigFromMap(map[string]string{ociEncodingFormatKey: "unknown-method"}) + if err == nil { + t.Error("expected error for invalid encoding format, got nil") + } +} diff --git a/pkg/config/options.go b/pkg/config/options.go index 361a60fc4f..8643885c33 100644 --- a/pkg/config/options.go +++ b/pkg/config/options.go @@ -17,7 +17,10 @@ limitations under the License. package config import ( + "crypto" + "github.com/sigstore/cosign/v2/pkg/cosign/bundle" + "github.com/sigstore/rekor/pkg/generated/models" ) // PayloadType specifies the format to store payload in. @@ -49,9 +52,19 @@ 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 // RekorBundle is an optional Rekor transparency log bundle for offline verification. RekorBundle *bundle.RekorBundle + + // RekorEntry is the raw Rekor transparency log entry. Storage backends that construct + // a Sigstore protobuf bundle (e.g. OCI sigstore-bundle mode) need it to embed the tlog + // entry inline; the converted RekorBundle alone does not carry enough data for MakeNewBundle. + RekorEntry *models.LogEntryAnon } diff --git a/pkg/config/store_test.go b/pkg/config/store_test.go index b08553ea03..e93772b224 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{ + EncodingFormat: OCIEncodingFormatDSSE, + }, 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{ + EncodingFormat: OCIEncodingFormatDSSE, + }, Grafeas: GrafeasConfig{ NoteHint: "a test message", }, diff --git a/test/oci_sigstore_bundle_e2e_test.go b/test/oci_sigstore_bundle_e2e_test.go new file mode 100644 index 0000000000..6f9b45b66b --- /dev/null +++ b/test/oci_sigstore_bundle_e2e_test.go @@ -0,0 +1,199 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2024 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 test + +import ( + "fmt" + "os" + "strings" + "testing" + "time" + + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + logtesting "knative.dev/pkg/logging/testing" + + "github.com/tektoncd/chains/pkg/chains/objects" + "github.com/tektoncd/chains/pkg/test/tekton" +) + +// TestOCIStorageSigstoreBundle_TaskRun verifies that when encoding-format is +// set to sigstore-bundle, Chains stores OCI artifact signatures and attestations +// as OCI 1.1 referrers instead of legacy digest-derived .sig/.att tags. +func TestOCIStorageSigstoreBundle_TaskRun(t *testing.T) { + ctx := logtesting.TestContextWithLogger(t) + c, ns, cleanup := setup(ctx, t, setupOpts{registry: true}) + t.Cleanup(cleanup) + + resetConfig := setConfigMap(ctx, t, c, map[string]string{ + "artifacts.oci.format": "simplesigning", + "artifacts.oci.storage": "oci", + "artifacts.oci.signer": "x509", + "artifacts.taskrun.format": "slsa/v1", + "artifacts.taskrun.signer": "x509", + "artifacts.taskrun.storage": "oci", + "storage.oci.repository.insecure": "true", + "storage.oci.encoding-format": "sigstore-bundle", + }) + t.Cleanup(resetConfig) + time.Sleep(3 * time.Second) // https://github.com/tektoncd/chains/issues/664 + + imageName := "chains-test-referrers-taskrun" + image := fmt.Sprintf("%s/%s", c.internalRegistry, imageName) + + if os.Getenv("OPENSHIFT") == localhost { + if err := assignSCC(ns); err != nil { + t.Fatalf("error creating scc: %s", err) + } + } + + task := kanikoTask(t, ns, image) + if _, err := c.PipelineClient.TektonV1().Tasks(ns).Create(ctx, task, metav1.CreateOptions{}); err != nil { + t.Fatalf("error creating kaniko task: %s", err) + } + + createdTro := tekton.CreateObject(t, ctx, c.PipelineClient, kanikoTaskRun(ns)) + + // Wait for the image build to complete. + if got := waitForCondition(ctx, t, c.PipelineClient, createdTro, done, 2*time.Minute); got == nil { + t.Fatal("kaniko TaskRun never finished") + } + + // Wait for Chains to sign the image and TaskRun. + obj := waitForCondition(ctx, t, c.PipelineClient, createdTro, signed, 2*time.Minute) + if obj == nil { + t.Fatal("kaniko TaskRun was never signed by Chains") + } + + // Verify that no legacy .sig or .att tags were written to the OCI image + // repository. In sigstore-bundle mode, signatures and attestations must be + // stored as referrer manifests discoverable via the OCI referrers API, not + // as digest-derived tags (e.g. sha256-.sig / sha256-.att). + verifyTro := verifyNoLegacyTagsTaskRun(ns, image) + createdVerify := tekton.CreateObject(t, ctx, c.PipelineClient, verifyTro) + if got := waitForCondition(ctx, t, c.PipelineClient, createdVerify, successful, time.Minute); got == nil { + t.Error("no-legacy-tags check TaskRun never succeeded; unexpected .sig/.att tags may exist") + } +} + +// TestOCIStorageSigstoreBundle_PipelineRun verifies that sigstore-bundle mode works +// correctly for OCI artifact signatures produced during a PipelineRun image build. +func TestOCIStorageSigstoreBundle_PipelineRun(t *testing.T) { + const imageName = "chains-test-referrers-pipelinerun" + ctx := logtesting.TestContextWithLogger(t) + c, ns, cleanup := setup(ctx, t, setupOpts{ + registry: true, + kanikoTaskImage: imageName, + }) + t.Cleanup(cleanup) + + resetConfig := setConfigMap(ctx, t, c, map[string]string{ + "artifacts.oci.format": "simplesigning", + "artifacts.oci.storage": "oci", + "artifacts.oci.signer": "x509", + "artifacts.pipelinerun.format": "slsa/v1", + "artifacts.pipelinerun.signer": "x509", + "artifacts.pipelinerun.storage": "oci", + "storage.oci.repository.insecure": "true", + "storage.oci.encoding-format": "sigstore-bundle", + }) + t.Cleanup(resetConfig) + time.Sleep(3 * time.Second) // https://github.com/tektoncd/chains/issues/664 + + if os.Getenv("OPENSHIFT") == localhost { + if err := assignSCC(ns); err != nil { + t.Fatalf("error creating scc: %s", err) + } + } + + createdObj := tekton.CreateObject(t, ctx, c.PipelineClient, kanikoPipelineRun(ns)) + + // Wait for the pipeline (image build) to complete. + if got := waitForCondition(ctx, t, c.PipelineClient, createdObj, done, 2*time.Minute); got == nil { + t.Fatal("PipelineRun never finished") + } + + // Wait for Chains to sign the built OCI image. + obj := waitForCondition(ctx, t, c.PipelineClient, createdObj, signed, 2*time.Minute) + if obj == nil { + t.Fatal("PipelineRun image was never signed by Chains") + } + _ = obj + + // Verify no legacy .sig or .att tags on the built OCI image. + // The image name is known from the kaniko task configuration created by setup(). + image := fmt.Sprintf("%s/%s", c.internalRegistry, imageName) + verifyTro := verifyNoLegacyTagsTaskRun(ns, image) + createdVerify := tekton.CreateObject(t, ctx, c.PipelineClient, verifyTro) + if got := waitForCondition(ctx, t, c.PipelineClient, createdVerify, successful, time.Minute); got == nil { + t.Error("no-legacy-tags check TaskRun never succeeded; unexpected .sig/.att tags may exist") + } +} + +// verifyNoLegacyTagsTaskRun returns a TaskRun that fails if any legacy .sig or +// .att tags exist in the given OCI image repository. This confirms that Chains +// stored signatures and attestations as OCI referrers rather than as tags. +// +// The check is performed from inside the cluster so that the internal registry +// (accessible only from within the cluster network) is reachable. +func verifyNoLegacyTagsTaskRun(ns, image string) *objects.TaskRunObjectV1 { + // Split "host:port/repo/name" into registry host and repository path. + parts := strings.SplitN(image, "/", 2) + if len(parts) != 2 { + panic(fmt.Sprintf("verifyNoLegacyTagsTaskRun: image %q has no '/' separator", image)) + } + registryHost := parts[0] + imageRepo := parts[1] + + // Query the registry's v2 tags/list API and assert that no tags ending in + // .sig or .att are present. Such tags are the hallmark of legacy cosign + // tag-based storage; they must NOT appear in sigstore-bundle mode. + script := fmt.Sprintf(`#!/bin/sh +set -e +# Fetch the tag list; exit immediately if wget fails so a registry error +# does not silently let the test pass with an empty/error response. +if ! TAGS=$(wget -qO- "http://%s/v2/%s/tags/list"); then + echo "FAIL: could not reach registry tags/list endpoint" + exit 1 +fi +echo "Tags response: ${TAGS}" +if printf '%%s' "${TAGS}" | grep -qE '"[^"]*\.(sig|att)"'; then + echo "FAIL: found legacy .sig or .att tags; sigstore-bundle mode must not create these" + exit 1 +fi +echo "PASS: no legacy signature or attestation tags found" +`, registryHost, imageRepo) + + return objects.NewTaskRunObjectV1(&v1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "verify-no-legacy-tags-", + Namespace: ns, + }, + Spec: v1.TaskRunSpec{ + TaskSpec: &v1.TaskSpec{ + Steps: []v1.Step{{ + Name: "check-no-legacy-tags", + Image: "alpine:3.19", + Script: script, + }}, + }, + }, + }) +} diff --git a/vendor/github.com/sigstore/cosign/v2/pkg/cosign/fetch.go b/vendor/github.com/sigstore/cosign/v2/pkg/cosign/fetch.go index 709333ac77..5a89aff994 100644 --- a/vendor/github.com/sigstore/cosign/v2/pkg/cosign/fetch.go +++ b/vendor/github.com/sigstore/cosign/v2/pkg/cosign/fetch.go @@ -154,15 +154,15 @@ func FetchAttestations(se oci.SignedEntity, predicateType string) ([]Attestation if err != nil { return nil, fmt.Errorf("fetching attestations: %w", err) } + attestations := make([]AttestationPayload, 0, len(l)) if len(l) == 0 { - return nil, errors.New("found no attestations") + return attestations, nil } if len(l) > maxAllowedSigsOrAtts { errMsg := fmt.Sprintf("maximum number of attestations on an image is %d, found %d", maxAllowedSigsOrAtts, len(l)) return nil, errors.New(errMsg) } - attestations := make([]AttestationPayload, 0, len(l)) var attMu sync.Mutex var g errgroup.Group diff --git a/vendor/github.com/sigstore/cosign/v2/pkg/oci/remote/write.go b/vendor/github.com/sigstore/cosign/v2/pkg/oci/remote/write.go index 20758b5526..1192ee2ea3 100644 --- a/vendor/github.com/sigstore/cosign/v2/pkg/oci/remote/write.go +++ b/vendor/github.com/sigstore/cosign/v2/pkg/oci/remote/write.go @@ -193,22 +193,15 @@ func WriteSignaturesExperimentalOCI(d name.Digest, se oci.SignedEntity, opts ... artifactType := ociexperimental.ArtifactType("sig") m.Config.MediaType = types.MediaType(artifactType) m.Subject = desc - b, err = json.Marshal(&m) - if err != nil { - return err - } - digest, _, err := v1.SHA256(bytes.NewReader(b)) - if err != nil { - return err - } - targetRef, err := name.ParseReference(fmt.Sprintf("%s/%s@%s", d.RegistryStr(), d.RepositoryStr(), digest.String())) + rm := referrerManifest{m, artifactType} + targetRef, err := rm.targetRef(d.Repository) if err != nil { return err } // TODO: use ui.Infof fmt.Fprintf(os.Stderr, "Uploading signature for [%s] to [%s] with config.mediaType [%s] layers[0].mediaType [%s].\n", d.String(), targetRef.String(), artifactType, ctypes.SimpleSigningMediaType) - return remotePut(targetRef, &taggableManifest{raw: b, mediaType: m.MediaType}, o.ROpt...) + return remotePut(targetRef, rm, o.ROpt...) } type taggableManifest struct { diff --git a/vendor/modules.txt b/vendor/modules.txt index 14785f3a45..df9dbb4a78 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1814,7 +1814,7 @@ github.com/sergi/go-diff/diffmatchpatch # github.com/shibumi/go-pathspec v1.3.0 ## explicit; go 1.17 github.com/shibumi/go-pathspec -# github.com/sigstore/cosign/v2 v2.6.3 +# github.com/sigstore/cosign/v2 v2.6.4 ## explicit; go 1.25.0 github.com/sigstore/cosign/v2/cmd/cosign/cli/fulcio github.com/sigstore/cosign/v2/cmd/cosign/cli/options