mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-25 07:34:58 -04:00
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:
1 parent
81eaca8768
commit
be0671c635
10 files changed
+202
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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")))
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user