mirror of
https://github.com/moby/buildkit.git
synced 2026-11-06 05:16:45 +00:00
Merge pull request #7345 from crazy-max/refactor-remote-cache-signing
cache: extract shared remote cache signing
This commit is contained in:
10 files changed
+830
-136
No files matched your search
Vendored
+21
-112
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -17,7 +16,7 @@ import (
|
||||
"github.com/containerd/containerd/v2/pkg/labels"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/buildkit/cache/remotecache"
|
||||
"github.com/moby/buildkit/cache/remotecache/gha/ghatypes"
|
||||
"github.com/moby/buildkit/cache/remotecache/signing"
|
||||
v1 "github.com/moby/buildkit/cache/remotecache/v1"
|
||||
cacheimporttypes "github.com/moby/buildkit/cache/remotecache/v1/types"
|
||||
"github.com/moby/buildkit/session"
|
||||
@@ -28,11 +27,9 @@ import (
|
||||
"github.com/moby/buildkit/util/tracing"
|
||||
bkversion "github.com/moby/buildkit/version"
|
||||
"github.com/moby/buildkit/worker"
|
||||
policy "github.com/moby/policy-helpers"
|
||||
digest "github.com/opencontainers/go-digest"
|
||||
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
|
||||
actionscache "github.com/tonistiigi/go-actions-cache"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
@@ -55,8 +52,6 @@ const (
|
||||
defaultTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
type VerifierProvider func() (*policy.Verifier, error)
|
||||
|
||||
type Config struct {
|
||||
Scope string
|
||||
URL string
|
||||
@@ -66,11 +61,11 @@ type Config struct {
|
||||
Version int
|
||||
Timeout time.Duration
|
||||
|
||||
*ghatypes.CacheConfig
|
||||
verifier VerifierProvider
|
||||
*signing.Config
|
||||
verifier signing.Verifier
|
||||
}
|
||||
|
||||
func getConfig(conf *ghatypes.CacheConfig, v VerifierProvider, attrs map[string]string) (*Config, error) {
|
||||
func getConfig(conf *signing.Config, v signing.VerifierProvider, attrs map[string]string) (*Config, error) {
|
||||
scope, ok := attrs[attrScope]
|
||||
if !ok {
|
||||
scope = "buildkit"
|
||||
@@ -120,24 +115,24 @@ func getConfig(conf *ghatypes.CacheConfig, v VerifierProvider, attrs map[string]
|
||||
}
|
||||
|
||||
if conf == nil {
|
||||
conf = &ghatypes.CacheConfig{}
|
||||
conf = &signing.Config{}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Scope: scope,
|
||||
URL: url,
|
||||
Token: token,
|
||||
Timeout: timeout,
|
||||
GHToken: attrs[attrGHToken],
|
||||
Repository: attrs[attrRepository],
|
||||
Version: apiVersionInt,
|
||||
CacheConfig: conf,
|
||||
verifier: v,
|
||||
Scope: scope,
|
||||
URL: url,
|
||||
Token: token,
|
||||
Timeout: timeout,
|
||||
GHToken: attrs[attrGHToken],
|
||||
Repository: attrs[attrRepository],
|
||||
Version: apiVersionInt,
|
||||
Config: conf,
|
||||
verifier: signing.NewVerifier(conf.Verify.Policy, v),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveCacheExporterFunc for Github actions cache exporter.
|
||||
func ResolveCacheExporterFunc(conf *ghatypes.CacheConfig, v VerifierProvider) remotecache.ResolveCacheExporterFunc {
|
||||
func ResolveCacheExporterFunc(conf *signing.Config, v signing.VerifierProvider) remotecache.ResolveCacheExporterFunc {
|
||||
return func(ctx context.Context, g session.Group, attrs map[string]string) (remotecache.Exporter, error) {
|
||||
cfg, err := getConfig(conf, v, attrs)
|
||||
if err != nil {
|
||||
@@ -330,93 +325,23 @@ func (ce *exporter) Finalize(ctx context.Context) (_ map[string]string, err erro
|
||||
|
||||
dgst := digest.FromBytes(dt)
|
||||
signDone := progress.OneOff(ctx, fmt.Sprintf("signing cache index %s", dgst))
|
||||
defer signDone(err)
|
||||
defer func() { signDone(err) }()
|
||||
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...) //nolint:gosec // defined in toml config
|
||||
cmd.Stdin = bytes.NewReader(dt)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errors.Wrapf(err, "signing command failed: %s", stderr.String())
|
||||
}
|
||||
|
||||
// validate signature before uploading
|
||||
if err := verifySignature(ctx, dgst, out.Bytes(), ce.config); err != nil {
|
||||
bundle, err := signing.NewSigner(args, ce.config.verifier).Sign(ctx, dt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := blobKey(dgst + "-sig")
|
||||
if err := ce.cache.Save(ctx, key, actionscache.NewBlob(out.Bytes())); err != nil {
|
||||
if err := ce.cache.Save(ctx, key, actionscache.NewBlob(bundle)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func verifySignature(ctx context.Context, dgst digest.Digest, bundle []byte, config *Config) error {
|
||||
v, err := config.verifier()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v == nil {
|
||||
return errors.New("no verifier available for signed github actions cache")
|
||||
}
|
||||
|
||||
policyRules := config.Verify.Policy
|
||||
opts := []policy.ArtifactVerifyOpt{policy.WithSLSANotRequired()}
|
||||
if policyRules.TlogThreshold != nil {
|
||||
opts = append(opts, policy.WithTlogThreshold(*policyRules.TlogThreshold))
|
||||
}
|
||||
sig, err := v.VerifyArtifact(ctx, dgst, bundle, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sig.Signer == nil {
|
||||
return errors.New("signature verification failed: no signer found")
|
||||
}
|
||||
numTimestamps := len(sig.Timestamps)
|
||||
if policyRules.TimestampThreshold > numTimestamps {
|
||||
return errors.Errorf("signature verification failed: not enough timestamp authorities: have %d, need %d", numTimestamps, policyRules.TimestampThreshold)
|
||||
}
|
||||
|
||||
certRules, err := certToStringMap(&config.Verify.Policy.Summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certFields, err := certToStringMap(sig.Signer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bklog.G(ctx).Debugf("signature verification: %+v", sig)
|
||||
bklog.G(ctx).Debugf("signer: %+v", sig.Signer)
|
||||
for k, v := range certRules {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if !simplePatternMatch(v, certFields[k]) {
|
||||
return errors.Errorf("signature verification failed: certificate field %q does not match policy (%q != %q)", k, certFields[k], v)
|
||||
}
|
||||
bklog.G(ctx).Debugf("certificate field %q matches policy (%q)", k, certFields[k])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certToStringMap(cert *certificate.Summary) (map[string]string, error) {
|
||||
dt, err := json.Marshal(cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]string{}
|
||||
if err := json.Unmarshal(dt, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ResolveCacheImporterFunc for Github actions cache importer.
|
||||
func ResolveCacheImporterFunc(conf *ghatypes.CacheConfig, v VerifierProvider) remotecache.ResolveCacheImporterFunc {
|
||||
func ResolveCacheImporterFunc(conf *signing.Config, v signing.VerifierProvider) remotecache.ResolveCacheImporterFunc {
|
||||
return func(ctx context.Context, g session.Group, attrs map[string]string) (remotecache.Importer, ocispecs.Descriptor, error) {
|
||||
cfg, err := getConfig(conf, v, attrs)
|
||||
if err != nil {
|
||||
@@ -513,7 +438,7 @@ func (ci *importer) loadScope(ctx context.Context, scope string) (*v1.CacheChain
|
||||
verifyDone(err)
|
||||
return nil, err
|
||||
}
|
||||
if err := verifySignature(ctx, dgst, sigBuf.Bytes(), ci.config); err != nil {
|
||||
if err := ci.config.verifier.Verify(ctx, dgst, sigBuf.Bytes()); err != nil {
|
||||
verifyDone(err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -643,19 +568,3 @@ func (r *readerAt) ReadAt(p []byte, off int64) (int, error) {
|
||||
func (r *readerAt) Size() int64 {
|
||||
return r.desc.Size
|
||||
}
|
||||
|
||||
func simplePatternMatch(pat, s string) bool {
|
||||
if pat == "*" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(pat, "*") && strings.HasSuffix(pat, "*") {
|
||||
return strings.Contains(s, pat[1:len(pat)-1])
|
||||
}
|
||||
if strings.HasPrefix(pat, "*") {
|
||||
return strings.HasSuffix(s, pat[1:])
|
||||
}
|
||||
if strings.HasSuffix(pat, "*") {
|
||||
return strings.HasPrefix(s, pat[:len(pat)-1])
|
||||
}
|
||||
return s == pat
|
||||
}
|
||||
Vendored
+184
-19
@@ -1,41 +1,30 @@
|
||||
package gha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/moby/buildkit/cache/remotecache/gha/ghatypes"
|
||||
"github.com/moby/buildkit/cache/remotecache/signing"
|
||||
"github.com/moby/buildkit/client"
|
||||
"github.com/moby/buildkit/client/llb"
|
||||
"github.com/moby/buildkit/util/testutil/integration"
|
||||
"github.com/moby/buildkit/util/testutil/workers"
|
||||
policy "github.com/moby/policy-helpers"
|
||||
digest "github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVerifySignatureNegativeTlogThreshold(t *testing.T) {
|
||||
config := &Config{
|
||||
CacheConfig: &ghatypes.CacheConfig{
|
||||
Verify: ghatypes.VerifyConfig{
|
||||
Policy: ghatypes.VerifyPolicy{
|
||||
TlogThreshold: new(-1),
|
||||
},
|
||||
},
|
||||
},
|
||||
verifier: func() (*policy.Verifier, error) {
|
||||
return &policy.Verifier{}, nil
|
||||
},
|
||||
}
|
||||
err := verifySignature(t.Context(), digest.FromString("cache index"), nil, config)
|
||||
require.EqualError(t, err, "transparency log threshold must not be negative")
|
||||
}
|
||||
|
||||
func init() {
|
||||
if workers.IsTestDockerd() {
|
||||
workers.InitDockerdWorker()
|
||||
@@ -193,3 +182,179 @@ func ensurePruneAll(t *testing.T, c *client.Client, sb integration.Sandbox) {
|
||||
func requiresLinux(t *testing.T) {
|
||||
integration.SkipOnPlatform(t, "!linux")
|
||||
}
|
||||
|
||||
func TestIndexKeySigning(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
config signing.Config
|
||||
key string
|
||||
}{
|
||||
{
|
||||
name: "unsigned",
|
||||
key: "index-buildkit-1-e3b0c442",
|
||||
},
|
||||
{
|
||||
name: "signing",
|
||||
config: signing.Config{
|
||||
Sign: &signing.SignConfig{Command: []string{"sign-cache"}},
|
||||
},
|
||||
key: "index-buildkit-1-e3b0c442-sig",
|
||||
},
|
||||
{
|
||||
name: "required verification",
|
||||
config: signing.Config{
|
||||
Verify: signing.VerifyConfig{Required: true},
|
||||
},
|
||||
key: "index-buildkit-1-e3b0c442-sig",
|
||||
},
|
||||
{
|
||||
name: "legacy empty signing command",
|
||||
config: signing.Config{
|
||||
Sign: &signing.SignConfig{},
|
||||
},
|
||||
key: "index-buildkit-1-e3b0c442-sig",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg, err := getConfig(&tc.config, nil, map[string]string{
|
||||
attrToken: "unused",
|
||||
attrURL: "https://cache.example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.key, indexKey("", cfg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type cacheVerifierFunc func(context.Context, digest.Digest, []byte) error
|
||||
|
||||
func (f cacheVerifierFunc) Verify(ctx context.Context, dgst digest.Digest, bundle []byte) error {
|
||||
return f(ctx, dgst, bundle)
|
||||
}
|
||||
|
||||
func TestLoadScopeSignature(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
required bool
|
||||
missingIndex bool
|
||||
missingBundle bool
|
||||
rejectBundle bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unsigned",
|
||||
},
|
||||
{
|
||||
name: "absent signed cache",
|
||||
required: true,
|
||||
missingIndex: true,
|
||||
},
|
||||
{
|
||||
name: "missing signature",
|
||||
required: true,
|
||||
missingBundle: true,
|
||||
wantErr: "missing signature for github actions cache",
|
||||
},
|
||||
{
|
||||
name: "verify before parsing",
|
||||
required: true,
|
||||
rejectBundle: true,
|
||||
wantErr: "signature rejected",
|
||||
},
|
||||
{
|
||||
name: "verified signature",
|
||||
required: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := []byte("{\n \"records\": []\n}\n")
|
||||
if tc.rejectBundle {
|
||||
payload = []byte("invalid JSON must not be parsed before verification")
|
||||
}
|
||||
bundle := []byte("test signature bundle")
|
||||
claims, err := json.Marshal(map[string]any{
|
||||
"ac": `[{"Scope":"refs/heads/main","Permission":1}]`,
|
||||
"nbf": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
token := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + "." + base64.RawURLEncoding.EncodeToString(claims) + "."
|
||||
cfg, err := getConfig(&signing.Config{
|
||||
Verify: signing.VerifyConfig{Required: tc.required},
|
||||
}, nil, map[string]string{
|
||||
attrToken: token,
|
||||
attrURL: "https://cache.example",
|
||||
attrAPIVersion: "1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
index := indexKey("refs/heads/main", cfg)
|
||||
sigKey := "buildkit-blob-1-" + digest.FromBytes(payload).String() + "-sig"
|
||||
var signatureLookups atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/_apis/artifactcache/cache":
|
||||
key := r.URL.Query().Get("keys")
|
||||
var path string
|
||||
switch key {
|
||||
case index:
|
||||
if !tc.missingIndex {
|
||||
path = "/index"
|
||||
}
|
||||
case sigKey:
|
||||
signatureLookups.Add(1)
|
||||
if !tc.missingBundle {
|
||||
path = "/signature"
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected cache key %q", key)
|
||||
}
|
||||
if path == "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"cacheKey": key,
|
||||
"archiveLocation": "http://" + r.Host + path,
|
||||
}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case "/index":
|
||||
_, _ = w.Write(payload)
|
||||
case "/signature":
|
||||
_, _ = w.Write(bundle)
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg.URL = server.URL
|
||||
verified := false
|
||||
cfg.verifier = cacheVerifierFunc(func(_ context.Context, dgst digest.Digest, got []byte) error {
|
||||
verified = true
|
||||
require.Equal(t, digest.FromBytes(payload), dgst)
|
||||
require.Equal(t, bundle, got)
|
||||
if tc.rejectBundle {
|
||||
return errors.New("signature rejected")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
ci, err := NewImporter(cfg)
|
||||
require.NoError(t, err)
|
||||
chains, err := ci.(*importer).loadScope(t.Context(), "refs/heads/main")
|
||||
if tc.wantErr != "" {
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
require.Nil(t, chains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chains)
|
||||
}
|
||||
require.Equal(t, tc.required && !tc.missingIndex && !tc.missingBundle, verified)
|
||||
if tc.required && !tc.missingIndex {
|
||||
require.Equal(t, int32(1), signatureLookups.Load())
|
||||
} else {
|
||||
require.Zero(t, signatureLookups.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -1,21 +1,25 @@
|
||||
package ghatypes
|
||||
package signing
|
||||
|
||||
import "github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
|
||||
|
||||
type CacheConfig struct {
|
||||
// Config controls signing and verification of remote cache metadata.
|
||||
type Config struct {
|
||||
Sign *SignConfig `toml:"sign"`
|
||||
Verify VerifyConfig `toml:"verify"`
|
||||
}
|
||||
|
||||
// SignConfig specifies the command that reads a payload and writes its bundle.
|
||||
type SignConfig struct {
|
||||
Command []string `toml:"command"`
|
||||
}
|
||||
|
||||
// VerifyConfig controls whether imported cache metadata must be verified.
|
||||
type VerifyConfig struct {
|
||||
Required bool `toml:"required"`
|
||||
Policy VerifyPolicy `toml:"policy"`
|
||||
}
|
||||
|
||||
// VerifyPolicy constrains the verified timestamps and signing certificate.
|
||||
type VerifyPolicy struct {
|
||||
TimestampThreshold int `toml:"timestampThreshold"`
|
||||
TlogThreshold *int `toml:"tlogThreshold"`
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
package signing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"slices"
|
||||
|
||||
digest "github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Signer signs the exact payload bytes and verifies the resulting bundle.
|
||||
type Signer interface {
|
||||
Sign(context.Context, []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// NewSigner returns a command-based signer using verifier to check its output.
|
||||
// The command is an argv array configured by the daemon administrator.
|
||||
func NewSigner(command []string, verifier Verifier) Signer {
|
||||
return &commandSigner{command: slices.Clone(command), verifier: verifier}
|
||||
}
|
||||
|
||||
type commandSigner struct {
|
||||
command []string
|
||||
verifier Verifier
|
||||
}
|
||||
|
||||
func (s *commandSigner) Sign(ctx context.Context, payload []byte) ([]byte, error) {
|
||||
if len(s.command) == 0 {
|
||||
return nil, errors.New("no signing command configured")
|
||||
}
|
||||
if s.verifier == nil {
|
||||
return nil, errors.New("no verifier available for signed cache")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, s.command[0], s.command[1:]...) //nolint:gosec // defined in toml config
|
||||
cmd.Stdin = bytes.NewReader(payload)
|
||||
var out, stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errors.Wrapf(err, "signing command failed: %s", stderr.String())
|
||||
}
|
||||
if err := s.verifier.Verify(ctx, digest.FromBytes(payload), out.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package signing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
digest "github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type verifierFunc func(context.Context, digest.Digest, []byte) error
|
||||
|
||||
func (f verifierFunc) Verify(ctx context.Context, dgst digest.Digest, bundle []byte) error {
|
||||
return f(ctx, dgst, bundle)
|
||||
}
|
||||
|
||||
func signingTestCommand(t *testing.T, mode string, args ...string) []string {
|
||||
t.Helper()
|
||||
executable, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
return append([]string{executable, "-test.run=^TestSigningCommand$", "--", "signing-test-helper", mode}, args...)
|
||||
}
|
||||
|
||||
// TestSigningCommand runs in a subprocess to exercise the actual command I/O.
|
||||
func TestSigningCommand(t *testing.T) {
|
||||
marker := slices.Index(os.Args, "signing-test-helper")
|
||||
if marker == -1 {
|
||||
return
|
||||
}
|
||||
args := os.Args[marker+1:]
|
||||
switch args[0] {
|
||||
case "echo":
|
||||
if len(args) != 2 || args[1] != "literal argument with spaces * $HOME" {
|
||||
os.Exit(2)
|
||||
}
|
||||
if _, err := io.Copy(os.Stdout, os.Stdin); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
case "fail":
|
||||
fmt.Fprint(os.Stderr, "signer failed")
|
||||
os.Exit(3)
|
||||
case "wait":
|
||||
if err := os.WriteFile(args[1], nil, 0600); err != nil { //nolint:gosec // path supplied by the parent test in its temporary directory
|
||||
os.Exit(2)
|
||||
}
|
||||
time.Sleep(time.Minute)
|
||||
default:
|
||||
os.Exit(2)
|
||||
}
|
||||
// Avoid the test runner's PASS output becoming part of the bundle.
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func TestSigner(t *testing.T) {
|
||||
payload := []byte("{\n \"records\": []\n}\n")
|
||||
verifyErr := errors.New("signature rejected")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
verifyErr error
|
||||
}{
|
||||
{
|
||||
name: "verified output",
|
||||
},
|
||||
{
|
||||
name: "rejected output",
|
||||
verifyErr: verifyErr,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
command := signingTestCommand(t, "echo", "literal argument with spaces * $HOME")
|
||||
s := NewSigner(command, verifierFunc(func(ctx context.Context, dgst digest.Digest, bundle []byte) error {
|
||||
calls++
|
||||
require.Equal(t, t.Context(), ctx)
|
||||
require.Equal(t, digest.FromBytes(payload), dgst)
|
||||
require.Equal(t, payload, bundle, "payload bytes must not be reserialized")
|
||||
return tc.verifyErr
|
||||
}))
|
||||
command[0] = "changed-after-construction"
|
||||
bundle, err := s.Sign(t.Context(), payload)
|
||||
require.Equal(t, 1, calls)
|
||||
if tc.verifyErr != nil {
|
||||
require.ErrorIs(t, err, tc.verifyErr)
|
||||
require.Nil(t, bundle)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, payload, bundle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignerCommandFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
command []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no command",
|
||||
wantErr: "no signing command configured",
|
||||
},
|
||||
{
|
||||
name: "missing executable",
|
||||
command: []string{filepath.Join(t.TempDir(), "missing-signer")},
|
||||
wantErr: "signing command failed",
|
||||
},
|
||||
{
|
||||
name: "nonzero exit",
|
||||
command: signingTestCommand(t, "fail"),
|
||||
wantErr: "signing command failed: signer failed",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := NewSigner(tc.command, verifierFunc(func(context.Context, digest.Digest, []byte) error {
|
||||
t.Fatal("must not verify output from a failed command")
|
||||
return nil
|
||||
}))
|
||||
bundle, err := s.Sign(t.Context(), []byte("payload"))
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
require.Nil(t, bundle)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignerWithoutVerifier(t *testing.T) {
|
||||
s := NewSigner([]string{"must-not-run"}, nil)
|
||||
bundle, err := s.Sign(t.Context(), nil)
|
||||
require.ErrorContains(t, err, "no verifier available")
|
||||
require.Nil(t, bundle)
|
||||
}
|
||||
|
||||
func TestSignerCancellation(t *testing.T) {
|
||||
started := filepath.Join(t.TempDir(), "started")
|
||||
s := NewSigner(signingTestCommand(t, "wait", started), verifierFunc(func(context.Context, digest.Digest, []byte) error {
|
||||
t.Error("must not verify output from a canceled command")
|
||||
return nil
|
||||
}))
|
||||
ctx, cancel := context.WithCancelCause(t.Context())
|
||||
defer cancel(context.Canceled)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.Sign(ctx, []byte("payload"))
|
||||
done <- err
|
||||
}()
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(started)
|
||||
return err == nil
|
||||
}, 10*time.Second, 10*time.Millisecond)
|
||||
cancel(context.Canceled)
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorContains(t, err, "signing command failed")
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("signing command did not exit after cancellation")
|
||||
}
|
||||
}
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
package signing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/moby/buildkit/util/bklog"
|
||||
policy "github.com/moby/policy-helpers"
|
||||
"github.com/moby/policy-helpers/types"
|
||||
digest "github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
|
||||
)
|
||||
|
||||
// Verifier authenticates a bundle against the digest and the configured policy.
|
||||
type Verifier interface {
|
||||
Verify(context.Context, digest.Digest, []byte) error
|
||||
}
|
||||
|
||||
// VerifierProvider lazily supplies the daemon's shared Sigstore verifier.
|
||||
type VerifierProvider func() (*policy.Verifier, error)
|
||||
|
||||
// NewVerifier returns a verifier applying the given policy to cache signatures.
|
||||
func NewVerifier(rules VerifyPolicy, provider VerifierProvider) Verifier {
|
||||
return &verifier{rules: rules, provider: provider}
|
||||
}
|
||||
|
||||
type verifier struct {
|
||||
rules VerifyPolicy
|
||||
provider VerifierProvider
|
||||
}
|
||||
|
||||
func (v *verifier) Verify(ctx context.Context, dgst digest.Digest, bundle []byte) error {
|
||||
if v.provider == nil {
|
||||
return errors.New("no verifier available for signed cache")
|
||||
}
|
||||
artifactVerifier, err := v.provider()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if artifactVerifier == nil {
|
||||
return errors.New("no verifier available for signed cache")
|
||||
}
|
||||
opts := []policy.ArtifactVerifyOpt{policy.WithSLSANotRequired()}
|
||||
if v.rules.TlogThreshold != nil {
|
||||
opts = append(opts, policy.WithTlogThreshold(*v.rules.TlogThreshold))
|
||||
}
|
||||
sig, err := artifactVerifier.VerifyArtifact(ctx, dgst, bundle, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return verifyPolicy(ctx, sig, v.rules)
|
||||
}
|
||||
|
||||
// verifyPolicy checks the authenticated certificate and timestamps.
|
||||
func verifyPolicy(ctx context.Context, sig *types.SignatureInfo, policyRules VerifyPolicy) error {
|
||||
if sig.Signer == nil {
|
||||
return errors.New("signature verification failed: no signer found")
|
||||
}
|
||||
numTimestamps := len(sig.Timestamps)
|
||||
if policyRules.TimestampThreshold > numTimestamps {
|
||||
return errors.Errorf("signature verification failed: not enough timestamp authorities: have %d, need %d", numTimestamps, policyRules.TimestampThreshold)
|
||||
}
|
||||
|
||||
certRules, err := certToStringMap(&policyRules.Summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certFields, err := certToStringMap(sig.Signer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bklog.G(ctx).Debugf("signature verification: %+v", sig)
|
||||
bklog.G(ctx).Debugf("signer: %+v", sig.Signer)
|
||||
for k, v := range certRules {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if !simplePatternMatch(v, certFields[k]) {
|
||||
return errors.Errorf("signature verification failed: certificate field %q does not match policy (%q != %q)", k, certFields[k], v)
|
||||
}
|
||||
bklog.G(ctx).Debugf("certificate field %q matches policy (%q)", k, certFields[k])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certToStringMap(cert *certificate.Summary) (map[string]string, error) {
|
||||
dt, err := json.Marshal(cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]string{}
|
||||
if err := json.Unmarshal(dt, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func simplePatternMatch(pat, s string) bool {
|
||||
if pat == "*" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(pat, "*") && strings.HasSuffix(pat, "*") {
|
||||
return strings.Contains(s, pat[1:len(pat)-1])
|
||||
}
|
||||
if strings.HasPrefix(pat, "*") {
|
||||
return strings.HasSuffix(s, pat[1:])
|
||||
}
|
||||
if strings.HasSuffix(pat, "*") {
|
||||
return strings.HasPrefix(s, pat[:len(pat)-1])
|
||||
}
|
||||
return s == pat
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package signing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
policy "github.com/moby/policy-helpers"
|
||||
"github.com/moby/policy-helpers/types"
|
||||
digest "github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSimplePatternMatch(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
pattern string
|
||||
value string
|
||||
match bool
|
||||
}{
|
||||
{
|
||||
pattern: "",
|
||||
value: "",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
pattern: "builder",
|
||||
value: "builder",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
pattern: "builder",
|
||||
value: "other-builder",
|
||||
match: false,
|
||||
},
|
||||
{
|
||||
pattern: "*",
|
||||
value: "",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
pattern: "https://github.com/org/*",
|
||||
value: "https://github.com/org/repo/workflow",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
pattern: "https://github.com/org/*",
|
||||
value: "https://github.com/other/repo",
|
||||
match: false,
|
||||
},
|
||||
{
|
||||
pattern: "*@refs/heads/main",
|
||||
value: "workflow@refs/heads/main",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
pattern: "*@refs/heads/main",
|
||||
value: "workflow@refs/heads/main-other",
|
||||
match: false,
|
||||
},
|
||||
{
|
||||
pattern: "*workflow*",
|
||||
value: "org/workflow@main",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
pattern: "*workflow*",
|
||||
value: "org/other@main",
|
||||
match: false,
|
||||
},
|
||||
{
|
||||
pattern: "org/*/workflow",
|
||||
value: "org/repo/workflow",
|
||||
match: false,
|
||||
},
|
||||
{
|
||||
pattern: "org/*/workflow",
|
||||
value: "org/*/workflow",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
pattern: "*org/*/workflow*",
|
||||
value: "org/repo/workflow@main",
|
||||
match: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.pattern+"/"+tc.value, func(t *testing.T) {
|
||||
require.Equal(t, tc.match, simplePatternMatch(tc.pattern, tc.value))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPolicy(t *testing.T) {
|
||||
signer := &certificate.Summary{
|
||||
CertificateIssuer: "https://issuer.example",
|
||||
SubjectAlternativeName: "https://github.com/org/repo/workflow@main",
|
||||
Extensions: certificate.Extensions{
|
||||
BuildSignerURI: "https://github.com/org/builder@main",
|
||||
},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
policy VerifyPolicy
|
||||
signer *certificate.Summary
|
||||
timestamps []types.TimestampVerificationResult
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing signer",
|
||||
wantErr: "no signer found",
|
||||
},
|
||||
{
|
||||
name: "unconstrained identity",
|
||||
signer: signer,
|
||||
},
|
||||
{
|
||||
name: "matching identity and extension",
|
||||
policy: VerifyPolicy{
|
||||
Summary: certificate.Summary{
|
||||
CertificateIssuer: "https://issuer.example",
|
||||
SubjectAlternativeName: "https://github.com/org/*",
|
||||
Extensions: certificate.Extensions{
|
||||
BuildSignerURI: "*builder@main",
|
||||
},
|
||||
},
|
||||
},
|
||||
signer: signer,
|
||||
},
|
||||
{
|
||||
name: "wrong issuer",
|
||||
policy: VerifyPolicy{
|
||||
Summary: certificate.Summary{
|
||||
CertificateIssuer: "https://other.example",
|
||||
},
|
||||
},
|
||||
signer: signer,
|
||||
wantErr: `certificate field "certificateIssuer" does not match policy`,
|
||||
},
|
||||
{
|
||||
name: "wrong workflow",
|
||||
policy: VerifyPolicy{
|
||||
Summary: certificate.Summary{
|
||||
SubjectAlternativeName: "https://github.com/trusted/*",
|
||||
},
|
||||
},
|
||||
signer: signer,
|
||||
wantErr: `certificate field "subjectAlternativeName" does not match policy`,
|
||||
},
|
||||
{
|
||||
name: "missing required extension",
|
||||
policy: VerifyPolicy{
|
||||
Summary: certificate.Summary{
|
||||
Extensions: certificate.Extensions{
|
||||
SourceRepositoryURI: "https://github.com/org/repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
signer: signer,
|
||||
wantErr: `certificate field "sourceRepositoryURI" does not match policy`,
|
||||
},
|
||||
{
|
||||
name: "multiple verified timestamps",
|
||||
policy: VerifyPolicy{
|
||||
TimestampThreshold: 2,
|
||||
},
|
||||
signer: signer,
|
||||
timestamps: []types.TimestampVerificationResult{
|
||||
{
|
||||
Type: "Tlog",
|
||||
},
|
||||
{
|
||||
Type: "TimestampAuthority",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "log also counts toward timestamp threshold",
|
||||
policy: VerifyPolicy{
|
||||
TimestampThreshold: 1,
|
||||
},
|
||||
signer: signer,
|
||||
timestamps: []types.TimestampVerificationResult{
|
||||
{
|
||||
Type: "Tlog",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "insufficient timestamps",
|
||||
policy: VerifyPolicy{
|
||||
TimestampThreshold: 1,
|
||||
},
|
||||
signer: signer,
|
||||
wantErr: "not enough timestamp authorities: have 0, need 1",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := verifyPolicy(t.Context(), &types.SignatureInfo{
|
||||
Signer: tc.signer,
|
||||
Timestamps: tc.timestamps,
|
||||
}, tc.policy)
|
||||
if tc.wantErr != "" {
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifierProvider(t *testing.T) {
|
||||
providerErr := errors.New("trust unavailable")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
provider VerifierProvider
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing provider",
|
||||
wantErr: "no verifier available",
|
||||
},
|
||||
{
|
||||
name: "missing verifier",
|
||||
provider: func() (*policy.Verifier, error) {
|
||||
return nil, nil
|
||||
},
|
||||
wantErr: "no verifier available",
|
||||
},
|
||||
{
|
||||
name: "provider failure",
|
||||
provider: func() (*policy.Verifier, error) {
|
||||
return nil, providerErr
|
||||
},
|
||||
wantErr: providerErr.Error(),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
v := NewVerifier(VerifyPolicy{}, tc.provider)
|
||||
err := v.Verify(t.Context(), digest.FromString("payload"), nil)
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
if tc.name == "provider failure" {
|
||||
require.ErrorIs(t, err, providerErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifierRejectsMalformedBundle(t *testing.T) {
|
||||
calls := 0
|
||||
v := NewVerifier(VerifyPolicy{}, func() (*policy.Verifier, error) {
|
||||
calls++
|
||||
// Malformed bundles are rejected before loading trust material.
|
||||
return &policy.Verifier{}, nil
|
||||
})
|
||||
require.Zero(t, calls, "constructing a verifier must not initialize trust")
|
||||
require.Error(t, v.Verify(t.Context(), digest.FromString("payload"), []byte("not a bundle")))
|
||||
require.Equal(t, 1, calls)
|
||||
}
|
||||
|
||||
func TestVerifierNegativeTlogThreshold(t *testing.T) {
|
||||
v := NewVerifier(VerifyPolicy{TlogThreshold: new(-1)}, func() (*policy.Verifier, error) {
|
||||
return &policy.Verifier{}, nil
|
||||
})
|
||||
err := v.Verify(t.Context(), digest.FromString("cache index"), nil)
|
||||
require.EqualError(t, err, "transparency log threshold must not be negative")
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/moby/buildkit/cache/remotecache/gha/ghatypes"
|
||||
"github.com/moby/buildkit/cache/remotecache/signing"
|
||||
"github.com/moby/buildkit/util/db/compaction"
|
||||
resolverconfig "github.com/moby/buildkit/util/resolver/config"
|
||||
)
|
||||
@@ -105,7 +105,7 @@ func (c CompactionConfig) Policy() (compaction.Config, error) {
|
||||
}
|
||||
|
||||
type CacheConfig struct {
|
||||
GHA *ghatypes.CacheConfig `toml:"gha"`
|
||||
GHA *signing.Config `toml:"gha"`
|
||||
}
|
||||
|
||||
type SystemConfig struct {
|
||||
|
||||
@@ -32,6 +32,31 @@ func TestLoadGCPolicyInvalidQuote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGHASigning(t *testing.T) {
|
||||
cfg, err := Load(strings.NewReader(`
|
||||
[cache.gha.sign]
|
||||
command = ["sign-cache", "--bundle", "-"]
|
||||
[cache.gha.verify]
|
||||
required = true
|
||||
[cache.gha.verify.policy]
|
||||
timestampThreshold = 2
|
||||
tlogThreshold = 1
|
||||
certificateIssuer = "https://issuer.example"
|
||||
subjectAlternativeName = "https://github.com/org/*"
|
||||
buildSignerURI = "https://github.com/org/builder@main"
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg.Cache.GHA)
|
||||
require.NotNil(t, cfg.Cache.GHA.Sign)
|
||||
require.Equal(t, []string{"sign-cache", "--bundle", "-"}, cfg.Cache.GHA.Sign.Command)
|
||||
require.True(t, cfg.Cache.GHA.Verify.Required)
|
||||
require.Equal(t, 2, cfg.Cache.GHA.Verify.Policy.TimestampThreshold)
|
||||
require.Equal(t, new(1), cfg.Cache.GHA.Verify.Policy.TlogThreshold)
|
||||
require.Equal(t, "https://issuer.example", cfg.Cache.GHA.Verify.Policy.CertificateIssuer)
|
||||
require.Equal(t, "https://github.com/org/*", cfg.Cache.GHA.Verify.Policy.SubjectAlternativeName)
|
||||
require.Equal(t, "https://github.com/org/builder@main", cfg.Cache.GHA.Verify.Policy.BuildSignerURI)
|
||||
}
|
||||
|
||||
func TestCompactionConfig(t *testing.T) {
|
||||
for _, enabled := range []string{"", "[compaction]\nenabled = false", "[compaction]\nenabled = true"} {
|
||||
cfg, err := Load(strings.NewReader(enabled))
|
||||
|
||||
@@ -936,7 +936,7 @@ func newController(ctx context.Context, c *cli.Command, cfg *config.Config, mp m
|
||||
}
|
||||
|
||||
var verifierProvider func() (*policy.Verifier, error)
|
||||
if cfg.Cache.GHA != nil && cfg.Cache.GHA.Verify.Required {
|
||||
if cfg.Cache.GHA != nil && (cfg.Cache.GHA.Sign != nil || cfg.Cache.GHA.Verify.Required) {
|
||||
verifierProvider = newVerifierProvider(cfg.Root)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user