Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions cmd/krel/cmd/sign_attestation.go
Original file line number Diff line number Diff line change
@@ -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
}
165 changes: 165 additions & 0 deletions cmd/krel/cmd/sign_attestation_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
Loading