mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-22 06:04:55 -04:00
* feat(credentials): parse and match download credential rules Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): keep secrets out of parse errors and tighten URL matching Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(credentials): resolve secrets lazily and authenticate HTTP per hop Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): redact secrets in nested and store formatting Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(credentials): add registry keychain and oras credential adapters Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): match repository rules for Docker Hub in the oras adapter Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(downloads): authenticate HTTP downloads and gallery reads from the credentials store Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(oci): authenticate registry pulls, resumes, blobs and cosign from the credentials store Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(cli): load download credentials from --credentials-file Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(credentials): correct the local-network registry rules Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): keep secrets out of match and YAML parse errors A match that fails to parse is no longer quoted in the Parse error, since it may be a URL with a token in it. Userinfo is detected before the scheme check, so ftp://user:token@host is refused as userinfo, and a match with a query string or fragment is refused because it can never apply and a query string is where signed URLs carry their token. Every YAML decode error is now redacted, not only type errors: quoted scalars such as a secret under a mismatched !!int tag are replaced and unquoted map keys are cut off. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(downloads): make auth errors name the real cause and never retry unresolved secrets AuthError now appends its cause, so a registry's DENIED or UNAUTHORIZED detail reaches the operator. HTTP auth errors print only the status text in place of the cause, because the downloader builds that cause from the requested URL, which can carry a signed query string. Registry pulls say that docker config credentials were tried too, and a download that carried a caller-provided credential (WithBearerToken, or an explicit authorization on gallery reads) reports that credential as rejected instead of blaming the store. The Range probe for a leftover partial file now returns an unresolved secret as a permanent error, like the download request already did. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): keep oras pulls anonymous on a broken docker helper and close bodies When docker config names a credsStore helper that cannot run, the oras credential func now logs at debug and returns no credential, so public pulls keep working as they did before the adapter existed. The transport closes the request body when a rule's secret cannot be resolved, as the RoundTripper contract requires. The redirect spec now uses a custom header rule on the origin, which net/http would not strip on its own, to prove the transport does not carry credentials to the next hop. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(oci): cover FetchImageBlob authentication against a private registry FetchImageBlob now has a spec that pulls a layer blob by digest from a basic-auth registry through the oras credential adapter, and one that shows the same fetch fails when no rule matches. oras only speaks HTTPS here, so the registry serves TLS and the spec points http.DefaultTransport, which retry.DefaultClient falls back to, at the test server's client for its duration instead of adding a transport seam to production code. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(credentials): document auth error wording, ollama manifests and registry tokens The errors section now lists the registry and provided-credential messages and says the server's reason is appended. ollama:// manifests are fetched without credentials, so only blob downloads use the file. GHCR, Docker Hub and Quay need basic auth with the token as password, and match rules must not carry a query string or fragment. The backend gallery docs and the container troubleshooting section now point to the private sources page. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): document trusted file path The credentials path comes from operator configuration. Mark the file read with a scoped G304 explanation to resolve the gosec false positive. Assisted-by: Codex:gpt-6 gosec --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
330 lines
11 KiB
Go
330 lines
11 KiB
Go
// Package cosignverify verifies cosign-signed OCI images using sigstore-go.
|
|
//
|
|
// LocalAI uses this to gate backend installs on a keyless-cosign signature
|
|
// from a trusted GitHub Actions OIDC identity, so a registry/tag compromise
|
|
// alone is not sufficient to ship a tampered backend image.
|
|
//
|
|
// Producer side: CI signs each pushed backend image with cosign 2.2+ and
|
|
// the `--new-bundle-format --registry-referrers-mode=oci-1-1` flags. The
|
|
// signature is then a standalone Sigstore bundle stored as an OCI 1.1
|
|
// referrer of the image manifest.
|
|
//
|
|
// Consumer side (this package): bundle.go discovers the bundle via the
|
|
// referrers API and hands it directly to sigstore-go's verifier. There is
|
|
// no legacy-cosign-annotation fallback — we own both ends.
|
|
package cosignverify
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
registrytypes "github.com/docker/docker/api/types/registry"
|
|
"github.com/google/go-containerregistry/pkg/authn"
|
|
"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/google/go-containerregistry/pkg/v1/remote/transport"
|
|
|
|
"github.com/mudler/LocalAI/internal"
|
|
"github.com/mudler/LocalAI/pkg/credentials"
|
|
"github.com/sigstore/sigstore-go/pkg/root"
|
|
"github.com/sigstore/sigstore-go/pkg/tuf"
|
|
"github.com/sigstore/sigstore-go/pkg/verify"
|
|
)
|
|
|
|
// Policy is the verification policy a backend image must satisfy.
|
|
//
|
|
// At least one of Issuer / IssuerRegex must be set, and at least one of
|
|
// Identity / IdentityRegex. The (Issuer, Identity) pair pins which OIDC
|
|
// principal Fulcio issued the signing cert to — for GitHub Actions keyless
|
|
// signing this is typically:
|
|
//
|
|
// Issuer: "https://token.actions.githubusercontent.com"
|
|
// IdentityRegex: "^https://github.com/<org>/<repo>/\\.github/workflows/<file>@refs/.*"
|
|
//
|
|
// A registry compromise alone cannot satisfy this; the attacker would also
|
|
// need to compromise the GitHub Actions OIDC identity to obtain a Fulcio
|
|
// cert with a matching SAN.
|
|
type Policy struct {
|
|
Issuer string
|
|
IssuerRegex string
|
|
Identity string
|
|
IdentityRegex string
|
|
|
|
// TUFRootURL overrides the default sigstore public-good TUF mirror
|
|
// (tuf-repo-cdn.sigstore.dev). Leave empty for the public good.
|
|
TUFRootURL string
|
|
|
|
// TUFCachePath overrides the on-disk cache directory for the TUF
|
|
// metadata. Leave empty for the sigstore-go default.
|
|
TUFCachePath string
|
|
|
|
// RequireTLog requires an inclusion proof from the Rekor transparency
|
|
// log. Defaults to true; only disable for testing.
|
|
RequireTLog *bool
|
|
|
|
// RequireSCT requires the signing certificate to embed a Signed
|
|
// Certificate Timestamp from the certificate-transparency log.
|
|
// Defaults to true.
|
|
RequireSCT *bool
|
|
|
|
// NotBefore rejects signatures whose Rekor integrated time is older
|
|
// than this. This is the revocation lever: keyless cosign certs are
|
|
// ephemeral so there is no CA-side revocation, but advancing NotBefore
|
|
// in the gallery YAML invalidates any signature produced before a
|
|
// known compromise window. Zero value means no time-based cutoff.
|
|
NotBefore time.Time
|
|
}
|
|
|
|
func boolOrTrue(b *bool) bool {
|
|
if b == nil {
|
|
return true
|
|
}
|
|
return *b
|
|
}
|
|
|
|
// Validate returns an error if the policy is missing required fields.
|
|
func (p Policy) Validate() error {
|
|
if p.Issuer == "" && p.IssuerRegex == "" {
|
|
return errors.New("cosignverify: policy must set Issuer or IssuerRegex")
|
|
}
|
|
if p.Identity == "" && p.IdentityRegex == "" {
|
|
return errors.New("cosignverify: policy must set Identity or IdentityRegex")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Verifier verifies cosign-signed OCI images against a fixed Policy.
|
|
//
|
|
// Cheap to construct, safe for concurrent use. The TUF trusted root is
|
|
// fetched once per (root URL, cache path) tuple across all Verifiers in
|
|
// the process — installing N backends from the same gallery does one TUF
|
|
// fetch, not N.
|
|
type Verifier struct {
|
|
policy Policy
|
|
|
|
// Registry plumbing — reused from the existing pkg/oci surface so we
|
|
// honor the same auth / transport conventions.
|
|
auth *registrytypes.AuthConfig
|
|
transport http.RoundTripper
|
|
}
|
|
|
|
// NewVerifier constructs a Verifier. The trusted root is not fetched yet;
|
|
// it is loaded on the first call to VerifyImage. auth and t may be nil.
|
|
func NewVerifier(p Policy, auth *registrytypes.AuthConfig, t http.RoundTripper) (*Verifier, error) {
|
|
if err := p.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Verifier{policy: p, auth: auth, transport: t}, nil
|
|
}
|
|
|
|
// trustedMaterialCacheKey identifies which TUF mirror + on-disk cache a
|
|
// Verifier wants. Two Verifiers with identical keys share trusted material.
|
|
type trustedMaterialCacheKey struct {
|
|
URL string
|
|
Path string
|
|
}
|
|
|
|
type trustedMaterialEntry struct {
|
|
once sync.Once
|
|
material root.TrustedMaterialCollection
|
|
err error
|
|
}
|
|
|
|
var trustedMaterialCache sync.Map // map[trustedMaterialCacheKey]*trustedMaterialEntry
|
|
|
|
func (v *Verifier) loadTrustedMaterial() (root.TrustedMaterialCollection, error) {
|
|
key := trustedMaterialCacheKey{URL: v.policy.TUFRootURL, Path: v.policy.TUFCachePath}
|
|
val, _ := trustedMaterialCache.LoadOrStore(key, &trustedMaterialEntry{})
|
|
entry := val.(*trustedMaterialEntry)
|
|
entry.once.Do(func() {
|
|
opts := tuf.DefaultOptions()
|
|
if v.policy.TUFRootURL != "" {
|
|
opts.RepositoryBaseURL = v.policy.TUFRootURL
|
|
}
|
|
if v.policy.TUFCachePath != "" {
|
|
opts.CachePath = v.policy.TUFCachePath
|
|
}
|
|
client, err := tuf.New(opts)
|
|
if err != nil {
|
|
entry.err = fmt.Errorf("cosignverify: initialising TUF client: %w", err)
|
|
return
|
|
}
|
|
trustedRootJSON, err := client.GetTarget("trusted_root.json")
|
|
if err != nil {
|
|
entry.err = fmt.Errorf("cosignverify: fetching trusted_root.json: %w", err)
|
|
return
|
|
}
|
|
tr, err := root.NewTrustedRootFromJSON(trustedRootJSON)
|
|
if err != nil {
|
|
entry.err = fmt.Errorf("cosignverify: parsing trusted root: %w", err)
|
|
return
|
|
}
|
|
entry.material = root.TrustedMaterialCollection{tr}
|
|
})
|
|
return entry.material, entry.err
|
|
}
|
|
|
|
// VerifyImage resolves imageRef to its manifest digest, fetches the cosign
|
|
// signature attachment (the conventional `:sha256-<hex>.sig` tag), assembles
|
|
// a Sigstore bundle from the cosign annotations, and verifies that bundle
|
|
// against the configured Policy.
|
|
//
|
|
// Returns nil on the first signature in the attachment that satisfies the
|
|
// policy. Returns an error if none do, or if any part of the fetch fails.
|
|
func (v *Verifier) VerifyImage(ctx context.Context, imageRef string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
trusted, err := v.loadTrustedMaterial()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ref, err := name.ParseReference(imageRef)
|
|
if err != nil {
|
|
return fmt.Errorf("cosignverify: parse image ref %q: %w", imageRef, err)
|
|
}
|
|
|
|
opts := v.remoteOptions(ctx)
|
|
|
|
// Resolve the image to its manifest digest. With the new-bundle-format
|
|
// flow the cosign signature is taken over the manifest digest directly,
|
|
// so this is also the artifact we ask the verifier to bind against.
|
|
// Skip the HEAD when the ref is already digest-pinned (the typical
|
|
// path from pkg/downloader, which resolves the digest before calling
|
|
// us): name.ParseReference returns a name.Digest in that case.
|
|
var digest v1.Hash
|
|
if d, ok := ref.(name.Digest); ok {
|
|
h, herr := v1.NewHash(d.DigestStr())
|
|
if herr != nil {
|
|
return fmt.Errorf("cosignverify: parsing pinned digest %q: %w", d.DigestStr(), herr)
|
|
}
|
|
digest = h
|
|
} else {
|
|
desc, herr := remote.Head(ref, opts...)
|
|
if herr != nil {
|
|
return fmt.Errorf("cosignverify: resolving image descriptor: %w", herr)
|
|
}
|
|
digest = desc.Digest
|
|
}
|
|
|
|
bun, err := bundleFromOCISignature(ref, digest, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
verifierOpts := []verify.VerifierOption{}
|
|
if boolOrTrue(v.policy.RequireSCT) {
|
|
verifierOpts = append(verifierOpts, verify.WithSignedCertificateTimestamps(1))
|
|
}
|
|
if boolOrTrue(v.policy.RequireTLog) {
|
|
verifierOpts = append(verifierOpts, verify.WithTransparencyLog(1))
|
|
verifierOpts = append(verifierOpts, verify.WithObserverTimestamps(1))
|
|
}
|
|
|
|
certID, err := verify.NewShortCertificateIdentity(
|
|
v.policy.Issuer,
|
|
v.policy.IssuerRegex,
|
|
v.policy.Identity,
|
|
v.policy.IdentityRegex,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("cosignverify: building identity policy: %w", err)
|
|
}
|
|
|
|
sev, err := verify.NewVerifier(trusted, verifierOpts...)
|
|
if err != nil {
|
|
return fmt.Errorf("cosignverify: constructing verifier: %w", err)
|
|
}
|
|
|
|
artifactDigest, err := hex.DecodeString(digest.Hex)
|
|
if err != nil {
|
|
return fmt.Errorf("cosignverify: decoding image digest: %w", err)
|
|
}
|
|
artifactPolicy := verify.WithArtifactDigest(digest.Algorithm, artifactDigest)
|
|
|
|
result, err := sev.Verify(bun, verify.NewPolicy(artifactPolicy, verify.WithCertificateIdentity(certID)))
|
|
if err != nil {
|
|
return fmt.Errorf("cosignverify: verification failed for %s: %w", imageRef, err)
|
|
}
|
|
|
|
if !v.policy.NotBefore.IsZero() {
|
|
if err := enforceNotBefore(result, v.policy.NotBefore); err != nil {
|
|
return fmt.Errorf("cosignverify: %s: %w", imageRef, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// enforceNotBefore rejects a verification result whose earliest verified
|
|
// timestamp predates cutoff. Used as a revocation lever — see Policy.NotBefore.
|
|
func enforceNotBefore(result *verify.VerificationResult, cutoff time.Time) error {
|
|
if result == nil || len(result.VerifiedTimestamps) == 0 {
|
|
// Defensive: with RequireTLog=true (the default) sigstore-go will
|
|
// have already failed verification if there was no verifiable
|
|
// timestamp, so this branch is only reachable if a caller set
|
|
// RequireTLog=false. Treat as a hard error: if you opted into
|
|
// NotBefore, you implicitly opted into needing a timestamp.
|
|
return errors.New("signature has no verified timestamp; cannot enforce NotBefore")
|
|
}
|
|
earliest := result.VerifiedTimestamps[0].Timestamp
|
|
for _, ts := range result.VerifiedTimestamps[1:] {
|
|
if ts.Timestamp.Before(earliest) {
|
|
earliest = ts.Timestamp
|
|
}
|
|
}
|
|
if earliest.Before(cutoff) {
|
|
return fmt.Errorf("signature integrated time %s is before NotBefore cutoff %s",
|
|
earliest.Format(time.RFC3339), cutoff.Format(time.RFC3339))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (v *Verifier) remoteOptions(ctx context.Context) []remote.Option {
|
|
t := v.transport
|
|
if t == nil {
|
|
t = http.DefaultTransport
|
|
}
|
|
// Match the retry policy used elsewhere in pkg/oci so transient
|
|
// registry hiccups don't fail verification.
|
|
t = transport.NewRetry(t)
|
|
|
|
opts := []remote.Option{
|
|
remote.WithContext(ctx),
|
|
remote.WithTransport(t),
|
|
remote.WithUserAgent(internal.UserAgent()),
|
|
}
|
|
if v.auth != nil {
|
|
opts = append(opts, remote.WithAuth(staticAuth{auth: v.auth}))
|
|
} else {
|
|
opts = append(opts, remote.WithAuthFromKeychain(credentials.Keychain()))
|
|
}
|
|
return opts
|
|
}
|
|
|
|
// staticAuth mirrors pkg/oci's adapter so callers can pass the same
|
|
// docker auth config they use everywhere else.
|
|
type staticAuth struct {
|
|
auth *registrytypes.AuthConfig
|
|
}
|
|
|
|
func (s staticAuth) Authorization() (*authn.AuthConfig, error) {
|
|
if s.auth == nil {
|
|
return nil, nil
|
|
}
|
|
return &authn.AuthConfig{
|
|
Username: s.auth.Username,
|
|
Password: s.auth.Password,
|
|
Auth: s.auth.Auth,
|
|
IdentityToken: s.auth.IdentityToken,
|
|
RegistryToken: s.auth.RegistryToken,
|
|
}, nil
|
|
}
|