diff --git a/.agents/backend-signing.md b/.agents/backend-signing.md index 5edec5891..e54dcd3ee 100644 --- a/.agents/backend-signing.md +++ b/.agents/backend-signing.md @@ -20,6 +20,12 @@ side (`pkg/oci/cosignverify` plus the gallery YAML). - **Consumer:** `pkg/oci/cosignverify` discovers the bundle via the referrers API, hands it to `sigstore-go`, and verifies it against the policy declared in the gallery YAML (`Gallery.Verification`). + A registry without the referrers API (CNCF distribution 3.0.0 has no such + route) sends the client to the referrers-tag index instead, and cosign + writes that index's `artifactType` from the manifest's *config* media + type. The verifier therefore falls back to asking each referrer manifest + what it is, rather than trusting the index entry: without that, correctly + signed images on such a registry read as unsigned. - **Revocation:** Keyless cosign certs are ephemeral (10-minute Fulcio validity), so revocation is policy-side, not CA-side. The gallery's `verification.not_before` (RFC3339) is the kill-switch — advance it to diff --git a/pkg/oci/cosignverify/bundle.go b/pkg/oci/cosignverify/bundle.go index 4202132db..188487fec 100644 --- a/pkg/oci/cosignverify/bundle.go +++ b/pkg/oci/cosignverify/bundle.go @@ -76,12 +76,63 @@ func bundleFromOCISignature(ref name.Reference, imageDigest v1.Hash, opts []remo } return b, nil } + + // Nothing advertised itself as a bundle, which does not mean nothing is + // one. A registry without the referrers API leaves the index to the + // signing client, and cosign fills the entry's artifactType from the + // manifest's config media type rather than from its artifactType, so a + // correctly signed image arrives here looking unsigned. The manifest + // itself always carries the truth, so ask it. + for i, desc := range manifest.Manifests { + if i >= maxReferrersInspected { + break + } + if isSigstoreBundleArtifactType(string(desc.ArtifactType)) { + continue // already tried above + } + if !isBundleManifest(ref, desc, opts) { + continue + } + b, err := fetchBundleFromReferrer(ref, desc, opts) + if err != nil { + lastErr = err + continue + } + return b, nil + } + if lastErr != nil { return nil, fmt.Errorf("cosignverify: no usable Sigstore bundle referrer for %s: %w", digestRef.Name(), lastErr) } return nil, fmt.Errorf("cosignverify: no Sigstore bundle referrer for %s (signed with --new-bundle-format?)", digestRef.Name()) } +// maxReferrersInspected bounds the second pass. Each step there is a manifest +// fetch, and the descriptors come from a registry, so an image with many +// referrers must not turn one verification into an unbounded walk. +const maxReferrersInspected = 16 + +// isBundleManifest reports whether a referrer manifest is a Sigstore bundle +// by its own account, whatever the index entry claimed. Both the manifest's +// artifactType and its first layer are checked: the layer is what actually +// holds the bundle, and a manifest can reach a registry with neither field +// copied onto the index. +func isBundleManifest(ref name.Reference, desc v1.Descriptor, opts []remote.Option) bool { + artRef := ref.Context().Digest(desc.Digest.String()) + img, err := remote.Image(artRef, opts...) + if err != nil { + return false + } + m, err := img.Manifest() + if err != nil { + return false + } + if isSigstoreBundleArtifactType(m.ArtifactType) { + return true + } + return len(m.Layers) > 0 && isSigstoreBundleArtifactType(string(m.Layers[0].MediaType)) +} + func fetchBundleFromReferrer(ref name.Reference, desc v1.Descriptor, opts []remote.Option) (*bundle.Bundle, error) { artRef := ref.Context().Digest(desc.Digest.String()) img, err := remote.Image(artRef, opts...) diff --git a/pkg/oci/cosignverify/referrers_internal_test.go b/pkg/oci/cosignverify/referrers_internal_test.go new file mode 100644 index 000000000..ff45147e3 --- /dev/null +++ b/pkg/oci/cosignverify/referrers_internal_test.go @@ -0,0 +1,156 @@ +package cosignverify + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + + "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/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeRegistry serves manifests and blobs from memory and answers the +// referrers API with a 404, the way CNCF distribution 3.0.0 does: that build +// does not register the route at all, so every client falls back to the +// referrers-tag scheme. +type fakeRegistry struct { + manifests map[string]manifestEntry // by tag and by digest + blobs map[string][]byte // by digest +} + +type manifestEntry struct { + mediaType string + body []byte +} + +func (f *fakeRegistry) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + switch { + case p == "/v2/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + case strings.Contains(p, "/referrers/"): + // Exactly what distribution answers: the router has no such route, so + // this is Go's plain text 404 rather than a registry error document. + http.NotFound(w, r) + case strings.Contains(p, "/manifests/"): + ref := p[strings.LastIndex(p, "/manifests/")+len("/manifests/"):] + m, ok := f.manifests[ref] + if !ok { + http.Error(w, "unknown manifest", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", m.mediaType) + w.Header().Set("Docker-Content-Digest", digestOf(m.body)) + if r.Method == http.MethodHead { + w.Header().Set("Content-Length", fmt.Sprint(len(m.body))) + w.WriteHeader(http.StatusOK) + return + } + _, _ = w.Write(m.body) + case strings.Contains(p, "/blobs/"): + d := p[strings.LastIndex(p, "/blobs/")+len("/blobs/"):] + b, ok := f.blobs[d] + if !ok { + http.Error(w, "unknown blob", http.StatusNotFound) + return + } + _, _ = w.Write(b) + default: + http.NotFound(w, r) + } +} + +func digestOf(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// signedByCosignWithoutReferrersAPI builds the shape a premium release +// actually publishes: a subject manifest, a signature manifest that carries +// the Sigstore artifact type, and a referrers-tag index whose entry reports +// the CONFIG media type instead of the manifest's own artifact type. That +// last part is what cosign writes when the registry has no referrers API, +// and it is what made every premium signature unverifiable. +func signedByCosignWithoutReferrersAPI(bundleBlob []byte) (*fakeRegistry, string) { + reg := &fakeRegistry{manifests: map[string]manifestEntry{}, blobs: map[string][]byte{}} + + emptyCfg := []byte("{}") + reg.blobs[digestOf(emptyCfg)] = emptyCfg + reg.blobs[digestOf(bundleBlob)] = bundleBlob + + subject, _ := json.Marshal(v1.Manifest{ + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.manifest.v1+json", + Config: v1.Descriptor{MediaType: "application/vnd.oci.empty.v1+json", Size: int64(len(emptyCfg)), Digest: mustHash(digestOf(emptyCfg))}, + }) + subjectDigest := digestOf(subject) + reg.manifests[subjectDigest] = manifestEntry{mediaType: "application/vnd.oci.image.manifest.v1+json", body: subject} + + sig, _ := json.Marshal(v1.Manifest{ + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.manifest.v1+json", + ArtifactType: "application/vnd.dev.sigstore.bundle.v0.3+json", + Config: v1.Descriptor{MediaType: "application/vnd.oci.empty.v1+json", Size: int64(len(emptyCfg)), Digest: mustHash(digestOf(emptyCfg))}, + Layers: []v1.Descriptor{{ + MediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", + Size: int64(len(bundleBlob)), + Digest: mustHash(digestOf(bundleBlob)), + }}, + }) + sigDigest := digestOf(sig) + reg.manifests[sigDigest] = manifestEntry{mediaType: "application/vnd.oci.image.manifest.v1+json", body: sig} + + // The referrers tag, with the wrong artifactType on the entry. + idx, _ := json.Marshal(v1.IndexManifest{ + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.index.v1+json", + Manifests: []v1.Descriptor{{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Size: int64(len(sig)), + Digest: mustHash(sigDigest), + ArtifactType: "application/vnd.oci.empty.v1+json", + }}, + }) + reg.manifests[strings.Replace(subjectDigest, ":", "-", 1)] = manifestEntry{mediaType: "application/vnd.oci.image.index.v1+json", body: idx} + + return reg, subjectDigest +} + +func mustHash(s string) v1.Hash { + h, err := v1.NewHash(s) + if err != nil { + panic(err) + } + return h +} + +var _ = Describe("referrer discovery", func() { + It("finds a bundle whose index entry reports the wrong artifact type", func() { + // Not a valid bundle: this asserts discovery, so reaching the parse + // step is the pass condition and parsing is expected to fail. + reg, subjectDigest := signedByCosignWithoutReferrersAPI([]byte(`{"not":"a bundle"}`)) + srv := httptest.NewServer(reg) + defer srv.Close() + u, err := url.Parse(srv.URL) + Expect(err).NotTo(HaveOccurred()) + + ref, err := name.ParseReference(u.Host+"/gallery:backends-0.1.2", name.Insecure) + Expect(err).NotTo(HaveOccurred()) + + _, err = bundleFromOCISignature(ref, mustHash(subjectDigest), []remote.Option{}) + Expect(err).To(HaveOccurred()) + // Before the fix this stopped at the index entry and never fetched the + // referrer, so it reported that no bundle referrer exists at all. + Expect(err.Error()).NotTo(ContainSubstring("no Sigstore bundle referrer")) + Expect(err.Error()).To(ContainSubstring("parsing bundle JSON")) + }) +})