From f1d07c3f11970214c0e70f4604037dcfee20f3fe Mon Sep 17 00:00:00 2001 From: Matt Butcher Date: Tue, 9 Aug 2016 16:36:00 -0600 Subject: [PATCH] feat(helm): add --verify flag to commands This adds the --verify and --keyring flags to: helm fetch helm inspect helm install helm upgrade Each of these commands can now make cryptographic verification a prerequisite for using a chart. --- cmd/helm/fetch.go | 175 +++++++++++++++--- cmd/helm/helm.go | 2 + cmd/helm/inspect.go | 21 ++- cmd/helm/install.go | 33 +++- cmd/helm/install_test.go | 18 ++ cmd/helm/list_test.go | 8 +- cmd/helm/package.go | 7 +- cmd/helm/testdata/helm-test-key.pub | Bin 0 -> 1243 bytes .../testdata/testcharts/signtest-0.1.0.tgz | Bin 0 -> 471 bytes .../testcharts/signtest-0.1.0.tgz.prov | 20 ++ .../testdata/testcharts/signtest/.helmignore | 5 + .../testdata/testcharts/signtest/Chart.yaml | 3 + .../testcharts/signtest/alpine/Chart.yaml | 6 + .../testcharts/signtest/alpine/README.md | 9 + .../signtest/alpine/templates/alpine-pod.yaml | 16 ++ .../testcharts/signtest/alpine/values.yaml | 2 + .../testcharts/signtest/templates/pod.yaml | 10 + .../testdata/testcharts/signtest/values.yaml | 0 cmd/helm/upgrade.go | 6 +- cmd/helm/verify.go | 67 +++++++ cmd/helm/verify_test.go | 83 +++++++++ docs/provenance.md | 173 +++++++++++++++++ pkg/provenance/sign.go | 47 +++-- pkg/provenance/sign_test.go | 10 +- 24 files changed, 658 insertions(+), 63 deletions(-) create mode 100644 cmd/helm/testdata/helm-test-key.pub create mode 100644 cmd/helm/testdata/testcharts/signtest-0.1.0.tgz create mode 100755 cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov create mode 100644 cmd/helm/testdata/testcharts/signtest/.helmignore create mode 100755 cmd/helm/testdata/testcharts/signtest/Chart.yaml create mode 100755 cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml create mode 100755 cmd/helm/testdata/testcharts/signtest/alpine/README.md create mode 100755 cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml create mode 100755 cmd/helm/testdata/testcharts/signtest/alpine/values.yaml create mode 100644 cmd/helm/testdata/testcharts/signtest/templates/pod.yaml create mode 100644 cmd/helm/testdata/testcharts/signtest/values.yaml create mode 100644 cmd/helm/verify.go create mode 100644 cmd/helm/verify_test.go create mode 100644 docs/provenance.md diff --git a/cmd/helm/fetch.go b/cmd/helm/fetch.go index 2aad66f37..cb077d7ce 100644 --- a/cmd/helm/fetch.go +++ b/cmd/helm/fetch.go @@ -17,8 +17,11 @@ limitations under the License. package main import ( + "bytes" + "errors" "fmt" "io" + "io/ioutil" "net/http" "net/url" "os" @@ -27,65 +30,179 @@ import ( "github.com/spf13/cobra" "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/provenance" "k8s.io/helm/pkg/repo" ) -var untarFile bool -var untarDir string +const fetchDesc = ` +Retrieve a package from a package repository, and download it locally. -func init() { - RootCommand.AddCommand(fetchCmd) - fetchCmd.Flags().BoolVar(&untarFile, "untar", false, "If set to true, will untar the chart after downloading it.") - fetchCmd.Flags().StringVar(&untarDir, "untardir", ".", "If untar is specified, this flag specifies where to untar the chart.") -} +This is useful for fetching packages to inspect, modify, or repackage. It can +also be used to perform cryptographic verification of a chart without installing +the chart. + +There are options for unpacking the chart after download. This will create a +directory for the chart and uncomparess into that directory. + +If the --verify flag is specified, the requested chart MUST have a provenance +file, and MUST pass the verification process. Failure in any part of this will +result in an error, and the chart will not be saved locally. +` -var fetchCmd = &cobra.Command{ - Use: "fetch [chart URL | repo/chartname]", - Short: "download a chart from a repository and (optionally) unpack it in local directory", - Long: "", - RunE: fetch, +type fetchCmd struct { + untar bool + untardir string + chartRef string + + verify bool + keyring string + + out io.Writer } -func fetch(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return fmt.Errorf("This command needs at least one argument, url or repo/name of the chart.") +func newFetchCmd(out io.Writer) *cobra.Command { + fch := &fetchCmd{out: out} + + cmd := &cobra.Command{ + Use: "fetch [chart URL | repo/chartname]", + Short: "download a chart from a repository and (optionally) unpack it in local directory", + Long: fetchDesc, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("This command needs at least one argument, url or repo/name of the chart.") + } + fch.chartRef = args[0] + return fch.run() + }, } - pname := args[0] + f := cmd.Flags() + f.BoolVar(&fch.untar, "untar", false, "If set to true, will untar the chart after downloading it.") + f.StringVar(&fch.untardir, "untardir", ".", "If untar is specified, this flag specifies where to untar the chart.") + f.BoolVar(&fch.verify, "verify", false, "Verify the package against its signature.") + f.StringVar(&fch.keyring, "keyring", defaultKeyring(), "The keyring containing public keys.") + + return cmd +} + +func (f *fetchCmd) run() error { + pname := f.chartRef if filepath.Ext(pname) != ".tgz" { pname += ".tgz" } - return fetchChart(pname) + return downloadChart(pname, f.untar, f.untardir, f.verify, f.keyring) } -func fetchChart(pname string) error { - - f, err := repo.LoadRepositoriesFile(repositoriesFile()) +// downloadChart fetches a chart over HTTP, and then (if verify is true) verifies it. +// +// If untar is true, it also unpacks the file into untardir. +func downloadChart(pname string, untar bool, untardir string, verify bool, keyring string) error { + r, err := repo.LoadRepositoriesFile(repositoriesFile()) if err != nil { return err } // get download url - u, err := mapRepoArg(pname, f.Repositories) + u, err := mapRepoArg(pname, r.Repositories) if err != nil { return err } - resp, err := http.Get(u.String()) + href := u.String() + buf, err := fetchChart(href) if err != nil { return err } - if resp.StatusCode != 200 { - return fmt.Errorf("Failed to fetch %s : %s", u.String(), resp.Status) + + if verify { + basename := filepath.Base(pname) + sigref := href + ".prov" + sig, err := fetchChart(sigref) + if err != nil { + return fmt.Errorf("provenance data not downloaded from %s: %s", sigref, err) + } + if err := ioutil.WriteFile(basename+".prov", sig.Bytes(), 0755); err != nil { + return fmt.Errorf("provenance data not saved: %s", err) + } + if err := verifyChart(basename, keyring); err != nil { + return err + } } - defer resp.Body.Close() - if untarFile { - return chartutil.Expand(untarDir, resp.Body) + return saveChart(pname, buf, untar, untardir) +} + +// verifyChart takes a path to a chart archive and a keyring, and verifies the chart. +// +// It assumes that a chart archive file is accompanied by a provenance file whose +// name is the archive file name plus the ".prov" extension. +func verifyChart(path string, keyring string) error { + // For now, error out if it's not a tar file. + if fi, err := os.Stat(path); err != nil { + return err + } else if fi.IsDir() { + return errors.New("unpacked charts cannot be verified") + } else if !isTar(path) { + return errors.New("chart must be a tgz file") } - p := strings.Split(u.String(), "/") - return saveChartFile(p[len(p)-1], resp.Body) + + provfile := path + ".prov" + if _, err := os.Stat(provfile); err != nil { + return fmt.Errorf("could not load provenance file %s: %s", provfile, err) + } + + sig, err := provenance.NewFromKeyring(keyring, "") + if err != nil { + return fmt.Errorf("failed to load keyring: %s", err) + } + ver, err := sig.Verify(path, provfile) + if flagDebug { + for name := range ver.SignedBy.Identities { + fmt.Printf("Signed by %q\n", name) + } + } + return err +} + +// defaultKeyring returns the expanded path to the default keyring. +func defaultKeyring() string { + return os.ExpandEnv("$HOME/.gnupg/pubring.gpg") +} + +// isTar tests whether the given file is a tar file. +// +// Currently, this simply checks extension, since a subsequent function will +// untar the file and validate its binary format. +func isTar(filename string) bool { + return strings.ToLower(filepath.Ext(filename)) == ".tgz" +} + +// saveChart saves a chart locally. +func saveChart(name string, buf *bytes.Buffer, untar bool, untardir string) error { + if untar { + return chartutil.Expand(untardir, buf) + } + + p := strings.Split(name, "/") + return saveChartFile(p[len(p)-1], buf) +} + +// fetchChart retrieves a chart over HTTP. +func fetchChart(href string) (*bytes.Buffer, error) { + buf := bytes.NewBuffer(nil) + + resp, err := http.Get(href) + if err != nil { + return buf, err + } + if resp.StatusCode != 200 { + return buf, fmt.Errorf("Failed to fetch %s : %s", href, resp.Status) + } + + _, err = io.Copy(buf, resp.Body) + resp.Body.Close() + return buf, err } // mapRepoArg figures out which format the argument is given, and creates a fetchable diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go index 42e7c6c26..81085e1ef 100644 --- a/cmd/helm/helm.go +++ b/cmd/helm/helm.go @@ -95,6 +95,8 @@ func newRootCmd(out io.Writer) *cobra.Command { newUpgradeCmd(nil, out), newRollbackCmd(nil, out), newPackageCmd(nil, out), + newFetchCmd(out), + newVerifyCmd(out), ) return cmd } diff --git a/cmd/helm/inspect.go b/cmd/helm/inspect.go index 8d9948b6f..cc02f80b1 100644 --- a/cmd/helm/inspect.go +++ b/cmd/helm/inspect.go @@ -46,6 +46,8 @@ of the Charts.yaml file type inspectCmd struct { chartpath string output string + verify bool + keyring string out io.Writer client helm.Interface } @@ -71,7 +73,7 @@ func newInspectCmd(c helm.Interface, out io.Writer) *cobra.Command { if err := checkArgsLength(1, len(args), "chart name"); err != nil { return err } - cp, err := locateChartPath(args[0]) + cp, err := locateChartPath(args[0], insp.verify, insp.keyring) if err != nil { return err } @@ -86,7 +88,7 @@ func newInspectCmd(c helm.Interface, out io.Writer) *cobra.Command { Long: inspectValuesDesc, RunE: func(cmd *cobra.Command, args []string) error { insp.output = valuesOnly - cp, err := locateChartPath(args[0]) + cp, err := locateChartPath(args[0], insp.verify, insp.keyring) if err != nil { return err } @@ -101,7 +103,7 @@ func newInspectCmd(c helm.Interface, out io.Writer) *cobra.Command { Long: inspectChartDesc, RunE: func(cmd *cobra.Command, args []string) error { insp.output = chartOnly - cp, err := locateChartPath(args[0]) + cp, err := locateChartPath(args[0], insp.verify, insp.keyring) if err != nil { return err } @@ -110,6 +112,19 @@ func newInspectCmd(c helm.Interface, out io.Writer) *cobra.Command { }, } + vflag := "verify" + vdesc := "verify the provenance data for this chart" + inspectCommand.Flags().BoolVar(&insp.verify, vflag, false, vdesc) + valuesSubCmd.Flags().BoolVar(&insp.verify, vflag, false, vdesc) + chartSubCmd.Flags().BoolVar(&insp.verify, vflag, false, vdesc) + + kflag := "keyring" + kdesc := "the path to the keyring containing public verification keys" + kdefault := defaultKeyring() + inspectCommand.Flags().StringVar(&insp.keyring, kflag, kdefault, kdesc) + valuesSubCmd.Flags().StringVar(&insp.keyring, kflag, kdefault, kdesc) + chartSubCmd.Flags().StringVar(&insp.keyring, kflag, kdefault, kdesc) + inspectCommand.AddCommand(valuesSubCmd) inspectCommand.AddCommand(chartSubCmd) diff --git a/cmd/helm/install.go b/cmd/helm/install.go index b41669d24..402c2a549 100644 --- a/cmd/helm/install.go +++ b/cmd/helm/install.go @@ -18,6 +18,7 @@ package main import ( "bytes" + "errors" "fmt" "io" "io/ioutil" @@ -54,6 +55,9 @@ or To check the generated manifests of a release without installing the chart, the '--debug' and '--dry-run' flags can be combined. This will still require a round-trip to the Tiller server. + +If --verify is set, the chart MUST have a provenance file, and the provenenace +fall MUST pass all verification steps. ` type installCmd struct { @@ -64,6 +68,8 @@ type installCmd struct { dryRun bool disableHooks bool replace bool + verify bool + keyring string out io.Writer client helm.Interface values *values @@ -86,7 +92,7 @@ func newInstallCmd(c helm.Interface, out io.Writer) *cobra.Command { if err := checkArgsLength(1, len(args), "chart name"); err != nil { return err } - cp, err := locateChartPath(args[0]) + cp, err := locateChartPath(args[0], inst.verify, inst.keyring) if err != nil { return err } @@ -106,6 +112,8 @@ func newInstallCmd(c helm.Interface, out io.Writer) *cobra.Command { f.BoolVar(&inst.replace, "replace", false, "re-use the given name, even if that name is already used. This is unsafe in production") f.Var(inst.values, "set", "set values on the command line. Separate values with commas: key1=val1,key2=val2") f.StringVar(&inst.nameTemplate, "name-template", "", "specify template used to name the release") + f.BoolVar(&inst.verify, "verify", false, "verify the package before installing it") + f.StringVar(&inst.keyring, "keyring", defaultKeyring(), "location of public keys used for verification") return cmd } @@ -171,6 +179,7 @@ func (i *installCmd) vals() ([]byte, error) { return buffer.Bytes(), nil } +// printRelease prints info about a release if the flagDebug is true. func (i *installCmd) printRelease(rel *release.Release) { if rel == nil { return @@ -251,9 +260,23 @@ func splitPair(item string) (name string, value interface{}) { // - current working directory // - if path is absolute or begins with '.', error out here // - chart repos in $HELM_HOME -func locateChartPath(name string) (string, error) { - if _, err := os.Stat(name); err == nil { - return filepath.Abs(name) +// +// If 'verify' is true, this will attempt to also verify the chart. +func locateChartPath(name string, verify bool, keyring string) (string, error) { + if fi, err := os.Stat(name); err == nil { + abs, err := filepath.Abs(name) + if err != nil { + return abs, err + } + if verify { + if fi.IsDir() { + return "", errors.New("cannot verify a directory") + } + if err := verifyChart(abs, keyring); err != nil { + return "", err + } + } + return abs, nil } if filepath.IsAbs(name) || strings.HasPrefix(name, ".") { return name, fmt.Errorf("path %q not found", name) @@ -269,7 +292,7 @@ func locateChartPath(name string) (string, error) { if filepath.Ext(name) != ".tgz" { name += ".tgz" } - if err := fetchChart(name); err == nil { + if err := downloadChart(name, false, ".", verify, keyring); err == nil { lname, err := filepath.Abs(filepath.Base(name)) if err != nil { return lname, err diff --git a/cmd/helm/install_test.go b/cmd/helm/install_test.go index 61bb8eb9a..48d7854cd 100644 --- a/cmd/helm/install_test.go +++ b/cmd/helm/install_test.go @@ -74,6 +74,24 @@ func TestInstall(t *testing.T) { expected: "FOOBAR", resp: releaseMock(&releaseOptions{name: "FOOBAR"}), }, + // Install, perform chart verification along the way. + { + name: "install with verification, missing provenance", + args: []string{"testdata/testcharts/compressedchart-0.1.0.tgz"}, + flags: strings.Split("--verify --keyring testdata/helm-test-key.pub", " "), + err: true, + }, + { + name: "install with verification, directory instead of file", + args: []string{"testdata/testcharts/signtest"}, + flags: strings.Split("--verify --keyring testdata/helm-test-key.pub", " "), + err: true, + }, + { + name: "install with verification, valid", + args: []string{"testdata/testcharts/signtest-0.1.0.tgz"}, + flags: strings.Split("--verify --keyring testdata/helm-test-key.pub", " "), + }, } runReleaseCases(t, tests, func(c *fakeReleaseClient, out io.Writer) *cobra.Command { diff --git a/cmd/helm/list_test.go b/cmd/helm/list_test.go index 0a2e88f3f..b12d41408 100644 --- a/cmd/helm/list_test.go +++ b/cmd/helm/list_test.go @@ -28,7 +28,6 @@ func TestListCmd(t *testing.T) { tests := []struct { name string args []string - flags map[string]string resp []*release.Release expected string err bool @@ -41,8 +40,9 @@ func TestListCmd(t *testing.T) { expected: "thomas-guide", }, { - name: "list --long", - flags: map[string]string{"long": "1"}, + name: "list --long", + //flags: map[string]string{"long": "1"}, + args: []string{"--long"}, resp: []*release.Release{ releaseMock(&releaseOptions{name: "atlas"}), }, @@ -56,7 +56,7 @@ func TestListCmd(t *testing.T) { rels: tt.resp, } cmd := newListCmd(c, &buf) - setFlags(cmd, tt.flags) + cmd.ParseFlags(tt.args) err := cmd.RunE(cmd, tt.args) if (err != nil) != tt.err { t.Errorf("%q. expected error: %v, got %v", tt.name, tt.err, err) diff --git a/cmd/helm/package.go b/cmd/helm/package.go index 81385224f..056fd39cb 100644 --- a/cmd/helm/package.go +++ b/cmd/helm/package.go @@ -43,11 +43,6 @@ Chart.yaml file, and (if found) build the current directory into a chart. Versioned chart archives are used by Helm package repositories. ` -const ( - envSigningKey = "HELM_SIGNING_KEY" - envKeyring = "HELM_KEYRING" -) - type packageCmd struct { save bool sign bool @@ -86,7 +81,7 @@ func newPackageCmd(client helm.Interface, out io.Writer) *cobra.Command { f.BoolVar(&pkg.save, "save", true, "save packaged chart to local chart repository") f.BoolVar(&pkg.sign, "sign", false, "use a PGP private key to sign this package") f.StringVar(&pkg.key, "key", "", "the name of the key to use when signing. Used if --sign is true.") - f.StringVar(&pkg.keyring, "keyring", os.ExpandEnv("$HOME/.gnupg/pubring.gpg"), "the location of a public keyring") + f.StringVar(&pkg.keyring, "keyring", defaultKeyring(), "the location of a public keyring") return cmd } diff --git a/cmd/helm/testdata/helm-test-key.pub b/cmd/helm/testdata/helm-test-key.pub new file mode 100644 index 0000000000000000000000000000000000000000..38714f25adaf701b08e11fd559a587074bbde0e4 GIT binary patch literal 1243 zcmV<11SI>J0SyFKmTjH^2mr{k15wFPQdpTAAEclY4oV^-7nTy5x$&xF;PC4il}o-Pk4@#$knlp#(|J0GE?qli_lr;7-o zyY8vBsN_GJe;#w<`JdR7riNL&RJlcS)FG+W=91;dYS6NZ2tY?kZ8Sw9{r=e4|L3E{ zRod|EPC!PWgW&pe&4qiqKQAijj;G~fyjcC^m%0p54Tn{h%YFKd0VC4n=#~SRc@BVd znj66*)Om%*SEQfX{1*Tb0RRECT}WkYZ6H)-b98BLXCNq4XlZjGYh`&Lb7*gMY-AvB zZftoVVr3w8b7f>8W^ZyJbY*jNX>MmOAVg0fPES-IR8mz_R4yqXJZNQXZ7p6uVakV zT7GGV$jaKjyjfI_a~N1!Hk?5C$0wa&4)R=i$v7t&ZMycW#RkavpF%A?>MTT2anNDzOQUm<++zEOykJ9-@&c2QXq3owqf7fek`=L@+7iF zv;IW2Q>Q&r+V@cWDF&hAUUsCKlDinerKgvJUJCl$5gjb7NhM{mBP%!M^mX-iS8xFf zuLB{@MDqvtZzF#Bxd9CXSC(y_0SExW>8~h=U8|!do4*OJj2u#!KDe3v+1T+aVzU5di=Ji2)x37y$|Z2?YXImTjH_8w>yn2@r%kznCAv zhhp0`2mpxVj5j%o&5i)?`r7iES|8dA@p2kk@+XS(tjBGN)6>tm^=gayCn`gTEC*K74Y~{I_PREk) z)PstIMx1RxB@cK8%Mey%;nVnKriAKUk2Ky?dBMG3uXItKL$3N(#3P^pQa*K$l)wUy F^>pMLK0g2e literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz b/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6de9d988d47cfea649eaa5b23ba09d8a5fee04ed GIT binary patch literal 471 zcmV;|0Vw_-iwG0|32ul0|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PL1s>(ek4#&_LMab!0N8r#jSu)EfR!Yhji_ntJ+cZn_Q$NgS zvpkzm;N}PUn+EH+q3y3-=lpU{L>1c7h~5dUR^fjXXQ78U)Tn=deive8Xe@3wX&i_1nlSlsVp($*z=7V%F7C^xM zSQIRo!k1Q9pohcP^~Vpd=yk`P!wPC4(Fbg>l-wYAgBYs_dM=Cwr=jqDYbjbN8Xoju zz+u-*PRsk`(N#iL^pHpB#6N4v`e~pI-g=LV{4bY(@IPBd{_mkFeD*jS6?h%LKkQpn zPz*v=LN!Ei`JFc-ufYxM(D&Ln>QK!{XrwNHOrdNk`Xv}7y2Z|u@7iDHxvD(y*l_=| z0ndAbwfI5Suoo2f>;;2QN*+L~km-*EJsOZgkHDk|z~{R{vA N|NqlRq0#^l007yS;m800 literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov b/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov new file mode 100755 index 000000000..94235399a --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov @@ -0,0 +1,20 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +description: A Helm chart for Kubernetes +name: signtest +version: 0.1.0 + +... +files: + signtest-0.1.0.tgz: sha256:dee72947753628425b82814516bdaa37aef49f25e8820dd2a6e15a33a007823b +-----BEGIN PGP SIGNATURE----- + +wsBcBAEBCgAQBQJXomNHCRCEO7+YH8GHYgAALywIAG1Me852Fpn1GYu8Q1GCcw4g +l2k7vOFchdDwDhdSVbkh4YyvTaIO3iE2Jtk1rxw+RIJiUr0eLO/rnIJuxZS8WKki +DR1LI9J1VD4dxN3uDETtWDWq7ScoPsRY5mJvYZXC8whrWEt/H2kfqmoA9LloRPWp +flOE0iktA4UciZOblTj6nAk3iDyjh/4HYL4a6tT0LjjKI7OTw4YyHfjHad1ywVCz +9dMUc1rPgTnl+fnRiSPSrlZIWKOt1mcQ4fVrU3nwtRUwTId2k8FtygL0G6M+Y6t0 +S6yaU7qfk9uTxkdkUF7Bf1X3ukxfe+cNBC32vf4m8LY4NkcYfSqK2fGtQsnVr6s= +=NyOM +-----END PGP SIGNATURE----- \ No newline at end of file diff --git a/cmd/helm/testdata/testcharts/signtest/.helmignore b/cmd/helm/testdata/testcharts/signtest/.helmignore new file mode 100644 index 000000000..435b756d8 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/.helmignore @@ -0,0 +1,5 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +.git diff --git a/cmd/helm/testdata/testcharts/signtest/Chart.yaml b/cmd/helm/testdata/testcharts/signtest/Chart.yaml new file mode 100755 index 000000000..90964b44a --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/Chart.yaml @@ -0,0 +1,3 @@ +description: A Helm chart for Kubernetes +name: signtest +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml b/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml new file mode 100755 index 000000000..6fbb27f18 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml @@ -0,0 +1,6 @@ +description: Deploy a basic Alpine Linux pod +home: https://k8s.io/helm +name: alpine +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/README.md b/cmd/helm/testdata/testcharts/signtest/alpine/README.md new file mode 100755 index 000000000..5bd595747 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.yaml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install docs/examples/alpine`. diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml b/cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml new file mode 100755 index 000000000..08cf3c2c1 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + heritage: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} + annotations: + "helm.sh/created": "{{.Release.Time.Seconds}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/values.yaml b/cmd/helm/testdata/testcharts/signtest/alpine/values.yaml new file mode 100755 index 000000000..bb6c06ae4 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: my-alpine diff --git a/cmd/helm/testdata/testcharts/signtest/templates/pod.yaml b/cmd/helm/testdata/testcharts/signtest/templates/pod.yaml new file mode 100644 index 000000000..9b00ccaf7 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/templates/pod.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Pod +metadata: + name: signtest +spec: + restartPolicy: Never + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/cmd/helm/testdata/testcharts/signtest/values.yaml b/cmd/helm/testdata/testcharts/signtest/values.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/cmd/helm/upgrade.go b/cmd/helm/upgrade.go index 2ad2f7800..3b7de8ec8 100644 --- a/cmd/helm/upgrade.go +++ b/cmd/helm/upgrade.go @@ -46,6 +46,8 @@ type upgradeCmd struct { disableHooks bool valuesFile string values *values + verify bool + keyring string } func newUpgradeCmd(client helm.Interface, out io.Writer) *cobra.Command { @@ -79,6 +81,8 @@ func newUpgradeCmd(client helm.Interface, out io.Writer) *cobra.Command { f.BoolVar(&upgrade.dryRun, "dry-run", false, "simulate an upgrade") f.Var(upgrade.values, "set", "set values on the command line. Separate values with commas: key1=val1,key2=val2") f.BoolVar(&upgrade.disableHooks, "disable-hooks", false, "disable pre/post upgrade hooks") + f.BoolVar(&upgrade.verify, "verify", false, "verify the provenance of the chart before upgrading") + f.StringVar(&upgrade.keyring, "keyring", defaultKeyring(), "the path to the keyring that contains public singing keys") return cmd } @@ -109,7 +113,7 @@ func (u *upgradeCmd) vals() ([]byte, error) { } func (u *upgradeCmd) run() error { - chartPath, err := locateChartPath(u.chart) + chartPath, err := locateChartPath(u.chart, u.verify, u.keyring) if err != nil { return err } diff --git a/cmd/helm/verify.go b/cmd/helm/verify.go new file mode 100644 index 000000000..4e342daaf --- /dev/null +++ b/cmd/helm/verify.go @@ -0,0 +1,67 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 ( + "errors" + "io" + + "github.com/spf13/cobra" +) + +const verifyDesc = ` +Verify that the given chart has a valid provenance file. + +Provenance files provide crytographic verification that a chart has not been +tampered with, and was packaged by a trusted provider. + +This command can be used to verify a local chart. Several other commands provide +'--verify' flags that run the same validation. To generate a signed package, use +the 'helm package --sign' command. +` + +type verifyCmd struct { + keyring string + chartfile string + + out io.Writer +} + +func newVerifyCmd(out io.Writer) *cobra.Command { + vc := &verifyCmd{out: out} + + cmd := &cobra.Command{ + Use: "verify [flags] PATH", + Short: "verify that a chart at the given path has been signed and is valid", + Long: verifyDesc, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.New("a path to a package file is required") + } + vc.chartfile = args[0] + return vc.run() + }, + } + + f := cmd.Flags() + f.StringVar(&vc.keyring, "keyring", defaultKeyring(), "the keyring containing public keys.") + + return cmd +} + +func (v *verifyCmd) run() error { + return verifyChart(v.chartfile, v.keyring) +} diff --git a/cmd/helm/verify_test.go b/cmd/helm/verify_test.go new file mode 100644 index 000000000..425f1a28b --- /dev/null +++ b/cmd/helm/verify_test.go @@ -0,0 +1,83 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 ( + "bytes" + "testing" +) + +func TestVerifyCmd(t *testing.T) { + tests := []struct { + name string + args []string + flags []string + expect string + err bool + }{ + { + name: "verify requires a chart", + expect: "a path to a package file is required", + err: true, + }, + { + name: "verify requires that chart exists", + args: []string{"no/such/file"}, + expect: "stat no/such/file: no such file or directory", + err: true, + }, + { + name: "verify requires that chart is not a directory", + args: []string{"testdata/testcharts/signtest"}, + expect: "unpacked charts cannot be verified", + err: true, + }, + { + name: "verify requires that chart has prov file", + args: []string{"testdata/testcharts/compressedchart-0.1.0.tgz"}, + expect: "could not load provenance file testdata/testcharts/compressedchart-0.1.0.tgz.prov: stat testdata/testcharts/compressedchart-0.1.0.tgz.prov: no such file or directory", + err: true, + }, + { + name: "verify validates a properly signed chart", + args: []string{"testdata/testcharts/signtest-0.1.0.tgz"}, + flags: []string{"--keyring", "testdata/helm-test-key.pub"}, + expect: "", + err: false, + }, + } + + for _, tt := range tests { + b := bytes.NewBuffer(nil) + vc := newVerifyCmd(b) + vc.ParseFlags(tt.flags) + err := vc.RunE(vc, tt.args) + if tt.err { + if err == nil { + t.Errorf("Expected error, but got none: %q", b.String()) + } + if err.Error() != tt.expect { + t.Errorf("Expected error %q, got %q", tt.expect, err) + } + continue + } else if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if b.String() != tt.expect { + t.Errorf("Expected %q, got %q", tt.expect, b.String()) + } + } +} diff --git a/docs/provenance.md b/docs/provenance.md new file mode 100644 index 000000000..13c989dee --- /dev/null +++ b/docs/provenance.md @@ -0,0 +1,173 @@ +# Helm Provenance and Integrity + +Helm has provenance tools which help chart users verify the integrity and origin +of a package. Using industry-standard tools based on PKI, GnuPG, and well-resepected +package managers, Helm can generate and verify signature files. + +**Note:** +Version 2.0.0-alpha.4 introduced a system for verifying the authenticity of charts. +While we do not anticipate that any major changes will be made to the file formats +or provenancing algorithms, this portion of Helm is not considered _frozen_ until +2.0.0-RC1 is released. The original plan for this feature can be found +[at issue 983](https://github.com/kubernetes/helm/issues/983). + +## Overview + +Integrity is established by comparing a chart to a provenance record. Provenance +records are stored in _provenance files_, which are stored alongside a packaged +chart. For example, if a chart is named `myapp-1.2.3.tgz`, its provenance file +will be `myapp-1.2.3.tgz.prov`. + +Provenance files are generated at packaging time (`helm package --sign ...`), and +can be checked by multiple commands, notable `helm install --verify`. + +## The Workflow + +This section describes a potential workflow for using provenance data effectively. + +WHAT YOU WILL NEED: + +- A valid PGP keypair in a binary (not ASCII-armored) format +- helm + +Creating a new chart is the same as before: + +``` +$ helm create mychart +Creating mychart +``` + +Once ready to package, add the `--verify` flag to `helm package`. Also, specify +the signing key and the keyring: + +``` +$ helm package --sign --key helm --keyring path/to/keyring.secret mychart +``` + +Tip: for GnuPG users, your secret keyring is in `~/.gpg/secring.gpg`. + +At this point, you should see both `mychart-0.1.0.tgz` and `mychart-0.1.0.tgz.prov`. +Both files should eventually be uploaded to your desired chart repository. + +You can verify a chart using `helm verify`: + +``` +$ helm verify mychart-0.1.0.tgz +``` + +A failed verification looks like this: + +``` +$ helm verify topchart-0.1.0.tgz +Error: sha256 sum does not match for topchart-0.1.0.tgz: "sha256:1939fbf7c1023d2f6b865d137bbb600e0c42061c3235528b1e8c82f4450c12a7" != "sha256:5a391a90de56778dd3274e47d789a2c84e0e106e1a37ef8cfa51fd60ac9e623a" +``` + +To verify during an install, use the `--verify` flag. + +``` +$ helm install --verify mychart-0.1.0.tgz +``` + +If the keyring is not in the default location, you may need to point to the +keyring with `--keyring PATH` as in the `helm package` example. + +If verification fails, the install will be aborted before the chart is even pushed +up to Tiller. + +### Reasons a chart may not verify + +These are common reasons for failure. + +- The prov file is missing or corrupt. This indicates that something is misconfigured + or that the original maintainer did not create a provenance file. +- The key used to sign the file is not in your keyring. This indicate that the + entity who signed the chart is not someone you've already signaled that you trust. +- The verification of the prov file failed. This indicates that something is wrong + with either the chart or the provenance data. +- The file hashes in the provenance file do not match the hash of the archive file. This + indicates that the archive has been tampered with. + +If a verification fails, there is reason to distrust the package. + +## The Provenance File +The provenance file contains a chart’s YAML file plus several pieces of +verification information. Provenance files are designed to be automatically +generated. + + +The following pieces of provenance data are added: + + +* The chart file (Chart.yaml) is included to give both humans and tools an easy + view into the contents of the chart. +* **Not Complete yet:** Every image file that the project references is + correlated with its hash (SHA256, used by Docker) for verification. +* The signature (SHA256, just like Docker) of the chart package (the .tgz file) + is included, and may be used to verify the integrity of the chart package. +* The entire body is signed using the algorithm used by PGP (see + [http://keybase.io] for an emerging way of making crypto signing and + verification easy). + +The combination of this gives users the following assurances: + +* The images this chart references at build time are still the same exact + version when installed (checksum images). + * This is distinct from asserting that the image Kubernetes is running is + exactly the same version that a chart references. Kubernetes does not + currently give us a way of verifying this. +* The package itself has not been tampered with (checksum package tgz). +* The entity who released this package is known (via the GnuPG/PGP signature). + +The format of the file looks something like this: + +``` +-----BEGIN PGP SIGNED MESSAGE----- +name: nginx +description: The nginx web server as a replication controller and service pair. +version: 0.5.1 +keywords: + - https + - http + - web server + - proxy +source: +- https://github.com/foo/bar +home: http://nginx.com + +... +files: + nginx-0.5.1.tgz: “sha256:9f5270f50fc842cfcb717f817e95178f” +images: + “hub.docker.com/_/nginx:5.6.0”: “sha256:f732c04f585170ed3bc99” +-----BEGIN PGP SIGNATURE----- +Version: GnuPG v1.4.9 (GNU/Linux) + +iEYEARECAAYFAkjilUEACgQkB01zfu119ZnHuQCdGCcg2YxF3XFscJLS4lzHlvte +WkQAmQGHuuoLEJuKhRNo+Wy7mhE7u1YG +=eifq +-----END PGP SIGNATURE----- +``` + +Note that the YAML section contains two documents (separated by `...\n`). The +first is the Chart.yaml. The second is the checksums, defined as follows. + +* Files: A map of filenames to SHA-256 checksums (value shown is + fake/truncated) +* Images: A map of image URLs to checksums (value shown is fake/truncated) + +The signature block is a standard PGP signature, which provides [tamper +resistance](http://www.rossde.com/PGP/pgp_signatures.html). + +## Chart Repositories + +Chart repositories serve as a centralized collection of Helm charts. + +Chart repositories must make it possible to serve provenance files over HTTP via +a specific request, and must make them available at the same URI path as the chart. + +For example, if the base URL for a package is `https://example.com/charts/mychart-1.2.3.tgz`, +the provenance file, if it exists, MUST be accessible at `https://example.com/charts/mychart-1.2.3.tgz.prov`. + +From the end user's perspective, `helm install --verify myrepo/mychart-1.2.3` +should result in the download of both the chart and the provenance file with no +additional user configuration or action. diff --git a/pkg/provenance/sign.go b/pkg/provenance/sign.go index 5fb9be8a8..4d379934d 100644 --- a/pkg/provenance/sign.go +++ b/pkg/provenance/sign.go @@ -23,7 +23,6 @@ import ( "fmt" "io" "io/ioutil" - "log" "os" "path/filepath" "strings" @@ -42,7 +41,7 @@ var defaultPGPConfig = packet.Config{ DefaultHash: crypto.SHA512, } -// SumCollection represents a collecton of file and image checksums. +// SumCollection represents a collection of file and image checksums. // // Files are of the form: // FILENAME: "sha256:SUM" @@ -55,6 +54,14 @@ type SumCollection struct { Images map[string]string `json:"images,omitempty"` } +// Verification contains information about a verification operation. +type Verification struct { + // SignedBy contains the entity that signed a chart. + SignedBy *openpgp.Entity + // FileHash is the hash, prepended with the scheme, for the file that was verified. + FileHash string +} + // Signatory signs things. // // Signatories can be constructed from a PGP private key file using NewFromFiles @@ -174,32 +181,50 @@ func (s *Signatory) ClearSign(chartpath string) (string, error) { } // Verify checks a signature and verifies that it is legit for a chart. -func (s *Signatory) Verify(chartpath, sigpath string) (bool, error) { +func (s *Signatory) Verify(chartpath, sigpath string) (*Verification, error) { + ver := &Verification{} for _, fname := range []string{chartpath, sigpath} { if fi, err := os.Stat(fname); err != nil { - return false, err + return ver, err } else if fi.IsDir() { - return false, fmt.Errorf("%s cannot be a directory", fname) + return ver, fmt.Errorf("%s cannot be a directory", fname) } } // First verify the signature sig, err := s.decodeSignature(sigpath) if err != nil { - return false, fmt.Errorf("failed to decode signature: %s", err) + return ver, fmt.Errorf("failed to decode signature: %s", err) } by, err := s.verifySignature(sig) if err != nil { - return false, err - } - for n := range by.Identities { - log.Printf("info: %s signed by %q", sigpath, n) + return ver, err } + ver.SignedBy = by // Second, verify the hash of the tarball. + sum, err := sumArchive(chartpath) + if err != nil { + return ver, err + } + _, sums, err := parseMessageBlock(sig.Plaintext) + if err != nil { + return ver, err + } + + sum = "sha256:" + sum + basename := filepath.Base(chartpath) + if sha, ok := sums.Files[basename]; !ok { + return ver, fmt.Errorf("provenance does not contain a SHA for a file named %q", basename) + } else if sha != sum { + return ver, fmt.Errorf("sha256 sum does not match for %s: %q != %q", basename, sha, sum) + } + ver.FileHash = sum + + // TODO: when image signing is added, verify that here. - return true, nil + return ver, nil } func (s *Signatory) decodeSignature(filename string) (*clearsign.Block, error) { diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go index 5dfd6bd68..2f66748c1 100644 --- a/pkg/provenance/sign_test.go +++ b/pkg/provenance/sign_test.go @@ -218,13 +218,15 @@ func TestVerify(t *testing.T) { t.Fatal(err) } - passed, err := signer.Verify(testChartfile, testSigBlock) - if !passed { + if ver, err := signer.Verify(testChartfile, testSigBlock); err != nil { t.Errorf("Failed to pass verify. Err: %s", err) + } else if len(ver.FileHash) == 0 { + t.Error("Verification is missing hash.") + } else if ver.SignedBy == nil { + t.Error("No SignedBy field") } - passed, err = signer.Verify(testChartfile, testTamperedSigBlock) - if passed { + if _, err = signer.Verify(testChartfile, testTamperedSigBlock); err == nil { t.Errorf("Expected %s to fail.", testTamperedSigBlock) }