mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-25 15:44:56 -04:00
* fix(gallery): verification follow-ups for oci:// galleries Follow-ups from the post-merge review of #12238 and #12239. Only a policy decision is a refusal now. cosignverify wraps ErrPolicyRejected around a failed signature check, an identity or source-repository mismatch, a not_before cutoff and a missing or unparseable bundle. A TUF, registry or network failure during verification, or a timeout, is an outage: the gallery falls back to the copy verified under the current policy, as it does when the registry is down. An oci:// gallery with a verification block, or any oci:// gallery under strict integrity, is no longer answered by an https://, github: or file:// mirror. Such a mirror is ignored with a warning, because nothing can check its signature. The index of an HTTP gallery, whose policy only covers its backend images, is cached under the URL-only name again, so no unchecked body is stored under a policy-keyed name. The in-memory index cache key now includes the policy. After a runtime policy change the index is fetched again, and entries with a relative url install again. The registry digest lookups after install and upgrade, and in the upgrade check, run only for real registry references (new URI.LooksLikeRegistryOCI), not for ollama:// or ocifile://. The refusal message names strict integrity when that is the cause, and the gallery name is no longer repeated. Specs pin the URL-only cache name for galleries without a policy, a fixed key for a fixed policy, and that every GalleryVerification field changes the key. The docs describe refusal, outage, mirrors and strict integrity. Assisted-by: Claude:claude-opus-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): reset listings on gallery changes, classify referrer outages Review follow-ups for this PR. The React UI lists from AvailableGalleryModelsCached, which is keyed by nothing. A gallery change through the settings API or a runtime_settings.json edit now drops that listing when the model or backend gallery configuration differs. Before, the UI kept the old list, with local paths into the old policy's tree, until the next background refresh, or for good when the new policy refused the gallery. In cosignverify, a referrer the registry fails to serve now makes the lookup an outage whatever other referrers failed and in any order, since the unread one may be the valid signature. An invalid policy (Validate in NewVerifier, an unparseable not_before) is ErrPolicyRejected, because no fetch can make it usable. The docs say that only an oci:// gallery with a verification block skips non-OCI mirrors, and list an unusable policy as a refusal. Assisted-by: Claude:claude-opus-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
189 lines
6.9 KiB
Go
189 lines
6.9 KiB
Go
// Sigstore-bundle discovery for cosign-signed OCI images.
|
|
//
|
|
// Cosign 2.2+ with `--new-bundle-format --registry-referrers-mode=oci-1-1`
|
|
// stores the signature as a standalone OCI artifact discoverable via the
|
|
// OCI 1.1 referrers API. The artifact payload is a Sigstore protobuf
|
|
// bundle that sigstore-go consumes natively (no manual annotation parsing).
|
|
//
|
|
// go-containerregistry's remote.Referrers transparently falls back to the
|
|
// referrers-tag scheme (`<algo>-<hex>` tag) for registries that don't yet
|
|
// implement the referrers endpoint, so the same code path covers both.
|
|
//
|
|
// We deliberately do not support the legacy `:sha256-<hex>.sig` cosign
|
|
// signature attachment with per-annotation cert/sig/Rekor fields. CI is
|
|
// expected to sign with `--new-bundle-format`; this is a fresh integration
|
|
// and LocalAI controls both the producer (CI) and the consumer (this
|
|
// binary), so there is no reason to carry the legacy path.
|
|
|
|
package cosignverify
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"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/sigstore/sigstore-go/pkg/bundle"
|
|
)
|
|
|
|
// sigstoreBundleMediaTypePrefix matches every published Sigstore bundle
|
|
// version (0.1, 0.2, 0.3, ...). The artifactType lives on the referrer
|
|
// descriptor in the OCI image index returned by the referrers API.
|
|
const sigstoreBundleMediaTypePrefix = "application/vnd.dev.sigstore.bundle."
|
|
|
|
// isSigstoreBundleArtifactType reports whether the given OCI artifactType
|
|
// identifies a Sigstore bundle blob.
|
|
func isSigstoreBundleArtifactType(mt string) bool {
|
|
return strings.HasPrefix(mt, sigstoreBundleMediaTypePrefix) && strings.HasSuffix(mt, "+json")
|
|
}
|
|
|
|
// bundleFromOCISignature locates a cosign-produced Sigstore bundle for the
|
|
// image identified by ref+imageDigest by querying the OCI 1.1 referrers
|
|
// API and returns the parsed bundle.
|
|
//
|
|
// Returns the first bundle whose JSON parses successfully — verification
|
|
// of identity, transparency log inclusion, and artifact digest is the
|
|
// caller's responsibility (driven by the Verifier).
|
|
func bundleFromOCISignature(ref name.Reference, imageDigest v1.Hash, opts []remote.Option) (*bundle.Bundle, error) {
|
|
digestRef := ref.Context().Digest(imageDigest.String())
|
|
|
|
idx, err := remote.Referrers(digestRef, opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cosignverify: querying referrers for %s: %w", digestRef.Name(), err)
|
|
}
|
|
manifest, err := idx.IndexManifest()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cosignverify: reading referrers index: %w", err)
|
|
}
|
|
|
|
if len(manifest.Manifests) == 0 {
|
|
return nil, fmt.Errorf("cosignverify: no referrers found for %s: %w", digestRef.Name(), ErrPolicyRejected)
|
|
}
|
|
|
|
// outage remembers a referrer the registry failed to serve. That one may
|
|
// be the valid signature, so it decides the result whatever else failed
|
|
// and in whatever order the index lists them.
|
|
var lastErr, outage error
|
|
noteFailure := func(err error) {
|
|
lastErr = err
|
|
if !errors.Is(err, ErrPolicyRejected) {
|
|
outage = err
|
|
}
|
|
}
|
|
for _, desc := range manifest.Manifests {
|
|
if !isSigstoreBundleArtifactType(string(desc.ArtifactType)) {
|
|
continue
|
|
}
|
|
b, err := fetchBundleFromReferrer(ref, desc, opts)
|
|
if err != nil {
|
|
noteFailure(err)
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
isBundle, err := isBundleManifest(ref, desc, opts)
|
|
if err != nil {
|
|
// Unread is not unsigned: a referrer the registry failed to
|
|
// serve may be the signature, so an outage here must not end
|
|
// up reported as "no signature".
|
|
noteFailure(err)
|
|
continue
|
|
}
|
|
if !isBundle {
|
|
continue
|
|
}
|
|
b, err := fetchBundleFromReferrer(ref, desc, opts)
|
|
if err != nil {
|
|
noteFailure(err)
|
|
continue
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
if outage != nil {
|
|
return nil, fmt.Errorf("cosignverify: could not read every referrer of %s: %w", digestRef.Name(), outage)
|
|
}
|
|
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?): %w", digestRef.Name(), ErrPolicyRejected)
|
|
}
|
|
|
|
// 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, error) {
|
|
artRef := ref.Context().Digest(desc.Digest.String())
|
|
img, err := remote.Image(artRef, opts...)
|
|
if err != nil {
|
|
return false, fmt.Errorf("fetching referrer image %s: %w", artRef.Name(), err)
|
|
}
|
|
m, err := img.Manifest()
|
|
if err != nil {
|
|
return false, fmt.Errorf("reading referrer manifest %s: %w", artRef.Name(), err)
|
|
}
|
|
if isSigstoreBundleArtifactType(m.ArtifactType) {
|
|
return true, nil
|
|
}
|
|
return len(m.Layers) > 0 && isSigstoreBundleArtifactType(string(m.Layers[0].MediaType)), nil
|
|
}
|
|
|
|
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...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching referrer image %s: %w", artRef.Name(), err)
|
|
}
|
|
layers, err := img.Layers()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading referrer layers: %w", err)
|
|
}
|
|
if len(layers) == 0 {
|
|
return nil, fmt.Errorf("referrer artifact has no layers: %w", ErrPolicyRejected)
|
|
}
|
|
|
|
rc, err := layers[0].Uncompressed()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening referrer blob: %w", err)
|
|
}
|
|
defer func() { _ = rc.Close() }()
|
|
|
|
data, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading referrer blob: %w", err)
|
|
}
|
|
|
|
b := &bundle.Bundle{}
|
|
if err := b.UnmarshalJSON(data); err != nil {
|
|
// The registry served this referrer in full; what it holds is not
|
|
// a signature, which is an answer about the image.
|
|
return nil, fmt.Errorf("parsing bundle JSON: %w: %w", ErrPolicyRejected, err)
|
|
}
|
|
return b, nil
|
|
}
|