diff --git a/go.mod b/go.mod index 9f7a8e24..723222a3 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-openapi/strfmt v0.26.3 github.com/go-openapi/swag/conv v0.26.0 github.com/google/certificate-transparency-go v1.3.3 + github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.6 github.com/google/uuid v1.6.0 github.com/hashicorp/go-cleanhttp v0.5.2 @@ -169,7 +170,6 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20260317232201-3888fb8f8738 // indirect github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20260317232201-3888fb8f8738 // indirect github.com/google/go-github/v88 v88.0.0 // indirect diff --git a/pkg/private/secant/bundlesign.go b/pkg/private/secant/bundlesign.go index 4f1f3a47..57a777f3 100644 --- a/pkg/private/secant/bundlesign.go +++ b/pkg/private/secant/bundlesign.go @@ -240,7 +240,7 @@ func SignBundle(ctx context.Context, conflict string, annotations map[string]any return fmt.Errorf("signing bundle for %q: %w", digest.String(), err) } - if err := ociremote.WriteAttestationNewBundleFormat(digest, bundleBytes, ctypes.CosignSignPredicateType, opts...); err != nil { + if err := writeBundleReferrer(digest, bundleBytes, ctypes.CosignSignPredicateType, nil, ropt); err != nil { return fmt.Errorf("writing sign bundle for %q: %w", digest.String(), err) } } @@ -250,6 +250,8 @@ func SignBundle(ctx context.Context, conflict string, annotations map[string]any // AttestBundle creates attestations using the cosign v3 bundle format // and writes them as OCI referrers. See SignBundle for conflict semantics. +// Statements may carry a verbatim subject descriptor (Statement.SubjectDescriptor) +// for subjects that are absent from the target repository. func AttestBundle(ctx context.Context, conflict string, statements []*types.Statement, signer *BundleSigner, ropt []remote.Option) error { if len(statements) == 0 { return nil @@ -281,7 +283,7 @@ func AttestBundle(ctx context.Context, conflict string, statements []*types.Stat return fmt.Errorf("signing attestation bundle for %q: %w", stmt.Digest.String(), err) } - if err := ociremote.WriteAttestationNewBundleFormat(stmt.Digest, bundleBytes, predicateType, ociOpts...); err != nil { + if err := writeBundleReferrer(stmt.Digest, bundleBytes, predicateType, stmt.SubjectDescriptor, ropt); err != nil { return fmt.Errorf("writing attestation bundle for %q: %w", stmt.Digest.String(), err) } } diff --git a/pkg/private/secant/types/types.go b/pkg/private/secant/types/types.go index 9f90f7e9..a67c83db 100644 --- a/pkg/private/secant/types/types.go +++ b/pkg/private/secant/types/types.go @@ -5,6 +5,7 @@ import ( "io" "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/sigstore/cosign/v3/pkg/oci" "github.com/sigstore/sigstore/pkg/signature" ) @@ -33,4 +34,11 @@ type Statement struct { Digest name.Digest Type string Payload []byte + // SubjectDescriptor, when non-nil, is used verbatim as the subject + // descriptor of the referrer manifest written by AttestBundle — the subject + // manifest need not exist in the target repository, but the descriptor's + // digest must match Digest. When nil, the descriptor is resolved via HEAD + // against Digest and any failure (including 404) is an error, matching + // cosign's WriteReferrer. Ignored by the legacy tag-based Attest path. + SubjectDescriptor *v1.Descriptor } diff --git a/pkg/private/secant/writereferrer.go b/pkg/private/secant/writereferrer.go new file mode 100644 index 00000000..d899f477 --- /dev/null +++ b/pkg/private/secant/writereferrer.go @@ -0,0 +1,171 @@ +// TODO: delete this file in favor of ociremote.WriteAttestationNewBundleFormat +// once the upstream cosign change adding a verbatim subject-descriptor option +// merges. + +package secant + +import ( + "bytes" + "encoding/json" + "fmt" + "time" + + "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/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" + ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote" + sgbundle "github.com/sigstore/sigstore-go/pkg/bundle" +) + +// referrerManifest augments v1.Manifest with the OCI 1.1 top-level artifactType +// field (which go-containerregistry's v1.Manifest does not model) and implements +// remote.Taggable so it can be PUT directly. It mirrors the unexported type of the +// same name in cosign's pkg/oci/remote so the serialized manifest is byte-identical. +type referrerManifest struct { + v1.Manifest + ArtifactType string `json:"artifactType,omitempty"` +} + +func (r referrerManifest) RawManifest() ([]byte, error) { + return json.Marshal(r) +} + +func (r referrerManifest) MediaType() (types.MediaType, error) { + return types.OCIManifestSchema1, nil +} + +// writeBundleReferrer writes a v0.3 DSSE bundle for d as an OCI referrer. It +// reproduces cosign's ociremote.WriteAttestationNewBundleFormat / WriteReferrer +// manifest layout byte-for-byte so the result is discoverable and verifiable +// identically, with one deliberate difference: a non-nil subject is used verbatim +// as the referrer's subject descriptor, with no HEAD against the registry, so the +// subject manifest need not exist in d's repository (though the descriptor's +// digest must match d's). This supports the +// COSIGN_REPOSITORY-style pattern where attestations live in a repository separate +// from (and without) the subject image. A nil subject is resolved via HEAD exactly +// like cosign's WriteReferrer, and any failure (including 404) is an error. +// +// Everything is written to d.Repository, which the caller has already pointed at the +// desired (possibly override) repository. +func writeBundleReferrer(d name.Digest, bundleBytes []byte, predicateType string, subject *v1.Descriptor, ropt []remote.Option) error { + bundleMediaType, err := sgbundle.MediaTypeString("0.3") + if err != nil { + return fmt.Errorf("generating bundle media type string: %w", err) + } + + // Empty config layer, matching cosign's writeEmptyConfigLayer. Note the config + // *blob* uses the image config media type while the config *descriptor* in the + // manifest below uses the OCI empty media type, exactly as cosign does. + configLayer := static.NewLayer([]byte("{}"), "application/vnd.oci.image.config.v1+json") + configDesc, err := layerDescriptor(configLayer) + if err != nil { + return fmt.Errorf("describing config layer: %w", err) + } + if err := remote.WriteLayer(d.Repository, configLayer, ropt...); err != nil { + return fmt.Errorf("uploading config layer: %w", err) + } + + // The bundle itself is the sole layer. + bundleLayer := static.NewLayer(bundleBytes, types.MediaType(bundleMediaType)) + bundleDesc, err := layerDescriptor(bundleLayer) + if err != nil { + return fmt.Errorf("describing bundle layer: %w", err) + } + if err := remote.WriteLayer(d.Repository, bundleLayer, ropt...); err != nil { + return fmt.Errorf("uploading bundle layer: %w", err) + } + + subject, err = subjectDescriptor(d, subject, ropt) + if err != nil { + return err + } + + manifest := newReferrerManifest(configDesc, bundleDesc, subject, bundleMediaType, predicateType) + + manifestBytes, err := manifest.RawManifest() + if err != nil { + return fmt.Errorf("marshaling referrer manifest: %w", err) + } + manifestDigest, _, err := v1.SHA256(bytes.NewReader(manifestBytes)) + if err != nil { + return fmt.Errorf("digesting referrer manifest: %w", err) + } + if err := remote.Put(d.Digest(manifestDigest.String()), manifest, ropt...); err != nil { + return fmt.Errorf("uploading referrer manifest for %q: %w", d.String(), err) + } + return nil +} + +// newReferrerManifest assembles the OCI 1.1 referrer manifest for a bundle +// attestation, mirroring cosign's layout (see writeBundleReferrer). +func newReferrerManifest(configDesc, bundleDesc v1.Descriptor, subject *v1.Descriptor, bundleMediaType, predicateType string) referrerManifest { + return referrerManifest{ + Manifest: v1.Manifest{ + SchemaVersion: 2, + MediaType: types.OCIManifestSchema1, + Config: v1.Descriptor{ + MediaType: types.MediaType("application/vnd.oci.empty.v1+json"), + ArtifactType: bundleMediaType, + Digest: configDesc.Digest, + Size: configDesc.Size, + }, + Layers: []v1.Descriptor{bundleDesc}, + Subject: subject, + Annotations: map[string]string{ + "org.opencontainers.image.created": time.Now().UTC().Format(time.RFC3339), + "dev.sigstore.bundle.content": "dsse-envelope", + ociremote.BundlePredicateType: predicateType, + }, + }, + ArtifactType: bundleMediaType, + } +} + +// layerDescriptor builds an OCI descriptor for an already-constructed layer. +func layerDescriptor(layer v1.Layer) (v1.Descriptor, error) { + mt, err := layer.MediaType() + if err != nil { + return v1.Descriptor{}, fmt.Errorf("layer media type: %w", err) + } + dig, err := layer.Digest() + if err != nil { + return v1.Descriptor{}, fmt.Errorf("layer digest: %w", err) + } + sz, err := layer.Size() + if err != nil { + return v1.Descriptor{}, fmt.Errorf("layer size: %w", err) + } + return v1.Descriptor{MediaType: mt, Digest: dig, Size: sz}, nil +} + +// subjectDescriptor returns the OCI descriptor for d's subject. A non-nil subject +// is returned verbatim with no network call; the caller vouches for it, and a +// synthetic descriptor (digest-only, "size":0 — v1.Descriptor.Size has no +// omitempty) is fine because the read and verify paths never consult the subject's +// media type/size (the OCI Referrers listing keys on the subject digest, and +// sigstore-go verifies the subject digest, not the referrer's subject descriptor). +// A nil subject is resolved via HEAD, where any failure — including 404 — is an +// error, matching cosign's WriteReferrer. +func subjectDescriptor(d name.Digest, subject *v1.Descriptor, ropt []remote.Option) (*v1.Descriptor, error) { + if subject != nil { + // A descriptor for a different digest would be indexed by the registry + // under that digest, leaving the referrer undiscoverable via d and + // invisible to conflict resolution. + if subject.Digest.String() != d.DigestStr() { + return nil, fmt.Errorf("subject descriptor digest %q does not match %q", subject.Digest.String(), d.String()) + } + return subject, nil + } + + desc, err := remote.Head(d, ropt...) + if err != nil { + return nil, fmt.Errorf("resolving subject descriptor for %q: %w", d.String(), err) + } + return &v1.Descriptor{ + MediaType: desc.MediaType, + Digest: desc.Digest, + Size: desc.Size, + }, nil +} diff --git a/pkg/private/secant/writereferrer_test.go b/pkg/private/secant/writereferrer_test.go new file mode 100644 index 00000000..545b1e4a --- /dev/null +++ b/pkg/private/secant/writereferrer_test.go @@ -0,0 +1,321 @@ +package secant + +import ( + "bytes" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "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/static" + "github.com/google/go-containerregistry/pkg/v1/types" + ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote" + sgbundle "github.com/sigstore/sigstore-go/pkg/bundle" +) + +const testPredicateType = "https://slsa.dev/provenance/v1" + +var testBundleBytes = []byte(`{"this":"stands in for a serialized sigstore bundle"}`) + +func setupTestRepo(t *testing.T) name.Repository { + t.Helper() + + srv := httptest.NewServer(registry.New(registry.WithReferrersSupport(true))) + t.Cleanup(srv.Close) + + repo, err := name.NewRepository(strings.TrimPrefix(srv.URL, "http://") + "/test-repo") + if err != nil { + t.Fatal(err) + } + return repo +} + +func pushImage(t *testing.T, repo name.Repository, img v1.Image) name.Digest { + t.Helper() + + if err := remote.Write(repo.Tag("latest"), img); err != nil { + t.Fatal(err) + } + h, err := img.Digest() + if err != nil { + t.Fatal(err) + } + return repo.Digest(h.String()) +} + +// referrerManifests fetches the raw manifest of every referrer of d. +func referrerManifests(t *testing.T, d name.Digest) [][]byte { + t.Helper() + + idx, err := remote.Referrers(d) + if err != nil { + t.Fatal(err) + } + im, err := idx.IndexManifest() + if err != nil { + t.Fatal(err) + } + raws := make([][]byte, 0, len(im.Manifests)) + for _, m := range im.Manifests { + desc, err := remote.Get(d.Context().Digest(m.Digest.String())) + if err != nil { + t.Fatal(err) + } + raws = append(raws, desc.Manifest) + } + return raws +} + +// normalizeManifest parses a raw manifest and drops the created annotation, +// the only field expected to vary between writers. +func normalizeManifest(t *testing.T, raw []byte) map[string]any { + t.Helper() + + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if ann, ok := m["annotations"].(map[string]any); ok { + delete(ann, "org.opencontainers.image.created") + } + return m +} + +// TestWriteBundleReferrerParity pins writeBundleReferrer's nil-subject output to +// cosign's WriteAttestationNewBundleFormat for the life of the fork: modulo the +// created timestamp, the two must produce identical referrer manifests. Each +// implementation writes into its own repository (holding the same image) so the +// test always compares exactly two independently-written manifests, with no +// registry dedup when both land in the same second. +func TestWriteBundleReferrerParity(t *testing.T) { + img, err := random.Image(1024, 1) + if err != nil { + t.Fatal(err) + } + cosignD := pushImage(t, setupTestRepo(t), img) + forkD := pushImage(t, setupTestRepo(t), img) + + if err := ociremote.WriteAttestationNewBundleFormat(cosignD, testBundleBytes, testPredicateType); err != nil { + t.Fatalf("cosign WriteAttestationNewBundleFormat: %v", err) + } + if err := writeBundleReferrer(forkD, testBundleBytes, testPredicateType, nil, nil); err != nil { + t.Fatalf("writeBundleReferrer: %v", err) + } + + cosignRaws := referrerManifests(t, cosignD) + if len(cosignRaws) != 1 { + t.Fatalf("expected 1 cosign referrer, got %d", len(cosignRaws)) + } + forkRaws := referrerManifests(t, forkD) + if len(forkRaws) != 1 { + t.Fatalf("expected 1 fork referrer, got %d", len(forkRaws)) + } + + want := normalizeManifest(t, cosignRaws[0]) + if diff := cmp.Diff(want, normalizeManifest(t, forkRaws[0])); diff != "" { + t.Errorf("referrer manifests diverge (-cosign +fork):\n%s", diff) + } +} + +// TestWriteBundleReferrerVerbatimSubject covers the synthetic-subject path: the +// subject manifest exists nowhere, and the caller-supplied descriptor must appear +// in the referrer manifest exactly as given. +func TestWriteBundleReferrerVerbatimSubject(t *testing.T) { + repo := setupTestRepo(t) + d := repo.Digest("sha256:" + strings.Repeat("ab", 32)) + + h, err := v1.NewHash(d.DigestStr()) + if err != nil { + t.Fatal(err) + } + subject := &v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Digest: h, + } + + if err := writeBundleReferrer(d, testBundleBytes, testPredicateType, subject, nil); err != nil { + t.Fatalf("writeBundleReferrer: %v", err) + } + + raws := referrerManifests(t, d) + if len(raws) != 1 { + t.Fatalf("expected 1 referrer, got %d", len(raws)) + } + + var got referrerManifest + if err := json.Unmarshal(raws[0], &got); err != nil { + t.Fatal(err) + } + + // The synthetic descriptor must serialize an explicit zero size. + var rawFields struct { + Subject map[string]json.RawMessage `json:"subject"` + } + if err := json.Unmarshal(raws[0], &rawFields); err != nil { + t.Fatal(err) + } + if size, ok := rawFields.Subject["size"]; !ok || string(size) != "0" { + t.Errorf("expected explicit \"size\":0 in subject descriptor: %s", raws[0]) + } + + bundleMediaType, err := sgbundle.MediaTypeString("0.3") + if err != nil { + t.Fatal(err) + } + configDesc, err := layerDescriptor(static.NewLayer([]byte("{}"), "application/vnd.oci.image.config.v1+json")) + if err != nil { + t.Fatal(err) + } + bundleDesc, err := layerDescriptor(static.NewLayer(testBundleBytes, types.MediaType(bundleMediaType))) + if err != nil { + t.Fatal(err) + } + + created := got.Annotations["org.opencontainers.image.created"] + if _, err := time.Parse(time.RFC3339, created); err != nil { + t.Errorf("created annotation %q is not RFC3339: %v", created, err) + } + + // The constructor is pinned field-by-field in TestNewReferrerManifest, so it + // serves as want here: this asserts the write path lands the constructed + // manifest in the registry unchanged. + want := newReferrerManifest(configDesc, bundleDesc, subject, bundleMediaType, testPredicateType) + want.Annotations["org.opencontainers.image.created"] = created + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("referrer manifest mismatch (-want +got):\n%s", diff) + } + + // The bundle blob itself must round-trip through the registry. + layer, err := remote.Layer(repo.Digest(bundleDesc.Digest.String())) + if err != nil { + t.Fatal(err) + } + rc, err := layer.Compressed() + if err != nil { + t.Fatal(err) + } + defer rc.Close() + gotBundle, err := io.ReadAll(rc) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotBundle, testBundleBytes) { + t.Errorf("bundle blob round-trip mismatch:\ngot: %s\nwant: %s", gotBundle, testBundleBytes) + } +} + +// TestNewReferrerManifest compares the assembled manifest against the expected +// layout wholesale. The created annotation is the only non-deterministic field: +// it is checked to be well-formed RFC3339 and then copied into want. +func TestNewReferrerManifest(t *testing.T) { + bundleMediaType, err := sgbundle.MediaTypeString("0.3") + if err != nil { + t.Fatal(err) + } + + configDigest, err := v1.NewHash("sha256:" + strings.Repeat("11", 32)) + if err != nil { + t.Fatal(err) + } + configDesc := v1.Descriptor{ + MediaType: types.MediaType("application/vnd.oci.image.config.v1+json"), + Digest: configDigest, + Size: 2, + } + + bundleDigest, err := v1.NewHash("sha256:" + strings.Repeat("22", 32)) + if err != nil { + t.Fatal(err) + } + bundleDesc := v1.Descriptor{ + MediaType: types.MediaType(bundleMediaType), + Digest: bundleDigest, + Size: int64(len(testBundleBytes)), + } + + subjectDigest, err := v1.NewHash("sha256:" + strings.Repeat("ab", 32)) + if err != nil { + t.Fatal(err) + } + subject := &v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Digest: subjectDigest, + } + + got := newReferrerManifest(configDesc, bundleDesc, subject, bundleMediaType, testPredicateType) + + created := got.Annotations["org.opencontainers.image.created"] + if _, err := time.Parse(time.RFC3339, created); err != nil { + t.Errorf("created annotation %q is not RFC3339: %v", created, err) + } + + want := referrerManifest{ + Manifest: v1.Manifest{ + SchemaVersion: 2, + MediaType: types.OCIManifestSchema1, + Config: v1.Descriptor{ + MediaType: types.MediaType("application/vnd.oci.empty.v1+json"), + ArtifactType: bundleMediaType, + Digest: configDesc.Digest, + Size: configDesc.Size, + }, + Layers: []v1.Descriptor{bundleDesc}, + Subject: subject, + Annotations: map[string]string{ + "org.opencontainers.image.created": created, + "dev.sigstore.bundle.content": "dsse-envelope", + ociremote.BundlePredicateType: testPredicateType, + }, + }, + ArtifactType: bundleMediaType, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("referrer manifest mismatch (-want +got):\n%s", diff) + } +} + +// TestWriteBundleReferrerSubjectDigestMismatch verifies that a caller-supplied +// descriptor whose digest differs from the target digest is rejected: the +// registry would index the referrer under the descriptor's digest, making it +// undiscoverable via the digest being attested. +func TestWriteBundleReferrerSubjectDigestMismatch(t *testing.T) { + repo := setupTestRepo(t) + d := repo.Digest("sha256:" + strings.Repeat("ab", 32)) + + h, err := v1.NewHash("sha256:" + strings.Repeat("ef", 32)) + if err != nil { + t.Fatal(err) + } + subject := &v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Digest: h, + } + + err = writeBundleReferrer(d, testBundleBytes, testPredicateType, subject, nil) + if err == nil { + t.Fatal("expected error for mismatched subject descriptor digest, got nil") + } + if !strings.Contains(err.Error(), "does not match") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestWriteBundleReferrerNilSubjectMissing verifies the strict behavior restored +// from cosign: with no caller-supplied descriptor, a missing subject manifest is +// an error rather than a silently degraded referrer. +func TestWriteBundleReferrerNilSubjectMissing(t *testing.T) { + repo := setupTestRepo(t) + d := repo.Digest("sha256:" + strings.Repeat("cd", 32)) + + if err := writeBundleReferrer(d, testBundleBytes, testPredicateType, nil, nil); err == nil { + t.Fatal("expected error for missing subject with nil descriptor, got nil") + } +}