Skip to content

Commit

Permalink
feat: cosign sign use executable avoid deps
Browse files Browse the repository at this point in the history
Signed-off-by: Batuhan Apaydın <batuhan.apaydin@trendyol.com>
Co-authored-by: Furkan Türkal <furkan.turkal@trendyol.com>

docs: add cosign.md

Signed-off-by: Batuhan Apaydın <batuhan.apaydin@trendyol.com>

feat: verify image with cosign

Fixes containerd#577

Signed-off-by: Furkan <furkan.turkal@trendyol.com>
Co-authored-by: Batuhan <batuhan.apaydin@trendyol.com>

feat: add cosign-key flag to pull command

Signed-off-by: Batuhan Apaydın <batuhan.apaydin@trendyol.com>

docs(cosign): clarify according to reviews

Signed-off-by: Furkan <furkan.turkal@trendyol.com>
Co-authored-by: Batuhan <batuha.apaydin@trendyol.com>

feat: updates according to code review

Signed-off-by: Batuhan Apaydın <batuhan.apaydin@trendyol.com>

feat: add resolve digest feature while pulling the image

Signed-off-by: Batuhan Apaydın <batuhan.apaydin@trendyol.com>

feat(cosign): cosign test for push and pull

Signed-off-by: Furkan <furkan.turkal@trendyol.com>
Co-authored-by: Batuhan <batuhan.apaydin@trendyol.com>
Signed-off-by: Batuhan Apaydın <batuhan.apaydin@trendyol.com>
  • Loading branch information
developer-guy committed Dec 2, 2021
1 parent 8c95977 commit 48a74f9
Show file tree
Hide file tree
Showing 10 changed files with 443 additions and 22 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

✅ Supports [P2P image distribution (IPFS)](./docs/ipfs.md)

✅ Supports [container image signing and verifying](./docs/cosign.md)

nerdctl is a **non-core** sub-project of containerd.

## Examples
Expand Down
73 changes: 73 additions & 0 deletions cmd/nerdctl/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,19 @@
package main

import (
"bufio"
"context"
"errors"
"os"
"os/exec"

"github.com/containerd/nerdctl/pkg/imgutil"
"github.com/containerd/nerdctl/pkg/ipfs"
"github.com/containerd/nerdctl/pkg/platformutil"
"github.com/containerd/nerdctl/pkg/referenceutil"
"github.com/containerd/nerdctl/pkg/strutil"
httpapi "github.com/ipfs/go-ipfs-http-client"
"github.com/sirupsen/logrus"

"github.com/spf13/cobra"
)
Expand All @@ -38,6 +43,9 @@ func newPullCommand() *cobra.Command {
SilenceErrors: true,
}
pullCommand.Flags().String("unpack", "auto", "Unpack the image for the current single platform (auto/true/false)")
pullCommand.Flags().String("cosign-key", "",
"path to the public key file, KMS, URI or Kubernetes Secret")

pullCommand.RegisterFlagCompletionFunc("unpack", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"auto", "true", "false"}, cobra.ShellCompDirectiveNoFileComp
})
Expand All @@ -47,6 +55,7 @@ func newPullCommand() *cobra.Command {
pullCommand.Flags().StringSlice("platform", nil, "Pull content for a specific platform")
pullCommand.RegisterFlagCompletionFunc("platform", shellCompletePlatforms)
pullCommand.Flags().Bool("all-platforms", false, "Pull content for all platforms")
pullCommand.Flags().String("verify", "none", "Verify the image with none|cosign. Default none")
// #endregion

return pullCommand
Expand All @@ -56,6 +65,7 @@ func pullAction(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errors.New("image name needs to be specified")
}
rawRef := args[0]
client, ctx, cancel, err := newClient(cmd)
if err != nil {
return err
Expand Down Expand Up @@ -91,6 +101,17 @@ func pullAction(cmd *cobra.Command, args []string) error {
return err
}

if isVerify, err := cmd.Flags().GetString("verify"); err == nil && isVerify == "cosign" {
keyRef, err := cmd.Flags().GetString("cosign-key")
if err != nil {
return err
}

if err := verifyCosign(ctx, rawRef, keyRef); err != nil {
return err
}
}

if scheme, ref, err := referenceutil.ParseIPFSRefWithScheme(args[0]); err == nil {
ipfsClient, err := httpapi.NewLocalApi()
if err != nil {
Expand All @@ -105,3 +126,55 @@ func pullAction(cmd *cobra.Command, args []string) error {
"always", insecure, ocispecPlatforms, unpack)
return err
}

func verifyCosign(ctx context.Context, rawRef string, keyRef string) error {
digest, err := imgutil.ResolveDigest(ctx, rawRef, false)
rawRef = rawRef + "@" + digest
if err != nil {
logrus.Errorf("Unable to resolve digest for an image %s: %v\n", rawRef, err)
}

logrus.Debugf("verifying image: %s\n", rawRef)

cosignExecutable, err := exec.LookPath("cosign")
if err != nil {
logrus.WithError(err).Error("cosign executable not found in path $PATH")
logrus.Info("you might consider installing cosign from: https://docs.sigstore.dev/cosign/installation")
return err
}

cosignCmd := exec.Command(cosignExecutable, []string{"verify"}...)
cosignCmd.Env = os.Environ()

if keyRef != "" {
cosignCmd.Args = append(cosignCmd.Args, "--key", keyRef)
} else {
cosignCmd.Env = append(cosignCmd.Env, "COSIGN_EXPERIMENTAL=true")
}

cosignCmd.Args = append(cosignCmd.Args, rawRef)

logrus.Debugf("running %s %v", cosignExecutable, cosignCmd.Args)

stdout, _ := cosignCmd.StdoutPipe()
stderr, _ := cosignCmd.StderrPipe()
if err := cosignCmd.Start(); err != nil {
return err
}

scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
logrus.Info("cosign: " + scanner.Text())
}

errScanner := bufio.NewScanner(stderr)
for errScanner.Scan() {
logrus.Info("cosign: " + errScanner.Text())
}

if err := cosignCmd.Wait(); err != nil {
return err
}

return nil
}
80 changes: 80 additions & 0 deletions cmd/nerdctl/pull_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
Copyright The containerd 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 main

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/containerd/nerdctl/pkg/testutil"
"gotest.tools/v3/assert"
)

type cosignKeyPair struct {
publicKey string
privateKey string
cleanup func()
}

func newCosignKeyPair(t testing.TB) *cosignKeyPair {
td, err := os.MkdirTemp(t.TempDir(), "cosign-key-pair")
assert.NilError(t, err)

t.Setenv("COSIGN_PASSWORD", "1")

cmd := exec.Command("cosign", "generate-key-pair")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to run %v: %v (%q)", cmd.Args, err, string(out))
}

publicKey, err := filepath.Abs("cosign.pub")
if err != nil {
t.Fatalf("failed to get public key %v", err)
}

privateKey, err := filepath.Abs("cosign.key")
if err != nil {
t.Fatalf("failed to get private key %v", err)
}

return &cosignKeyPair{
publicKey: publicKey,
privateKey: privateKey,
cleanup: func() {
_ = os.RemoveAll(td)
},
}
}

func TestImageVerifyWithCosign(t *testing.T) {
testutil.DockerIncompatible(t)
keyPair := newCosignKeyPair(t)
defer keyPair.cleanup()
base := testutil.NewBase(t)
reg := newTestRegistry(base, "test-image-cosign")
defer reg.cleanup()
localhostIP := "127.0.0.1"
t.Logf("localhost IP=%q", localhostIP)
testImageRef := fmt.Sprintf("%s:%d/test-push-signed-image",
localhostIP, reg.listenPort)
t.Logf("testImageRef=%q", testImageRef)
base.Cmd("push", testImageRef, "--sign", "--cosign-key="+keyPair.publicKey).AssertOK()
base.Cmd("pull", testImageRef, "--verify", "--cosign-key="+keyPair.privateKey).AssertOK()
}
65 changes: 65 additions & 0 deletions cmd/nerdctl/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@
package main

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"

"github.com/containerd/containerd/content"
"github.com/containerd/containerd/images/converter"
Expand Down Expand Up @@ -61,6 +64,11 @@ func newPushCommand() *cobra.Command {
pushCommand.Flags().Bool("estargz", false, "Convert the image into eStargz")
pushCommand.Flags().Bool("ipfs-ensure-image", true, "Ensure the entire contents of the image is locally available before push")

pushCommand.Flags().String("sign", "none", "Sign the image with none|cosign. Default none")

pushCommand.Flags().String("cosign-key", "",
"path to the private key file, KMS URI or Kubernetes Secret")

return pushCommand
}

Expand Down Expand Up @@ -187,6 +195,19 @@ func pushAction(cmd *cobra.Command, args []string) error {
return err
}
}

if isSign, err := cmd.Flags().GetString("sign"); err == nil && isSign == "cosign" {
keyRef, err := cmd.Flags().GetString("cosign-key")
if err != nil {
return err
}

err = signCosign(rawRef, keyRef)
if err != nil {
return err
}
}

return nil
}

Expand Down Expand Up @@ -235,3 +256,47 @@ func isReusableESGZ(ctx context.Context, cs content.Store, desc ocispec.Descript
}
return true
}

func signCosign(rawRef string, keyRef string) error {
cosignExecutable, err := exec.LookPath("cosign")
if err != nil {
logrus.WithError(err).Error("cosign executable not found in path $PATH")
logrus.Info("you might consider installing cosign from: https://docs.sigstore.dev/cosign/installation")
return err
}

cosignCmd := exec.Command(cosignExecutable, []string{"sign"}...)
cosignCmd.Env = os.Environ()

if keyRef != "" {
cosignCmd.Args = append(cosignCmd.Args, "--key", keyRef)
} else {
cosignCmd.Env = append(cosignCmd.Env, "COSIGN_EXPERIMENTAL=true")
}

cosignCmd.Args = append(cosignCmd.Args, rawRef)

logrus.Debugf("running %s %v", cosignExecutable, cosignCmd.Args)

stdout, _ := cosignCmd.StdoutPipe()
stderr, _ := cosignCmd.StderrPipe()
if err := cosignCmd.Start(); err != nil {
return err
}

scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
logrus.Info("cosign: " + scanner.Text())
}

errScanner := bufio.NewScanner(stderr)
for errScanner.Scan() {
logrus.Info("cosign: " + errScanner.Text())
}

if err := cosignCmd.Wait(); err != nil {
return err
}

return nil
}
1 change: 1 addition & 0 deletions cmd/nerdctl/run_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package main
import (
"context"
"fmt"

"github.com/containerd/containerd/containers"
"github.com/containerd/containerd/oci"
"github.com/docker/go-units"
Expand Down
74 changes: 74 additions & 0 deletions docs/cosign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Container Image Sign and Verify with cosign tool

[cosign](https://github.com/sigstore/cosign) is tool that allows you to sign and verify container images with the
public/private key pairs or without them by providing
a [Keyless support](https://github.com/sigstore/cosign/blob/main/KEYLESS.md).

Keyless uses ephemeral keys and certificates, which are signed automatically by
the [fulcio](https://github.com/sigstore/fulcio) root CA. Signatures are stored in
the [rekor](https://github.com/sigstore/rekor) transparency log, which automatically provides an attestation as to when
the signature was created.

You can enable container signing and verifying features with `push` and `pull` commands of `nerdctl` by using `cosign`
under the hood with make use of flags `--sign` while pushing the container image, and `--verify` while pulling the
container image.

> * Ensure cosign executable in your `$PATH`.
> * You can install cosign by following this page: https://docs.sigstore.dev/cosign/installation
Prepare your environment:

```shell
# Create a sample Dockerfile
$ cat <<EOF | tee Dockerfile.dummy
FROM alpine:latest
CMD [ "echo", "Hello World" ]
EOF
```

> Please do not forget, we won't be validating the base images, which is `alpine:latest` in this case, of the container image that was built on,
> we'll only verify the container image itself once we sign it.
```shell

# Build the image
$ nerdctl build -t devopps/hello-world -f Dockerfile.dummy .

# Generate a key-pair: cosign.key and cosign.pub
$ cosign generate-key-pair

# Export your COSIGN_PASSWORD to prevent CLI prompting
$ export COSIGN_PASSWORD=$COSIGN_PASSWORD
```

Sign the container image while pushing:

```
# Sign the image with Keyless mode
$ nerdctl push --sign=cosign devopps/hello-world
# Sign the image and store the signature in the registry
$ nerdctl push --sign=cosign --cosign-key cosign.key devopps/hello-world
```

Verify the container image while pulling:

> REMINDER: Image won't be pulled if there are no matching signatures in case you passed `--verify` flag.
```shell
# Verify the image with Keyless mode
$ nerdctl pull --verify=cosign devopps/hello-world
INFO[0004] cosign:
INFO[0004] cosign: [{"critical":{"identity":...}]
docker.io/devopps/nginx-new:latest: resolved |++++++++++++++++++++++++++++++++++++++|
manifest-sha256:0910d404e58dd320c3c0c7ea31bf5fbfe7544b26905c5eccaf87c3af7bcf9b88: done |++++++++++++++++++++++++++++++++++++++|
config-sha256:1de1c4fb5122ac8650e349e018fba189c51300cf8800d619e92e595d6ddda40e: done |++++++++++++++++++++++++++++++++++++++|
elapsed: 1.4 s total: 1.3 Ki (928.0 B/s)

# You can not verify the image if it is not signed
$ nerdctl pull --verify=cosign --cosign-key cosign.pub devopps/hello-world-bad
INFO[0003] cosign: Error: no matching signatures:
INFO[0003] cosign: failed to verify signature
INFO[0003] cosign: main.go:46: error during command execution: no matching signatures:
INFO[0003] cosign: failed to verify signature
```
1 change: 1 addition & 0 deletions docs/experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ The following features are experimental and subject to change:
- Importing an external eStargz record JSON file with `nerdctl image convert --estargz-record-in=FILE` .
eStargz itself is out of experimental.
- [Image Distribution on IPFS](./ipfs.md)
- [Image Sign and Verify](./cosign.md)
Loading

0 comments on commit 48a74f9

Please sign in to comment.