327 lines
11 KiB
Go
327 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/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),
|
|
}
|
|
if v.auth != nil {
|
|
opts = append(opts, remote.WithAuth(staticAuth{auth: v.auth}))
|
|
} else {
|
|
opts = append(opts, remote.WithAuthFromKeychain(authn.DefaultKeychain))
|
|
}
|
|
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
|
|
}
|