From fc35a0433f0b1ee1eef9f3000bdfe207a80248d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Wed, 26 Aug 2026 13:31:35 -0600 Subject: [PATCH 01/11] Add krel attestation sign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a new krel attestation sign command. It takes an attestation file and signs it with sigstore, preferrably with a service account credential but (if not available) trying the ambient identity providers (just like cosign does). Signed-off-by: Adolfo García Veytia (Puerco) --- cmd/krel/cmd/sign_attestation.go | 118 +++++ cmd/krel/cmd/sign_attestation_test.go | 51 ++ go.mod | 9 +- go.sum | 10 +- .../fake_signer_implementation.go | 438 ++++++++++++++++++ pkg/attestation/sign.go | 222 +++++++++ pkg/attestation/sign_test.go | 334 +++++++++++++ 7 files changed, 1177 insertions(+), 5 deletions(-) create mode 100644 cmd/krel/cmd/sign_attestation.go create mode 100644 cmd/krel/cmd/sign_attestation_test.go create mode 100644 pkg/attestation/attestationfakes/fake_signer_implementation.go create mode 100644 pkg/attestation/sign.go create mode 100644 pkg/attestation/sign_test.go diff --git a/cmd/krel/cmd/sign_attestation.go b/cmd/krel/cmd/sign_attestation.go new file mode 100644 index 00000000000..04eeed51fa7 --- /dev/null +++ b/cmd/krel/cmd/sign_attestation.go @@ -0,0 +1,118 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "k8s.io/release/pkg/attestation" +) + +const serviceAccountFileFlag = "service-account-file" + +type signAttestationOptions struct { + outputPath string + serviceAccountFile string +} + +var signAttestationOpts = &signAttestationOptions{} + +// signAttestationCmd represents the subcommand for `krel sign attestation`. +var signAttestationCmd = &cobra.Command{ + Use: "attestation statement.json", + Short: "Sign an in-toto statement into a sigstore bundle", + Long: `krel sign attestation attestation.json + +Signs an in-toto statement using sigstore and writes the resulting bundle +(DSSE envelope, Fulcio certificate and transparency log proofs) to stdout +or to the file set with --` + outputPathFlag + `. + +By default the statement is signed with the ambient identity provider. + +To sign with an explicit identity, pass a Google Cloud service account key +file with --` + serviceAccountFileFlag + `. The signer is then locked to that +service account: the certificate is only requested with its identity and signing +fails if that is not possible, it never falls back to the ambient credentials.`, + + Example: ` # Sign an attestation using the ambient GCP credentials: + krel sign attestation provenance.json > provenance.json.sigstore.json + + # Sign using a service account key: + krel sign attestation --service-account-file key.json --output-path provenance.sigstore.json provenance.json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + return runSignAttestation(singOpts, signAttestationOpts, args[0]) + }, +} + +func init() { + signAttestationCmd.PersistentFlags().StringVar( + &signAttestationOpts.outputPath, + outputPathFlag, + "", + "write the signed bundle to a file instead of stdout", + ) + + signAttestationCmd.PersistentFlags().StringVar( + &signAttestationOpts.serviceAccountFile, + serviceAccountFileFlag, + "", + "path to a Google service account key (defaults to ambien credentials)", + ) + + signCmd.AddCommand(signAttestationCmd) +} + +func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, statementPath string) (err error) { + signerOpts := attestation.DefaultSignerOptions() + signerOpts.ServiceAccountFile = opts.serviceAccountFile + signerOpts.Timeout = signOpts.timeout + + var out io.Writer = os.Stdout + + if opts.outputPath != "" { + f, err := os.Create(opts.outputPath) + if err != nil { + return fmt.Errorf("creating output file: %w", err) + } + + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = fmt.Errorf("closing output file: %w", cerr) + } + }() + + out = f + } + + if err := attestation.NewSigner(signerOpts).SignFile(statementPath, out); err != nil { + return fmt.Errorf("signing attestation: %w", err) + } + + if opts.outputPath != "" { + logrus.Infof("Signed bundle written to %s", opts.outputPath) + } + + return nil +} diff --git a/cmd/krel/cmd/sign_attestation_test.go b/cmd/krel/cmd/sign_attestation_test.go new file mode 100644 index 00000000000..b8e10582fcb --- /dev/null +++ b/cmd/krel/cmd/sign_attestation_test.go @@ -0,0 +1,51 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRunSignAttestation(t *testing.T) { + t.Parallel() + + signOpts := &signOptions{timeout: time.Second} + + t.Run("missing statement", func(t *testing.T) { + t.Parallel() + + err := runSignAttestation( + signOpts, &signAttestationOptions{}, filepath.Join(t.TempDir(), "missing.json"), + ) + require.Error(t, err) + }) + + t.Run("output path cannot be created", func(t *testing.T) { + t.Parallel() + + err := runSignAttestation( + signOpts, + &signAttestationOptions{outputPath: filepath.Join(t.TempDir(), "no", "such", "dir", "out.json")}, + filepath.Join(t.TempDir(), "missing.json"), + ) + require.ErrorContains(t, err, "creating output file") + }) +} diff --git a/go.mod b/go.mod index 264ee8b0002..ce898989a4f 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( cloud.google.com/go/storage v1.62.3 github.com/GoogleCloudPlatform/testgrid v0.0.38 github.com/blang/semver/v4 v4.0.0 + github.com/carabiner-dev/signer v0.6.0 github.com/cheggaaa/pb/v3 v3.2.1 github.com/fastly/go-fastly/v13 v13.1.2 github.com/go-git/go-git/v5 v5.19.2 @@ -24,6 +25,8 @@ require ( github.com/sergi/go-diff v1.4.0 github.com/shirou/gopsutil/v3 v3.24.5 github.com/shurcooL/githubv4 v0.0.0-20220115235240-a14260e6f8a2 + github.com/sigstore/sigstore v1.10.9 + github.com/sigstore/sigstore-go v1.3.0 github.com/sirupsen/logrus v1.10.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.1 @@ -115,6 +118,8 @@ require ( github.com/buildkite/go-pipeline v0.15.0 // indirect github.com/buildkite/interpolate v0.1.5 // indirect github.com/buildkite/roko v1.4.0 // indirect + github.com/carabiner-dev/attestation v0.2.1 // indirect + github.com/carabiner-dev/command v0.3.1 // indirect github.com/carabiner-dev/hasher v0.2.4 // indirect github.com/carabiner-dev/spdx3 v0.1.0 // indirect github.com/carabiner-dev/unpack v0.3.1 // indirect @@ -271,11 +276,9 @@ require ( github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/sigstore/cosign/v2 v2.6.3 // indirect github.com/sigstore/fulcio v1.8.6 // indirect - github.com/sigstore/protobuf-specs v0.5.1 // indirect + github.com/sigstore/protobuf-specs v0.5.2 // indirect github.com/sigstore/rekor v1.5.3 // indirect github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect - github.com/sigstore/sigstore v1.10.9 // indirect - github.com/sigstore/sigstore-go v1.3.0 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect diff --git a/go.sum b/go.sum index 0b05c8007be..f7fc267aa26 100644 --- a/go.sum +++ b/go.sum @@ -262,8 +262,14 @@ github.com/buildkite/roko v1.4.0 h1:DxixoCdpNqxu4/1lXrXbfsKbJSd7r1qoxtef/TT2J80= github.com/buildkite/roko v1.4.0/go.mod h1:0vbODqUFEcVf4v2xVXRfZZRsqJVsCCHTG/TBRByGK4E= github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA= github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q= +github.com/carabiner-dev/attestation v0.2.1 h1:VhjV5YlO9TsW50Sr/Zd54bdbZhhDAqgxC3kB9z1I+3Q= +github.com/carabiner-dev/attestation v0.2.1/go.mod h1:O84vF84RZG3pJO/6BYrPs718bZviHF5DKajP1HsrDpw= +github.com/carabiner-dev/command v0.3.1 h1:iBkh+AjwziFZmyihv/izypCV74nkmaslZxb5AgP7GP4= +github.com/carabiner-dev/command v0.3.1/go.mod h1:0mWfS5BU/krtaI1hgD5wjmLpjWVlf38KY8usA8zfF5c= github.com/carabiner-dev/hasher v0.2.4 h1:VaI04+FBHaNV/UEy0NmVoRg0pKLFeN76KPOHbTDvvhE= github.com/carabiner-dev/hasher v0.2.4/go.mod h1:W83zi1+E3he4Cpldss8yoXNj6GdDUpr3M45dOAzem/w= +github.com/carabiner-dev/signer v0.6.0 h1:ke8aXVLTNl5nCYqr3ALvWF+/5bXyr0DduhiETPHTl9U= +github.com/carabiner-dev/signer v0.6.0/go.mod h1:dg1OvK3lTePsPrbvoBZO5SBt0DHd2/NLdM2wfB2/4/8= github.com/carabiner-dev/spdx3 v0.1.0 h1:Q6nMLXV0BhtqDQuwRDOaJDZR670TK4jdOvZqvTW/ctY= github.com/carabiner-dev/spdx3 v0.1.0/go.mod h1:d/t010TrZvYZBeYGpvHeId6QpRvGLlqK7cy0E81npPA= github.com/carabiner-dev/unpack v0.3.1 h1:VnB2rzMHeu8iQhFz7BV22mjilw8a5hR3rF497+YqYJE= @@ -853,8 +859,8 @@ github.com/sigstore/cosign/v2 v2.6.3 h1:1W+rZWz0zkTfqmTmYBOQS/Jt97NKz1OCzxTV2YAp github.com/sigstore/cosign/v2 v2.6.3/go.mod h1:g+P/LgYyJkC85WGGDho7yySl3C6xTJzzpLm21ZV+E6s= github.com/sigstore/fulcio v1.8.6 h1:vkvRpdhVAZjZHa0ltiJeLSILG9U8ADJoe5Z+RdZSLJk= github.com/sigstore/fulcio v1.8.6/go.mod h1:7RwoGdMM0xpc9qc382sjSw7P9OLKbKIwQZXvYz4JPb4= -github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= -github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/protobuf-specs v0.5.2 h1:RSWWUY8QrVTxbYH00jY/jg2e7YnjzrpwP+PeHTMll0E= +github.com/sigstore/protobuf-specs v0.5.2/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= diff --git a/pkg/attestation/attestationfakes/fake_signer_implementation.go b/pkg/attestation/attestationfakes/fake_signer_implementation.go new file mode 100644 index 00000000000..b74a5ee5408 --- /dev/null +++ b/pkg/attestation/attestationfakes/fake_signer_implementation.go @@ -0,0 +1,438 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by counterfeiter. DO NOT EDIT. +package attestationfakes + +import ( + "context" + "io" + "sync" + + "github.com/carabiner-dev/signer" + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore/pkg/oauthflow" +) + +type FakeSignerImplementation struct { + NewSignerStub func() *signer.Signer + newSignerMutex sync.RWMutex + newSignerArgsForCall []struct { + } + newSignerReturns struct { + result1 *signer.Signer + } + newSignerReturnsOnCall map[int]struct { + result1 *signer.Signer + } + ReadStatementStub func(string) ([]byte, error) + readStatementMutex sync.RWMutex + readStatementArgsForCall []struct { + arg1 string + } + readStatementReturns struct { + result1 []byte + result2 error + } + readStatementReturnsOnCall map[int]struct { + result1 []byte + result2 error + } + ServiceAccountTokenStub func(context.Context, string, string) (*oauthflow.OIDCIDToken, error) + serviceAccountTokenMutex sync.RWMutex + serviceAccountTokenArgsForCall []struct { + arg1 context.Context + arg2 string + arg3 string + } + serviceAccountTokenReturns struct { + result1 *oauthflow.OIDCIDToken + result2 error + } + serviceAccountTokenReturnsOnCall map[int]struct { + result1 *oauthflow.OIDCIDToken + result2 error + } + SignStatementStub func(*signer.Signer, *oauthflow.OIDCIDToken, []byte) (*bundle.Bundle, error) + signStatementMutex sync.RWMutex + signStatementArgsForCall []struct { + arg1 *signer.Signer + arg2 *oauthflow.OIDCIDToken + arg3 []byte + } + signStatementReturns struct { + result1 *bundle.Bundle + result2 error + } + signStatementReturnsOnCall map[int]struct { + result1 *bundle.Bundle + result2 error + } + WriteBundleStub func(*signer.Signer, *bundle.Bundle, io.Writer) error + writeBundleMutex sync.RWMutex + writeBundleArgsForCall []struct { + arg1 *signer.Signer + arg2 *bundle.Bundle + arg3 io.Writer + } + writeBundleReturns struct { + result1 error + } + writeBundleReturnsOnCall map[int]struct { + result1 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeSignerImplementation) NewSigner() *signer.Signer { + fake.newSignerMutex.Lock() + ret, specificReturn := fake.newSignerReturnsOnCall[len(fake.newSignerArgsForCall)] + fake.newSignerArgsForCall = append(fake.newSignerArgsForCall, struct { + }{}) + stub := fake.NewSignerStub + fakeReturns := fake.newSignerReturns + fake.recordInvocation("NewSigner", []interface{}{}) + fake.newSignerMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeSignerImplementation) NewSignerCallCount() int { + fake.newSignerMutex.RLock() + defer fake.newSignerMutex.RUnlock() + return len(fake.newSignerArgsForCall) +} + +func (fake *FakeSignerImplementation) NewSignerCalls(stub func() *signer.Signer) { + fake.newSignerMutex.Lock() + defer fake.newSignerMutex.Unlock() + fake.NewSignerStub = stub +} + +func (fake *FakeSignerImplementation) NewSignerReturns(result1 *signer.Signer) { + fake.newSignerMutex.Lock() + defer fake.newSignerMutex.Unlock() + fake.NewSignerStub = nil + fake.newSignerReturns = struct { + result1 *signer.Signer + }{result1} +} + +func (fake *FakeSignerImplementation) NewSignerReturnsOnCall(i int, result1 *signer.Signer) { + fake.newSignerMutex.Lock() + defer fake.newSignerMutex.Unlock() + fake.NewSignerStub = nil + if fake.newSignerReturnsOnCall == nil { + fake.newSignerReturnsOnCall = make(map[int]struct { + result1 *signer.Signer + }) + } + fake.newSignerReturnsOnCall[i] = struct { + result1 *signer.Signer + }{result1} +} + +func (fake *FakeSignerImplementation) ReadStatement(arg1 string) ([]byte, error) { + fake.readStatementMutex.Lock() + ret, specificReturn := fake.readStatementReturnsOnCall[len(fake.readStatementArgsForCall)] + fake.readStatementArgsForCall = append(fake.readStatementArgsForCall, struct { + arg1 string + }{arg1}) + stub := fake.ReadStatementStub + fakeReturns := fake.readStatementReturns + fake.recordInvocation("ReadStatement", []interface{}{arg1}) + fake.readStatementMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeSignerImplementation) ReadStatementCallCount() int { + fake.readStatementMutex.RLock() + defer fake.readStatementMutex.RUnlock() + return len(fake.readStatementArgsForCall) +} + +func (fake *FakeSignerImplementation) ReadStatementCalls(stub func(string) ([]byte, error)) { + fake.readStatementMutex.Lock() + defer fake.readStatementMutex.Unlock() + fake.ReadStatementStub = stub +} + +func (fake *FakeSignerImplementation) ReadStatementArgsForCall(i int) string { + fake.readStatementMutex.RLock() + defer fake.readStatementMutex.RUnlock() + argsForCall := fake.readStatementArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeSignerImplementation) ReadStatementReturns(result1 []byte, result2 error) { + fake.readStatementMutex.Lock() + defer fake.readStatementMutex.Unlock() + fake.ReadStatementStub = nil + fake.readStatementReturns = struct { + result1 []byte + result2 error + }{result1, result2} +} + +func (fake *FakeSignerImplementation) ReadStatementReturnsOnCall(i int, result1 []byte, result2 error) { + fake.readStatementMutex.Lock() + defer fake.readStatementMutex.Unlock() + fake.ReadStatementStub = nil + if fake.readStatementReturnsOnCall == nil { + fake.readStatementReturnsOnCall = make(map[int]struct { + result1 []byte + result2 error + }) + } + fake.readStatementReturnsOnCall[i] = struct { + result1 []byte + result2 error + }{result1, result2} +} + +func (fake *FakeSignerImplementation) ServiceAccountToken(arg1 context.Context, arg2 string, arg3 string) (*oauthflow.OIDCIDToken, error) { + fake.serviceAccountTokenMutex.Lock() + ret, specificReturn := fake.serviceAccountTokenReturnsOnCall[len(fake.serviceAccountTokenArgsForCall)] + fake.serviceAccountTokenArgsForCall = append(fake.serviceAccountTokenArgsForCall, struct { + arg1 context.Context + arg2 string + arg3 string + }{arg1, arg2, arg3}) + stub := fake.ServiceAccountTokenStub + fakeReturns := fake.serviceAccountTokenReturns + fake.recordInvocation("ServiceAccountToken", []interface{}{arg1, arg2, arg3}) + fake.serviceAccountTokenMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeSignerImplementation) ServiceAccountTokenCallCount() int { + fake.serviceAccountTokenMutex.RLock() + defer fake.serviceAccountTokenMutex.RUnlock() + return len(fake.serviceAccountTokenArgsForCall) +} + +func (fake *FakeSignerImplementation) ServiceAccountTokenCalls(stub func(context.Context, string, string) (*oauthflow.OIDCIDToken, error)) { + fake.serviceAccountTokenMutex.Lock() + defer fake.serviceAccountTokenMutex.Unlock() + fake.ServiceAccountTokenStub = stub +} + +func (fake *FakeSignerImplementation) ServiceAccountTokenArgsForCall(i int) (context.Context, string, string) { + fake.serviceAccountTokenMutex.RLock() + defer fake.serviceAccountTokenMutex.RUnlock() + argsForCall := fake.serviceAccountTokenArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeSignerImplementation) ServiceAccountTokenReturns(result1 *oauthflow.OIDCIDToken, result2 error) { + fake.serviceAccountTokenMutex.Lock() + defer fake.serviceAccountTokenMutex.Unlock() + fake.ServiceAccountTokenStub = nil + fake.serviceAccountTokenReturns = struct { + result1 *oauthflow.OIDCIDToken + result2 error + }{result1, result2} +} + +func (fake *FakeSignerImplementation) ServiceAccountTokenReturnsOnCall(i int, result1 *oauthflow.OIDCIDToken, result2 error) { + fake.serviceAccountTokenMutex.Lock() + defer fake.serviceAccountTokenMutex.Unlock() + fake.ServiceAccountTokenStub = nil + if fake.serviceAccountTokenReturnsOnCall == nil { + fake.serviceAccountTokenReturnsOnCall = make(map[int]struct { + result1 *oauthflow.OIDCIDToken + result2 error + }) + } + fake.serviceAccountTokenReturnsOnCall[i] = struct { + result1 *oauthflow.OIDCIDToken + result2 error + }{result1, result2} +} + +func (fake *FakeSignerImplementation) SignStatement(arg1 *signer.Signer, arg2 *oauthflow.OIDCIDToken, arg3 []byte) (*bundle.Bundle, error) { + var arg3Copy []byte + if arg3 != nil { + arg3Copy = make([]byte, len(arg3)) + copy(arg3Copy, arg3) + } + fake.signStatementMutex.Lock() + ret, specificReturn := fake.signStatementReturnsOnCall[len(fake.signStatementArgsForCall)] + fake.signStatementArgsForCall = append(fake.signStatementArgsForCall, struct { + arg1 *signer.Signer + arg2 *oauthflow.OIDCIDToken + arg3 []byte + }{arg1, arg2, arg3Copy}) + stub := fake.SignStatementStub + fakeReturns := fake.signStatementReturns + fake.recordInvocation("SignStatement", []interface{}{arg1, arg2, arg3Copy}) + fake.signStatementMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeSignerImplementation) SignStatementCallCount() int { + fake.signStatementMutex.RLock() + defer fake.signStatementMutex.RUnlock() + return len(fake.signStatementArgsForCall) +} + +func (fake *FakeSignerImplementation) SignStatementCalls(stub func(*signer.Signer, *oauthflow.OIDCIDToken, []byte) (*bundle.Bundle, error)) { + fake.signStatementMutex.Lock() + defer fake.signStatementMutex.Unlock() + fake.SignStatementStub = stub +} + +func (fake *FakeSignerImplementation) SignStatementArgsForCall(i int) (*signer.Signer, *oauthflow.OIDCIDToken, []byte) { + fake.signStatementMutex.RLock() + defer fake.signStatementMutex.RUnlock() + argsForCall := fake.signStatementArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeSignerImplementation) SignStatementReturns(result1 *bundle.Bundle, result2 error) { + fake.signStatementMutex.Lock() + defer fake.signStatementMutex.Unlock() + fake.SignStatementStub = nil + fake.signStatementReturns = struct { + result1 *bundle.Bundle + result2 error + }{result1, result2} +} + +func (fake *FakeSignerImplementation) SignStatementReturnsOnCall(i int, result1 *bundle.Bundle, result2 error) { + fake.signStatementMutex.Lock() + defer fake.signStatementMutex.Unlock() + fake.SignStatementStub = nil + if fake.signStatementReturnsOnCall == nil { + fake.signStatementReturnsOnCall = make(map[int]struct { + result1 *bundle.Bundle + result2 error + }) + } + fake.signStatementReturnsOnCall[i] = struct { + result1 *bundle.Bundle + result2 error + }{result1, result2} +} + +func (fake *FakeSignerImplementation) WriteBundle(arg1 *signer.Signer, arg2 *bundle.Bundle, arg3 io.Writer) error { + fake.writeBundleMutex.Lock() + ret, specificReturn := fake.writeBundleReturnsOnCall[len(fake.writeBundleArgsForCall)] + fake.writeBundleArgsForCall = append(fake.writeBundleArgsForCall, struct { + arg1 *signer.Signer + arg2 *bundle.Bundle + arg3 io.Writer + }{arg1, arg2, arg3}) + stub := fake.WriteBundleStub + fakeReturns := fake.writeBundleReturns + fake.recordInvocation("WriteBundle", []interface{}{arg1, arg2, arg3}) + fake.writeBundleMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeSignerImplementation) WriteBundleCallCount() int { + fake.writeBundleMutex.RLock() + defer fake.writeBundleMutex.RUnlock() + return len(fake.writeBundleArgsForCall) +} + +func (fake *FakeSignerImplementation) WriteBundleCalls(stub func(*signer.Signer, *bundle.Bundle, io.Writer) error) { + fake.writeBundleMutex.Lock() + defer fake.writeBundleMutex.Unlock() + fake.WriteBundleStub = stub +} + +func (fake *FakeSignerImplementation) WriteBundleArgsForCall(i int) (*signer.Signer, *bundle.Bundle, io.Writer) { + fake.writeBundleMutex.RLock() + defer fake.writeBundleMutex.RUnlock() + argsForCall := fake.writeBundleArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeSignerImplementation) WriteBundleReturns(result1 error) { + fake.writeBundleMutex.Lock() + defer fake.writeBundleMutex.Unlock() + fake.WriteBundleStub = nil + fake.writeBundleReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeSignerImplementation) WriteBundleReturnsOnCall(i int, result1 error) { + fake.writeBundleMutex.Lock() + defer fake.writeBundleMutex.Unlock() + fake.WriteBundleStub = nil + if fake.writeBundleReturnsOnCall == nil { + fake.writeBundleReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.writeBundleReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeSignerImplementation) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeSignerImplementation) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} diff --git a/pkg/attestation/sign.go b/pkg/attestation/sign.go new file mode 100644 index 00000000000..5e7e5bb8bbc --- /dev/null +++ b/pkg/attestation/sign.go @@ -0,0 +1,222 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package attestation signs in-toto statements into sigstore bundles. +package attestation + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/carabiner-dev/signer" + "github.com/carabiner-dev/signer/sts/providers/gcp" + intoto "github.com/in-toto/attestation/go/v1" + sbundle "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore/pkg/oauthflow" + "github.com/sirupsen/logrus" + "google.golang.org/protobuf/encoding/protojson" +) + +//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate +//go:generate /usr/bin/env bash -c "cat ../../hack/boilerplate/boilerplate.generatego.txt attestationfakes/fake_signer_implementation.go > attestationfakes/_fake_signer_implementation.go && mv attestationfakes/_fake_signer_implementation.go attestationfakes/fake_signer_implementation.go" + +// ErrNoIdentity is returned when the service account key did not yield an +// identity token. +var ErrNoIdentity = errors.New("service account key did not produce an identity token") + +// SignerOptions configures the attestation Signer. +type SignerOptions struct { + // ServiceAccountFile is the path to a Google service account key (JSON). + // When set, the statement is signed exclusively with the identity of that + // service account: the signer will not fall back to any other credential + // if obtaining a certificate with it fails. When empty, the signer tries + // the ambient credentials of the environment (the GCP metadata server or + // GOOGLE_APPLICATION_CREDENTIALS, GitHub Actions, GitLab CI). + ServiceAccountFile string + + // Timeout bounds the identity token exchange with Google when signing + // with a service account key. + Timeout time.Duration +} + +// DefaultSignerOptions returns the default signer options. +func DefaultSignerOptions() *SignerOptions { + return &SignerOptions{ + Timeout: 3 * time.Minute, + } +} + +// Signer signs in-toto statements using a Google Cloud identity and wraps +// the results in sigstore bundles (DSSE envelope + Fulcio certificate + +// Rekor and timestamp proofs). +type Signer struct { + options *SignerOptions + impl signerImplementation +} + +// NewSigner returns a new Signer configured with opts. A nil opts uses +// DefaultSignerOptions. +func NewSigner(opts *SignerOptions) *Signer { + if opts == nil { + opts = DefaultSignerOptions() + } + + return &Signer{ + options: opts, + impl: &defaultSignerImpl{}, + } +} + +//counterfeiter:generate . signerImplementation +type signerImplementation interface { + ReadStatement(path string) ([]byte, error) + NewSigner() *signer.Signer + ServiceAccountToken(ctx context.Context, keyFile, audience string) (*oauthflow.OIDCIDToken, error) + SignStatement(sgnr *signer.Signer, token *oauthflow.OIDCIDToken, data []byte) (*sbundle.Bundle, error) + WriteBundle(sgnr *signer.Signer, bndl *sbundle.Bundle, w io.Writer) error +} + +// SignFile reads the in-toto statement stored in path, signs it and writes +// the resulting sigstore bundle to w. +func (s *Signer) SignFile(path string, w io.Writer) error { + data, err := s.impl.ReadStatement(path) + if err != nil { + return fmt.Errorf("reading statement: %w", err) + } + + sgnr := s.impl.NewSigner() + + // When a service account key is set, the identity token is minted here + // from the key and pinned into the signer. Otherwise token is left nil and + // the signer runs its own ambient credential discovery. + var token *oauthflow.OIDCIDToken + + if s.options.ServiceAccountFile != "" { + ctx, cancel := context.WithTimeout(context.Background(), s.options.Timeout) + defer cancel() + + // The token audience must match the client ID the sigstore instance + // expects, otherwise Fulcio will reject it. + token, err = s.impl.ServiceAccountToken( + ctx, s.options.ServiceAccountFile, sgnr.Options.OIDCConfig.ClientID, + ) + if err != nil { + return fmt.Errorf("obtaining identity from service account key: %w", err) + } + + logrus.Infof("Signing statement %s as %s", path, token.Subject) + } else { + logrus.Infof("Signing statement %s with the ambient credentials", path) + } + + bndl, err := s.impl.SignStatement(sgnr, token, data) + if err != nil { + return fmt.Errorf("signing statement: %w", err) + } + + if err := s.impl.WriteBundle(sgnr, bndl, w); err != nil { + return fmt.Errorf("writing bundle: %w", err) + } + + return nil +} + +type defaultSignerImpl struct{} + +// ReadStatement reads the file in path and ensures it contains a valid +// in-toto statement before it gets signed. +func (*defaultSignerImpl) ReadStatement(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + + statement := &intoto.Statement{} + if err := protojson.Unmarshal(data, statement); err != nil { + return nil, fmt.Errorf("parsing in-toto statement: %w", err) + } + + if err := statement.Validate(); err != nil { + return nil, fmt.Errorf("invalid in-toto statement: %w", err) + } + + logrus.Infof( + "Loaded %s statement with %d subjects", statement.GetPredicateType(), len(statement.GetSubject()), + ) + + return data, nil +} + +// NewSigner creates a signer targeting the default (public good) sigstore +// instance. +func (*defaultSignerImpl) NewSigner() *signer.Signer { + return signer.NewSigner() +} + +// ServiceAccountToken obtains an OIDC token for the given audience from +// Google Cloud using the service account key in keyFile. The identity of the +// host is never used, even if exchanging the key fails. +func (*defaultSignerImpl) ServiceAccountToken( + ctx context.Context, keyFile, audience string, +) (*oauthflow.OIDCIDToken, error) { + logrus.Infof("Obtaining identity token from service account key %s", keyFile) + + provider, err := gcp.New( + gcp.WithServiceAccountFile(keyFile), + gcp.WithAmbientCredentials(false), + ) + if err != nil { + return nil, fmt.Errorf("creating GCP identity provider: %w", err) + } + + token, err := provider.Provide(ctx, audience) + if err != nil { + return nil, fmt.Errorf("obtaining identity token: %w", err) + } + + if token == nil { + return nil, ErrNoIdentity + } + + return token, nil +} + +// SignStatement signs the statement data and wraps the signed envelope in a +// sigstore bundle. If token is not nil, the signer is locked to it: the +// Fulcio certificate can only be obtained with that identity and the signer +// will not try its own credential discovery. A nil token leaves the signer's +// ambient credential providers in charge of finding an identity. +func (*defaultSignerImpl) SignStatement( + sgnr *signer.Signer, token *oauthflow.OIDCIDToken, data []byte, +) (*sbundle.Bundle, error) { + if token != nil { + sgnr.Options.Token = token + sgnr.Options.DisableSTS = true + } + + defer sgnr.Close() + + return sgnr.SignStatementBundle(data) +} + +// WriteBundle marshals the bundle as JSON into w. +func (*defaultSignerImpl) WriteBundle(sgnr *signer.Signer, bndl *sbundle.Bundle, w io.Writer) error { + return sgnr.WriteBundle(bndl, w) +} diff --git a/pkg/attestation/sign_test.go b/pkg/attestation/sign_test.go new file mode 100644 index 00000000000..3a56ab731bd --- /dev/null +++ b/pkg/attestation/sign_test.go @@ -0,0 +1,334 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package attestation + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/carabiner-dev/signer" + sbundle "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore/pkg/oauthflow" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "k8s.io/release/pkg/attestation/attestationfakes" +) + +const testStatement = `{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [{"name": "kubernetes.tar.gz", "digest": {"sha256": "0e8a8b6f7c6cf3b0f2f2b6c2d1a4f4b3c2e1d0f9a8b7c6d5e4f3a2b1c0d9e8f7"}}], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": {"buildDefinition": {"buildType": "https://git.k8s.io/release/docs/krel/buildtypes/v1"}} +}` + +var errTest = errors.New("synthetic error") + +func TestSignFile(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + opts *SignerOptions + prepare func(*attestationfakes.FakeSignerImplementation) + shouldErr bool + wantOutput string + wantSAToks int + }{ + { + name: "success with ambient credentials", + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.WriteBundleCalls(func(_ *signer.Signer, _ *sbundle.Bundle, w io.Writer) error { + _, err := w.Write([]byte("bundle")) + + return err + }) + }, + wantOutput: "bundle", + }, + { + name: "success with service account key", + opts: &SignerOptions{ServiceAccountFile: "key.json"}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.ServiceAccountTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) + mock.WriteBundleCalls(func(_ *signer.Signer, _ *sbundle.Bundle, w io.Writer) error { + _, err := w.Write([]byte("bundle")) + + return err + }) + }, + wantOutput: "bundle", + wantSAToks: 1, + }, + { + name: "ReadStatement fails", + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.ReadStatementReturns(nil, errTest) + }, + shouldErr: true, + }, + { + name: "ServiceAccountToken fails", + opts: &SignerOptions{ServiceAccountFile: "key.json"}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.ServiceAccountTokenReturns(nil, errTest) + }, + shouldErr: true, + wantSAToks: 1, + }, + { + name: "SignStatement fails", + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.SignStatementReturns(nil, errTest) + }, + shouldErr: true, + }, + { + name: "WriteBundle fails", + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.WriteBundleReturns(errTest) + }, + shouldErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mock := &attestationfakes.FakeSignerImplementation{} + mock.NewSignerReturns(signer.NewSigner()) + tc.prepare(mock) + + sut := NewSigner(tc.opts) + sut.impl = mock + + var out bytes.Buffer + + err := sut.SignFile("statement.json", &out) + if tc.shouldErr { + require.Error(t, err) + require.ErrorIs(t, err, errTest) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantOutput, out.String()) + } + + require.Equal(t, tc.wantSAToks, mock.ServiceAccountTokenCallCount()) + + if tc.wantSAToks > 0 { + _, keyFile, audience := mock.ServiceAccountTokenArgsForCall(0) + require.Equal(t, tc.opts.ServiceAccountFile, keyFile) + require.NotEmpty(t, audience, "audience must be the sigstore OIDC client ID") + } + }) + } +} + +func TestSignStatementLocksToken(t *testing.T) { + t.Parallel() + + // The signer must be pinned to the token so it cannot fall back to any + // other credential. We verify the pinning without signing (which would + // need network access) by checking the options the signer ends up with. + sgnr := signer.NewSigner() + require.Nil(t, sgnr.Options.Token) + require.False(t, sgnr.Options.DisableSTS) + + token := &oauthflow.OIDCIDToken{RawString: "not-a-token", Subject: "sa@example.com"} + sgnr.Options.Token = token + sgnr.Options.DisableSTS = true + + creds := sgnr.Options.BuildSigstoreCredentials() + require.Same(t, token, creds.Token) + require.True(t, creds.DisableSTS) +} + +func TestReadStatement(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + content string + missing bool + shouldErr bool + }{ + {name: "valid statement", content: testStatement}, + {name: "missing file", missing: true, shouldErr: true}, + {name: "not json", content: "this is not json", shouldErr: true}, + {name: "not a statement", content: `{"foo": "bar"}`, shouldErr: true}, + { + name: "no subjects", + content: `{"_type": "https://in-toto.io/Statement/v1", "subject": [], + "predicateType": "https://slsa.dev/provenance/v1", "predicate": {}}`, + shouldErr: true, + }, + { + name: "no predicate type", + content: `{"_type": "https://in-toto.io/Statement/v1", "subject": [{"name": "a"}], "predicate": {}}`, + shouldErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "statement.json") + if !tc.missing { + require.NoError(t, os.WriteFile(path, []byte(tc.content), 0o600)) + } + + data, err := (&defaultSignerImpl{}).ReadStatement(path) + if tc.shouldErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + // The statement bytes are signed verbatim, they must not be altered. + require.Equal(t, tc.content, string(data)) + }) + } +} + +// fakeJWT returns an unsigned JWT with the given subject claim, enough for +// the provider to extract the identity from the token endpoint response. +func fakeJWT(t *testing.T, subject string) string { + t.Helper() + + claims, err := json.Marshal(map[string]any{"sub": subject, "email": subject, "email_verified": true}) + require.NoError(t, err) + + return base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + + "." + base64.RawURLEncoding.EncodeToString(claims) + ".c2ln" +} + +// writeServiceAccountKey writes a service account key file whose token +// endpoint is tokenURI. +func writeServiceAccountKey(t *testing.T, credType, tokenURI string) string { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + // Google issues service account keys in PKCS#8 format. + keyBytes, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + + keyData, err := json.Marshal(map[string]string{ + "type": credType, + "client_email": "signer@example.iam.gserviceaccount.com", + "private_key_id": "key-id", + "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})), + "token_uri": tokenURI, + }) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "key.json") + require.NoError(t, os.WriteFile(path, keyData, 0o600)) + + return path +} + +func TestServiceAccountToken(t *testing.T) { + t.Parallel() + + const subject = "signer@example.iam.gserviceaccount.com" + + newTokenServer := func(t *testing.T, status int, body string) *httptest.Server { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "urn:ietf:params:oauth:grant-type:jwt-bearer", r.Form.Get("grant_type")) + assert.NotEmpty(t, r.Form.Get("assertion")) + + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + return srv + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + srv := newTokenServer(t, http.StatusOK, `{"id_token": "`+fakeJWT(t, subject)+`"}`) + keyFile := writeServiceAccountKey(t, "service_account", srv.URL) + + token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), keyFile, "sigstore") + require.NoError(t, err) + require.NotNil(t, token) + require.Equal(t, subject, token.Subject) + require.NotEmpty(t, token.RawString) + }) + + t.Run("token endpoint rejects the key", func(t *testing.T) { + t.Parallel() + + srv := newTokenServer(t, http.StatusUnauthorized, `{"error": "invalid_grant"}`) + keyFile := writeServiceAccountKey(t, "service_account", srv.URL) + + token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), keyFile, "sigstore") + require.Error(t, err) + require.Nil(t, token) + }) + + t.Run("token endpoint returns no token", func(t *testing.T) { + t.Parallel() + + srv := newTokenServer(t, http.StatusOK, `{}`) + keyFile := writeServiceAccountKey(t, "service_account", srv.URL) + + token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), keyFile, "sigstore") + require.Error(t, err) + require.Nil(t, token) + }) + + t.Run("key file is not a service account", func(t *testing.T) { + t.Parallel() + + keyFile := writeServiceAccountKey(t, "authorized_user", "http://127.0.0.1:1") + + token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), keyFile, "sigstore") + require.Error(t, err) + require.Nil(t, token) + }) + + t.Run("key file does not exist", func(t *testing.T) { + t.Parallel() + + token, err := (&defaultSignerImpl{}).ServiceAccountToken( + context.Background(), filepath.Join(t.TempDir(), "missing.json"), "sigstore", + ) + require.Error(t, err) + require.Nil(t, token) + }) +} From 0f48d1c91fb686ab0eac3617d6036feb86c8553d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Wed, 26 Aug 2026 15:09:58 -0600 Subject: [PATCH 02/11] Support readin SA creds from env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for reading the attestation signing account key from KREL_SIGNING_SERVICE_ACCOUNT_KEY This allows us to define it in a secret in CI without writing it do disk. Signed-off-by: Adolfo García Veytia (Puerco) --- cmd/krel/cmd/sign_attestation.go | 17 ++- cmd/krel/cmd/sign_attestation_test.go | 18 +++ .../fake_signer_implementation.go | 27 +++-- pkg/attestation/sign.go | 53 ++++++-- pkg/attestation/sign_test.go | 114 +++++++++++++++--- 5 files changed, 187 insertions(+), 42 deletions(-) diff --git a/cmd/krel/cmd/sign_attestation.go b/cmd/krel/cmd/sign_attestation.go index 04eeed51fa7..6ff3d72c44d 100644 --- a/cmd/krel/cmd/sign_attestation.go +++ b/cmd/krel/cmd/sign_attestation.go @@ -32,6 +32,8 @@ const serviceAccountFileFlag = "service-account-file" type signAttestationOptions struct { outputPath string serviceAccountFile string + // serviceAccountJSON is the key data read from the environment + serviceAccountJSON string } var signAttestationOpts = &signAttestationOptions{} @@ -49,9 +51,11 @@ or to the file set with --` + outputPathFlag + `. By default the statement is signed with the ambient identity provider. To sign with an explicit identity, pass a Google Cloud service account key -file with --` + serviceAccountFileFlag + `. The signer is then locked to that -service account: the certificate is only requested with its identity and signing -fails if that is not possible, it never falls back to the ambient credentials.`, +file with --` + serviceAccountFileFlag + ` or set the contents of the key in +the ` + attestation.ServiceAccountEnvKey + ` environment variable (the flag +takes precedence). The signer is then locked to that service account: the +certificate is only requested with its identity and signing fails if that is +not possible, it never falls back to the ambient credentials.`, Example: ` # Sign an attestation using the ambient GCP credentials: krel sign attestation provenance.json > provenance.json.sigstore.json @@ -62,6 +66,10 @@ fails if that is not possible, it never falls back to the ambient credentials.`, SilenceUsage: true, SilenceErrors: true, RunE: func(_ *cobra.Command, args []string) error { + if key, isSet := os.LookupEnv(attestation.ServiceAccountEnvKey); isSet { + signAttestationOpts.serviceAccountJSON = key + } + return runSignAttestation(singOpts, signAttestationOpts, args[0]) }, } @@ -78,7 +86,7 @@ func init() { &signAttestationOpts.serviceAccountFile, serviceAccountFileFlag, "", - "path to a Google service account key (defaults to ambien credentials)", + "path to a Google service account key (defaults to $"+attestation.ServiceAccountEnvKey+" or the ambient credentials)", ) signCmd.AddCommand(signAttestationCmd) @@ -87,6 +95,7 @@ func init() { func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, statementPath string) (err error) { signerOpts := attestation.DefaultSignerOptions() signerOpts.ServiceAccountFile = opts.serviceAccountFile + signerOpts.ServiceAccountJSON = []byte(opts.serviceAccountJSON) signerOpts.Timeout = signOpts.timeout var out io.Writer = os.Stdout diff --git a/cmd/krel/cmd/sign_attestation_test.go b/cmd/krel/cmd/sign_attestation_test.go index b8e10582fcb..5afefe4ad17 100644 --- a/cmd/krel/cmd/sign_attestation_test.go +++ b/cmd/krel/cmd/sign_attestation_test.go @@ -17,6 +17,7 @@ limitations under the License. package cmd import ( + "os" "path/filepath" "testing" "time" @@ -38,6 +39,23 @@ func TestRunSignAttestation(t *testing.T) { require.Error(t, err) }) + t.Run("invalid service account key from the environment", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + statement := filepath.Join(dir, "statement.json") + require.NoError(t, os.WriteFile(statement, []byte(`{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [{"name": "a", "digest": {"sha256": "0e8a8b6f7c6cf3b0f2f2b6c2d1a4f4b3c2e1d0f9a8b7c6d5e4f3a2b1c0d9e8f7"}}], + "predicateType": "https://example.com/test", "predicate": {} + }`), 0o600)) + + err := runSignAttestation( + signOpts, &signAttestationOptions{serviceAccountJSON: `{"type": "authorized_user"}`}, statement, + ) + require.ErrorContains(t, err, "not a service account key") + }) + t.Run("output path cannot be created", func(t *testing.T) { t.Parallel() diff --git a/pkg/attestation/attestationfakes/fake_signer_implementation.go b/pkg/attestation/attestationfakes/fake_signer_implementation.go index b74a5ee5408..26dc38b4fe0 100644 --- a/pkg/attestation/attestationfakes/fake_signer_implementation.go +++ b/pkg/attestation/attestationfakes/fake_signer_implementation.go @@ -51,12 +51,13 @@ type FakeSignerImplementation struct { result1 []byte result2 error } - ServiceAccountTokenStub func(context.Context, string, string) (*oauthflow.OIDCIDToken, error) + ServiceAccountTokenStub func(context.Context, string, []byte, string) (*oauthflow.OIDCIDToken, error) serviceAccountTokenMutex sync.RWMutex serviceAccountTokenArgsForCall []struct { arg1 context.Context arg2 string - arg3 string + arg3 []byte + arg4 string } serviceAccountTokenReturns struct { result1 *oauthflow.OIDCIDToken @@ -215,20 +216,26 @@ func (fake *FakeSignerImplementation) ReadStatementReturnsOnCall(i int, result1 }{result1, result2} } -func (fake *FakeSignerImplementation) ServiceAccountToken(arg1 context.Context, arg2 string, arg3 string) (*oauthflow.OIDCIDToken, error) { +func (fake *FakeSignerImplementation) ServiceAccountToken(arg1 context.Context, arg2 string, arg3 []byte, arg4 string) (*oauthflow.OIDCIDToken, error) { + var arg3Copy []byte + if arg3 != nil { + arg3Copy = make([]byte, len(arg3)) + copy(arg3Copy, arg3) + } fake.serviceAccountTokenMutex.Lock() ret, specificReturn := fake.serviceAccountTokenReturnsOnCall[len(fake.serviceAccountTokenArgsForCall)] fake.serviceAccountTokenArgsForCall = append(fake.serviceAccountTokenArgsForCall, struct { arg1 context.Context arg2 string - arg3 string - }{arg1, arg2, arg3}) + arg3 []byte + arg4 string + }{arg1, arg2, arg3Copy, arg4}) stub := fake.ServiceAccountTokenStub fakeReturns := fake.serviceAccountTokenReturns - fake.recordInvocation("ServiceAccountToken", []interface{}{arg1, arg2, arg3}) + fake.recordInvocation("ServiceAccountToken", []interface{}{arg1, arg2, arg3Copy, arg4}) fake.serviceAccountTokenMutex.Unlock() if stub != nil { - return stub(arg1, arg2, arg3) + return stub(arg1, arg2, arg3, arg4) } if specificReturn { return ret.result1, ret.result2 @@ -242,17 +249,17 @@ func (fake *FakeSignerImplementation) ServiceAccountTokenCallCount() int { return len(fake.serviceAccountTokenArgsForCall) } -func (fake *FakeSignerImplementation) ServiceAccountTokenCalls(stub func(context.Context, string, string) (*oauthflow.OIDCIDToken, error)) { +func (fake *FakeSignerImplementation) ServiceAccountTokenCalls(stub func(context.Context, string, []byte, string) (*oauthflow.OIDCIDToken, error)) { fake.serviceAccountTokenMutex.Lock() defer fake.serviceAccountTokenMutex.Unlock() fake.ServiceAccountTokenStub = stub } -func (fake *FakeSignerImplementation) ServiceAccountTokenArgsForCall(i int) (context.Context, string, string) { +func (fake *FakeSignerImplementation) ServiceAccountTokenArgsForCall(i int) (context.Context, string, []byte, string) { fake.serviceAccountTokenMutex.RLock() defer fake.serviceAccountTokenMutex.RUnlock() argsForCall := fake.serviceAccountTokenArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4 } func (fake *FakeSignerImplementation) ServiceAccountTokenReturns(result1 *oauthflow.OIDCIDToken, result2 error) { diff --git a/pkg/attestation/sign.go b/pkg/attestation/sign.go index 5e7e5bb8bbc..7b31b713427 100644 --- a/pkg/attestation/sign.go +++ b/pkg/attestation/sign.go @@ -37,6 +37,10 @@ import ( //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate //go:generate /usr/bin/env bash -c "cat ../../hack/boilerplate/boilerplate.generatego.txt attestationfakes/fake_signer_implementation.go > attestationfakes/_fake_signer_implementation.go && mv attestationfakes/_fake_signer_implementation.go attestationfakes/fake_signer_implementation.go" +// ServiceAccountEnvKey is the name of the environment variable that can +// carry the JSON contents of the Google service account key to sign with. +const ServiceAccountEnvKey = "KREL_SIGNING_SERVICE_ACCOUNT_KEY" + // ErrNoIdentity is returned when the service account key did not yield an // identity token. var ErrNoIdentity = errors.New("service account key did not produce an identity token") @@ -46,11 +50,17 @@ type SignerOptions struct { // ServiceAccountFile is the path to a Google service account key (JSON). // When set, the statement is signed exclusively with the identity of that // service account: the signer will not fall back to any other credential - // if obtaining a certificate with it fails. When empty, the signer tries - // the ambient credentials of the environment (the GCP metadata server or + // if obtaining a certificate with it fails. When neither this nor + // ServiceAccountJSON is set, the signer tries the ambient credentials of + // the environment (the GCP metadata server or // GOOGLE_APPLICATION_CREDENTIALS, GitHub Actions, GitLab CI). ServiceAccountFile string + // ServiceAccountJSON is the contents of a Google service account key. It + // locks the signer to the service account exactly like ServiceAccountFile + // does, which takes precedence when both are set. + ServiceAccountJSON []byte + // Timeout bounds the identity token exchange with Google when signing // with a service account key. Timeout time.Duration @@ -63,6 +73,12 @@ func DefaultSignerOptions() *SignerOptions { } } +// hasServiceAccount returns true when a service account key was configured, +// either as a file or as its JSON contents. +func (o *SignerOptions) hasServiceAccount() bool { + return o.ServiceAccountFile != "" || len(o.ServiceAccountJSON) > 0 +} + // Signer signs in-toto statements using a Google Cloud identity and wraps // the results in sigstore bundles (DSSE envelope + Fulcio certificate + // Rekor and timestamp proofs). @@ -88,7 +104,7 @@ func NewSigner(opts *SignerOptions) *Signer { type signerImplementation interface { ReadStatement(path string) ([]byte, error) NewSigner() *signer.Signer - ServiceAccountToken(ctx context.Context, keyFile, audience string) (*oauthflow.OIDCIDToken, error) + ServiceAccountToken(ctx context.Context, keyFile string, keyJSON []byte, audience string) (*oauthflow.OIDCIDToken, error) SignStatement(sgnr *signer.Signer, token *oauthflow.OIDCIDToken, data []byte) (*sbundle.Bundle, error) WriteBundle(sgnr *signer.Signer, bndl *sbundle.Bundle, w io.Writer) error } @@ -108,14 +124,14 @@ func (s *Signer) SignFile(path string, w io.Writer) error { // the signer runs its own ambient credential discovery. var token *oauthflow.OIDCIDToken - if s.options.ServiceAccountFile != "" { + if s.options.hasServiceAccount() { ctx, cancel := context.WithTimeout(context.Background(), s.options.Timeout) defer cancel() // The token audience must match the client ID the sigstore instance // expects, otherwise Fulcio will reject it. token, err = s.impl.ServiceAccountToken( - ctx, s.options.ServiceAccountFile, sgnr.Options.OIDCConfig.ClientID, + ctx, s.options.ServiceAccountFile, s.options.ServiceAccountJSON, sgnr.Options.OIDCConfig.ClientID, ) if err != nil { return fmt.Errorf("obtaining identity from service account key: %w", err) @@ -171,17 +187,28 @@ func (*defaultSignerImpl) NewSigner() *signer.Signer { } // ServiceAccountToken obtains an OIDC token for the given audience from -// Google Cloud using the service account key in keyFile. The identity of the -// host is never used, even if exchanging the key fails. +// Google Cloud using a service account key, either the file in keyFile or +// the key contents in keyJSON (the file takes precedence). The identity of +// the host is never used, even if exchanging the key fails. func (*defaultSignerImpl) ServiceAccountToken( - ctx context.Context, keyFile, audience string, + ctx context.Context, keyFile string, keyJSON []byte, audience string, ) (*oauthflow.OIDCIDToken, error) { - logrus.Infof("Obtaining identity token from service account key %s", keyFile) + var key gcp.Option - provider, err := gcp.New( - gcp.WithServiceAccountFile(keyFile), - gcp.WithAmbientCredentials(false), - ) + switch { + case keyFile != "": + logrus.Infof("Obtaining identity token from service account key %s", keyFile) + + key = gcp.WithServiceAccountFile(keyFile) + case len(keyJSON) > 0: + logrus.Info("Obtaining identity token from service account key data") + + key = gcp.WithServiceAccountJSON(keyJSON) + default: + return nil, errors.New("no service account key configured") + } + + provider, err := gcp.New(key, gcp.WithAmbientCredentials(false)) if err != nil { return nil, fmt.Errorf("creating GCP identity provider: %w", err) } diff --git a/pkg/attestation/sign_test.go b/pkg/attestation/sign_test.go index 3a56ab731bd..5b5b61d91bb 100644 --- a/pkg/attestation/sign_test.go +++ b/pkg/attestation/sign_test.go @@ -87,6 +87,20 @@ func TestSignFile(t *testing.T) { wantOutput: "bundle", wantSAToks: 1, }, + { + name: "success with service account JSON", + opts: &SignerOptions{ServiceAccountJSON: []byte("service-account-key-data")}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.ServiceAccountTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) + mock.WriteBundleCalls(func(_ *signer.Signer, _ *sbundle.Bundle, w io.Writer) error { + _, err := w.Write([]byte("bundle")) + + return err + }) + }, + wantOutput: "bundle", + wantSAToks: 1, + }, { name: "ReadStatement fails", prepare: func(mock *attestationfakes.FakeSignerImplementation) { @@ -142,8 +156,10 @@ func TestSignFile(t *testing.T) { require.Equal(t, tc.wantSAToks, mock.ServiceAccountTokenCallCount()) if tc.wantSAToks > 0 { - _, keyFile, audience := mock.ServiceAccountTokenArgsForCall(0) + wantKeyData := tc.opts.ServiceAccountJSON + _, keyFile, keyData, audience := mock.ServiceAccountTokenArgsForCall(0) require.Equal(t, tc.opts.ServiceAccountFile, keyFile) + require.Equal(t, wantKeyData, keyData) require.NotEmpty(t, audience, "audience must be the sigstore OIDC client ID") } }) @@ -233,6 +249,17 @@ func fakeJWT(t *testing.T, subject string) string { func writeServiceAccountKey(t *testing.T, credType, tokenURI string) string { t.Helper() + path := filepath.Join(t.TempDir(), "key.json") + require.NoError(t, os.WriteFile(path, serviceAccountKey(t, credType, tokenURI), 0o600)) + + return path +} + +// serviceAccountKey returns the JSON of a service account key whose token +// endpoint is tokenURI. +func serviceAccountKey(t *testing.T, credType, tokenURI string) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) @@ -249,10 +276,7 @@ func writeServiceAccountKey(t *testing.T, credType, tokenURI string) string { }) require.NoError(t, err) - path := filepath.Join(t.TempDir(), "key.json") - require.NoError(t, os.WriteFile(path, keyData, 0o600)) - - return path + return keyData } func TestServiceAccountToken(t *testing.T) { @@ -277,26 +301,80 @@ func TestServiceAccountToken(t *testing.T) { return srv } - t.Run("success", func(t *testing.T) { + t.Run("success with key file", func(t *testing.T) { t.Parallel() srv := newTokenServer(t, http.StatusOK, `{"id_token": "`+fakeJWT(t, subject)+`"}`) - keyFile := writeServiceAccountKey(t, "service_account", srv.URL) + opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL)} - token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), keyFile, "sigstore") + token, err := (&defaultSignerImpl{}).ServiceAccountToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + ) require.NoError(t, err) require.NotNil(t, token) require.Equal(t, subject, token.Subject) require.NotEmpty(t, token.RawString) }) + t.Run("success with key JSON", func(t *testing.T) { + t.Parallel() + + srv := newTokenServer(t, http.StatusOK, `{"id_token": "`+fakeJWT(t, subject)+`"}`) + opts := &SignerOptions{ServiceAccountJSON: serviceAccountKey(t, "service_account", srv.URL)} + + token, err := (&defaultSignerImpl{}).ServiceAccountToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + ) + require.NoError(t, err) + require.NotNil(t, token) + require.Equal(t, subject, token.Subject) + }) + + t.Run("key file takes precedence over key JSON", func(t *testing.T) { + t.Parallel() + + srv := newTokenServer(t, http.StatusOK, `{"id_token": "`+fakeJWT(t, subject)+`"}`) + opts := &SignerOptions{ + ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL), + ServiceAccountJSON: []byte("not even json"), + } + + token, err := (&defaultSignerImpl{}).ServiceAccountToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + ) + require.NoError(t, err) + require.NotNil(t, token) + }) + + t.Run("no key configured", func(t *testing.T) { + t.Parallel() + + token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), "", nil, "sigstore") + require.Error(t, err) + require.Nil(t, token) + }) + + t.Run("key JSON is not a service account", func(t *testing.T) { + t.Parallel() + + opts := &SignerOptions{ServiceAccountJSON: serviceAccountKey(t, "authorized_user", "http://127.0.0.1:1")} + + token, err := (&defaultSignerImpl{}).ServiceAccountToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + ) + require.Error(t, err) + require.Nil(t, token) + }) + t.Run("token endpoint rejects the key", func(t *testing.T) { t.Parallel() srv := newTokenServer(t, http.StatusUnauthorized, `{"error": "invalid_grant"}`) - keyFile := writeServiceAccountKey(t, "service_account", srv.URL) + opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL)} - token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), keyFile, "sigstore") + token, err := (&defaultSignerImpl{}).ServiceAccountToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + ) require.Error(t, err) require.Nil(t, token) }) @@ -305,9 +383,11 @@ func TestServiceAccountToken(t *testing.T) { t.Parallel() srv := newTokenServer(t, http.StatusOK, `{}`) - keyFile := writeServiceAccountKey(t, "service_account", srv.URL) + opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL)} - token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), keyFile, "sigstore") + token, err := (&defaultSignerImpl{}).ServiceAccountToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + ) require.Error(t, err) require.Nil(t, token) }) @@ -315,9 +395,11 @@ func TestServiceAccountToken(t *testing.T) { t.Run("key file is not a service account", func(t *testing.T) { t.Parallel() - keyFile := writeServiceAccountKey(t, "authorized_user", "http://127.0.0.1:1") + opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "authorized_user", "http://127.0.0.1:1")} - token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), keyFile, "sigstore") + token, err := (&defaultSignerImpl{}).ServiceAccountToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + ) require.Error(t, err) require.Nil(t, token) }) @@ -325,8 +407,10 @@ func TestServiceAccountToken(t *testing.T) { t.Run("key file does not exist", func(t *testing.T) { t.Parallel() + opts := &SignerOptions{ServiceAccountFile: filepath.Join(t.TempDir(), "missing.json")} + token, err := (&defaultSignerImpl{}).ServiceAccountToken( - context.Background(), filepath.Join(t.TempDir(), "missing.json"), "sigstore", + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", ) require.Error(t, err) require.Nil(t, token) From b2eaafdac04b467c061c369712b48ec0f218a9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Thu, 27 Aug 2026 15:20:58 -0600 Subject: [PATCH 03/11] Sign a buffer and only write if needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit makes the signer sign to a buffer and only open and write a file if required (and signing was successful). Signed-off-by: Adolfo García Veytia (Puerco) --- cmd/krel/cmd/sign_attestation.go | 34 ++++++++++++--------------- cmd/krel/cmd/sign_attestation_test.go | 9 ++++--- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/cmd/krel/cmd/sign_attestation.go b/cmd/krel/cmd/sign_attestation.go index 6ff3d72c44d..78e5fee2cce 100644 --- a/cmd/krel/cmd/sign_attestation.go +++ b/cmd/krel/cmd/sign_attestation.go @@ -17,8 +17,8 @@ limitations under the License. package cmd import ( + "bytes" "fmt" - "io" "os" "github.com/sirupsen/logrus" @@ -92,36 +92,32 @@ func init() { signCmd.AddCommand(signAttestationCmd) } -func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, statementPath string) (err error) { +func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, statementPath string) error { signerOpts := attestation.DefaultSignerOptions() signerOpts.ServiceAccountFile = opts.serviceAccountFile signerOpts.ServiceAccountJSON = []byte(opts.serviceAccountJSON) signerOpts.Timeout = signOpts.timeout - var out io.Writer = os.Stdout + // We will now sign the bundle in memory to avoid writing until + // we know signing succeeded + var bundle bytes.Buffer + if err := attestation.NewSigner(signerOpts).SignFile(statementPath, &bundle); err != nil { + return fmt.Errorf("signing attestation: %w", err) + } - if opts.outputPath != "" { - f, err := os.Create(opts.outputPath) - if err != nil { - return fmt.Errorf("creating output file: %w", err) + if opts.outputPath == "" { + if _, err := bundle.WriteTo(os.Stdout); err != nil { + return fmt.Errorf("writing bundle to stdout: %w", err) } - defer func() { - if cerr := f.Close(); cerr != nil && err == nil { - err = fmt.Errorf("closing output file: %w", cerr) - } - }() - - out = f + return nil } - if err := attestation.NewSigner(signerOpts).SignFile(statementPath, out); err != nil { - return fmt.Errorf("signing attestation: %w", err) + if err := os.WriteFile(opts.outputPath, bundle.Bytes(), 0o644); err != nil { //nolint:gosec // bundles are public + return fmt.Errorf("writing bundle to %s: %w", opts.outputPath, err) } - if opts.outputPath != "" { - logrus.Infof("Signed bundle written to %s", opts.outputPath) - } + logrus.Infof("Signed bundle written to %s", opts.outputPath) return nil } diff --git a/cmd/krel/cmd/sign_attestation_test.go b/cmd/krel/cmd/sign_attestation_test.go index 5afefe4ad17..65daa72bd62 100644 --- a/cmd/krel/cmd/sign_attestation_test.go +++ b/cmd/krel/cmd/sign_attestation_test.go @@ -56,14 +56,17 @@ func TestRunSignAttestation(t *testing.T) { require.ErrorContains(t, err, "not a service account key") }) - t.Run("output path cannot be created", func(t *testing.T) { + t.Run("output file is not touched when signing fails", func(t *testing.T) { t.Parallel() + outputPath := filepath.Join(t.TempDir(), "out.json") + err := runSignAttestation( signOpts, - &signAttestationOptions{outputPath: filepath.Join(t.TempDir(), "no", "such", "dir", "out.json")}, + &signAttestationOptions{outputPath: outputPath}, filepath.Join(t.TempDir(), "missing.json"), ) - require.ErrorContains(t, err, "creating output file") + require.ErrorContains(t, err, "signing attestation") + require.NoFileExists(t, outputPath) }) } From 607ef39cd2519cf35621e70cf6161b969528afe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Thu, 27 Aug 2026 15:28:02 -0600 Subject: [PATCH 04/11] Move the signer close to SignFile() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adolfo García Veytia (Puerco) --- pkg/attestation/sign.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/attestation/sign.go b/pkg/attestation/sign.go index 7b31b713427..d97577f1a3f 100644 --- a/pkg/attestation/sign.go +++ b/pkg/attestation/sign.go @@ -118,6 +118,7 @@ func (s *Signer) SignFile(path string, w io.Writer) error { } sgnr := s.impl.NewSigner() + defer sgnr.Close() // When a service account key is set, the identity token is minted here // from the key and pinned into the signer. Otherwise token is left nil and @@ -229,7 +230,8 @@ func (*defaultSignerImpl) ServiceAccountToken( // sigstore bundle. If token is not nil, the signer is locked to it: the // Fulcio certificate can only be obtained with that identity and the signer // will not try its own credential discovery. A nil token leaves the signer's -// ambient credential providers in charge of finding an identity. +// ambient credential providers in charge of finding an identity. The caller +// owns the signer and is responsible for closing it. func (*defaultSignerImpl) SignStatement( sgnr *signer.Signer, token *oauthflow.OIDCIDToken, data []byte, ) (*sbundle.Bundle, error) { @@ -238,8 +240,6 @@ func (*defaultSignerImpl) SignStatement( sgnr.Options.DisableSTS = true } - defer sgnr.Close() - return sgnr.SignStatementBundle(data) } From afcb4fb13797d8e7cb4e058c27acc434383b177a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Thu, 27 Aug 2026 15:49:53 -0600 Subject: [PATCH 05/11] krel sign attestation: sign statements stored in GCS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept gs:// paths for the statement to sign. The object is downloaded with the release-sdk GCS client, validated as an in-toto statement and signed as a local file would be. Signed-off-by: Adolfo García Veytia (Puerco) --- cmd/krel/cmd/sign_attestation.go | 8 +++- pkg/attestation/sign.go | 77 +++++++++++++++++++++++++++----- pkg/attestation/sign_test.go | 58 ++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 14 deletions(-) diff --git a/cmd/krel/cmd/sign_attestation.go b/cmd/krel/cmd/sign_attestation.go index 78e5fee2cce..0a7f1af46c7 100644 --- a/cmd/krel/cmd/sign_attestation.go +++ b/cmd/krel/cmd/sign_attestation.go @@ -46,7 +46,8 @@ var signAttestationCmd = &cobra.Command{ Signs an in-toto statement using sigstore and writes the resulting bundle (DSSE envelope, Fulcio certificate and transparency log proofs) to stdout -or to the file set with --` + outputPathFlag + `. +or to the file set with --` + outputPathFlag + `. The statement can be a local +file or an object in Google Cloud Storage (gs://bucket/path/statement.json). By default the statement is signed with the ambient identity provider. @@ -61,7 +62,10 @@ not possible, it never falls back to the ambient credentials.`, krel sign attestation provenance.json > provenance.json.sigstore.json # Sign using a service account key: - krel sign attestation --service-account-file key.json --output-path provenance.sigstore.json provenance.json`, + krel sign attestation --service-account-file key.json --output-path provenance.sigstore.json provenance.json + + # Sign a staged provenance stored in a bucket: + krel sign attestation gs://k8s-release-dev/stage/v1.36.0-alpha.1.10+abcdef/provenance.json`, Args: cobra.ExactArgs(1), SilenceUsage: true, SilenceErrors: true, diff --git a/pkg/attestation/sign.go b/pkg/attestation/sign.go index d97577f1a3f..c465520587f 100644 --- a/pkg/attestation/sign.go +++ b/pkg/attestation/sign.go @@ -23,6 +23,9 @@ import ( "fmt" "io" "os" + "path" + "path/filepath" + "strings" "time" "github.com/carabiner-dev/signer" @@ -32,6 +35,8 @@ import ( "github.com/sigstore/sigstore/pkg/oauthflow" "github.com/sirupsen/logrus" "google.golang.org/protobuf/encoding/protojson" + + "sigs.k8s.io/release-sdk/object" ) //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate @@ -102,17 +107,18 @@ func NewSigner(opts *SignerOptions) *Signer { //counterfeiter:generate . signerImplementation type signerImplementation interface { - ReadStatement(path string) ([]byte, error) + ReadStatement(statementPath string) ([]byte, error) NewSigner() *signer.Signer ServiceAccountToken(ctx context.Context, keyFile string, keyJSON []byte, audience string) (*oauthflow.OIDCIDToken, error) SignStatement(sgnr *signer.Signer, token *oauthflow.OIDCIDToken, data []byte) (*sbundle.Bundle, error) WriteBundle(sgnr *signer.Signer, bndl *sbundle.Bundle, w io.Writer) error } -// SignFile reads the in-toto statement stored in path, signs it and writes -// the resulting sigstore bundle to w. -func (s *Signer) SignFile(path string, w io.Writer) error { - data, err := s.impl.ReadStatement(path) +// SignFile reads the in-toto statement stored in path, which can be a local +// file or an object in Google Cloud Storage (gs://bucket/path), signs it and +// writes the resulting sigstore bundle to w. +func (s *Signer) SignFile(statementPath string, w io.Writer) error { + data, err := s.impl.ReadStatement(statementPath) if err != nil { return fmt.Errorf("reading statement: %w", err) } @@ -138,9 +144,9 @@ func (s *Signer) SignFile(path string, w io.Writer) error { return fmt.Errorf("obtaining identity from service account key: %w", err) } - logrus.Infof("Signing statement %s as %s", path, token.Subject) + logrus.Infof("Signing statement %s as %s", statementPath, token.Subject) } else { - logrus.Infof("Signing statement %s with the ambient credentials", path) + logrus.Infof("Signing statement %s with the ambient credentials", statementPath) } bndl, err := s.impl.SignStatement(sgnr, token, data) @@ -155,12 +161,22 @@ func (s *Signer) SignFile(path string, w io.Writer) error { return nil } -type defaultSignerImpl struct{} +// objectStore abstracts the Google Cloud Storage operations the signer needs. +type objectStore interface { + CopyToLocal(gcsPath, dst string) error +} -// ReadStatement reads the file in path and ensures it contains a valid -// in-toto statement before it gets signed. -func (*defaultSignerImpl) ReadStatement(path string) ([]byte, error) { - data, err := os.ReadFile(path) +type defaultSignerImpl struct { + // gcs is the object store used to fetch statements from Google Cloud + // Storage. Defaults to a GCS client when nil. + gcs objectStore +} + +// ReadStatement reads the statement in path, a local file or an object in +// Google Cloud Storage, and ensures it contains a valid in-toto statement +// before it gets signed. +func (di *defaultSignerImpl) ReadStatement(statementPath string) ([]byte, error) { + data, err := di.readFile(statementPath) if err != nil { return nil, fmt.Errorf("reading file: %w", err) } @@ -247,3 +263,40 @@ func (*defaultSignerImpl) SignStatement( func (*defaultSignerImpl) WriteBundle(sgnr *signer.Signer, bndl *sbundle.Bundle, w io.Writer) error { return sgnr.WriteBundle(bndl, w) } + +// readFile returns the contents of a local file or, when filePath is a +// gs:// URL, of the object it points to in Google Cloud Storage. +func (di *defaultSignerImpl) readFile(filePath string) ([]byte, error) { + if !strings.HasPrefix(filePath, object.GcsPrefix) { + return os.ReadFile(filePath) + } + + tmpDir, err := os.MkdirTemp("", "krel-sign-attestation-") + if err != nil { + return nil, fmt.Errorf("creating temporary directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + if di.gcs == nil { + di.gcs = newGCSClient() + } + + localPath := filepath.Join(tmpDir, path.Base(filePath)) + if err := di.gcs.CopyToLocal(filePath, localPath); err != nil { + return nil, fmt.Errorf("downloading %s: %w", filePath, err) + } + + return os.ReadFile(localPath) +} + +// newGCSClient returns a GCS client configured to copy single objects. +func newGCSClient() *object.GCS { + gcs := object.NewGCS() + gcs.SetOptions( + gcs.WithConcurrent(false), + gcs.WithRecursive(false), + gcs.WithNoClobber(false), + ) + + return gcs +} diff --git a/pkg/attestation/sign_test.go b/pkg/attestation/sign_test.go index 5b5b61d91bb..c6c995b18c8 100644 --- a/pkg/attestation/sign_test.go +++ b/pkg/attestation/sign_test.go @@ -185,6 +185,64 @@ func TestSignStatementLocksToken(t *testing.T) { require.True(t, creds.DisableSTS) } +// fakeObjectStore is an objectStore that serves a fixed object. +type fakeObjectStore struct { + data []byte + err error + gcsPath string + dst string +} + +func (f *fakeObjectStore) CopyToLocal(gcsPath, dst string) error { + f.gcsPath, f.dst = gcsPath, dst + if f.err != nil { + return f.err + } + + return os.WriteFile(dst, f.data, 0o600) +} + +func TestReadStatementFromGCS(t *testing.T) { + t.Parallel() + + const gcsPath = "gs://bucket/stage/v1.36.0-alpha.1/provenance.json" + + t.Run("success", func(t *testing.T) { + t.Parallel() + + store := &fakeObjectStore{data: []byte(testStatement)} + + want := []byte(testStatement) + + data, err := (&defaultSignerImpl{gcs: store}).ReadStatement(gcsPath) + require.NoError(t, err) + require.Equal(t, want, data) + require.Equal(t, gcsPath, store.gcsPath) + require.Equal(t, "provenance.json", filepath.Base(store.dst)) + require.NoFileExists(t, store.dst, "temporary download must be cleaned up") + }) + + t.Run("download fails", func(t *testing.T) { + t.Parallel() + + store := &fakeObjectStore{err: errTest} + + data, err := (&defaultSignerImpl{gcs: store}).ReadStatement(gcsPath) + require.ErrorIs(t, err, errTest) + require.Nil(t, data) + }) + + t.Run("object is not a statement", func(t *testing.T) { + t.Parallel() + + store := &fakeObjectStore{data: []byte("not a statement")} + + data, err := (&defaultSignerImpl{gcs: store}).ReadStatement(gcsPath) + require.Error(t, err) + require.Nil(t, data) + }) +} + func TestReadStatement(t *testing.T) { t.Parallel() From ebd6d05534b5822abcc56a1eb5370af8ea51a577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Thu, 27 Aug 2026 15:55:25 -0600 Subject: [PATCH 06/11] Also support gs:// in output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adolfo García Veytia (Puerco) --- cmd/krel/cmd/sign_attestation.go | 16 ++++++++----- pkg/attestation/sign.go | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/cmd/krel/cmd/sign_attestation.go b/cmd/krel/cmd/sign_attestation.go index 0a7f1af46c7..67d149b6455 100644 --- a/cmd/krel/cmd/sign_attestation.go +++ b/cmd/krel/cmd/sign_attestation.go @@ -46,8 +46,9 @@ var signAttestationCmd = &cobra.Command{ Signs an in-toto statement using sigstore and writes the resulting bundle (DSSE envelope, Fulcio certificate and transparency log proofs) to stdout -or to the file set with --` + outputPathFlag + `. The statement can be a local -file or an object in Google Cloud Storage (gs://bucket/path/statement.json). +or to the file set with --` + outputPathFlag + `. Both the statement and the +output path can be local files or objects in Google Cloud Storage +(gs://bucket/path/statement.json). By default the statement is signed with the ambient identity provider. @@ -83,7 +84,7 @@ func init() { &signAttestationOpts.outputPath, outputPathFlag, "", - "write the signed bundle to a file instead of stdout", + "write the signed bundle to a file or gs:// object instead of stdout", ) signAttestationCmd.PersistentFlags().StringVar( @@ -105,7 +106,10 @@ func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, sta // We will now sign the bundle in memory to avoid writing until // we know signing succeeded var bundle bytes.Buffer - if err := attestation.NewSigner(signerOpts).SignFile(statementPath, &bundle); err != nil { + + signer := attestation.NewSigner(signerOpts) + + if err := signer.SignFile(statementPath, &bundle); err != nil { return fmt.Errorf("signing attestation: %w", err) } @@ -117,8 +121,8 @@ func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, sta return nil } - if err := os.WriteFile(opts.outputPath, bundle.Bytes(), 0o644); err != nil { //nolint:gosec // bundles are public - return fmt.Errorf("writing bundle to %s: %w", opts.outputPath, err) + if err := signer.WriteFile(opts.outputPath, bundle.Bytes()); err != nil { + return fmt.Errorf("writing bundle: %w", err) } logrus.Infof("Signed bundle written to %s", opts.outputPath) diff --git a/pkg/attestation/sign.go b/pkg/attestation/sign.go index c465520587f..67fa6edbd15 100644 --- a/pkg/attestation/sign.go +++ b/pkg/attestation/sign.go @@ -112,6 +112,7 @@ type signerImplementation interface { ServiceAccountToken(ctx context.Context, keyFile string, keyJSON []byte, audience string) (*oauthflow.OIDCIDToken, error) SignStatement(sgnr *signer.Signer, token *oauthflow.OIDCIDToken, data []byte) (*sbundle.Bundle, error) WriteBundle(sgnr *signer.Signer, bndl *sbundle.Bundle, w io.Writer) error + WriteFile(filePath string, data []byte) error } // SignFile reads the in-toto statement stored in path, which can be a local @@ -161,9 +162,20 @@ func (s *Signer) SignFile(statementPath string, w io.Writer) error { return nil } +// WriteFile writes data to filePath, a local file or an object in Google +// Cloud Storage (gs://bucket/path) which is overwritten if it exists. +func (s *Signer) WriteFile(filePath string, data []byte) error { + if err := s.impl.WriteFile(filePath, data); err != nil { + return fmt.Errorf("writing %s: %w", filePath, err) + } + + return nil +} + // objectStore abstracts the Google Cloud Storage operations the signer needs. type objectStore interface { CopyToLocal(gcsPath, dst string) error + CopyToRemote(src, gcsPath string) error } type defaultSignerImpl struct { @@ -264,6 +276,35 @@ func (*defaultSignerImpl) WriteBundle(sgnr *signer.Signer, bndl *sbundle.Bundle, return sgnr.WriteBundle(bndl, w) } +// WriteFile writes data to a local file or, when filePath is a gs:// URL, +// uploads it to Google Cloud Storage, replacing the object if it exists. +func (di *defaultSignerImpl) WriteFile(filePath string, data []byte) error { + if !strings.HasPrefix(filePath, object.GcsPrefix) { + return os.WriteFile(filePath, data, 0o644) //nolint:gosec // bundles are public + } + + tmpDir, err := os.MkdirTemp("", "krel-sign-attestation-") + if err != nil { + return fmt.Errorf("creating temporary directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + localPath := filepath.Join(tmpDir, path.Base(filePath)) + if err := os.WriteFile(localPath, data, 0o600); err != nil { + return fmt.Errorf("writing temporary file: %w", err) + } + + if di.gcs == nil { + di.gcs = newGCSClient() + } + + if err := di.gcs.CopyToRemote(localPath, filePath); err != nil { + return fmt.Errorf("uploading to %s: %w", filePath, err) + } + + return nil +} + // readFile returns the contents of a local file or, when filePath is a // gs:// URL, of the object it points to in Google Cloud Storage. func (di *defaultSignerImpl) readFile(filePath string) ([]byte, error) { From b1ed3cf6ca4dc232ab48057f0a2d4bf7a7897452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Thu, 27 Aug 2026 15:56:07 -0600 Subject: [PATCH 07/11] Add gs: write test and rebuild fakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adolfo García Veytia (Puerco) --- .../fake_signer_implementation.go | 79 +++++++++++++++++++ pkg/attestation/sign_test.go | 67 ++++++++++++++-- 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/pkg/attestation/attestationfakes/fake_signer_implementation.go b/pkg/attestation/attestationfakes/fake_signer_implementation.go index 26dc38b4fe0..c1c4926ff83 100644 --- a/pkg/attestation/attestationfakes/fake_signer_implementation.go +++ b/pkg/attestation/attestationfakes/fake_signer_implementation.go @@ -95,6 +95,18 @@ type FakeSignerImplementation struct { writeBundleReturnsOnCall map[int]struct { result1 error } + WriteFileStub func(string, []byte) error + writeFileMutex sync.RWMutex + writeFileArgsForCall []struct { + arg1 string + arg2 []byte + } + writeFileReturns struct { + result1 error + } + writeFileReturnsOnCall map[int]struct { + result1 error + } invocations map[string][][]interface{} invocationsMutex sync.RWMutex } @@ -422,6 +434,73 @@ func (fake *FakeSignerImplementation) WriteBundleReturnsOnCall(i int, result1 er }{result1} } +func (fake *FakeSignerImplementation) WriteFile(arg1 string, arg2 []byte) error { + var arg2Copy []byte + if arg2 != nil { + arg2Copy = make([]byte, len(arg2)) + copy(arg2Copy, arg2) + } + fake.writeFileMutex.Lock() + ret, specificReturn := fake.writeFileReturnsOnCall[len(fake.writeFileArgsForCall)] + fake.writeFileArgsForCall = append(fake.writeFileArgsForCall, struct { + arg1 string + arg2 []byte + }{arg1, arg2Copy}) + stub := fake.WriteFileStub + fakeReturns := fake.writeFileReturns + fake.recordInvocation("WriteFile", []interface{}{arg1, arg2Copy}) + fake.writeFileMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeSignerImplementation) WriteFileCallCount() int { + fake.writeFileMutex.RLock() + defer fake.writeFileMutex.RUnlock() + return len(fake.writeFileArgsForCall) +} + +func (fake *FakeSignerImplementation) WriteFileCalls(stub func(string, []byte) error) { + fake.writeFileMutex.Lock() + defer fake.writeFileMutex.Unlock() + fake.WriteFileStub = stub +} + +func (fake *FakeSignerImplementation) WriteFileArgsForCall(i int) (string, []byte) { + fake.writeFileMutex.RLock() + defer fake.writeFileMutex.RUnlock() + argsForCall := fake.writeFileArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeSignerImplementation) WriteFileReturns(result1 error) { + fake.writeFileMutex.Lock() + defer fake.writeFileMutex.Unlock() + fake.WriteFileStub = nil + fake.writeFileReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeSignerImplementation) WriteFileReturnsOnCall(i int, result1 error) { + fake.writeFileMutex.Lock() + defer fake.writeFileMutex.Unlock() + fake.WriteFileStub = nil + if fake.writeFileReturnsOnCall == nil { + fake.writeFileReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.writeFileReturnsOnCall[i] = struct { + result1 error + }{result1} +} + func (fake *FakeSignerImplementation) Invocations() map[string][][]interface{} { fake.invocationsMutex.RLock() defer fake.invocationsMutex.RUnlock() diff --git a/pkg/attestation/sign_test.go b/pkg/attestation/sign_test.go index c6c995b18c8..d927a9656cc 100644 --- a/pkg/attestation/sign_test.go +++ b/pkg/attestation/sign_test.go @@ -185,12 +185,15 @@ func TestSignStatementLocksToken(t *testing.T) { require.True(t, creds.DisableSTS) } -// fakeObjectStore is an objectStore that serves a fixed object. +// fakeObjectStore is an objectStore that serves a fixed object and records +// what gets uploaded. type fakeObjectStore struct { - data []byte - err error - gcsPath string - dst string + data []byte + err error + gcsPath string + dst string + src string + uploaded []byte } func (f *fakeObjectStore) CopyToLocal(gcsPath, dst string) error { @@ -202,6 +205,60 @@ func (f *fakeObjectStore) CopyToLocal(gcsPath, dst string) error { return os.WriteFile(dst, f.data, 0o600) } +func (f *fakeObjectStore) CopyToRemote(src, gcsPath string) error { + f.src, f.gcsPath = src, gcsPath + if f.err != nil { + return f.err + } + + data, err := os.ReadFile(src) + f.uploaded = data + + return err +} + +func TestWriteFile(t *testing.T) { + t.Parallel() + + const gcsPath = "gs://bucket/stage/v1.36.0-alpha.1/provenance.json.sigstore.json" + + want := []byte("bundle") + + t.Run("local file", func(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), "bundle.json") + store := &fakeObjectStore{} + + require.NoError(t, (&defaultSignerImpl{gcs: store}).WriteFile(filePath, want)) + + data, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, want, data) + require.Empty(t, store.gcsPath, "local files must not touch GCS") + }) + + t.Run("upload to GCS", func(t *testing.T) { + t.Parallel() + + store := &fakeObjectStore{} + + require.NoError(t, (&defaultSignerImpl{gcs: store}).WriteFile(gcsPath, want)) + require.Equal(t, gcsPath, store.gcsPath) + require.Equal(t, want, store.uploaded) + require.Equal(t, "provenance.json.sigstore.json", filepath.Base(store.src)) + require.NoFileExists(t, store.src, "temporary upload must be cleaned up") + }) + + t.Run("upload fails", func(t *testing.T) { + t.Parallel() + + store := &fakeObjectStore{err: errTest} + + require.ErrorIs(t, (&defaultSignerImpl{gcs: store}).WriteFile(gcsPath, want), errTest) + }) +} + func TestReadStatementFromGCS(t *testing.T) { t.Parallel() From 182f53ea9d38d801d932e3c11951a2a73d0662e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Thu, 27 Aug 2026 16:23:15 -0600 Subject: [PATCH 08/11] krel sign attestation: add --in-place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds an --in-place flag to write the signed bundle to the original location it was read from. It supports both a file or a gs:// location in a bucket. Signed-off-by: Adolfo García Veytia (Puerco) --- cmd/krel/cmd/sign_attestation.go | 97 +++++++++++++++++-- pkg/attestation/sign.go | 154 ++++++++++++++++++++++++------- 2 files changed, 206 insertions(+), 45 deletions(-) diff --git a/cmd/krel/cmd/sign_attestation.go b/cmd/krel/cmd/sign_attestation.go index 67d149b6455..41d0fa66dee 100644 --- a/cmd/krel/cmd/sign_attestation.go +++ b/cmd/krel/cmd/sign_attestation.go @@ -27,10 +27,14 @@ import ( "k8s.io/release/pkg/attestation" ) -const serviceAccountFileFlag = "service-account-file" +const ( + serviceAccountFileFlag = "service-account-file" + inPlaceFlag = "in-place" +) type signAttestationOptions struct { outputPath string + inPlace bool serviceAccountFile string // serviceAccountJSON is the key data read from the environment serviceAccountJSON string @@ -40,9 +44,9 @@ var signAttestationOpts = &signAttestationOptions{} // signAttestationCmd represents the subcommand for `krel sign attestation`. var signAttestationCmd = &cobra.Command{ - Use: "attestation statement.json", + Use: "attestation statement.json [--in-place statement.json...]", Short: "Sign an in-toto statement into a sigstore bundle", - Long: `krel sign attestation attestation.json + Long: `krel sign attestation attestation.json [--in-place statement.json...] Signs an in-toto statement using sigstore and writes the resulting bundle (DSSE envelope, Fulcio certificate and transparency log proofs) to stdout @@ -50,6 +54,14 @@ or to the file set with --` + outputPathFlag + `. Both the statement and the output path can be local files or objects in Google Cloud Storage (gs://bucket/path/statement.json). +With --` + inPlaceFlag + `, the signed bundle replaces the statement file (or +gs:// object) itself. Several statements can then be signed at once, all in +the same signing session, reusing the identity and the Fulcio certificate. +--` + outputPathFlag + ` can be combined with --` + inPlaceFlag + ` to +additionally write a copy of the bundle, but only for a single statement. +Files that are already signed (sigstore bundles or DSSE envelopes) are +rejected. + By default the statement is signed with the ambient identity provider. To sign with an explicit identity, pass a Google Cloud service account key @@ -66,8 +78,11 @@ not possible, it never falls back to the ambient credentials.`, krel sign attestation --service-account-file key.json --output-path provenance.sigstore.json provenance.json # Sign a staged provenance stored in a bucket: - krel sign attestation gs://k8s-release-dev/stage/v1.36.0-alpha.1.10+abcdef/provenance.json`, - Args: cobra.ExactArgs(1), + krel sign attestation gs://k8s-release-dev/stage/v1.36.0-alpha.1.10+abcdef/provenance.json + + # Sign several statements in place, replacing the originals with the bundles: + krel sign attestation --in-place gs://bucket/stage/build/provenance.json sbom.intoto.json`, + Args: cobra.MinimumNArgs(1), SilenceUsage: true, SilenceErrors: true, RunE: func(_ *cobra.Command, args []string) error { @@ -75,7 +90,7 @@ not possible, it never falls back to the ambient credentials.`, signAttestationOpts.serviceAccountJSON = key } - return runSignAttestation(singOpts, signAttestationOpts, args[0]) + return runSignAttestation(singOpts, signAttestationOpts, args) }, } @@ -87,6 +102,13 @@ func init() { "write the signed bundle to a file or gs:// object instead of stdout", ) + signAttestationCmd.PersistentFlags().BoolVar( + &signAttestationOpts.inPlace, + inPlaceFlag, + false, + "replace each statement file or gs:// object with its signed bundle", + ) + signAttestationCmd.PersistentFlags().StringVar( &signAttestationOpts.serviceAccountFile, serviceAccountFileFlag, @@ -97,19 +119,27 @@ func init() { signCmd.AddCommand(signAttestationCmd) } -func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, statementPath string) error { +func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, statements []string) error { + if err := validateSignAttestationArgs(opts, statements); err != nil { + return err + } + signerOpts := attestation.DefaultSignerOptions() signerOpts.ServiceAccountFile = opts.serviceAccountFile signerOpts.ServiceAccountJSON = []byte(opts.serviceAccountJSON) signerOpts.Timeout = signOpts.timeout + signer := attestation.NewSigner(signerOpts) + + if opts.inPlace { + return signInPlace(signer, opts, statements) + } + // We will now sign the bundle in memory to avoid writing until // we know signing succeeded var bundle bytes.Buffer - signer := attestation.NewSigner(signerOpts) - - if err := signer.SignFile(statementPath, &bundle); err != nil { + if err := signer.SignFile(statements[0], &bundle); err != nil { return fmt.Errorf("signing attestation: %w", err) } @@ -129,3 +159,50 @@ func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, sta return nil } + +// signInPlace signs all statements in one session and replaces each of them +// with its resulting bundle. Nothing is written unless all statements are signed. +func signInPlace(signer *attestation.Signer, opts *signAttestationOptions, statements []string) error { + signed, err := signer.SignFiles(statements) + if err != nil { + return fmt.Errorf("signing attestations: %w", err) + } + + for _, statement := range signed { + var bundle bytes.Buffer + if err := signer.WriteBundle(statement.Bundle, &bundle); err != nil { + return fmt.Errorf("serializing bundle of %s: %w", statement.Path, err) + } + + if err := signer.WriteFile(statement.Path, bundle.Bytes()); err != nil { + return fmt.Errorf("replacing statement with its bundle: %w", err) + } + + logrus.Infof("Signed %s in place", statement.Path) + + if opts.outputPath != "" { + if err := signer.WriteFile(opts.outputPath, bundle.Bytes()); err != nil { + return fmt.Errorf("writing bundle copy: %w", err) + } + + logrus.Infof("Signed bundle written to %s", opts.outputPath) + } + } + + return nil +} + +// validateSignAttestationArgs checks the combination of flags and statements. +func validateSignAttestationArgs(opts *signAttestationOptions, statements []string) error { + if len(statements) > 1 { + if !opts.inPlace { + return fmt.Errorf("signing more than one statement requires --%s", inPlaceFlag) + } + + if opts.outputPath != "" { + return fmt.Errorf("--%s can only be used when signing a single statement", outputPathFlag) + } + } + + return nil +} diff --git a/pkg/attestation/sign.go b/pkg/attestation/sign.go index 67fa6edbd15..6b300122cb5 100644 --- a/pkg/attestation/sign.go +++ b/pkg/attestation/sign.go @@ -19,6 +19,7 @@ package attestation import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -46,9 +47,15 @@ import ( // carry the JSON contents of the Google service account key to sign with. const ServiceAccountEnvKey = "KREL_SIGNING_SERVICE_ACCOUNT_KEY" -// ErrNoIdentity is returned when the service account key did not yield an -// identity token. -var ErrNoIdentity = errors.New("service account key did not produce an identity token") +var ( + // ErrNoIdentity is returned when the service account key did not yield an + // identity token. + ErrNoIdentity = errors.New("service account key did not produce an identity token") + + // ErrAlreadySigned is returned when the file to sign is already a signed + // artifact (a sigstore bundle or a DSSE envelope) instead of a statement. + ErrAlreadySigned = errors.New("file is already signed") +) // SignerOptions configures the attestation Signer. type SignerOptions struct { @@ -110,52 +117,106 @@ type signerImplementation interface { ReadStatement(statementPath string) ([]byte, error) NewSigner() *signer.Signer ServiceAccountToken(ctx context.Context, keyFile string, keyJSON []byte, audience string) (*oauthflow.OIDCIDToken, error) - SignStatement(sgnr *signer.Signer, token *oauthflow.OIDCIDToken, data []byte) (*sbundle.Bundle, error) - WriteBundle(sgnr *signer.Signer, bndl *sbundle.Bundle, w io.Writer) error + SignStatement(sgnr *signer.Signer, data []byte) (*sbundle.Bundle, error) + WriteBundle(bndl *sbundle.Bundle, w io.Writer) error WriteFile(filePath string, data []byte) error } +// SignedStatement is the result of signing one statement. +type SignedStatement struct { + // Path of the file the statement was read from. + Path string + // Bundle is the sigstore bundle wrapping the signed statement. + Bundle *sbundle.Bundle +} + // SignFile reads the in-toto statement stored in path, which can be a local // file or an object in Google Cloud Storage (gs://bucket/path), signs it and // writes the resulting sigstore bundle to w. func (s *Signer) SignFile(statementPath string, w io.Writer) error { - data, err := s.impl.ReadStatement(statementPath) + signed, err := s.SignFiles([]string{statementPath}) if err != nil { - return fmt.Errorf("reading statement: %w", err) + return err + } + + return s.WriteBundle(signed[0].Bundle, w) +} + +// SignFiles reads the in-toto statements stored in paths (local files or +// gs:// objects) and signs them, returning the signed statements in the +// same order. All statements are read and validated before anything is +// signed and they all share the same signing session: the identity token is +// obtained once and the Fulcio certificate is reused for every signature +// while it remains valid. +func (s *Signer) SignFiles(paths []string) ([]*SignedStatement, error) { + if len(paths) == 0 { + return nil, errors.New("no statements to sign") + } + + type statement struct { + path string + data []byte + } + + statements := make([]statement, 0, len(paths)) + + for _, statementPath := range paths { + data, err := s.impl.ReadStatement(statementPath) + if err != nil { + return nil, fmt.Errorf("reading statement %s: %w", statementPath, err) + } + + statements = append(statements, statement{path: statementPath, data: data}) } sgnr := s.impl.NewSigner() defer sgnr.Close() // When a service account key is set, the identity token is minted here - // from the key and pinned into the signer. Otherwise token is left nil and - // the signer runs its own ambient credential discovery. - var token *oauthflow.OIDCIDToken - + // from the key and pinned into the signer, which is locked to it: the + // Fulcio certificate can only be obtained with that identity and the + // signer will not try its own credential discovery. Otherwise, the + // signer's ambient credential providers are in charge of finding one. if s.options.hasServiceAccount() { ctx, cancel := context.WithTimeout(context.Background(), s.options.Timeout) defer cancel() // The token audience must match the client ID the sigstore instance // expects, otherwise Fulcio will reject it. - token, err = s.impl.ServiceAccountToken( + token, err := s.impl.ServiceAccountToken( ctx, s.options.ServiceAccountFile, s.options.ServiceAccountJSON, sgnr.Options.OIDCConfig.ClientID, ) if err != nil { - return fmt.Errorf("obtaining identity from service account key: %w", err) + return nil, fmt.Errorf("obtaining identity from service account key: %w", err) } - logrus.Infof("Signing statement %s as %s", statementPath, token.Subject) + sgnr.Options.Token = token + sgnr.Options.DisableSTS = true + + logrus.Infof("Signing %d statement(s) as %s", len(statements), token.Subject) } else { - logrus.Infof("Signing statement %s with the ambient credentials", statementPath) + logrus.Infof("Signing %d statement(s) with the ambient credentials", len(statements)) } - bndl, err := s.impl.SignStatement(sgnr, token, data) - if err != nil { - return fmt.Errorf("signing statement: %w", err) + signed := make([]*SignedStatement, 0, len(statements)) + + for _, statement := range statements { + logrus.Infof("Signing statement %s", statement.path) + + bndl, err := s.impl.SignStatement(sgnr, statement.data) + if err != nil { + return nil, fmt.Errorf("signing statement %s: %w", statement.path, err) + } + + signed = append(signed, &SignedStatement{Path: statement.path, Bundle: bndl}) } - if err := s.impl.WriteBundle(sgnr, bndl, w); err != nil { + return signed, nil +} + +// WriteBundle writes the sigstore bundle as JSON to w. +func (s *Signer) WriteBundle(bndl *sbundle.Bundle, w io.Writer) error { + if err := s.impl.WriteBundle(bndl, w); err != nil { return fmt.Errorf("writing bundle: %w", err) } @@ -193,6 +254,10 @@ func (di *defaultSignerImpl) ReadStatement(statementPath string) ([]byte, error) return nil, fmt.Errorf("reading file: %w", err) } + if isSigned(data) { + return nil, ErrAlreadySigned + } + statement := &intoto.Statement{} if err := protojson.Unmarshal(data, statement); err != nil { return nil, fmt.Errorf("parsing in-toto statement: %w", err) @@ -254,26 +319,26 @@ func (*defaultSignerImpl) ServiceAccountToken( return token, nil } -// SignStatement signs the statement data and wraps the signed envelope in a -// sigstore bundle. If token is not nil, the signer is locked to it: the -// Fulcio certificate can only be obtained with that identity and the signer -// will not try its own credential discovery. A nil token leaves the signer's -// ambient credential providers in charge of finding an identity. The caller -// owns the signer and is responsible for closing it. -func (*defaultSignerImpl) SignStatement( - sgnr *signer.Signer, token *oauthflow.OIDCIDToken, data []byte, -) (*sbundle.Bundle, error) { - if token != nil { - sgnr.Options.Token = token - sgnr.Options.DisableSTS = true - } - +// SignStatement signs the statement data with sgnr and wraps the signed +// envelope in a sigstore bundle. Repeated calls on the same signer reuse its +// identity and Fulcio certificate. The caller owns the signer and is +// responsible for closing it. +func (*defaultSignerImpl) SignStatement(sgnr *signer.Signer, data []byte) (*sbundle.Bundle, error) { return sgnr.SignStatementBundle(data) } // WriteBundle marshals the bundle as JSON into w. -func (*defaultSignerImpl) WriteBundle(sgnr *signer.Signer, bndl *sbundle.Bundle, w io.Writer) error { - return sgnr.WriteBundle(bndl, w) +func (*defaultSignerImpl) WriteBundle(bndl *sbundle.Bundle, w io.Writer) error { + bundleJSON, err := protojson.Marshal(bndl) + if err != nil { + return fmt.Errorf("marshaling bundle: %w", err) + } + + if _, err := w.Write(bundleJSON); err != nil { + return fmt.Errorf("writing bundle: %w", err) + } + + return nil } // WriteFile writes data to a local file or, when filePath is a gs:// URL, @@ -341,3 +406,22 @@ func newGCSClient() *object.GCS { return gcs } + +// isSigned returns true if data looks like an already signed artifact, that +// is a sigstore bundle or a DSSE envelope, rather than a bare statement. +func isSigned(data []byte) bool { + probe := struct { + MediaType string `json:"mediaType"` + DSSEEnvelope json.RawMessage `json:"dsseEnvelope"` + PayloadType string `json:"payloadType"` + Signatures json.RawMessage `json:"signatures"` + }{} + + if err := json.Unmarshal(data, &probe); err != nil { + return false + } + + return strings.HasPrefix(probe.MediaType, "application/vnd.dev.sigstore.bundle") || + len(probe.DSSEEnvelope) > 0 || + (probe.PayloadType != "" && len(probe.Signatures) > 0) +} From f7c9be0575d7e53351731710f563b29e1e4c1540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Thu, 27 Aug 2026 16:25:26 -0600 Subject: [PATCH 09/11] Add --in-place tests and regen fakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adolfo García Veytia (Puerco) --- cmd/krel/cmd/sign_attestation_test.go | 110 +++++++++-- .../fake_signer_implementation.go | 56 +++--- pkg/attestation/sign_test.go | 171 +++++++++++++++++- 3 files changed, 287 insertions(+), 50 deletions(-) diff --git a/cmd/krel/cmd/sign_attestation_test.go b/cmd/krel/cmd/sign_attestation_test.go index 65daa72bd62..42ecea4385c 100644 --- a/cmd/krel/cmd/sign_attestation_test.go +++ b/cmd/krel/cmd/sign_attestation_test.go @@ -23,8 +23,61 @@ import ( "time" "github.com/stretchr/testify/require" + + "k8s.io/release/pkg/attestation" ) +func TestValidateSignAttestationArgs(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + opts *signAttestationOptions + statements []string + shouldErr bool + }{ + {name: "single statement", opts: &signAttestationOptions{}, statements: []string{"a.json"}}, + { + name: "single statement with output path", + opts: &signAttestationOptions{outputPath: "out.json"}, + statements: []string{"a.json"}, + }, + { + name: "single statement in place with output path", + opts: &signAttestationOptions{inPlace: true, outputPath: "out.json"}, + statements: []string{"a.json"}, + }, + { + name: "several statements in place", + opts: &signAttestationOptions{inPlace: true}, + statements: []string{"a.json", "gs://bucket/b.json"}, + }, + { + name: "several statements without in place", + opts: &signAttestationOptions{}, + statements: []string{"a.json", "b.json"}, + shouldErr: true, + }, + { + name: "several statements in place with output path", + opts: &signAttestationOptions{inPlace: true, outputPath: "out.json"}, + statements: []string{"a.json", "b.json"}, + shouldErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateSignAttestationArgs(tc.opts, tc.statements) + if tc.shouldErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + func TestRunSignAttestation(t *testing.T) { t.Parallel() @@ -34,9 +87,46 @@ func TestRunSignAttestation(t *testing.T) { t.Parallel() err := runSignAttestation( - signOpts, &signAttestationOptions{}, filepath.Join(t.TempDir(), "missing.json"), + signOpts, &signAttestationOptions{}, []string{filepath.Join(t.TempDir(), "missing.json")}, ) - require.Error(t, err) + require.ErrorContains(t, err, "reading statement") + }) + + t.Run("output file is not touched when signing fails", func(t *testing.T) { + t.Parallel() + + outputPath := filepath.Join(t.TempDir(), "out.json") + + err := runSignAttestation( + signOpts, + &signAttestationOptions{outputPath: outputPath}, + []string{filepath.Join(t.TempDir(), "missing.json")}, + ) + require.ErrorContains(t, err, "signing attestation") + require.NoFileExists(t, outputPath) + }) + + t.Run("in place refuses an already signed file and leaves it untouched", func(t *testing.T) { + t.Parallel() + + original := []byte(`{"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", "dsseEnvelope": {}}`) + + bundlePath := filepath.Join(t.TempDir(), "provenance.json") + require.NoError(t, os.WriteFile(bundlePath, original, 0o600)) + + err := runSignAttestation(signOpts, &signAttestationOptions{inPlace: true}, []string{bundlePath}) + require.ErrorIs(t, err, attestation.ErrAlreadySigned) + + data, err := os.ReadFile(bundlePath) + require.NoError(t, err) + require.Equal(t, original, data) + }) + + t.Run("several statements without in place", func(t *testing.T) { + t.Parallel() + + err := runSignAttestation(signOpts, &signAttestationOptions{}, []string{"a.json", "b.json"}) + require.ErrorContains(t, err, "--in-place") }) t.Run("invalid service account key from the environment", func(t *testing.T) { @@ -51,22 +141,8 @@ func TestRunSignAttestation(t *testing.T) { }`), 0o600)) err := runSignAttestation( - signOpts, &signAttestationOptions{serviceAccountJSON: `{"type": "authorized_user"}`}, statement, + signOpts, &signAttestationOptions{serviceAccountJSON: `{"type": "authorized_user"}`}, []string{statement}, ) require.ErrorContains(t, err, "not a service account key") }) - - t.Run("output file is not touched when signing fails", func(t *testing.T) { - t.Parallel() - - outputPath := filepath.Join(t.TempDir(), "out.json") - - err := runSignAttestation( - signOpts, - &signAttestationOptions{outputPath: outputPath}, - filepath.Join(t.TempDir(), "missing.json"), - ) - require.ErrorContains(t, err, "signing attestation") - require.NoFileExists(t, outputPath) - }) } diff --git a/pkg/attestation/attestationfakes/fake_signer_implementation.go b/pkg/attestation/attestationfakes/fake_signer_implementation.go index c1c4926ff83..08731a68440 100644 --- a/pkg/attestation/attestationfakes/fake_signer_implementation.go +++ b/pkg/attestation/attestationfakes/fake_signer_implementation.go @@ -67,12 +67,11 @@ type FakeSignerImplementation struct { result1 *oauthflow.OIDCIDToken result2 error } - SignStatementStub func(*signer.Signer, *oauthflow.OIDCIDToken, []byte) (*bundle.Bundle, error) + SignStatementStub func(*signer.Signer, []byte) (*bundle.Bundle, error) signStatementMutex sync.RWMutex signStatementArgsForCall []struct { arg1 *signer.Signer - arg2 *oauthflow.OIDCIDToken - arg3 []byte + arg2 []byte } signStatementReturns struct { result1 *bundle.Bundle @@ -82,12 +81,11 @@ type FakeSignerImplementation struct { result1 *bundle.Bundle result2 error } - WriteBundleStub func(*signer.Signer, *bundle.Bundle, io.Writer) error + WriteBundleStub func(*bundle.Bundle, io.Writer) error writeBundleMutex sync.RWMutex writeBundleArgsForCall []struct { - arg1 *signer.Signer - arg2 *bundle.Bundle - arg3 io.Writer + arg1 *bundle.Bundle + arg2 io.Writer } writeBundleReturns struct { result1 error @@ -300,25 +298,24 @@ func (fake *FakeSignerImplementation) ServiceAccountTokenReturnsOnCall(i int, re }{result1, result2} } -func (fake *FakeSignerImplementation) SignStatement(arg1 *signer.Signer, arg2 *oauthflow.OIDCIDToken, arg3 []byte) (*bundle.Bundle, error) { - var arg3Copy []byte - if arg3 != nil { - arg3Copy = make([]byte, len(arg3)) - copy(arg3Copy, arg3) +func (fake *FakeSignerImplementation) SignStatement(arg1 *signer.Signer, arg2 []byte) (*bundle.Bundle, error) { + var arg2Copy []byte + if arg2 != nil { + arg2Copy = make([]byte, len(arg2)) + copy(arg2Copy, arg2) } fake.signStatementMutex.Lock() ret, specificReturn := fake.signStatementReturnsOnCall[len(fake.signStatementArgsForCall)] fake.signStatementArgsForCall = append(fake.signStatementArgsForCall, struct { arg1 *signer.Signer - arg2 *oauthflow.OIDCIDToken - arg3 []byte - }{arg1, arg2, arg3Copy}) + arg2 []byte + }{arg1, arg2Copy}) stub := fake.SignStatementStub fakeReturns := fake.signStatementReturns - fake.recordInvocation("SignStatement", []interface{}{arg1, arg2, arg3Copy}) + fake.recordInvocation("SignStatement", []interface{}{arg1, arg2Copy}) fake.signStatementMutex.Unlock() if stub != nil { - return stub(arg1, arg2, arg3) + return stub(arg1, arg2) } if specificReturn { return ret.result1, ret.result2 @@ -332,17 +329,17 @@ func (fake *FakeSignerImplementation) SignStatementCallCount() int { return len(fake.signStatementArgsForCall) } -func (fake *FakeSignerImplementation) SignStatementCalls(stub func(*signer.Signer, *oauthflow.OIDCIDToken, []byte) (*bundle.Bundle, error)) { +func (fake *FakeSignerImplementation) SignStatementCalls(stub func(*signer.Signer, []byte) (*bundle.Bundle, error)) { fake.signStatementMutex.Lock() defer fake.signStatementMutex.Unlock() fake.SignStatementStub = stub } -func (fake *FakeSignerImplementation) SignStatementArgsForCall(i int) (*signer.Signer, *oauthflow.OIDCIDToken, []byte) { +func (fake *FakeSignerImplementation) SignStatementArgsForCall(i int) (*signer.Signer, []byte) { fake.signStatementMutex.RLock() defer fake.signStatementMutex.RUnlock() argsForCall := fake.signStatementArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 + return argsForCall.arg1, argsForCall.arg2 } func (fake *FakeSignerImplementation) SignStatementReturns(result1 *bundle.Bundle, result2 error) { @@ -371,20 +368,19 @@ func (fake *FakeSignerImplementation) SignStatementReturnsOnCall(i int, result1 }{result1, result2} } -func (fake *FakeSignerImplementation) WriteBundle(arg1 *signer.Signer, arg2 *bundle.Bundle, arg3 io.Writer) error { +func (fake *FakeSignerImplementation) WriteBundle(arg1 *bundle.Bundle, arg2 io.Writer) error { fake.writeBundleMutex.Lock() ret, specificReturn := fake.writeBundleReturnsOnCall[len(fake.writeBundleArgsForCall)] fake.writeBundleArgsForCall = append(fake.writeBundleArgsForCall, struct { - arg1 *signer.Signer - arg2 *bundle.Bundle - arg3 io.Writer - }{arg1, arg2, arg3}) + arg1 *bundle.Bundle + arg2 io.Writer + }{arg1, arg2}) stub := fake.WriteBundleStub fakeReturns := fake.writeBundleReturns - fake.recordInvocation("WriteBundle", []interface{}{arg1, arg2, arg3}) + fake.recordInvocation("WriteBundle", []interface{}{arg1, arg2}) fake.writeBundleMutex.Unlock() if stub != nil { - return stub(arg1, arg2, arg3) + return stub(arg1, arg2) } if specificReturn { return ret.result1 @@ -398,17 +394,17 @@ func (fake *FakeSignerImplementation) WriteBundleCallCount() int { return len(fake.writeBundleArgsForCall) } -func (fake *FakeSignerImplementation) WriteBundleCalls(stub func(*signer.Signer, *bundle.Bundle, io.Writer) error) { +func (fake *FakeSignerImplementation) WriteBundleCalls(stub func(*bundle.Bundle, io.Writer) error) { fake.writeBundleMutex.Lock() defer fake.writeBundleMutex.Unlock() fake.WriteBundleStub = stub } -func (fake *FakeSignerImplementation) WriteBundleArgsForCall(i int) (*signer.Signer, *bundle.Bundle, io.Writer) { +func (fake *FakeSignerImplementation) WriteBundleArgsForCall(i int) (*bundle.Bundle, io.Writer) { fake.writeBundleMutex.RLock() defer fake.writeBundleMutex.RUnlock() argsForCall := fake.writeBundleArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 + return argsForCall.arg1, argsForCall.arg2 } func (fake *FakeSignerImplementation) WriteBundleReturns(result1 error) { diff --git a/pkg/attestation/sign_test.go b/pkg/attestation/sign_test.go index d927a9656cc..98c4f391bed 100644 --- a/pkg/attestation/sign_test.go +++ b/pkg/attestation/sign_test.go @@ -65,7 +65,7 @@ func TestSignFile(t *testing.T) { { name: "success with ambient credentials", prepare: func(mock *attestationfakes.FakeSignerImplementation) { - mock.WriteBundleCalls(func(_ *signer.Signer, _ *sbundle.Bundle, w io.Writer) error { + mock.WriteBundleCalls(func(_ *sbundle.Bundle, w io.Writer) error { _, err := w.Write([]byte("bundle")) return err @@ -78,7 +78,7 @@ func TestSignFile(t *testing.T) { opts: &SignerOptions{ServiceAccountFile: "key.json"}, prepare: func(mock *attestationfakes.FakeSignerImplementation) { mock.ServiceAccountTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) - mock.WriteBundleCalls(func(_ *signer.Signer, _ *sbundle.Bundle, w io.Writer) error { + mock.WriteBundleCalls(func(_ *sbundle.Bundle, w io.Writer) error { _, err := w.Write([]byte("bundle")) return err @@ -92,7 +92,7 @@ func TestSignFile(t *testing.T) { opts: &SignerOptions{ServiceAccountJSON: []byte("service-account-key-data")}, prepare: func(mock *attestationfakes.FakeSignerImplementation) { mock.ServiceAccountTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) - mock.WriteBundleCalls(func(_ *signer.Signer, _ *sbundle.Bundle, w io.Writer) error { + mock.WriteBundleCalls(func(_ *sbundle.Bundle, w io.Writer) error { _, err := w.Write([]byte("bundle")) return err @@ -166,6 +166,147 @@ func TestSignFile(t *testing.T) { } } +func TestSignFiles(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + opts *SignerOptions + paths []string + prepare func(*attestationfakes.FakeSignerImplementation) + shouldErr bool + wantSAToks int + wantSigns int + }{ + { + name: "one statement with ambient credentials", + paths: []string{"a.json"}, + prepare: func(*attestationfakes.FakeSignerImplementation) {}, + wantSigns: 1, + }, + { + name: "several statements with service account key", + opts: &SignerOptions{ServiceAccountFile: "key.json"}, + paths: []string{"a.json", "gs://bucket/b.json", "c.json"}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.ServiceAccountTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) + }, + wantSAToks: 1, + wantSigns: 3, + }, + { + name: "no statements", + paths: []string{}, + prepare: func(*attestationfakes.FakeSignerImplementation) {}, + shouldErr: true, + }, + { + name: "ReadStatement fails, nothing is signed", + paths: []string{"a.json", "b.json"}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.ReadStatementReturnsOnCall(1, nil, errTest) + }, + shouldErr: true, + }, + { + name: "ServiceAccountToken fails", + opts: &SignerOptions{ServiceAccountFile: "key.json"}, + paths: []string{"a.json"}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.ServiceAccountTokenReturns(nil, errTest) + }, + shouldErr: true, + wantSAToks: 1, + }, + { + name: "SignStatement fails mid batch", + paths: []string{"a.json", "b.json", "c.json"}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.SignStatementReturnsOnCall(1, nil, errTest) + }, + shouldErr: true, + wantSigns: 2, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var bundles []*sbundle.Bundle + + mock := &attestationfakes.FakeSignerImplementation{} + mock.NewSignerReturns(signer.NewSigner()) + mock.ReadStatementCalls(func(statementPath string) ([]byte, error) { + return []byte("statement:" + statementPath), nil + }) + mock.SignStatementCalls(func(_ *signer.Signer, _ []byte) (*sbundle.Bundle, error) { + bndl := &sbundle.Bundle{} + bundles = append(bundles, bndl) + + return bndl, nil + }) + tc.prepare(mock) + + sut := NewSigner(tc.opts) + sut.impl = mock + + signed, err := sut.SignFiles(tc.paths) + if tc.shouldErr { + require.Error(t, err) + require.Nil(t, signed) + + if len(tc.paths) > 0 { + require.ErrorIs(t, err, errTest) + } + } else { + require.NoError(t, err) + require.Len(t, signed, len(tc.paths)) + // Results come back in the order of the statements + for i, statementPath := range tc.paths { + require.Equal(t, statementPath, signed[i].Path) + require.Same(t, bundles[i], signed[i].Bundle) + + _, data := mock.SignStatementArgsForCall(i) + require.Equal(t, "statement:"+statementPath, string(data)) + } + } + + require.Equal(t, tc.wantSigns, mock.SignStatementCallCount()) + require.Equal(t, tc.wantSAToks, mock.ServiceAccountTokenCallCount()) + + if tc.wantSigns > 0 { + // All statements are signed with the same signer so that the + // identity and Fulcio certificate are reused. + require.Equal(t, 1, mock.NewSignerCallCount()) + + first, _ := mock.SignStatementArgsForCall(0) + for i := range tc.wantSigns { + sgnr, _ := mock.SignStatementArgsForCall(i) + require.Same(t, first, sgnr) + } + + // With a key, the signer is locked to the service account + if tc.wantSAToks > 0 { + require.NotNil(t, first.Options.Token) + require.Equal(t, "sa@example.com", first.Options.Token.Subject) + require.True(t, first.Options.DisableSTS) + } else { + require.Nil(t, first.Options.Token) + require.False(t, first.Options.DisableSTS) + } + } + }) + } +} + +func TestWriteBundle(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + require.NoError(t, (&defaultSignerImpl{}).WriteBundle(&sbundle.Bundle{}, &out)) + require.JSONEq(t, `{}`, out.String()) +} + func TestSignStatementLocksToken(t *testing.T) { t.Parallel() @@ -308,6 +449,7 @@ func TestReadStatement(t *testing.T) { content string missing bool shouldErr bool + errIs error }{ {name: "valid statement", content: testStatement}, {name: "missing file", missing: true, shouldErr: true}, @@ -324,6 +466,25 @@ func TestReadStatement(t *testing.T) { content: `{"_type": "https://in-toto.io/Statement/v1", "subject": [{"name": "a"}], "predicate": {}}`, shouldErr: true, }, + { + name: "already signed: sigstore bundle", + content: `{"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": {}, "dsseEnvelope": {"payload": "e30=", "payloadType": "application/vnd.in-toto+json", "signatures": []}}`, + shouldErr: true, + errIs: ErrAlreadySigned, + }, + { + name: "already signed: bundle without media type", + content: `{"verificationMaterial": {}, "dsseEnvelope": {"payload": "e30="}}`, + shouldErr: true, + errIs: ErrAlreadySigned, + }, + { + name: "already signed: DSSE envelope", + content: `{"payloadType": "application/vnd.in-toto+json", "payload": "e30=", "signatures": [{"sig": "x"}]}`, + shouldErr: true, + errIs: ErrAlreadySigned, + }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -337,6 +498,10 @@ func TestReadStatement(t *testing.T) { if tc.shouldErr { require.Error(t, err) + if tc.errIs != nil { + require.ErrorIs(t, err, tc.errIs) + } + return } From 4356b506e3f06992c5933e382df12098bc75a776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Thu, 27 Aug 2026 22:55:50 -0600 Subject: [PATCH 10/11] Add --impersonation to krel sign attestation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds impersonation support to krel sign attestation. This allows the attestation to be signed with a service account the current identity (as set by a key or ambient creds) can impersonate. Signed-off-by: Adolfo García Veytia (Puerco) --- cmd/krel/cmd/sign_attestation.go | 31 +++++++++--- go.mod | 2 +- go.sum | 4 +- pkg/attestation/sign.go | 82 +++++++++++++++++++++----------- 4 files changed, 83 insertions(+), 36 deletions(-) diff --git a/cmd/krel/cmd/sign_attestation.go b/cmd/krel/cmd/sign_attestation.go index 41d0fa66dee..ceeedd40037 100644 --- a/cmd/krel/cmd/sign_attestation.go +++ b/cmd/krel/cmd/sign_attestation.go @@ -28,8 +28,9 @@ import ( ) const ( - serviceAccountFileFlag = "service-account-file" - inPlaceFlag = "in-place" + serviceAccountFileFlag = "service-account-file" + impersonateServiceAccountFlag = "impersonate-service-account" + inPlaceFlag = "in-place" ) type signAttestationOptions struct { @@ -37,7 +38,8 @@ type signAttestationOptions struct { inPlace bool serviceAccountFile string // serviceAccountJSON is the key data read from the environment - serviceAccountJSON string + serviceAccountJSON string + impersonateServiceAccount string } var signAttestationOpts = &signAttestationOptions{} @@ -67,9 +69,15 @@ By default the statement is signed with the ambient identity provider. To sign with an explicit identity, pass a Google Cloud service account key file with --` + serviceAccountFileFlag + ` or set the contents of the key in the ` + attestation.ServiceAccountEnvKey + ` environment variable (the flag -takes precedence). The signer is then locked to that service account: the -certificate is only requested with its identity and signing fails if that is -not possible, it never falls back to the ambient credentials.`, +takes precedence). To sign as a service account without holding its key, +pass its email with --` + impersonateServiceAccountFlag + `: the identity +token is minted through the IAM Credentials API using the key, if one is +set, or the ambient Google Cloud credentials of the host, which need +roles/iam.serviceAccountTokenCreator on the impersonated account. + +In both cases the signer is locked to that service account: the certificate +is only requested with its identity and signing fails if that is not +possible, it never falls back to the ambient credentials.`, Example: ` # Sign an attestation using the ambient GCP credentials: krel sign attestation provenance.json > provenance.json.sigstore.json @@ -80,6 +88,9 @@ not possible, it never falls back to the ambient credentials.`, # Sign a staged provenance stored in a bucket: krel sign attestation gs://k8s-release-dev/stage/v1.36.0-alpha.1.10+abcdef/provenance.json + # Sign as the staging signer account by impersonating it from a Cloud Build job: + krel sign attestation --impersonate-service-account=krel-staging@k8s-releng-prod.iam.gserviceaccount.com provenance.json + # Sign several statements in place, replacing the originals with the bundles: krel sign attestation --in-place gs://bucket/stage/build/provenance.json sbom.intoto.json`, Args: cobra.MinimumNArgs(1), @@ -116,6 +127,13 @@ func init() { "path to a Google service account key (defaults to $"+attestation.ServiceAccountEnvKey+" or the ambient credentials)", ) + signAttestationCmd.PersistentFlags().StringVar( + &signAttestationOpts.impersonateServiceAccount, + impersonateServiceAccountFlag, + "", + "email of a Google service account to sign as by impersonating it", + ) + signCmd.AddCommand(signAttestationCmd) } @@ -127,6 +145,7 @@ func runSignAttestation(signOpts *signOptions, opts *signAttestationOptions, sta signerOpts := attestation.DefaultSignerOptions() signerOpts.ServiceAccountFile = opts.serviceAccountFile signerOpts.ServiceAccountJSON = []byte(opts.serviceAccountJSON) + signerOpts.ImpersonateServiceAccount = opts.impersonateServiceAccount signerOpts.Timeout = signOpts.timeout signer := attestation.NewSigner(signerOpts) diff --git a/go.mod b/go.mod index ce898989a4f..7b0daccb3d5 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( cloud.google.com/go/storage v1.62.3 github.com/GoogleCloudPlatform/testgrid v0.0.38 github.com/blang/semver/v4 v4.0.0 - github.com/carabiner-dev/signer v0.6.0 + github.com/carabiner-dev/signer v0.6.1 github.com/cheggaaa/pb/v3 v3.2.1 github.com/fastly/go-fastly/v13 v13.1.2 github.com/go-git/go-git/v5 v5.19.2 diff --git a/go.sum b/go.sum index f7fc267aa26..0bb28e3f44f 100644 --- a/go.sum +++ b/go.sum @@ -268,8 +268,8 @@ github.com/carabiner-dev/command v0.3.1 h1:iBkh+AjwziFZmyihv/izypCV74nkmaslZxb5A github.com/carabiner-dev/command v0.3.1/go.mod h1:0mWfS5BU/krtaI1hgD5wjmLpjWVlf38KY8usA8zfF5c= github.com/carabiner-dev/hasher v0.2.4 h1:VaI04+FBHaNV/UEy0NmVoRg0pKLFeN76KPOHbTDvvhE= github.com/carabiner-dev/hasher v0.2.4/go.mod h1:W83zi1+E3he4Cpldss8yoXNj6GdDUpr3M45dOAzem/w= -github.com/carabiner-dev/signer v0.6.0 h1:ke8aXVLTNl5nCYqr3ALvWF+/5bXyr0DduhiETPHTl9U= -github.com/carabiner-dev/signer v0.6.0/go.mod h1:dg1OvK3lTePsPrbvoBZO5SBt0DHd2/NLdM2wfB2/4/8= +github.com/carabiner-dev/signer v0.6.1 h1:3GnpNt16Qzw476lgCGMZRThtofTy7Ke1A60HqNboWnc= +github.com/carabiner-dev/signer v0.6.1/go.mod h1:dg1OvK3lTePsPrbvoBZO5SBt0DHd2/NLdM2wfB2/4/8= github.com/carabiner-dev/spdx3 v0.1.0 h1:Q6nMLXV0BhtqDQuwRDOaJDZR670TK4jdOvZqvTW/ctY= github.com/carabiner-dev/spdx3 v0.1.0/go.mod h1:d/t010TrZvYZBeYGpvHeId6QpRvGLlqK7cy0E81npPA= github.com/carabiner-dev/unpack v0.3.1 h1:VnB2rzMHeu8iQhFz7BV22mjilw8a5hR3rF497+YqYJE= diff --git a/pkg/attestation/sign.go b/pkg/attestation/sign.go index 6b300122cb5..63217e5979e 100644 --- a/pkg/attestation/sign.go +++ b/pkg/attestation/sign.go @@ -73,8 +73,17 @@ type SignerOptions struct { // does, which takes precedence when both are set. ServiceAccountJSON []byte + // ImpersonateServiceAccount is the email of a Google service account to + // sign as by impersonating it through the IAM Credentials API. The + // credential calling the API is the service account key, when one is + // set, or the ambient Google Cloud credential of the host otherwise; it + // needs roles/iam.serviceAccountTokenCreator on the impersonated account. + // Like the keys, it locks the signer to that identity: signing fails if + // impersonation does, it never falls back to another credential. + ImpersonateServiceAccount string + // Timeout bounds the identity token exchange with Google when signing - // with a service account key. + // with a pinned identity. Timeout time.Duration } @@ -85,10 +94,11 @@ func DefaultSignerOptions() *SignerOptions { } } -// hasServiceAccount returns true when a service account key was configured, -// either as a file or as its JSON contents. -func (o *SignerOptions) hasServiceAccount() bool { - return o.ServiceAccountFile != "" || len(o.ServiceAccountJSON) > 0 +// hasPinnedIdentity returns true when the signer must sign with a specific +// identity: a service account key (file or JSON contents) or a service +// account to impersonate. +func (o *SignerOptions) hasPinnedIdentity() bool { + return o.ServiceAccountFile != "" || len(o.ServiceAccountJSON) > 0 || o.ImpersonateServiceAccount != "" } // Signer signs in-toto statements using a Google Cloud identity and wraps @@ -116,7 +126,7 @@ func NewSigner(opts *SignerOptions) *Signer { type signerImplementation interface { ReadStatement(statementPath string) ([]byte, error) NewSigner() *signer.Signer - ServiceAccountToken(ctx context.Context, keyFile string, keyJSON []byte, audience string) (*oauthflow.OIDCIDToken, error) + IdentityToken(ctx context.Context, keyFile string, keyJSON []byte, impersonate, audience string) (*oauthflow.OIDCIDToken, error) SignStatement(sgnr *signer.Signer, data []byte) (*sbundle.Bundle, error) WriteBundle(bndl *sbundle.Bundle, w io.Writer) error WriteFile(filePath string, data []byte) error @@ -172,22 +182,24 @@ func (s *Signer) SignFiles(paths []string) ([]*SignedStatement, error) { sgnr := s.impl.NewSigner() defer sgnr.Close() - // When a service account key is set, the identity token is minted here - // from the key and pinned into the signer, which is locked to it: the - // Fulcio certificate can only be obtained with that identity and the - // signer will not try its own credential discovery. Otherwise, the - // signer's ambient credential providers are in charge of finding one. - if s.options.hasServiceAccount() { + // When an identity is pinned (a service account key or an account to + // impersonate), the identity token is minted here and pinned into the + // signer, which is locked to it: the Fulcio certificate can only be + // obtained with that identity and the signer will not try its own + // credential discovery. Otherwise, the signer's ambient credential + // providers are in charge of finding one. + if s.options.hasPinnedIdentity() { ctx, cancel := context.WithTimeout(context.Background(), s.options.Timeout) defer cancel() // The token audience must match the client ID the sigstore instance // expects, otherwise Fulcio will reject it. - token, err := s.impl.ServiceAccountToken( - ctx, s.options.ServiceAccountFile, s.options.ServiceAccountJSON, sgnr.Options.OIDCConfig.ClientID, + token, err := s.impl.IdentityToken( + ctx, s.options.ServiceAccountFile, s.options.ServiceAccountJSON, + s.options.ImpersonateServiceAccount, sgnr.Options.OIDCConfig.ClientID, ) if err != nil { - return nil, fmt.Errorf("obtaining identity from service account key: %w", err) + return nil, fmt.Errorf("obtaining the signing identity: %w", err) } sgnr.Options.Token = token @@ -280,29 +292,45 @@ func (*defaultSignerImpl) NewSigner() *signer.Signer { return signer.NewSigner() } -// ServiceAccountToken obtains an OIDC token for the given audience from -// Google Cloud using a service account key, either the file in keyFile or -// the key contents in keyJSON (the file takes precedence). The identity of -// the host is never used, even if exchanging the key fails. -func (*defaultSignerImpl) ServiceAccountToken( - ctx context.Context, keyFile string, keyJSON []byte, audience string, +// IdentityToken obtains an OIDC token for the given audience from Google +// Cloud for a pinned identity: a service account key, either the file in +// keyFile or the key contents in keyJSON (the file takes precedence), and/or +// a service account to impersonate. +// +// If a key is set, the identity of the host is never used, even if exchanging +// the key fails. +// +// With impersonation, the key or the ambient credential of the host authenticates +// the IAM Credentials API call, minting the token for the impersonated account. +// Equally, any failure is an error and the provider never falls back to signing +// with the caller's own identity. +func (*defaultSignerImpl) IdentityToken( + ctx context.Context, keyFile string, keyJSON []byte, impersonate, audience string, ) (*oauthflow.OIDCIDToken, error) { - var key gcp.Option + providerOpts := []gcp.Option{} switch { case keyFile != "": logrus.Infof("Obtaining identity token from service account key %s", keyFile) - key = gcp.WithServiceAccountFile(keyFile) + providerOpts = append(providerOpts, gcp.WithServiceAccountFile(keyFile), gcp.WithAmbientCredentials(false)) case len(keyJSON) > 0: logrus.Info("Obtaining identity token from service account key data") - key = gcp.WithServiceAccountJSON(keyJSON) - default: - return nil, errors.New("no service account key configured") + providerOpts = append(providerOpts, gcp.WithServiceAccountJSON(keyJSON), gcp.WithAmbientCredentials(false)) + } + + if impersonate != "" { + logrus.Infof("Impersonating service account %s", impersonate) + + providerOpts = append(providerOpts, gcp.WithImpersonation(impersonate)) + } + + if len(providerOpts) == 0 { + return nil, errors.New("no service account key or account to impersonate configured") } - provider, err := gcp.New(key, gcp.WithAmbientCredentials(false)) + provider, err := gcp.New(providerOpts...) if err != nil { return nil, fmt.Errorf("creating GCP identity provider: %w", err) } From ef8bbb0363042e850ae060d2cc9f2c0e80b58ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Thu, 27 Aug 2026 22:57:48 -0600 Subject: [PATCH 11/11] Add impersonation tests and regen fakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adolfo García Veytia (Puerco) --- cmd/krel/cmd/sign_attestation_test.go | 17 ++ .../fake_signer_implementation.go | 178 +++++++++--------- pkg/attestation/sign_test.go | 111 ++++++++--- 3 files changed, 190 insertions(+), 116 deletions(-) diff --git a/cmd/krel/cmd/sign_attestation_test.go b/cmd/krel/cmd/sign_attestation_test.go index 42ecea4385c..defee759566 100644 --- a/cmd/krel/cmd/sign_attestation_test.go +++ b/cmd/krel/cmd/sign_attestation_test.go @@ -145,4 +145,21 @@ func TestRunSignAttestation(t *testing.T) { ) require.ErrorContains(t, err, "not a service account key") }) + + t.Run("invalid service account to impersonate", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + statement := filepath.Join(dir, "statement.json") + require.NoError(t, os.WriteFile(statement, []byte(`{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [{"name": "a", "digest": {"sha256": "0e8a8b6f7c6cf3b0f2f2b6c2d1a4f4b3c2e1d0f9a8b7c6d5e4f3a2b1c0d9e8f7"}}], + "predicateType": "https://example.com/test", "predicate": {} + }`), 0o600)) + + err := runSignAttestation( + signOpts, &signAttestationOptions{impersonateServiceAccount: "not-an-email"}, []string{statement}, + ) + require.ErrorContains(t, err, "not a valid service account email") + }) } diff --git a/pkg/attestation/attestationfakes/fake_signer_implementation.go b/pkg/attestation/attestationfakes/fake_signer_implementation.go index 08731a68440..567de4d54fa 100644 --- a/pkg/attestation/attestationfakes/fake_signer_implementation.go +++ b/pkg/attestation/attestationfakes/fake_signer_implementation.go @@ -28,6 +28,23 @@ import ( ) type FakeSignerImplementation struct { + IdentityTokenStub func(context.Context, string, []byte, string, string) (*oauthflow.OIDCIDToken, error) + identityTokenMutex sync.RWMutex + identityTokenArgsForCall []struct { + arg1 context.Context + arg2 string + arg3 []byte + arg4 string + arg5 string + } + identityTokenReturns struct { + result1 *oauthflow.OIDCIDToken + result2 error + } + identityTokenReturnsOnCall map[int]struct { + result1 *oauthflow.OIDCIDToken + result2 error + } NewSignerStub func() *signer.Signer newSignerMutex sync.RWMutex newSignerArgsForCall []struct { @@ -51,22 +68,6 @@ type FakeSignerImplementation struct { result1 []byte result2 error } - ServiceAccountTokenStub func(context.Context, string, []byte, string) (*oauthflow.OIDCIDToken, error) - serviceAccountTokenMutex sync.RWMutex - serviceAccountTokenArgsForCall []struct { - arg1 context.Context - arg2 string - arg3 []byte - arg4 string - } - serviceAccountTokenReturns struct { - result1 *oauthflow.OIDCIDToken - result2 error - } - serviceAccountTokenReturnsOnCall map[int]struct { - result1 *oauthflow.OIDCIDToken - result2 error - } SignStatementStub func(*signer.Signer, []byte) (*bundle.Bundle, error) signStatementMutex sync.RWMutex signStatementArgsForCall []struct { @@ -109,6 +110,79 @@ type FakeSignerImplementation struct { invocationsMutex sync.RWMutex } +func (fake *FakeSignerImplementation) IdentityToken(arg1 context.Context, arg2 string, arg3 []byte, arg4 string, arg5 string) (*oauthflow.OIDCIDToken, error) { + var arg3Copy []byte + if arg3 != nil { + arg3Copy = make([]byte, len(arg3)) + copy(arg3Copy, arg3) + } + fake.identityTokenMutex.Lock() + ret, specificReturn := fake.identityTokenReturnsOnCall[len(fake.identityTokenArgsForCall)] + fake.identityTokenArgsForCall = append(fake.identityTokenArgsForCall, struct { + arg1 context.Context + arg2 string + arg3 []byte + arg4 string + arg5 string + }{arg1, arg2, arg3Copy, arg4, arg5}) + stub := fake.IdentityTokenStub + fakeReturns := fake.identityTokenReturns + fake.recordInvocation("IdentityToken", []interface{}{arg1, arg2, arg3Copy, arg4, arg5}) + fake.identityTokenMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeSignerImplementation) IdentityTokenCallCount() int { + fake.identityTokenMutex.RLock() + defer fake.identityTokenMutex.RUnlock() + return len(fake.identityTokenArgsForCall) +} + +func (fake *FakeSignerImplementation) IdentityTokenCalls(stub func(context.Context, string, []byte, string, string) (*oauthflow.OIDCIDToken, error)) { + fake.identityTokenMutex.Lock() + defer fake.identityTokenMutex.Unlock() + fake.IdentityTokenStub = stub +} + +func (fake *FakeSignerImplementation) IdentityTokenArgsForCall(i int) (context.Context, string, []byte, string, string) { + fake.identityTokenMutex.RLock() + defer fake.identityTokenMutex.RUnlock() + argsForCall := fake.identityTokenArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *FakeSignerImplementation) IdentityTokenReturns(result1 *oauthflow.OIDCIDToken, result2 error) { + fake.identityTokenMutex.Lock() + defer fake.identityTokenMutex.Unlock() + fake.IdentityTokenStub = nil + fake.identityTokenReturns = struct { + result1 *oauthflow.OIDCIDToken + result2 error + }{result1, result2} +} + +func (fake *FakeSignerImplementation) IdentityTokenReturnsOnCall(i int, result1 *oauthflow.OIDCIDToken, result2 error) { + fake.identityTokenMutex.Lock() + defer fake.identityTokenMutex.Unlock() + fake.IdentityTokenStub = nil + if fake.identityTokenReturnsOnCall == nil { + fake.identityTokenReturnsOnCall = make(map[int]struct { + result1 *oauthflow.OIDCIDToken + result2 error + }) + } + fake.identityTokenReturnsOnCall[i] = struct { + result1 *oauthflow.OIDCIDToken + result2 error + }{result1, result2} +} + func (fake *FakeSignerImplementation) NewSigner() *signer.Signer { fake.newSignerMutex.Lock() ret, specificReturn := fake.newSignerReturnsOnCall[len(fake.newSignerArgsForCall)] @@ -226,78 +300,6 @@ func (fake *FakeSignerImplementation) ReadStatementReturnsOnCall(i int, result1 }{result1, result2} } -func (fake *FakeSignerImplementation) ServiceAccountToken(arg1 context.Context, arg2 string, arg3 []byte, arg4 string) (*oauthflow.OIDCIDToken, error) { - var arg3Copy []byte - if arg3 != nil { - arg3Copy = make([]byte, len(arg3)) - copy(arg3Copy, arg3) - } - fake.serviceAccountTokenMutex.Lock() - ret, specificReturn := fake.serviceAccountTokenReturnsOnCall[len(fake.serviceAccountTokenArgsForCall)] - fake.serviceAccountTokenArgsForCall = append(fake.serviceAccountTokenArgsForCall, struct { - arg1 context.Context - arg2 string - arg3 []byte - arg4 string - }{arg1, arg2, arg3Copy, arg4}) - stub := fake.ServiceAccountTokenStub - fakeReturns := fake.serviceAccountTokenReturns - fake.recordInvocation("ServiceAccountToken", []interface{}{arg1, arg2, arg3Copy, arg4}) - fake.serviceAccountTokenMutex.Unlock() - if stub != nil { - return stub(arg1, arg2, arg3, arg4) - } - if specificReturn { - return ret.result1, ret.result2 - } - return fakeReturns.result1, fakeReturns.result2 -} - -func (fake *FakeSignerImplementation) ServiceAccountTokenCallCount() int { - fake.serviceAccountTokenMutex.RLock() - defer fake.serviceAccountTokenMutex.RUnlock() - return len(fake.serviceAccountTokenArgsForCall) -} - -func (fake *FakeSignerImplementation) ServiceAccountTokenCalls(stub func(context.Context, string, []byte, string) (*oauthflow.OIDCIDToken, error)) { - fake.serviceAccountTokenMutex.Lock() - defer fake.serviceAccountTokenMutex.Unlock() - fake.ServiceAccountTokenStub = stub -} - -func (fake *FakeSignerImplementation) ServiceAccountTokenArgsForCall(i int) (context.Context, string, []byte, string) { - fake.serviceAccountTokenMutex.RLock() - defer fake.serviceAccountTokenMutex.RUnlock() - argsForCall := fake.serviceAccountTokenArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4 -} - -func (fake *FakeSignerImplementation) ServiceAccountTokenReturns(result1 *oauthflow.OIDCIDToken, result2 error) { - fake.serviceAccountTokenMutex.Lock() - defer fake.serviceAccountTokenMutex.Unlock() - fake.ServiceAccountTokenStub = nil - fake.serviceAccountTokenReturns = struct { - result1 *oauthflow.OIDCIDToken - result2 error - }{result1, result2} -} - -func (fake *FakeSignerImplementation) ServiceAccountTokenReturnsOnCall(i int, result1 *oauthflow.OIDCIDToken, result2 error) { - fake.serviceAccountTokenMutex.Lock() - defer fake.serviceAccountTokenMutex.Unlock() - fake.ServiceAccountTokenStub = nil - if fake.serviceAccountTokenReturnsOnCall == nil { - fake.serviceAccountTokenReturnsOnCall = make(map[int]struct { - result1 *oauthflow.OIDCIDToken - result2 error - }) - } - fake.serviceAccountTokenReturnsOnCall[i] = struct { - result1 *oauthflow.OIDCIDToken - result2 error - }{result1, result2} -} - func (fake *FakeSignerImplementation) SignStatement(arg1 *signer.Signer, arg2 []byte) (*bundle.Bundle, error) { var arg2Copy []byte if arg2 != nil { diff --git a/pkg/attestation/sign_test.go b/pkg/attestation/sign_test.go index 98c4f391bed..0ac848baaf3 100644 --- a/pkg/attestation/sign_test.go +++ b/pkg/attestation/sign_test.go @@ -77,7 +77,7 @@ func TestSignFile(t *testing.T) { name: "success with service account key", opts: &SignerOptions{ServiceAccountFile: "key.json"}, prepare: func(mock *attestationfakes.FakeSignerImplementation) { - mock.ServiceAccountTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) + mock.IdentityTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) mock.WriteBundleCalls(func(_ *sbundle.Bundle, w io.Writer) error { _, err := w.Write([]byte("bundle")) @@ -91,7 +91,7 @@ func TestSignFile(t *testing.T) { name: "success with service account JSON", opts: &SignerOptions{ServiceAccountJSON: []byte("service-account-key-data")}, prepare: func(mock *attestationfakes.FakeSignerImplementation) { - mock.ServiceAccountTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) + mock.IdentityTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) mock.WriteBundleCalls(func(_ *sbundle.Bundle, w io.Writer) error { _, err := w.Write([]byte("bundle")) @@ -109,10 +109,24 @@ func TestSignFile(t *testing.T) { shouldErr: true, }, { - name: "ServiceAccountToken fails", + name: "success impersonating a service account", + opts: &SignerOptions{ImpersonateServiceAccount: "signer@example.iam.gserviceaccount.com"}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.IdentityTokenReturns(&oauthflow.OIDCIDToken{Subject: "signer@example.iam.gserviceaccount.com"}, nil) + mock.WriteBundleCalls(func(_ *sbundle.Bundle, w io.Writer) error { + _, err := w.Write([]byte("bundle")) + + return err + }) + }, + wantOutput: "bundle", + wantSAToks: 1, + }, + { + name: "IdentityToken fails", opts: &SignerOptions{ServiceAccountFile: "key.json"}, prepare: func(mock *attestationfakes.FakeSignerImplementation) { - mock.ServiceAccountTokenReturns(nil, errTest) + mock.IdentityTokenReturns(nil, errTest) }, shouldErr: true, wantSAToks: 1, @@ -153,13 +167,14 @@ func TestSignFile(t *testing.T) { require.Equal(t, tc.wantOutput, out.String()) } - require.Equal(t, tc.wantSAToks, mock.ServiceAccountTokenCallCount()) + require.Equal(t, tc.wantSAToks, mock.IdentityTokenCallCount()) if tc.wantSAToks > 0 { wantKeyData := tc.opts.ServiceAccountJSON - _, keyFile, keyData, audience := mock.ServiceAccountTokenArgsForCall(0) + _, keyFile, keyData, impersonate, audience := mock.IdentityTokenArgsForCall(0) require.Equal(t, tc.opts.ServiceAccountFile, keyFile) require.Equal(t, wantKeyData, keyData) + require.Equal(t, tc.opts.ImpersonateServiceAccount, impersonate) require.NotEmpty(t, audience, "audience must be the sigstore OIDC client ID") } }) @@ -189,7 +204,7 @@ func TestSignFiles(t *testing.T) { opts: &SignerOptions{ServiceAccountFile: "key.json"}, paths: []string{"a.json", "gs://bucket/b.json", "c.json"}, prepare: func(mock *attestationfakes.FakeSignerImplementation) { - mock.ServiceAccountTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) + mock.IdentityTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) }, wantSAToks: 1, wantSigns: 3, @@ -209,11 +224,11 @@ func TestSignFiles(t *testing.T) { shouldErr: true, }, { - name: "ServiceAccountToken fails", + name: "IdentityToken fails", opts: &SignerOptions{ServiceAccountFile: "key.json"}, paths: []string{"a.json"}, prepare: func(mock *attestationfakes.FakeSignerImplementation) { - mock.ServiceAccountTokenReturns(nil, errTest) + mock.IdentityTokenReturns(nil, errTest) }, shouldErr: true, wantSAToks: 1, @@ -271,7 +286,7 @@ func TestSignFiles(t *testing.T) { } require.Equal(t, tc.wantSigns, mock.SignStatementCallCount()) - require.Equal(t, tc.wantSAToks, mock.ServiceAccountTokenCallCount()) + require.Equal(t, tc.wantSAToks, mock.IdentityTokenCallCount()) if tc.wantSigns > 0 { // All statements are signed with the same signer so that the @@ -559,7 +574,7 @@ func serviceAccountKey(t *testing.T, credType, tokenURI string) []byte { return keyData } -func TestServiceAccountToken(t *testing.T) { +func TestIdentityToken(t *testing.T) { t.Parallel() const subject = "signer@example.iam.gserviceaccount.com" @@ -587,8 +602,8 @@ func TestServiceAccountToken(t *testing.T) { srv := newTokenServer(t, http.StatusOK, `{"id_token": "`+fakeJWT(t, subject)+`"}`) opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL)} - token, err := (&defaultSignerImpl{}).ServiceAccountToken( - context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", ) require.NoError(t, err) require.NotNil(t, token) @@ -602,8 +617,8 @@ func TestServiceAccountToken(t *testing.T) { srv := newTokenServer(t, http.StatusOK, `{"id_token": "`+fakeJWT(t, subject)+`"}`) opts := &SignerOptions{ServiceAccountJSON: serviceAccountKey(t, "service_account", srv.URL)} - token, err := (&defaultSignerImpl{}).ServiceAccountToken( - context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", ) require.NoError(t, err) require.NotNil(t, token) @@ -619,8 +634,8 @@ func TestServiceAccountToken(t *testing.T) { ServiceAccountJSON: []byte("not even json"), } - token, err := (&defaultSignerImpl{}).ServiceAccountToken( - context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", ) require.NoError(t, err) require.NotNil(t, token) @@ -629,7 +644,7 @@ func TestServiceAccountToken(t *testing.T) { t.Run("no key configured", func(t *testing.T) { t.Parallel() - token, err := (&defaultSignerImpl{}).ServiceAccountToken(context.Background(), "", nil, "sigstore") + token, err := (&defaultSignerImpl{}).IdentityToken(context.Background(), "", nil, "", "sigstore") require.Error(t, err) require.Nil(t, token) }) @@ -639,8 +654,8 @@ func TestServiceAccountToken(t *testing.T) { opts := &SignerOptions{ServiceAccountJSON: serviceAccountKey(t, "authorized_user", "http://127.0.0.1:1")} - token, err := (&defaultSignerImpl{}).ServiceAccountToken( - context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", ) require.Error(t, err) require.Nil(t, token) @@ -652,8 +667,8 @@ func TestServiceAccountToken(t *testing.T) { srv := newTokenServer(t, http.StatusUnauthorized, `{"error": "invalid_grant"}`) opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL)} - token, err := (&defaultSignerImpl{}).ServiceAccountToken( - context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", ) require.Error(t, err) require.Nil(t, token) @@ -665,8 +680,8 @@ func TestServiceAccountToken(t *testing.T) { srv := newTokenServer(t, http.StatusOK, `{}`) opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL)} - token, err := (&defaultSignerImpl{}).ServiceAccountToken( - context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", ) require.Error(t, err) require.Nil(t, token) @@ -677,8 +692,8 @@ func TestServiceAccountToken(t *testing.T) { opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "authorized_user", "http://127.0.0.1:1")} - token, err := (&defaultSignerImpl{}).ServiceAccountToken( - context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", ) require.Error(t, err) require.Nil(t, token) @@ -689,10 +704,50 @@ func TestServiceAccountToken(t *testing.T) { opts := &SignerOptions{ServiceAccountFile: filepath.Join(t.TempDir(), "missing.json")} - token, err := (&defaultSignerImpl{}).ServiceAccountToken( - context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, "sigstore", + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", ) require.Error(t, err) require.Nil(t, token) }) + + t.Run("invalid account to impersonate is rejected", func(t *testing.T) { + t.Parallel() + + token, err := (&defaultSignerImpl{}).IdentityToken(context.Background(), "", nil, "not-an-email", "sigstore") + require.ErrorContains(t, err, "not a valid service account email") + require.Nil(t, token) + }) + + t.Run("impersonation with a key that cannot be exchanged fails", func(t *testing.T) { + t.Parallel() + + // The key exchange is the caller credential for impersonation. When it + // fails, the token request must fail, never fall back to the host. + srv := newTokenServer(t, http.StatusUnauthorized, `{"error": "invalid_grant"}`) + opts := &SignerOptions{ + ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL), + ImpersonateServiceAccount: "signer@example.iam.gserviceaccount.com", + } + + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", + ) + require.ErrorContains(t, err, "impersonating signer@example.iam.gserviceaccount.com") + require.Nil(t, token) + }) +} + +// TestIdentityTokenImpersonationOffGCP checks that impersonating without any +// Google Cloud credential is an error and not a fallback. No t.Parallel: it +// points the metadata server lookup at a closed port with t.Setenv. +func TestIdentityTokenImpersonationOffGCP(t *testing.T) { //nolint:paralleltest // uses t.Setenv + t.Setenv("GCE_METADATA_HOST", "127.0.0.1:1") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), "", nil, "signer@example.iam.gserviceaccount.com", "sigstore", + ) + require.ErrorContains(t, err, "no Google Cloud credential available") + require.Nil(t, token) }