diff --git a/.agents/backend-signing.md b/.agents/backend-signing.md index 67d1abe55..6d388dbba 100644 --- a/.agents/backend-signing.md +++ b/.agents/backend-signing.md @@ -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 diff --git a/core/config/gallery.go b/core/config/gallery.go index 031bffd76..e22cbc94f 100644 --- a/core/config/gallery.go +++ b/core/config/gallery.go @@ -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"` } diff --git a/core/config/gallery_test.go b/core/config/gallery_test.go index bcc4b2f2c..71f4f3a36 100644 --- a/core/config/gallery_test.go +++ b/core/config/gallery_test.go @@ -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")) + }) +}) diff --git a/core/gallery/backends.go b/core/gallery/backends.go index 95ec4145e..55f998a74 100644 --- a/core/gallery/backends.go +++ b/core/gallery/backends.go @@ -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) diff --git a/core/gallery/backends_verifier_internal_test.go b/core/gallery/backends_verifier_internal_test.go new file mode 100644 index 000000000..0c8a5aa18 --- /dev/null +++ b/core/gallery/backends_verifier_internal_test.go @@ -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"))) + }) +}) diff --git a/docs/content/features/backends.md b/docs/content/features/backends.md index 3aeb2a600..69ae0cf0f 100644 --- a/docs/content/features/backends.md +++ b/docs/content/features/backends.md @@ -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: diff --git a/docs/content/features/model-gallery.md b/docs/content/features/model-gallery.md index 8e5cce556..674cd8eb3 100644 --- a/docs/content/features/model-gallery.md +++ b/docs/content/features/model-gallery.md @@ -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 %}} diff --git a/pkg/oci/cosignverify/identity_internal_test.go b/pkg/oci/cosignverify/identity_internal_test.go new file mode 100644 index 000000000..b7883c859 --- /dev/null +++ b/pkg/oci/cosignverify/identity_internal_test.go @@ -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()) + }) +}) diff --git a/pkg/oci/cosignverify/verify.go b/pkg/oci/cosignverify/verify.go index a34dca2a6..4988bd41b 100644 --- a/pkg/oci/cosignverify/verify.go +++ b/pkg/oci/cosignverify/verify.go @@ -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//). 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//") + } + } 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) } diff --git a/pkg/oci/cosignverify/verify_test.go b/pkg/oci/cosignverify/verify_test.go index 0f90f5ead..972a48de5 100644 --- a/pkg/oci/cosignverify/verify_test.go +++ b/pkg/oci/cosignverify/verify_test.go @@ -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