fix(cosignverify): find a bundle the index entry describes badly

A registry without the OCI 1.1 referrers API leaves the referrers index to
the signing client, and cosign fills each entry's artifactType from the
manifest's config media type rather than from its artifactType. CNCF
distribution 3.0.0 has no referrers route at all, so on that registry every
correctly signed image reads as unsigned: the verifier filtered the index
entries by artifactType and matched nothing.

The verifier now asks the referrer manifest what it is when no entry
advertises itself, checking the manifest's own artifactType and its first
layer. The fast path is unchanged, the second pass runs only when the first
finds nothing, and it is bounded so a heavily referenced image cannot turn
one verification into an unbounded walk.

Found by a real release: the images and signatures were correct and no
client could verify them.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5 [Claude Code]
This commit is contained in:
Ettore Di Giacinto committed 2026-09-20 21:04:13 +00:00
1 parent 19a66fd898
commit a3f25ffc28
3 files changed
+213

No files matched your search

+6
View File
@@ -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
+51
View File
@@ -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...)
@@ -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"))
})
})