diff options
-rw-r--r-- | cmd/podman/common/sign.go | 36 | ||||
-rw-r--r-- | cmd/podman/images/push.go | 19 | ||||
-rw-r--r-- | cmd/podman/manifest/push.go | 19 | ||||
-rw-r--r-- | docs/source/markdown/podman-manifest-push.1.md | 10 | ||||
-rw-r--r-- | docs/source/markdown/podman-push.1.md | 10 | ||||
-rw-r--r-- | pkg/domain/entities/images.go | 10 | ||||
-rw-r--r-- | pkg/domain/infra/abi/images.go | 3 | ||||
-rw-r--r-- | pkg/domain/infra/abi/manifest.go | 3 | ||||
-rw-r--r-- | test/e2e/push_test.go | 40 | ||||
-rw-r--r-- | test/e2e/sign/policy.json | 6 | ||||
-rw-r--r-- | test/e2e/testdata/sigstore-key.key | 11 | ||||
-rw-r--r-- | test/e2e/testdata/sigstore-key.key.pass | 1 | ||||
-rw-r--r-- | test/e2e/testdata/sigstore-key.pub | 4 | ||||
-rw-r--r-- | test/e2e/testdata/sigstore-registries.d-fragment.yaml | 3 | ||||
-rw-r--r-- | vendor/github.com/containers/image/v5/pkg/cli/passphrase.go | 36 | ||||
-rw-r--r-- | vendor/modules.txt | 1 |
16 files changed, 206 insertions, 6 deletions
diff --git a/cmd/podman/common/sign.go b/cmd/podman/common/sign.go new file mode 100644 index 000000000..e8a90ed57 --- /dev/null +++ b/cmd/podman/common/sign.go @@ -0,0 +1,36 @@ +package common + +import ( + "fmt" + + "github.com/containers/image/v5/pkg/cli" + "github.com/containers/podman/v4/pkg/domain/entities" + "github.com/containers/podman/v4/pkg/terminal" +) + +// PrepareSigningPassphrase updates pushOpts.SignPassphrase and SignSigstorePrivateKeyPassphrase based on a --sign-passphrase-file value signPassphraseFile, +// and validates pushOpts.Sign* consistency. +// It may interactively prompt for a passphrase if one is required and wasn’t provided otherwise. +func PrepareSigningPassphrase(pushOpts *entities.ImagePushOptions, signPassphraseFile string) error { + // c/common/libimage.Image does allow creating both simple signing and sigstore signatures simultaneously, + // with independent passphrases, but that would make the CLI probably too confusing. + // For now, use the passphrase with either, but only one of them. + if signPassphraseFile != "" && pushOpts.SignBy != "" && pushOpts.SignBySigstorePrivateKeyFile != "" { + return fmt.Errorf("only one of --sign-by and sign-by-sigstore-private-key can be used with --sign-passphrase-file") + } + + var passphrase string + if signPassphraseFile != "" { + p, err := cli.ReadPassphraseFile(signPassphraseFile) + if err != nil { + return err + } + passphrase = p + } else if pushOpts.SignBySigstorePrivateKeyFile != "" { + p := terminal.ReadPassphrase() + passphrase = string(p) + } // pushOpts.SignBy triggers a GPG-agent passphrase prompt, possibly using a more secure channel, so we usually shouldn’t prompt ourselves if no passphrase was explicitly provided. + pushOpts.SignPassphrase = passphrase + pushOpts.SignSigstorePrivateKeyPassphrase = []byte(passphrase) + return nil +} diff --git a/cmd/podman/images/push.go b/cmd/podman/images/push.go index 764936426..1734900de 100644 --- a/cmd/podman/images/push.go +++ b/cmd/podman/images/push.go @@ -17,8 +17,9 @@ import ( // CLI-only fields into the API types. type pushOptionsWrapper struct { entities.ImagePushOptions - TLSVerifyCLI bool // CLI only - CredentialsCLI string + TLSVerifyCLI bool // CLI only + CredentialsCLI string + SignPassphraseFileCLI string } var ( @@ -106,6 +107,14 @@ func pushFlags(cmd *cobra.Command) { flags.StringVar(&pushOptions.SignBy, signByFlagName, "", "Add a signature at the destination using the specified key") _ = cmd.RegisterFlagCompletionFunc(signByFlagName, completion.AutocompleteNone) + signBySigstorePrivateKeyFlagName := "sign-by-sigstore-private-key" + flags.StringVar(&pushOptions.SignBySigstorePrivateKeyFile, signBySigstorePrivateKeyFlagName, "", "Sign the image using a sigstore private key at `PATH`") + _ = cmd.RegisterFlagCompletionFunc(signBySigstorePrivateKeyFlagName, completion.AutocompleteDefault) + + signPassphraseFileFlagName := "sign-passphrase-file" + flags.StringVar(&pushOptions.SignPassphraseFileCLI, signPassphraseFileFlagName, "", "Read a passphrase for signing an image from `PATH`") + _ = cmd.RegisterFlagCompletionFunc(signPassphraseFileFlagName, completion.AutocompleteDefault) + flags.BoolVar(&pushOptions.TLSVerifyCLI, "tls-verify", true, "Require HTTPS and verify certificates when contacting registries") compressionFormat := "compression-format" @@ -118,6 +127,8 @@ func pushFlags(cmd *cobra.Command) { _ = flags.MarkHidden("digestfile") _ = flags.MarkHidden("quiet") _ = flags.MarkHidden(signByFlagName) + _ = flags.MarkHidden(signBySigstorePrivateKeyFlagName) + _ = flags.MarkHidden(signPassphraseFileFlagName) } if !registry.IsRemote() { flags.StringVar(&pushOptions.SignaturePolicy, "signature-policy", "", "Path to a signature-policy file") @@ -153,6 +164,10 @@ func imagePush(cmd *cobra.Command, args []string) error { pushOptions.Password = creds.Password } + if err := common.PrepareSigningPassphrase(&pushOptions.ImagePushOptions, pushOptions.SignPassphraseFileCLI); err != nil { + return err + } + // Let's do all the remaining Yoga in the API to prevent us from scattering // logic across (too) many parts of the code. return registry.ImageEngine().Push(registry.GetContext(), source, destination, pushOptions.ImagePushOptions) diff --git a/cmd/podman/manifest/push.go b/cmd/podman/manifest/push.go index df394d275..6afd8ea4c 100644 --- a/cmd/podman/manifest/push.go +++ b/cmd/podman/manifest/push.go @@ -20,8 +20,9 @@ import ( type manifestPushOptsWrapper struct { entities.ImagePushOptions - TLSVerifyCLI bool // CLI only - CredentialsCLI string + TLSVerifyCLI bool // CLI only + CredentialsCLI string + SignPassphraseFileCLI string } var ( @@ -72,6 +73,14 @@ func init() { flags.StringVar(&manifestPushOpts.SignBy, signByFlagName, "", "sign the image using a GPG key with the specified `FINGERPRINT`") _ = pushCmd.RegisterFlagCompletionFunc(signByFlagName, completion.AutocompleteNone) + signBySigstorePrivateKeyFlagName := "sign-by-sigstore-private-key" + flags.StringVar(&manifestPushOpts.SignBySigstorePrivateKeyFile, signBySigstorePrivateKeyFlagName, "", "Sign the image using a sigstore private key at `PATH`") + _ = pushCmd.RegisterFlagCompletionFunc(signBySigstorePrivateKeyFlagName, completion.AutocompleteDefault) + + signPassphraseFileFlagName := "sign-passphrase-file" + flags.StringVar(&manifestPushOpts.SignPassphraseFileCLI, signPassphraseFileFlagName, "", "Read a passphrase for signing an image from `PATH`") + _ = pushCmd.RegisterFlagCompletionFunc(signPassphraseFileFlagName, completion.AutocompleteDefault) + flags.BoolVar(&manifestPushOpts.TLSVerifyCLI, "tls-verify", true, "require HTTPS and verify certificates when accessing the registry") flags.BoolVarP(&manifestPushOpts.Quiet, "quiet", "q", false, "don't output progress information when pushing lists") flags.SetNormalizeFunc(utils.AliasFlags) @@ -79,6 +88,8 @@ func init() { if registry.IsRemote() { _ = flags.MarkHidden("cert-dir") _ = flags.MarkHidden(signByFlagName) + _ = flags.MarkHidden(signBySigstorePrivateKeyFlagName) + _ = flags.MarkHidden(signPassphraseFileFlagName) } } @@ -104,6 +115,10 @@ func push(cmd *cobra.Command, args []string) error { manifestPushOpts.Password = creds.Password } + if err := common.PrepareSigningPassphrase(&manifestPushOpts.ImagePushOptions, manifestPushOpts.SignPassphraseFileCLI); err != nil { + return err + } + // TLS verification in c/image is controlled via a `types.OptionalBool` // which allows for distinguishing among set-true, set-false, unspecified // which is important to implement a sane way of dealing with defaults of diff --git a/docs/source/markdown/podman-manifest-push.1.md b/docs/source/markdown/podman-manifest-push.1.md index 22e8cae9a..66cdb8324 100644 --- a/docs/source/markdown/podman-manifest-push.1.md +++ b/docs/source/markdown/podman-manifest-push.1.md @@ -60,7 +60,15 @@ Delete the manifest list or image index from local storage if pushing succeeds. #### **--sign-by**=*fingerprint* -Sign the pushed images using the GPG key that matches the specified fingerprint. +Sign the pushed images with a “simple signing” signature using the specified key. (This option is not available with the remote Podman client, including Mac and Windows (excluding WSL2) machines) + +#### **--sign-by-sigstore-private-key**=*path* + +Sign the pushed images with a sigstore signature using a private key at the specified path. (This option is not available with the remote Podman client, including Mac and Windows (excluding WSL2) machines) + +#### **--sign-passphrase-file**=*path* + +If signing the image (using either **--sign-by** or **--sign-by-sigstore-private-key**), read the passphrase to use from the specified path. #### **--tls-verify** diff --git a/docs/source/markdown/podman-push.1.md b/docs/source/markdown/podman-push.1.md index 3cda982ac..a69bdce0a 100644 --- a/docs/source/markdown/podman-push.1.md +++ b/docs/source/markdown/podman-push.1.md @@ -99,7 +99,15 @@ Discard any pre-existing signatures in the image. #### **--sign-by**=*key* -Add a signature at the destination using the specified key. (This option is not available with the remote Podman client, including Mac and Windows (excluding WSL2) machines) +Add a “simple signing” signature at the destination using the specified key. (This option is not available with the remote Podman client, including Mac and Windows (excluding WSL2) machines) + +#### **--sign-by-sigstore-private-key**=*path* + +Add a sigstore signature at the destination using a private key at the specified path. (This option is not available with the remote Podman client, including Mac and Windows (excluding WSL2) machines) + +#### **--sign-passphrase-file**=*path* + +If signing the image (using either **--sign-by** or **--sign-by-sigstore-private-key**), read the passphrase to use from the specified path. #### **--tls-verify** diff --git a/pkg/domain/entities/images.go b/pkg/domain/entities/images.go index b8b346005..dad2dc6cc 100644 --- a/pkg/domain/entities/images.go +++ b/pkg/domain/entities/images.go @@ -205,6 +205,16 @@ type ImagePushOptions struct { // SignBy adds a signature at the destination using the specified key. // Ignored for remote calls. SignBy string + // SignPassphrase, if non-empty, specifies a passphrase to use when signing + // with the key ID from SignBy. + SignPassphrase string + // SignBySigstorePrivateKeyFile, if non-empty, asks for a signature to be added + // during the copy, using a sigstore private key file at the provided path. + // Ignored for remote calls. + SignBySigstorePrivateKeyFile string + // SignSigstorePrivateKeyPassphrase is the passphrase to use when signing with + // SignBySigstorePrivateKeyFile. + SignSigstorePrivateKeyPassphrase []byte // SkipTLSVerify to skip HTTPS and certificate verification. SkipTLSVerify types.OptionalBool // Progress to get progress notifications diff --git a/pkg/domain/infra/abi/images.go b/pkg/domain/infra/abi/images.go index ff42b0367..94178a8e2 100644 --- a/pkg/domain/infra/abi/images.go +++ b/pkg/domain/infra/abi/images.go @@ -304,6 +304,9 @@ func (ir *ImageEngine) Push(ctx context.Context, source string, destination stri pushOptions.ManifestMIMEType = manifestType pushOptions.RemoveSignatures = options.RemoveSignatures pushOptions.SignBy = options.SignBy + pushOptions.SignPassphrase = options.SignPassphrase + pushOptions.SignBySigstorePrivateKeyFile = options.SignBySigstorePrivateKeyFile + pushOptions.SignSigstorePrivateKeyPassphrase = options.SignSigstorePrivateKeyPassphrase pushOptions.InsecureSkipTLSVerify = options.SkipTLSVerify pushOptions.Writer = options.Writer diff --git a/pkg/domain/infra/abi/manifest.go b/pkg/domain/infra/abi/manifest.go index d20744d76..60f727234 100644 --- a/pkg/domain/infra/abi/manifest.go +++ b/pkg/domain/infra/abi/manifest.go @@ -316,6 +316,9 @@ func (ir *ImageEngine) ManifestPush(ctx context.Context, name, destination strin pushOptions.ManifestMIMEType = manifestType pushOptions.RemoveSignatures = opts.RemoveSignatures pushOptions.SignBy = opts.SignBy + pushOptions.SignPassphrase = opts.SignPassphrase + pushOptions.SignBySigstorePrivateKeyFile = opts.SignBySigstorePrivateKeyFile + pushOptions.SignSigstorePrivateKeyPassphrase = opts.SignSigstorePrivateKeyPassphrase pushOptions.InsecureSkipTLSVerify = opts.SkipTLSVerify if opts.All { diff --git a/test/e2e/push_test.go b/test/e2e/push_test.go index 0faa040f4..898d21d00 100644 --- a/test/e2e/push_test.go +++ b/test/e2e/push_test.go @@ -4,6 +4,7 @@ import ( "fmt" "io/ioutil" "os" + "os/exec" "path/filepath" "strings" @@ -136,6 +137,45 @@ var _ = Describe("Podman push", func() { Expect(fi.Name()).To(Equal("digestfile.txt")) Expect(push2).Should(Exit(0)) } + + if !IsRemote() { // Remote does not support signing + By("pushing and pulling with sigstore signatures") + // Ideally, this should set SystemContext.RegistriesDirPath, but Podman currently doesn’t + // expose that as an option. So, for now, modify /etc/directly, and skip testing sigstore if + // we don’t have permission to do so. + systemRegistriesDAddition := "/etc/containers/registries.d/podman-test-only-temporary-addition.yaml" + cmd := exec.Command("cp", "testdata/sigstore-registries.d-fragment.yaml", systemRegistriesDAddition) + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "Skipping sigstore tests because /etc/containers/registries.d isn’t writable: %s", string(output)) + } else { + defer func() { + err := os.Remove(systemRegistriesDAddition) + Expect(err).ToNot(HaveOccurred()) + }() + + // Verify that the policy rejects unsigned images + push := podmanTest.Podman([]string{"push", "-q", "--tls-verify=false", "--remove-signatures", ALPINE, "localhost:5000/sigstore-signed"}) + push.WaitWithDefaultTimeout() + Expect(push).Should(Exit(0)) + Expect(len(push.ErrorToString())).To(Equal(0)) + + pull := podmanTest.Podman([]string{"pull", "-q", "--tls-verify=false", "--signature-policy", "sign/policy.json", "localhost:5000/sigstore-signed"}) + pull.WaitWithDefaultTimeout() + Expect(pull).To(ExitWithError()) + Expect(pull.ErrorToString()).To(ContainSubstring("A signature was required, but no signature exists")) + + // Sign an image, and verify it is accepted. + push = podmanTest.Podman([]string{"push", "-q", "--tls-verify=false", "--remove-signatures", "--sign-by-sigstore-private-key", "testdata/sigstore-key.key", "--sign-passphrase-file", "testdata/sigstore-key.key.pass", ALPINE, "localhost:5000/sigstore-signed"}) + push.WaitWithDefaultTimeout() + Expect(push).Should(Exit(0)) + Expect(len(push.ErrorToString())).To(Equal(0)) + + pull = podmanTest.Podman([]string{"pull", "-q", "--tls-verify=false", "--signature-policy", "sign/policy.json", "localhost:5000/sigstore-signed"}) + pull.WaitWithDefaultTimeout() + Expect(pull).Should(Exit(0)) + } + } }) It("podman push to local registry with authorization", func() { diff --git a/test/e2e/sign/policy.json b/test/e2e/sign/policy.json index ab01137bf..812c14989 100644 --- a/test/e2e/sign/policy.json +++ b/test/e2e/sign/policy.json @@ -12,6 +12,12 @@ "keyType": "GPGKeys", "keyPath": "/tmp/key.gpg" } + ], + "localhost:5000/sigstore-signed": [ + { + "type": "sigstoreSigned", + "keyPath": "testdata/sigstore-key.pub" + } ] } } diff --git a/test/e2e/testdata/sigstore-key.key b/test/e2e/testdata/sigstore-key.key new file mode 100644 index 000000000..c4eed76a8 --- /dev/null +++ b/test/e2e/testdata/sigstore-key.key @@ -0,0 +1,11 @@ +-----BEGIN ENCRYPTED COSIGN PRIVATE KEY----- +eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjozMjc2OCwiciI6 +OCwicCI6MX0sInNhbHQiOiI2ckxVcEl1M1pTallrY3dua1pNVktuTHNDUjRENTJv +Y3J5Wmh2anZ4L1VrPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94 +Iiwibm9uY2UiOiJMTVpkeTNBL285NS9SektUZGR3RURhODJTVThVcDdlKyJ9LCJj +aXBoZXJ0ZXh0IjoiNkkzUlRCc1IwRXpHZWs0SE5LazlVdlpyMEp6Y1Bxemw0ZkEr +SitJdHlCc0RBSkcyNmhESnFLUDFuQkJTUE5XdHpJRzJUVzQ5Z2hObEJmQy9qYVNk +eFo2QmhXYk9ldlY0MDB4WjVNZ1oyVHdGSnJxaE9HK0JMdmNvanVkc2tOUFpJTlpE +LytFZVBIYTRlRVJPTWhnSWlTRC9BYTd3eitlc2trVjkrN216Y3N2RVRiTTJTZGd6 +L3daMUtqV3FlOUc2MWlXSTJPSm1rRlhxQWc9PSJ9 +-----END ENCRYPTED COSIGN PRIVATE KEY----- diff --git a/test/e2e/testdata/sigstore-key.key.pass b/test/e2e/testdata/sigstore-key.key.pass new file mode 100644 index 000000000..beb5c7687 --- /dev/null +++ b/test/e2e/testdata/sigstore-key.key.pass @@ -0,0 +1 @@ +sigstore pass diff --git a/test/e2e/testdata/sigstore-key.pub b/test/e2e/testdata/sigstore-key.pub new file mode 100644 index 000000000..1f470f72b --- /dev/null +++ b/test/e2e/testdata/sigstore-key.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEX/AWbBiFPuAU5+ys+Ce8YFPhTr1a +nM7A8h6NrQi6w8w8/4dJCzlGH4SN+P93nopATs6jDXs4Lpc2/tiA1SBmzA== +-----END PUBLIC KEY----- diff --git a/test/e2e/testdata/sigstore-registries.d-fragment.yaml b/test/e2e/testdata/sigstore-registries.d-fragment.yaml new file mode 100644 index 000000000..d79f4c935 --- /dev/null +++ b/test/e2e/testdata/sigstore-registries.d-fragment.yaml @@ -0,0 +1,3 @@ +docker: + localhost:5000/sigstore-signed: + use-sigstore-attachments: true diff --git a/vendor/github.com/containers/image/v5/pkg/cli/passphrase.go b/vendor/github.com/containers/image/v5/pkg/cli/passphrase.go new file mode 100644 index 000000000..c46650cdc --- /dev/null +++ b/vendor/github.com/containers/image/v5/pkg/cli/passphrase.go @@ -0,0 +1,36 @@ +package cli + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/sirupsen/logrus" +) + +// ReadPassphraseFile returns the first line of the specified path. +// For convenience, an empty string is returned if the path is empty. +func ReadPassphraseFile(path string) (string, error) { + if path == "" { + return "", nil + } + + logrus.Debugf("Reading user-specified passphrase for signing from %s", path) + + ppf, err := os.Open(path) + if err != nil { + return "", err + } + defer ppf.Close() + + // Read the *first* line in the passphrase file, just as gpg(1) does. + buf, err := bufio.NewReader(ppf).ReadBytes('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("reading passphrase file: %w", err) + } + + return strings.TrimSuffix(string(buf), "\n"), nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 2330107ab..8d23648f0 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -210,6 +210,7 @@ github.com/containers/image/v5/pkg/blobinfocache/boltdb github.com/containers/image/v5/pkg/blobinfocache/internal/prioritize github.com/containers/image/v5/pkg/blobinfocache/memory github.com/containers/image/v5/pkg/blobinfocache/none +github.com/containers/image/v5/pkg/cli github.com/containers/image/v5/pkg/compression github.com/containers/image/v5/pkg/compression/internal github.com/containers/image/v5/pkg/compression/types |