mirror of
https://github.com/hauler-dev/hauler.git
synced 2026-08-19 04:16:27 +00:00
395 lines
16 KiB
Go
395 lines
16 KiB
Go
package cosign
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
|
|
gname "github.com/google/go-containerregistry/pkg/name"
|
|
"github.com/sigstore/cosign/v3/cmd/cosign/cli/options"
|
|
"github.com/sigstore/cosign/v3/cmd/cosign/cli/verify"
|
|
cosignpkg "github.com/sigstore/cosign/v3/pkg/cosign"
|
|
|
|
"hauler.dev/go/hauler/v2/internal/flags"
|
|
"hauler.dev/go/hauler/v2/pkg/retry"
|
|
)
|
|
|
|
// defaultMaxWorkers bounds cosign's own per-image signature fan-out. Hauler
|
|
// already runs one Verify per image goroutine, so this only governs work
|
|
// within a single image's signature set.
|
|
const defaultMaxWorkers = 10
|
|
|
|
// Config is the fully-resolved verification input for one image. It is
|
|
// deliberately a comparable struct: Cache keys on it directly, so every image
|
|
// in a sync sharing a key -- 880 of them, in the Rancher case -- shares one
|
|
// Verifier and therefore one trust-material setup instead of 880.
|
|
type Config struct {
|
|
Key string
|
|
Tlog bool
|
|
|
|
CertIdentity string
|
|
CertIdentityRegexp string
|
|
CertOidcIssuer string
|
|
CertOidcIssuerRegexp string
|
|
CertGithubWorkflowRepository string
|
|
|
|
// TLS options for reaching the registry (signatures/attestations/SBOMs)
|
|
// and the transparency log. InsecureSkipTLSVerify takes precedence over
|
|
// CaFile -- see NewVerifier.
|
|
InsecureSkipTLSVerify bool
|
|
CaFile string
|
|
}
|
|
|
|
// Empty reports whether cfg requests no verification at all.
|
|
func (c Config) Empty() bool { return c == Config{} }
|
|
|
|
// Keyless reports whether cfg verifies against a Fulcio identity rather than
|
|
// an explicit public key.
|
|
func (c Config) Keyless() bool { return c.Key == "" }
|
|
|
|
// validate rejects a key paired with any Cert* constraint, because supplying a
|
|
// key makes every one of them dead weight: cosign reads them only where
|
|
// co.SigVerifier is nil. verifyInternal (pkg/cosign/verify.go:871) skips the
|
|
// whole ValidateAndUnpackCertWithIntermediates -> CheckCertificatePolicy ->
|
|
// validateCertExtensions path once a verifier is set, and the only other
|
|
// reader, CheckOpts.verificationOptions, belongs to the NewBundleFormat path
|
|
// this package pins off. Accepting the combination would let a user who passed
|
|
// --certificate-identity believe they had pinned who signed the image when the
|
|
// signature was checked against the key alone. Cosign's own Exec rejects the
|
|
// narrower Key+CertIdentity case as KeyAndIdentityParseError
|
|
// (cmd/cosign/cli/verify/verify.go:102); the extra fields fail identically, so
|
|
// they are guarded identically.
|
|
func (c Config) validate() error {
|
|
if c.Keyless() {
|
|
return nil
|
|
}
|
|
for _, f := range []struct{ name, value string }{
|
|
{"CertIdentity", c.CertIdentity},
|
|
{"CertIdentityRegexp", c.CertIdentityRegexp},
|
|
{"CertOidcIssuer", c.CertOidcIssuer},
|
|
{"CertOidcIssuerRegexp", c.CertOidcIssuerRegexp},
|
|
{"CertGithubWorkflowRepository", c.CertGithubWorkflowRepository},
|
|
} {
|
|
if f.value != "" {
|
|
return fmt.Errorf("%s is set alongside a verification key; identity constraints apply only to keyless verification and would be ignored", f.name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Verifier verifies images against one Config. Both CheckOpts are built once
|
|
// and then treated as read-only, which is what makes concurrent Verify calls
|
|
// safe: sigstore's package-level TUF and Fulcio state is touched only by the
|
|
// setup helpers below, all of which run before the Verifier is published.
|
|
type Verifier struct {
|
|
// co drives classic tag-based verification; coBundle is its shallow copy
|
|
// with NewBundleFormat set -- see NewVerifier for why they cannot be one.
|
|
co *cosignpkg.CheckOpts
|
|
coBundle *cosignpkg.CheckOpts
|
|
closeSV func()
|
|
|
|
// Retry settings, applied once per verification path by withRetry.
|
|
rso *flags.StoreRootOpts
|
|
ro *flags.CliRootOpts
|
|
}
|
|
|
|
// NewVerifier builds the CheckOpts for cfg. It replicates the setup sequence in
|
|
// cosign v3.1.2's verify.VerifyCommand.Exec (cmd/cosign/cli/verify/verify.go:95-190)
|
|
// -- hauler cannot call Exec here because Exec prints, which forces the
|
|
// process-global output capture that serializes verification.
|
|
//
|
|
// ctx governs setup only in appearance: options.RegistryOptions.ClientOpts bakes
|
|
// it into co.RegistryClientOpts, which every later Verify reuses, so ctx also
|
|
// governs all registry I/O for the returned Verifier's whole lifetime. Pass a
|
|
// run-scoped ctx. A per-image or per-timeout ctx here cancels registry reads for
|
|
// every image sharing this Verifier the moment that one image's deadline fires.
|
|
func NewVerifier(ctx context.Context, cfg Config, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) (*Verifier, error) {
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var identities []cosignpkg.Identity
|
|
if cfg.Keyless() {
|
|
certOpts := options.CertVerifyOptions{
|
|
CertOidcIssuer: cfg.CertOidcIssuer,
|
|
CertOidcIssuerRegexp: cfg.CertOidcIssuerRegexp,
|
|
CertIdentity: cfg.CertIdentity,
|
|
CertIdentityRegexp: cfg.CertIdentityRegexp,
|
|
CertGithubWorkflowRepository: cfg.CertGithubWorkflowRepository,
|
|
}
|
|
var err error
|
|
if identities, err = certOpts.Identities(); err != nil {
|
|
return nil, fmt.Errorf("building identities: %w", err)
|
|
}
|
|
}
|
|
|
|
// insecureSkipTLSVerify takes precedence: when set, caFile is ignored --
|
|
// mirrors content.BuildTransport's precedence for the plain registry pull.
|
|
regOpts := options.RegistryOptions{}
|
|
if cfg.InsecureSkipTLSVerify {
|
|
regOpts.AllowInsecure = true
|
|
} else {
|
|
regOpts.RegistryCACert = cfg.CaFile
|
|
}
|
|
ociremoteOpts, err := regOpts.ClientOpts(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("constructing registry client options: %w", err)
|
|
}
|
|
|
|
// Keyless Fulcio certs expire ~10 minutes after issue, so the transparency
|
|
// log is mandatory there to prove the cert was valid at signing time.
|
|
// Keyed verification honors the caller's --tlog choice.
|
|
ignoreTlog := !cfg.Tlog
|
|
if cfg.Keyless() {
|
|
ignoreTlog = false
|
|
}
|
|
|
|
co := &cosignpkg.CheckOpts{
|
|
RegistryClientOpts: ociremoteOpts,
|
|
Identities: identities,
|
|
CertGithubWorkflowRepository: cfg.CertGithubWorkflowRepository,
|
|
IgnoreTlog: ignoreTlog,
|
|
MaxWorkers: defaultMaxWorkers,
|
|
// Must stay false: VerifyImageSignatures rejects a true value outright
|
|
// with "bundle support for image signatures is not yet implemented"
|
|
// (pkg/cosign/verify.go:645).
|
|
NewBundleFormat: false,
|
|
}
|
|
|
|
// Mirrors cosign's unexported verifyOfflineWithKey (cli/verify/common.go:413):
|
|
// no trusted root is needed when a key is supplied and neither Rekor nor
|
|
// signed timestamps are consulted.
|
|
offlineWithKey := !cfg.Keyless() && co.IgnoreTlog && !co.UseSignedTimestamps
|
|
|
|
// Order is load-bearing and copied from Exec: trust material first, then
|
|
// legacy clients, then the verifier -- LoadVerifierFromKeyOrCert validates
|
|
// a certificate chain against the trust material and must see it populated.
|
|
if err := verify.SetTrustedMaterial(ctx, "", "", "", "", "", offlineWithKey, co); err != nil {
|
|
return nil, fmt.Errorf("setting trusted material: %w", err)
|
|
}
|
|
|
|
// The second and third arguments mirror cosign's unexported shouldVerifySCT
|
|
// and keylessVerification (cli/verify/common.go:387,397); both reduce to
|
|
// "no explicit key" given hauler never sets IgnoreSCT or a security key.
|
|
if err := verify.SetLegacyClientsAndKeys(ctx, co.IgnoreTlog, cfg.Keyless(), cfg.Keyless(), "", "", "", "", "", co); err != nil {
|
|
return nil, fmt.Errorf("setting up clients and keys: %w", err)
|
|
}
|
|
|
|
sv, _, closeSV, err := verify.LoadVerifierFromKeyOrCert(ctx, cfg.Key, "", "", "", crypto.SHA256, false, false, co)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading verifier from key opts: %w", err)
|
|
}
|
|
co.SigVerifier = sv
|
|
|
|
// VerifyImageAttestations reads co.NewBundleFormat directly, and co's copy
|
|
// must stay false, so the bundle path needs a CheckOpts of its own. A
|
|
// shallow copy rather than a second setup run: repeating the sequence above
|
|
// would re-read the key file and, wherever offlineWithKey is false, refetch
|
|
// TUF metadata, for a path most runs never take. Sharing RegistryClientOpts,
|
|
// SigVerifier and TrustedMaterial by pointer is safe for the same reason one
|
|
// CheckOpts can serve concurrent Verify calls -- cosign's verification paths
|
|
// only read the CheckOpts they are handed; the one write on the bundle side
|
|
// lands on a copy VerifyNewBundle makes first (pkg/cosign/verify_bundle.go:30).
|
|
coBundle := *co
|
|
coBundle.NewBundleFormat = true
|
|
|
|
return &Verifier{co: co, coBundle: &coBundle, closeSV: closeSV, rso: rso, ro: ro}, nil
|
|
}
|
|
|
|
// Verify checks ref against v's config. ref should be a digest reference so the
|
|
// bytes verified are the bytes the caller goes on to store.
|
|
//
|
|
// The classic path handles tag-based signatures, which is every image in the
|
|
// workloads hauler targets. An image signed with the new sigstore bundle format
|
|
// has no .sig tag and so fails there with a not-found error; only that specific
|
|
// failure falls back to the bundle path.
|
|
func (v *Verifier) Verify(ctx context.Context, ref string) error {
|
|
r, err := gname.ParseReference(ref)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing reference %q: %w", ref, err)
|
|
}
|
|
|
|
if err = v.verifyImage(ctx, r); err == nil {
|
|
return nil
|
|
}
|
|
if !fallbackEligible(err) {
|
|
return err
|
|
}
|
|
return v.verifyBundle(ctx, r)
|
|
}
|
|
|
|
// verifyImage checks ref for a classic tag-based signature.
|
|
func (v *Verifier) verifyImage(ctx context.Context, r gname.Reference) error {
|
|
return v.withRetry(ctx, func() error {
|
|
_, _, err := cosignpkg.VerifyImageSignatures(ctx, r, v.co)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// verifyBundle checks ref for a new-format sigstore bundle. In that format the
|
|
// signature is carried as an attestation, so VerifyImageAttestations is the
|
|
// entry point -- VerifyImageSignatures rejects NewBundleFormat outright
|
|
// (pkg/cosign/verify.go:645), and cosign's own Exec dispatches bundles the same
|
|
// way (cmd/cosign/cli/verify/verify.go:234). It returns its result rather than
|
|
// printing the verification report Exec prints, so unlike the CLI this path
|
|
// needs no log.CaptureOutput and does not serialize on its process-global
|
|
// mutex. The registry path it actually reaches --
|
|
// verifyImageAttestationsSigstoreBundle -> GetBundles + VerifyNewBundle
|
|
// (verify.go:1905, 1713; verify_bundle.go:25) -- has cosign write no
|
|
// diagnostics of its own: the ui.Warnf calls near this code (verify.go:1795-
|
|
// 1806) belong to GetLocalBundles, the local-OCI-layout sibling this function
|
|
// never calls.
|
|
//
|
|
// Reached only for images with no classic signature, so it stays off the hot
|
|
// path for the workloads hauler actually syncs.
|
|
func (v *Verifier) verifyBundle(ctx context.Context, r gname.Reference) error {
|
|
return v.withRetry(ctx, func() error {
|
|
_, _, err := cosignpkg.VerifyImageAttestations(ctx, r, v.coBundle)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// withRetry runs one verification call under the caller's --retries budget.
|
|
//
|
|
// The budget is applied per path rather than around Verify as a whole: a loop
|
|
// outside Verify would re-run the classic attempt on its way back to the bundle
|
|
// path, costing retries*retries attempts for every bundle-signed image.
|
|
//
|
|
// Only errors another attempt could plausibly clear consume a retry --
|
|
// retryableVerifyErr decides. A terminal error leaves the loop by returning nil
|
|
// from the operation and travelling out through the captured variable, because
|
|
// retry.Operation has no other way to be told "stop, and this is not an
|
|
// exhausted budget".
|
|
func (v *Verifier) withRetry(ctx context.Context, call func() error) error {
|
|
var terminal error
|
|
exhausted := retry.Operation(ctx, v.rso, v.ro, func() error {
|
|
err := call()
|
|
if err != nil && !retryableVerifyErr(err) {
|
|
terminal = err
|
|
return nil
|
|
}
|
|
return err
|
|
})
|
|
if terminal != nil {
|
|
return terminal
|
|
}
|
|
return exhausted
|
|
}
|
|
|
|
// retryableVerifyErr reports whether a further attempt at err could succeed for
|
|
// some reason other than luck.
|
|
//
|
|
// ErrNoMatchingSignatures is excluded for exactly the reason fallbackEligible
|
|
// excludes it: signatures were found and none validated, so every extra attempt
|
|
// is another chance for a bad signature to pass. Leaving it retryable would
|
|
// reintroduce that hazard by way of --retries.
|
|
//
|
|
// ErrNoMatchingAttestations is the bundle path's terminal answer, and cosign
|
|
// spells two conditions with it: every bundle found failed verification
|
|
// (verify.go:1986), which carries the same hazard as ErrNoMatchingSignatures,
|
|
// and no bundle was found at all (verify.go:1756). The type does not
|
|
// distinguish them, and GetBundles reaches the second by skipping past
|
|
// per-referrer fetch failures (verify.go:1746-1751), so a transient blip can
|
|
// arrive here spelled as absence. Excluding it reads that ambiguity the failing
|
|
// way: the cost is an image a further attempt might have verified, against a
|
|
// failed bundle drawing --retries more chances to pass.
|
|
//
|
|
// The fallbackEligible errors are excluded because the classic signature is
|
|
// absent and will stay absent; Verify hands them to verifyBundle, which draws
|
|
// its own full budget from withRetry.
|
|
func retryableVerifyErr(err error) bool {
|
|
var noMatching *cosignpkg.ErrNoMatchingSignatures
|
|
var noAtts *cosignpkg.ErrNoMatchingAttestations
|
|
return !errors.As(err, &noMatching) && !errors.As(err, &noAtts) && !fallbackEligible(err)
|
|
}
|
|
|
|
// fallbackEligible reports whether err means "this image carries no classic
|
|
// tag-based signature" -- the only condition under which retrying down the
|
|
// bundle path is sound.
|
|
//
|
|
// ErrNoMatchingSignatures is deliberately excluded. It means signatures were
|
|
// found and none of them validated; retrying that would give an image with a
|
|
// bad signature a second chance to pass, which is the one outcome this design
|
|
// must never permit. Every other error -- transport failures included -- also
|
|
// fails closed rather than falling back.
|
|
func fallbackEligible(err error) bool {
|
|
var tagNotFound *cosignpkg.ErrImageTagNotFound
|
|
var noSigs *cosignpkg.ErrNoSignaturesFound
|
|
return errors.As(err, &tagNotFound) || errors.As(err, &noSigs)
|
|
}
|
|
|
|
// Close releases the signature verifier's resources. It must not be called
|
|
// until every in-flight Verify has returned.
|
|
func (v *Verifier) Close() {
|
|
if v.closeSV != nil {
|
|
v.closeSV()
|
|
}
|
|
}
|
|
|
|
// Cache hands out one Verifier per distinct Config for the lifetime of a run.
|
|
// The retry options live here rather than in Get's arguments because only
|
|
// Config keys the map: a per-call value would be silently ignored for every
|
|
// image after the first that shares a Config.
|
|
//
|
|
// With several distinct configs, building one Verifier can overlap another
|
|
// Verifier's in-flight Verify calls. That is safe for the reason Verifier's doc
|
|
// gives, plus sigstore v1.10.8's pkg/tuf guarding its own singleton (sync.Once
|
|
// plus initMu, client.go:61-68).
|
|
type Cache struct {
|
|
mu sync.Mutex
|
|
m map[Config]*cacheEntry
|
|
rso *flags.StoreRootOpts
|
|
ro *flags.CliRootOpts
|
|
}
|
|
|
|
type cacheEntry struct {
|
|
v *Verifier
|
|
err error
|
|
}
|
|
|
|
// NewCache returns an empty Cache. rso and ro carry the --retries/--ignore-errors
|
|
// settings every Verifier it builds applies to verification.
|
|
func NewCache(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *Cache {
|
|
return &Cache{m: make(map[Config]*cacheEntry), rso: rso, ro: ro}
|
|
}
|
|
|
|
// Get returns the Verifier for cfg, building it on first request. A build
|
|
// failure is cached too, so a bad key path fails fast for every image that
|
|
// shares it instead of re-reading and re-failing 880 times.
|
|
//
|
|
// ctx must be run-scoped, never per-image. Only the caller that loses no race
|
|
// -- whichever one finds cfg cold -- has its ctx handed to NewVerifier, and
|
|
// that ctx then lives inside the shared registry options every subsequent
|
|
// caller's Verify uses (see NewVerifier's doc). Passing a per-image timeout ctx
|
|
// therefore cancels registry I/O for all images sharing cfg when that one
|
|
// image's deadline fires, and which image that is depends on scheduling.
|
|
//
|
|
// The lock is held across NewVerifier rather than released around it: the whole
|
|
// point of the cache is that sigstore's trust-material setup runs once, and a
|
|
// double-checked scheme would let two goroutines both run it on a cold key.
|
|
func (c *Cache) Get(ctx context.Context, cfg Config) (*Verifier, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if e, ok := c.m[cfg]; ok {
|
|
return e.v, e.err
|
|
}
|
|
v, err := NewVerifier(ctx, cfg, c.rso, c.ro)
|
|
c.m[cfg] = &cacheEntry{v: v, err: err}
|
|
return v, err
|
|
}
|
|
|
|
// Close releases every Verifier built by this Cache. It must not be called
|
|
// until every in-flight Verify has returned.
|
|
func (c *Cache) Close() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for _, e := range c.m {
|
|
if e.v != nil {
|
|
e.v.Close()
|
|
}
|
|
}
|
|
}
|