From 3ceedf97e1badf1c9a5b63394f41fd7b96b6b7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miloslav=20Trma=C4=8D?= Date: Sat, 21 Jan 2023 01:45:48 +0100 Subject: [PATCH] Add (skopeo generate-sigstore-key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miloslav Trmač --- README.md | 1 + cmd/skopeo/generate_sigstore_key.go | 90 ++++++++++++++++++++ cmd/skopeo/generate_sigstore_key_test.go | 102 +++++++++++++++++++++++ cmd/skopeo/main.go | 1 + docs/skopeo-generate-sigstore-key.1.md | 43 ++++++++++ docs/skopeo.1.md | 1 + 6 files changed, 238 insertions(+) create mode 100644 cmd/skopeo/generate_sigstore_key.go create mode 100644 cmd/skopeo/generate_sigstore_key_test.go create mode 100644 docs/skopeo-generate-sigstore-key.1.md diff --git a/README.md b/README.md index d85dfe4fc5..ed27abed2e 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ Please read the [contribution guide](CONTRIBUTING.md) if you want to collaborate | -------------------------------------------------- | ---------------------------------------------------------------------------------------------| | [skopeo-copy(1)](/docs/skopeo-copy.1.md) | Copy an image (manifest, filesystem layers, signatures) from one location to another. | | [skopeo-delete(1)](/docs/skopeo-delete.1.md) | Mark the image-name for later deletion by the registry's garbage collector. | +| [skopeo-generate-sigstore-key(1)](/docs/skopeo-generate-sigstore-key.1.md) | Generate a sigstore public/private key pair. | | [skopeo-inspect(1)](/docs/skopeo-inspect.1.md) | Return low-level information about image-name in a registry. | | [skopeo-list-tags(1)](/docs/skopeo-list-tags.1.md) | Return a list of tags for the transport-specific image repository. | | [skopeo-login(1)](/docs/skopeo-login.1.md) | Login to a container registry. | diff --git a/cmd/skopeo/generate_sigstore_key.go b/cmd/skopeo/generate_sigstore_key.go new file mode 100644 index 0000000000..2d5aebe39e --- /dev/null +++ b/cmd/skopeo/generate_sigstore_key.go @@ -0,0 +1,90 @@ +package main + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + + "github.com/containers/image/v5/pkg/cli" + "github.com/containers/image/v5/signature/sigstore" + "github.com/spf13/cobra" +) + +type generateSigstoreKeyOptions struct { + outputPrefix string + passphraseFile string +} + +func generateSigstoreKeyCmd() *cobra.Command { + var opts generateSigstoreKeyOptions + cmd := &cobra.Command{ + Use: "generate-sigstore-key --output-prefix PREFIX", + Short: "Generate a sigstore public/private key pair", + RunE: commandAction(opts.run), + Example: "skopeo generate-sigstore-key --output-prefix my-key", + } + adjustUsage(cmd) + flags := cmd.Flags() + flags.StringVar(&opts.outputPrefix, "output-prefix", "", "Write the keys to `PREFIX`.pub and `PREFIX`.private") + flags.StringVar(&opts.passphraseFile, "passphrase-file", "", "Read a passphrase for the private key from `PATH`") + return cmd +} + +// ensurePathDoesNotExist verifies that path does not refer to an existing file, +// and returns an error if so. +func ensurePathDoesNotExist(path string) error { + switch _, err := os.Stat(path); { + case err == nil: + return fmt.Errorf("Refusing to overwrite existing %q", path) + case errors.Is(err, fs.ErrNotExist): + return nil + default: + return fmt.Errorf("Error checking existence of %q: %w", path, err) + } +} + +func (opts *generateSigstoreKeyOptions) run(args []string, stdout io.Writer) error { + if len(args) != 0 || opts.outputPrefix == "" { + return errors.New("Usage: generate-sigstore-key --output-prefix PREFIX") + } + + pubKeyPath := opts.outputPrefix + ".pub" + privateKeyPath := opts.outputPrefix + ".private" + if err := ensurePathDoesNotExist(pubKeyPath); err != nil { + return err + } + if err := ensurePathDoesNotExist(privateKeyPath); err != nil { + return err + } + + var passphrase string + if opts.passphraseFile != "" { + p, err := cli.ReadPassphraseFile(opts.passphraseFile) + if err != nil { + return err + } + passphrase = p + } else { + p, err := promptForPassphrase(privateKeyPath, os.Stdin, os.Stdout) + if err != nil { + return err + } + passphrase = p + } + + keys, err := sigstore.GenerateKeyPair([]byte(passphrase)) + if err != nil { + return fmt.Errorf("Error generating key pair: %w", err) + } + + if err := os.WriteFile(privateKeyPath, keys.PrivateKey, 0600); err != nil { + return fmt.Errorf("Error writing private key to %q: %w", privateKeyPath, err) + } + if err := os.WriteFile(pubKeyPath, keys.PublicKey, 0644); err != nil { + return fmt.Errorf("Error writing private key to %q: %w", pubKeyPath, err) + } + fmt.Fprintf(stdout, "Key written to %q and %q", privateKeyPath, pubKeyPath) + return nil +} diff --git a/cmd/skopeo/generate_sigstore_key_test.go b/cmd/skopeo/generate_sigstore_key_test.go new file mode 100644 index 0000000000..1bccfad26a --- /dev/null +++ b/cmd/skopeo/generate_sigstore_key_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateSigstoreKey(t *testing.T) { + // Invalid command-line arguments + for _, args := range [][]string{ + {}, + {"--output-prefix", "foo", "a1"}, + } { + out, err := runSkopeo(append([]string{"generate-sigstore-key"}, args...)...) + assertTestFailed(t, out, err, "Usage") + } + + // One of the destination files already exists + outputSuffixes := []string{".pub", ".private"} + for _, suffix := range outputSuffixes { + dir := t.TempDir() + prefix := filepath.Join(dir, "prefix") + err := os.WriteFile(prefix+suffix, []byte{}, 0600) + require.NoError(t, err) + out, err := runSkopeo("generate-sigstore-key", + "--output-prefix", prefix, "--passphrase-file", "/dev/null", + ) + assertTestFailed(t, out, err, "Refusing to overwrite") + } + + // One of the destinations is inaccessible (simulate by a symlink to an inaccessible + // directory) + for _, suffix := range outputSuffixes { + dir := t.TempDir() + unaccessible := filepath.Join(dir, "unaccessible") + err := os.Mkdir(unaccessible, 0000) + require.NoError(t, err) + t.Cleanup(func() { + err := os.Chmod(unaccessible, 0700) + require.NoError(t, err) + err = os.Remove(unaccessible) + require.NoError(t, err) + }) + prefix := filepath.Join(dir, "prefix") + err = os.Symlink(filepath.Join(unaccessible, "unaccessible"), prefix+suffix) + require.NoError(t, err) + out, err := runSkopeo("generate-sigstore-key", + "--output-prefix", prefix, "--passphrase-file", "/dev/null", + ) + assertTestFailed(t, out, err, prefix+suffix) // + an OS-specific error message + } + destDir := t.TempDir() + // Error reading passphrase + out, err := runSkopeo("generate-sigstore-key", + "--output-prefix", filepath.Join(destDir, "prefix"), + "--passphrase-file", filepath.Join(destDir, "this-does-not-exist"), + ) + assertTestFailed(t, out, err, "this-does-not-exist") + + // (The interactive passphrase prompting is not yet tested) + + // Error writing one of the outputs: an unmodifiable directory + for _, suffix := range outputSuffixes { + dir := t.TempDir() + unwriteable := filepath.Join(dir, "subdir") + err := os.Mkdir(unwriteable, 0500) + require.NoError(t, err) + t.Cleanup(func() { + err := os.Chmod(unwriteable, 0700) + require.NoError(t, err) + err = os.Remove(unwriteable) + require.NoError(t, err) + }) + prefix := filepath.Join(dir, "prefix") + err = os.Symlink(filepath.Join(unwriteable, "unwriteable"), prefix+suffix) + require.NoError(t, err) + out, err := runSkopeo("generate-sigstore-key", + "--output-prefix", prefix, "--passphrase-file", "/dev/null", + ) + assertTestFailed(t, out, err, "Error writing") + } + + // Success + // Just a smoke-test, useability of the keys is tested in the generate implementation. + dir := t.TempDir() + prefix := filepath.Join(dir, "prefix") + passphraseFile := filepath.Join(dir, "passphrase") + err = os.WriteFile(passphraseFile, []byte("some passphrase"), 0600) + require.NoError(t, err) + out, err = runSkopeo("generate-sigstore-key", + "--output-prefix", prefix, "--passphrase-file", passphraseFile, + ) + assert.NoError(t, err) + for _, suffix := range outputSuffixes { + assert.Contains(t, out, prefix+suffix) + } + +} diff --git a/cmd/skopeo/main.go b/cmd/skopeo/main.go index 3f8a9621ec..434fee400b 100644 --- a/cmd/skopeo/main.go +++ b/cmd/skopeo/main.go @@ -98,6 +98,7 @@ func createApp() (*cobra.Command, *globalOptions) { rootCommand.AddCommand( copyCmd(&opts), deleteCmd(&opts), + generateSigstoreKeyCmd(), inspectCmd(&opts), layersCmd(&opts), loginCmd(&opts), diff --git a/docs/skopeo-generate-sigstore-key.1.md b/docs/skopeo-generate-sigstore-key.1.md new file mode 100644 index 0000000000..5310f7aac1 --- /dev/null +++ b/docs/skopeo-generate-sigstore-key.1.md @@ -0,0 +1,43 @@ +% skopeo-generate-sigstore-key(1) + +## NAME +skopeo\-generate-sigstore-key - Generate a sigstore public/private key pair. + +## SYNOPSIS +**skopeo generate-sigstore-key** [*options*] **--output-prefix** _prefix_ + +## DESCRIPTION + +Generates a public/private key pair suitable for creating sigstore image signatures. +The private key is encrypted with a passphrase; +if one is not provided using an option, this command prompts for it interactively. + +The private key is written to _prefix_**.private** . +The private key is written to _prefix_**.pub** . + +## OPTIONS + +**--output-prefix** _prefix_ + +Mandatory. +Path prefix for the output keys (_prefix_**.private** and _prefix_**.pub**). + +**--passphrase-file** _path_ + +The passphare to use to encrypt the private key. +Only the first line will be read. +A passphrase stored in a file is of questionable security if other users can read this file. +Do not use this option if at all avoidable. + +## EXAMPLES + +```sh +$ skopeo generate-sigstore-key --output-prefix mykey +``` + +# SEE ALSO +skopeo(1), skopeo-copy(1), containers-policy.json(5) + +## AUTHORS + +Miloslav Trmač diff --git a/docs/skopeo.1.md b/docs/skopeo.1.md index 4421a4764f..c10a00e994 100644 --- a/docs/skopeo.1.md +++ b/docs/skopeo.1.md @@ -101,6 +101,7 @@ Print the version number | ----------------------------------------- | ------------------------------------------------------------------------------ | | [skopeo-copy(1)](skopeo-copy.1.md) | Copy an image (manifest, filesystem layers, signatures) from one location to another. | | [skopeo-delete(1)](skopeo-delete.1.md) | Mark the _image-name_ for later deletion by the registry's garbage collector. | +| [skopeo-generate-sigstore-key(1)](skopeo-generate-sigstore-key.1.md) | Generate a sigstore public/private key pair. | | [skopeo-inspect(1)](skopeo-inspect.1.md) | Return low-level information about _image-name_ in a registry. | | [skopeo-list-tags(1)](skopeo-list-tags.1.md) | List image names in a transport-specific collection of images.| | [skopeo-login(1)](skopeo-login.1.md) | Login to a container registry. |