mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-20 21:28:16 -04:00
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:
1 parent
19a66fd898
commit
a3f25ffc28
3 files changed
+213
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user