feat(gallery): optionally pin the signing certificate's source repository (#12235)

* feat(cosignverify): optionally pin the certificate's source repository

Assisted-by: Claude:claude-opus-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): source_repository in the verification policy

Assisted-by: Claude:claude-opus-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(gallery): when source_repository is checked; test the issuer

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>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-09-24 09:04:55 +02:00
1 parent 81eaca8768
commit be0671c635
10 files changed
+202 -11

No files matched your search

+12
View File
@@ -87,12 +87,24 @@ entry (`backend/index.yaml`):
identity_regex: "^https://github\\.com/mudler/LocalAI/\\.github/workflows/backend_merge\\.yml@refs/(heads/master|tags/.+)$"
# Optional revocation cutoff; advance during incident response.
# not_before: "2026-06-01T00:00:00Z"
# Optional exact source-repository pin (https URL); see below.
# source_repository: "https://github.com/acme/backends"
```
Identity matching pins the OIDC subject Fulcio issued the signing cert
to. Without this, any image signed by *anyone* with a Fulcio cert would
pass — the regex is what makes a signature mean "produced by our CI".
Policy keys: `issuer` or `issuer_regex`, `identity` or `identity_regex`
(one of each is required), and the optional `not_before` and
`source_repository`. `source_repository` is compared exactly against the
certificate's source-repository extension, and a value that is not an
`https://` URL is refused when LocalAI uses the policy, when it installs a
backend or fetches an `oci://` gallery. Set it when a reusable
workflow shared by several repositories does the signing: the identity
then names the shared workflow, and only the source repository says which
repository the signature was made for.
## Strict mode
Default behaviour: OCI backends without a `verification:` block install
+9
View File
@@ -22,12 +22,21 @@ import "slices"
// NotBefore is the revocation lever: advance it to invalidate every
// signature produced before a known compromise window. Keyless cosign
// certs are ephemeral so there is no CA-side revocation.
//
// SourceRepository pins the certificate's source-repository extension. Set
// it when the signing workflow is a reusable workflow shared by several
// repositories: the identity then names the shared workflow, and only the
// source repository says which repository the signature was made for.
type GalleryVerification struct {
Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty"`
IssuerRegex string `json:"issuer_regex,omitempty" yaml:"issuer_regex,omitempty"`
Identity string `json:"identity,omitempty" yaml:"identity,omitempty"`
IdentityRegex string `json:"identity_regex,omitempty" yaml:"identity_regex,omitempty"`
// SourceRepository is an https URL compared exactly against the
// certificate's source-repository extension. Empty skips the check.
SourceRepository string `json:"source_repository,omitempty" yaml:"source_repository,omitempty"`
// NotBefore is an RFC3339 timestamp. Empty disables the time check.
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
}
+11 -1
View File
@@ -147,12 +147,13 @@ var _ = Describe("GalleriesEqual", func() {
[]config.Gallery{{URL: "u", Verification: &config.GalleryVerification{Issuer: "i"}}})).To(BeFalse())
})
// GalleryVerification has five string fields; a value comparison must
// GalleryVerification has six string fields; a value comparison must
// notice a change in any of them, not just the first.
DescribeTable("notices a change in any verification field",
func(mutate func(*config.GalleryVerification)) {
full := config.GalleryVerification{
Issuer: "i", IssuerRegex: "ir", Identity: "id", IdentityRegex: "idr", NotBefore: "2026-05-01T00:00:00Z",
SourceRepository: "https://github.com/acme/gallery",
}
other := full
mutate(&other)
@@ -166,6 +167,15 @@ var _ = Describe("GalleriesEqual", func() {
Entry("identity", func(v *config.GalleryVerification) { v.Identity = "x" }),
Entry("identity regex", func(v *config.GalleryVerification) { v.IdentityRegex = "x" }),
Entry("not before", func(v *config.GalleryVerification) { v.NotBefore = "2030-01-01T00:00:00Z" }),
Entry("source repository", func(v *config.GalleryVerification) { v.SourceRepository = "https://github.com/acme/other" }),
)
})
})
var _ = Describe("GalleryVerification", func() {
It("reads source_repository from a gallery's verification block", func() {
var g []config.Gallery
Expect(json.Unmarshal([]byte(`[{"name":"g","url":"oci://example.com/acme/gallery:latest","verification":{"issuer":"https://token.actions.githubusercontent.com","identity_regex":"^https://github\\.com/example/.*$","source_repository":"https://github.com/acme/gallery"}}]`), &g)).To(Succeed())
Expect(g[0].Verification.SourceRepository).To(Equal("https://github.com/acme/gallery"))
})
})
+5 -4
View File
@@ -175,10 +175,11 @@ func backendDownloadOptions(config *GalleryBackend, requireIntegrity bool) ([]do
// time rather than during signature verification.
func newGalleryVerifier(p *config.GalleryVerification) (*cosignverify.Verifier, error) {
pol := cosignverify.Policy{
Issuer: p.Issuer,
IssuerRegex: p.IssuerRegex,
Identity: p.Identity,
IdentityRegex: p.IdentityRegex,
Issuer: p.Issuer,
IssuerRegex: p.IssuerRegex,
Identity: p.Identity,
IdentityRegex: p.IdentityRegex,
SourceRepository: p.SourceRepository,
}
if p.NotBefore != "" {
t, err := time.Parse(time.RFC3339, p.NotBefore)
@@ -0,0 +1,20 @@
package gallery
import (
"github.com/mudler/LocalAI/core/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// An invalid source_repository is refused by the policy's own validation, so
// seeing that refusal here proves the gallery field reaches the policy.
var _ = Describe("newGalleryVerifier", func() {
It("passes the source repository to the policy", func() {
_, err := newGalleryVerifier(&config.GalleryVerification{
Issuer: "https://token.actions.githubusercontent.com",
IdentityRegex: `^https://github\.com/example/.*$`,
SourceRepository: "not-a-url",
})
Expect(err).To(MatchError(ContainSubstring("source repository")))
})
})
+21
View File
@@ -118,6 +118,27 @@ the trusted images:
}
```
When one reusable workflow signs images for several repositories, the
certificate identity names the shared workflow, not the repository that called
it, so an identity match alone accepts an image signed for any of those
repositories. Add `source_repository` to pin the repository the signature was
made for. LocalAI compares it exactly with the source-repository extension of
the signing certificate: a trailing slash, a different letter case or a `.git`
suffix does not match. The value must be an `https://` URL, or LocalAI refuses
the policy when it uses it, when it installs a backend or fetches an `oci://`
gallery. LocalAI versions before this field existed ignore it and do not pin
the repository, so upgrade every node, workers included, before you rely on it:
```json
{
"verification": {
"issuer": "https://token.actions.githubusercontent.com",
"identity_regex": "^https://github\\.com/example/signer/\\.github/workflows/release\\.yml@refs/tags/v.+$",
"source_repository": "https://github.com/acme/backends"
}
}
```
## Pre-installing Backends
You can pre-install backends when starting LocalAI using the `LOCALAI_EXTERNAL_BACKENDS` environment variable:
+2
View File
@@ -136,6 +136,8 @@ GALLERIES=[{"name":"premium","url":"oci://quay.io/acme/gallery:latest","verifica
The tag is resolved to a digest, the signature is checked against that digest, and the same digest is then pulled. A gallery that fails verification is never written to the cache, so no unverified file reaches your disk. The optional `not_before` RFC3339 value revokes signatures logged before that time, exactly as it does for backends.
The optional `source_repository` value works the same for `oci://` galleries as it does for backends: it pins the repository the signature was made for when a shared reusable workflow does the signing. See [Verifying OCI Backends]({{%relref "features/backends#verifying-oci-backends" %}}).
{{% notice warning %}}
With `--require-backend-integrity` (`LOCALAI_REQUIRE_BACKEND_INTEGRITY=1`), an `oci://` gallery that has no `verification` block is refused when the models are listed, not only when one is installed. Add a `verification` block to every `oci://` gallery before you turn strict integrity on, or the galleries without one stop listing. An `oci://` gallery without a policy still lists outside strict mode, with a warning in the log.
{{% /notice %}}
@@ -0,0 +1,67 @@
// certificateIdentity is unexported, so its tests live in package
// cosignverify; the external suite's RunSpecs picks them up.
package cosignverify
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
)
const (
testIssuer = "https://token.actions.githubusercontent.com"
sharedSAN = "https://github.com/example/signer/.github/workflows/release.yml@refs/tags/v1.0.0"
callerRepo = "https://github.com/acme/gallery"
otherCaller = "https://github.com/acme/other"
)
func summary(san, repo string) certificate.Summary {
return certificate.Summary{
SubjectAlternativeName: san,
Extensions: certificate.Extensions{Issuer: testIssuer, SourceRepositoryURI: repo},
}
}
var _ = Describe("certificateIdentity", func() {
shared := Policy{Issuer: testIssuer, IdentityRegex: `^https://github\.com/example/signer/\.github/workflows/release\.yml@refs/tags/.+$`}
It("builds the short identity when no source repository is set", func() {
id, err := shared.certificateIdentity()
Expect(err).NotTo(HaveOccurred())
// Without the field, any caller of the shared workflow matches,
// which is today's behaviour and must stay so.
Expect(id.Verify(summary(sharedSAN, callerRepo))).To(Succeed())
Expect(id.Verify(summary(sharedSAN, otherCaller))).To(Succeed())
})
It("pins the caller when a source repository is set", func() {
p := shared
p.SourceRepository = callerRepo
id, err := p.certificateIdentity()
Expect(err).NotTo(HaveOccurred())
Expect(id.Verify(summary(sharedSAN, callerRepo))).To(Succeed())
Expect(id.Verify(summary(sharedSAN, otherCaller))).NotTo(Succeed())
})
It("compares the source repository exactly", func() {
p := shared
p.SourceRepository = callerRepo
id, err := p.certificateIdentity()
Expect(err).NotTo(HaveOccurred())
for _, near := range []string{callerRepo + "/", callerRepo + ".git", "https://github.com/ACME/gallery"} {
Expect(id.Verify(summary(sharedSAN, near))).NotTo(Succeed(), near)
}
})
It("still checks the SAN and the issuer", func() {
p := shared
p.SourceRepository = callerRepo
id, err := p.certificateIdentity()
Expect(err).NotTo(HaveOccurred())
Expect(id.Verify(summary("https://github.com/acme/gallery/.github/workflows/evil.yml@refs/heads/main", callerRepo))).NotTo(Succeed())
// Matching SAN and repository must still fail under a different issuer.
otherIssuer := summary(sharedSAN, callerRepo)
otherIssuer.Extensions.Issuer = "https://accounts.example.com"
Expect(id.Verify(otherIssuer)).NotTo(Succeed())
})
})
+36 -6
View File
@@ -20,6 +20,8 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -32,6 +34,7 @@ import (
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/credentials"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
"github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore-go/pkg/tuf"
"github.com/sigstore/sigstore-go/pkg/verify"
@@ -56,6 +59,14 @@ type Policy struct {
Identity string
IdentityRegex string
// SourceRepository, when set, must equal the signing certificate's
// source-repository extension exactly (for GitHub Actions,
// https://github.com/<owner>/<repo>). When a reusable signing
// workflow is shared by several repositories, the SAN names that
// shared workflow, so the SAN alone accepts a signature made for any
// of its callers; the source repository is what pins the caller.
SourceRepository string
// TUFRootURL overrides the default sigstore public-good TUF mirror
// (tuf-repo-cdn.sigstore.dev). Leave empty for the public good.
TUFRootURL string
@@ -96,9 +107,33 @@ func (p Policy) Validate() error {
if p.Identity == "" && p.IdentityRegex == "" {
return errors.New("cosignverify: policy must set Identity or IdentityRegex")
}
if p.SourceRepository != "" {
u, err := url.Parse(p.SourceRepository)
if err != nil || u.Scheme != "https" || u.Host == "" || strings.TrimSpace(p.SourceRepository) != p.SourceRepository {
return errors.New("cosignverify: source repository must be an https URL, such as https://github.com/<owner>/<repo>")
}
}
return nil
}
// certificateIdentity is the identity a signature's certificate must match.
// Without a source repository it is exactly the short identity used before
// the field existed.
func (p Policy) certificateIdentity() (verify.CertificateIdentity, error) {
if p.SourceRepository == "" {
return verify.NewShortCertificateIdentity(p.Issuer, p.IssuerRegex, p.Identity, p.IdentityRegex)
}
san, err := verify.NewSANMatcher(p.Identity, p.IdentityRegex)
if err != nil {
return verify.CertificateIdentity{}, err
}
issuer, err := verify.NewIssuerMatcher(p.Issuer, p.IssuerRegex)
if err != nil {
return verify.CertificateIdentity{}, err
}
return verify.NewCertificateIdentity(san, issuer, certificate.Extensions{SourceRepositoryURI: p.SourceRepository})
}
// Verifier verifies cosign-signed OCI images against a fixed Policy.
//
// Cheap to construct, safe for concurrent use. The TUF trusted root is
@@ -229,12 +264,7 @@ func (v *Verifier) VerifyImage(ctx context.Context, imageRef string) error {
verifierOpts = append(verifierOpts, verify.WithObserverTimestamps(1))
}
certID, err := verify.NewShortCertificateIdentity(
v.policy.Issuer,
v.policy.IssuerRegex,
v.policy.Identity,
v.policy.IdentityRegex,
)
certID, err := v.policy.certificateIdentity()
if err != nil {
return fmt.Errorf("cosignverify: building identity policy: %w", err)
}
+19
View File
@@ -38,6 +38,25 @@ var _ = Describe("Policy", func() {
Expect(err).NotTo(HaveOccurred())
Expect(v).NotTo(BeNil())
})
It("rejects a source repository that is not an https URL", func() {
for _, bad := range []string{"github.com/acme/gallery", "http://github.com/acme/gallery", "https://", " https://github.com/acme/gallery"} {
_, err := cosignverify.NewVerifier(cosignverify.Policy{
Issuer: "https://token.actions.githubusercontent.com",
IdentityRegex: `^https://github.com/example/.*`,
SourceRepository: bad,
}, nil, nil)
Expect(err).To(HaveOccurred(), bad)
}
})
It("still requires the identity when a source repository is set", func() {
_, err := cosignverify.NewVerifier(cosignverify.Policy{
Issuer: "https://token.actions.githubusercontent.com",
SourceRepository: "https://github.com/acme/gallery",
}, nil, nil)
Expect(err).To(HaveOccurred())
})
})
// Live tests hit the public Sigstore TUF mirror, the source registry, and