diff --git a/cmd/krel/cmd/sign_attestation.go b/cmd/krel/cmd/sign_attestation.go new file mode 100644 index 00000000000..ceeedd40037 --- /dev/null +++ b/cmd/krel/cmd/sign_attestation.go @@ -0,0 +1,227 @@ +/* +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 ( + "bytes" + "fmt" + "os" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "k8s.io/release/pkg/attestation" +) + +const ( + serviceAccountFileFlag = "service-account-file" + impersonateServiceAccountFlag = "impersonate-service-account" + inPlaceFlag = "in-place" +) + +type signAttestationOptions struct { + outputPath string + inPlace bool + serviceAccountFile string + // serviceAccountJSON is the key data read from the environment + serviceAccountJSON string + impersonateServiceAccount string +} + +var signAttestationOpts = &signAttestationOptions{} + +// signAttestationCmd represents the subcommand for `krel sign attestation`. +var signAttestationCmd = &cobra.Command{ + Use: "attestation statement.json [--in-place statement.json...]", + Short: "Sign an in-toto statement into a sigstore bundle", + 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 +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 +file with --` + serviceAccountFileFlag + ` or set the contents of the key in +the ` + attestation.ServiceAccountEnvKey + ` environment variable (the flag +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 + + # Sign using a service account key: + 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 + + # 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), + 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) + }, +} + +func init() { + signAttestationCmd.PersistentFlags().StringVar( + &signAttestationOpts.outputPath, + outputPathFlag, + "", + "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, + "", + "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) +} + +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.ImpersonateServiceAccount = opts.impersonateServiceAccount + 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 + + if err := signer.SignFile(statements[0], &bundle); err != nil { + return fmt.Errorf("signing attestation: %w", err) + } + + if opts.outputPath == "" { + if _, err := bundle.WriteTo(os.Stdout); err != nil { + return fmt.Errorf("writing bundle to stdout: %w", err) + } + + return nil + } + + 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) + + 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/cmd/krel/cmd/sign_attestation_test.go b/cmd/krel/cmd/sign_attestation_test.go new file mode 100644 index 00000000000..defee759566 --- /dev/null +++ b/cmd/krel/cmd/sign_attestation_test.go @@ -0,0 +1,165 @@ +/* +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 ( + "os" + "path/filepath" + "testing" + "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() + + signOpts := &signOptions{timeout: time.Second} + + t.Run("missing statement", func(t *testing.T) { + t.Parallel() + + err := runSignAttestation( + signOpts, &signAttestationOptions{}, []string{filepath.Join(t.TempDir(), "missing.json")}, + ) + 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) { + 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"}`}, []string{statement}, + ) + 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/go.mod b/go.mod index 264ee8b0002..7b0daccb3d5 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.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 @@ -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..0bb28e3f44f 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.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= @@ -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..567de4d54fa --- /dev/null +++ b/pkg/attestation/attestationfakes/fake_signer_implementation.go @@ -0,0 +1,522 @@ +/* +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 { + 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 { + } + 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 + } + SignStatementStub func(*signer.Signer, []byte) (*bundle.Bundle, error) + signStatementMutex sync.RWMutex + signStatementArgsForCall []struct { + arg1 *signer.Signer + arg2 []byte + } + signStatementReturns struct { + result1 *bundle.Bundle + result2 error + } + signStatementReturnsOnCall map[int]struct { + result1 *bundle.Bundle + result2 error + } + WriteBundleStub func(*bundle.Bundle, io.Writer) error + writeBundleMutex sync.RWMutex + writeBundleArgsForCall []struct { + arg1 *bundle.Bundle + arg2 io.Writer + } + writeBundleReturns struct { + result1 error + } + 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 +} + +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)] + 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) 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 []byte + }{arg1, arg2Copy}) + stub := fake.SignStatementStub + fakeReturns := fake.signStatementReturns + fake.recordInvocation("SignStatement", []interface{}{arg1, arg2Copy}) + fake.signStatementMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + 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, []byte) (*bundle.Bundle, error)) { + fake.signStatementMutex.Lock() + defer fake.signStatementMutex.Unlock() + fake.SignStatementStub = stub +} + +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 +} + +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 *bundle.Bundle, arg2 io.Writer) error { + fake.writeBundleMutex.Lock() + ret, specificReturn := fake.writeBundleReturnsOnCall[len(fake.writeBundleArgsForCall)] + fake.writeBundleArgsForCall = append(fake.writeBundleArgsForCall, struct { + arg1 *bundle.Bundle + arg2 io.Writer + }{arg1, arg2}) + stub := fake.WriteBundleStub + fakeReturns := fake.writeBundleReturns + fake.recordInvocation("WriteBundle", []interface{}{arg1, arg2}) + fake.writeBundleMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + 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(*bundle.Bundle, io.Writer) error) { + fake.writeBundleMutex.Lock() + defer fake.writeBundleMutex.Unlock() + fake.WriteBundleStub = stub +} + +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 +} + +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) 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() + 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..63217e5979e --- /dev/null +++ b/pkg/attestation/sign.go @@ -0,0 +1,455 @@ +/* +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" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "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" + + "sigs.k8s.io/release-sdk/object" +) + +//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" + +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 { + // 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 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 + + // 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 pinned identity. + Timeout time.Duration +} + +// DefaultSignerOptions returns the default signer options. +func DefaultSignerOptions() *SignerOptions { + return &SignerOptions{ + Timeout: 3 * time.Minute, + } +} + +// 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 +// 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(statementPath string) ([]byte, error) + NewSigner() *signer.Signer + 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 +} + +// 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 { + signed, err := s.SignFiles([]string{statementPath}) + if err != nil { + 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 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.IdentityToken( + ctx, s.options.ServiceAccountFile, s.options.ServiceAccountJSON, + s.options.ImpersonateServiceAccount, sgnr.Options.OIDCConfig.ClientID, + ) + if err != nil { + return nil, fmt.Errorf("obtaining the signing identity: %w", err) + } + + sgnr.Options.Token = token + sgnr.Options.DisableSTS = true + + logrus.Infof("Signing %d statement(s) as %s", len(statements), token.Subject) + } else { + logrus.Infof("Signing %d statement(s) with the ambient credentials", len(statements)) + } + + 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}) + } + + 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) + } + + 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 { + // 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) + } + + 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) + } + + 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() +} + +// 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) { + providerOpts := []gcp.Option{} + + switch { + case keyFile != "": + logrus.Infof("Obtaining identity token from service account key %s", keyFile) + + providerOpts = append(providerOpts, gcp.WithServiceAccountFile(keyFile), gcp.WithAmbientCredentials(false)) + case len(keyJSON) > 0: + logrus.Info("Obtaining identity token from service account key data") + + 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(providerOpts...) + 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 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(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, +// 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) { + 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 +} + +// 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) +} diff --git a/pkg/attestation/sign_test.go b/pkg/attestation/sign_test.go new file mode 100644 index 00000000000..0ac848baaf3 --- /dev/null +++ b/pkg/attestation/sign_test.go @@ -0,0 +1,753 @@ +/* +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(_ *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.IdentityTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) + mock.WriteBundleCalls(func(_ *sbundle.Bundle, w io.Writer) error { + _, err := w.Write([]byte("bundle")) + + return err + }) + }, + wantOutput: "bundle", + wantSAToks: 1, + }, + { + name: "success with service account JSON", + opts: &SignerOptions{ServiceAccountJSON: []byte("service-account-key-data")}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.IdentityTokenReturns(&oauthflow.OIDCIDToken{Subject: "sa@example.com"}, nil) + mock.WriteBundleCalls(func(_ *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: "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.IdentityTokenReturns(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.IdentityTokenCallCount()) + + if tc.wantSAToks > 0 { + wantKeyData := tc.opts.ServiceAccountJSON + _, 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") + } + }) + } +} + +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.IdentityTokenReturns(&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: "IdentityToken fails", + opts: &SignerOptions{ServiceAccountFile: "key.json"}, + paths: []string{"a.json"}, + prepare: func(mock *attestationfakes.FakeSignerImplementation) { + mock.IdentityTokenReturns(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.IdentityTokenCallCount()) + + 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() + + // 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) +} + +// 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 + src string + uploaded []byte +} + +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 (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() + + 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() + + for _, tc := range []struct { + name string + content string + missing bool + shouldErr bool + errIs error + }{ + {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, + }, + { + 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() + + 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) + + if tc.errIs != nil { + require.ErrorIs(t, err, tc.errIs) + } + + 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() + + 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) + + // 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) + + return keyData +} + +func TestIdentityToken(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 with key file", 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)} + + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "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{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "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{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", + ) + require.NoError(t, err) + require.NotNil(t, token) + }) + + t.Run("no key configured", func(t *testing.T) { + t.Parallel() + + token, err := (&defaultSignerImpl{}).IdentityToken(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{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "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"}`) + opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL)} + + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "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, `{}`) + opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "service_account", srv.URL)} + + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", + ) + require.Error(t, err) + require.Nil(t, token) + }) + + t.Run("key file is not a service account", func(t *testing.T) { + t.Parallel() + + opts := &SignerOptions{ServiceAccountFile: writeServiceAccountKey(t, "authorized_user", "http://127.0.0.1:1")} + + token, err := (&defaultSignerImpl{}).IdentityToken( + context.Background(), opts.ServiceAccountFile, opts.ServiceAccountJSON, opts.ImpersonateServiceAccount, "sigstore", + ) + require.Error(t, err) + require.Nil(t, token) + }) + + 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{}).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) +}