From ce83a8a7776809d2257cc966f62d2cc857cf672b Mon Sep 17 00:00:00 2001 From: Matt Butcher Date: Mon, 25 Jul 2016 17:34:05 -0600 Subject: [PATCH 1/3] feat(pkg/provenance): add OpenPGP signatures This adds support for OpenPGP signatures containing provenance data. Such information can be used to verify the integrity of a Chart by testing that its file hash, metadata, and images are correct. This first PR does not contain all of the tooling necessary for end-to-end chart integrity. It contains just the library. See #983 --- glide.lock | 15 +- glide.yaml | 3 + pkg/provenance/doc.go | 37 +++ pkg/provenance/sign.go | 280 ++++++++++++++++++ pkg/provenance/sign_test.go | 249 ++++++++++++++++ pkg/provenance/testdata/hashtest-1.2.3.tgz | Bin 0 -> 465 bytes pkg/provenance/testdata/hashtest.sha256 | 1 + pkg/provenance/testdata/hashtest/.helmignore | 5 + pkg/provenance/testdata/hashtest/Chart.yaml | 3 + pkg/provenance/testdata/hashtest/values.yaml | 4 + pkg/provenance/testdata/helm-test-key.pub | Bin 0 -> 1243 bytes pkg/provenance/testdata/helm-test-key.secret | Bin 0 -> 2545 bytes pkg/provenance/testdata/msgblock.yaml | 7 + pkg/provenance/testdata/msgblock.yaml.asc | 21 ++ .../testdata/msgblock.yaml.tampered | 21 ++ pkg/provenance/testdata/regen-hashtest.sh | 3 + 16 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 pkg/provenance/doc.go create mode 100644 pkg/provenance/sign.go create mode 100644 pkg/provenance/sign_test.go create mode 100644 pkg/provenance/testdata/hashtest-1.2.3.tgz create mode 100644 pkg/provenance/testdata/hashtest.sha256 create mode 100644 pkg/provenance/testdata/hashtest/.helmignore create mode 100755 pkg/provenance/testdata/hashtest/Chart.yaml create mode 100644 pkg/provenance/testdata/hashtest/values.yaml create mode 100644 pkg/provenance/testdata/helm-test-key.pub create mode 100644 pkg/provenance/testdata/helm-test-key.secret create mode 100644 pkg/provenance/testdata/msgblock.yaml create mode 100644 pkg/provenance/testdata/msgblock.yaml.asc create mode 100644 pkg/provenance/testdata/msgblock.yaml.tampered create mode 100755 pkg/provenance/testdata/regen-hashtest.sh diff --git a/glide.lock b/glide.lock index 1bf1d6940..808be278c 100644 --- a/glide.lock +++ b/glide.lock @@ -1,5 +1,5 @@ -hash: 410e784360a10f716d4bf4d22decf81f75b327d051b3f2d23f55aa9049c09676 -updated: 2016-08-19T12:19:48.074620307-06:00 +hash: 05c56f2ae4c8bcbaf2c428e2e070ec00f865b284ea61dd671e2c4e117f2d6528 +updated: 2016-08-19T17:30:32.462379907-06:00 imports: - name: github.com/aokoli/goutils version: 9c37978a95bd5c709a15883b6242714ea6709e64 @@ -247,6 +247,17 @@ imports: subpackages: - codec - codec/codecgen +- name: golang.org/x/crypto + version: c84e1f8e3a7e322d497cd16c0e8a13c7e127baf3 + subpackages: + - cast5 + - openpgp + - openpgp/armor + - openpgp/clearsign + - openpgp/elgamal + - openpgp/errors + - openpgp/packet + - openpgp/s2k - name: golang.org/x/net version: fb93926129b8ec0056f2f458b1f519654814edf0 subpackages: diff --git a/glide.yaml b/glide.yaml index ed96b0204..2f7567469 100644 --- a/glide.yaml +++ b/glide.yaml @@ -50,3 +50,6 @@ import: - package: google.golang.org/cloud vcs: git repo: https://code.googlesource.com/gocloud +- package: golang.org/x/crypto + subpackages: + - openpgp diff --git a/pkg/provenance/doc.go b/pkg/provenance/doc.go new file mode 100644 index 000000000..dacfa9e69 --- /dev/null +++ b/pkg/provenance/doc.go @@ -0,0 +1,37 @@ +/* +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 provenance provides tools for establishing the authenticity of a chart. + +In Helm, provenance is established via several factors. The primary factor is the +cryptographic signature of a chart. Chart authors may sign charts, which in turn +provide the necessary metadata to ensure the integrity of the chart file, the +Chart.yaml, and the referenced Docker images. + +A provenance file is clear-signed. This provides cryptographic verification that +a particular block of information (Chart.yaml, archive file, images) have not +been tampered with or altered. To learn more, read the GnuPG documentation on +clear signatures: +https://www.gnupg.org/gph/en/manual/x135.html + +The cryptography used by Helm should be compatible with OpenGPG. For example, +you should be able to verify a signature by importing the desired public key +and using `gpg --verify`, `keybase pgp verify`, or similar: + + $ gpg --verify some.sig + gpg: Signature made Mon Jul 25 17:23:44 2016 MDT using RSA key ID 1FC18762 + gpg: Good signature from "Helm Testing (This key should only be used for testing. DO NOT TRUST.) " [ultimate] +*/ +package provenance // import "k8s.io/helm/pkg/provenance" diff --git a/pkg/provenance/sign.go b/pkg/provenance/sign.go new file mode 100644 index 000000000..3dd8cfb74 --- /dev/null +++ b/pkg/provenance/sign.go @@ -0,0 +1,280 @@ +/* +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 provenance + +import ( + "bytes" + "crypto" + "encoding/hex" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "path/filepath" + + "github.com/ghodss/yaml" + + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/clearsign" + "golang.org/x/crypto/openpgp/packet" + + "k8s.io/helm/pkg/chartutil" + hapi "k8s.io/helm/pkg/proto/hapi/chart" +) + +var defaultPGPConfig = packet.Config{ + DefaultHash: crypto.SHA512, +} + +// SumCollection represents a collecton of file and image checksums. +// +// Files are of the form: +// FILENAME: "sha256:SUM" +// Images are of the form: +// "IMAGE:TAG": "sha256:SUM" +// Docker optionally supports sha512, and if this is the case, the hash marker +// will be 'sha512' instead of 'sha256'. +type SumCollection struct { + Files map[string]string `json:"files"` + Images map[string]string `json:"images,omitempty"` +} + +// Signatory signs things. +// +// Signatories can be constructed from a PGP private key file using NewFromFiles +// or they can be constructed manually by setting the Entity to a valid +// PGP entity. +// +// The same Signatory can be used to sign or validate multiple charts. +type Signatory struct { + // The signatory for this instance of Helm. This is used for signing. + Entity *openpgp.Entity + // The keyring for this instance of Helm. This is used for verification. + KeyRing openpgp.EntityList +} + +// NewFromFiles constructs a new Signatory from the PGP key in the given filename. +// +// This will emit an error if it cannot find a valid GPG keyfile (entity) at the +// given location. +// +// Note that the keyfile may have just a public key, just a private key, or +// both. The Signatory methods may have different requirements of the keys. For +// example, ClearSign must have a valid `openpgp.Entity.PrivateKey` before it +// can sign something. +func NewFromFiles(keyfile, keyringfile string) (*Signatory, error) { + e, err := loadKey(keyfile) + if err != nil { + return nil, err + } + + ring, err := loadKeyRing(keyringfile) + if err != nil { + return nil, err + } + + return &Signatory{ + Entity: e, + KeyRing: ring, + }, nil +} + +// Sign signs a chart with the given key. +// +// This takes the path to a chart archive file and a key, and it returns a clear signature. +// +// The Signatory must have a valid Entity.PrivateKey for this to work. If it does +// not, an error will be returned. +func (s *Signatory) ClearSign(chartpath string) (string, error) { + if s.Entity.PrivateKey == nil { + return "", errors.New("private key not found") + } + + if fi, err := os.Stat(chartpath); err != nil { + return "", err + } else if fi.IsDir() { + return "", errors.New("cannot sign a directory") + } + + out := bytes.NewBuffer(nil) + + b, err := messageBlock(chartpath) + if err != nil { + return "", nil + } + + // Sign the buffer + w, err := clearsign.Encode(out, s.Entity.PrivateKey, &defaultPGPConfig) + if err != nil { + return "", err + } + _, err = io.Copy(w, b) + w.Close() + return out.String(), err +} + +func (s *Signatory) Verify(chartpath, sigpath string) (bool, error) { + for _, fname := range []string{chartpath, sigpath} { + if fi, err := os.Stat(fname); err != nil { + return false, err + } else if fi.IsDir() { + return false, 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) + } + + 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) + } + + // Second, verify the hash of the tarball. + + return true, nil +} + +func (s *Signatory) decodeSignature(filename string) (*clearsign.Block, error) { + data, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + block, _ := clearsign.Decode(data) + if block == nil { + // There was no sig in the file. + return nil, errors.New("signature block not found") + } + + return block, nil +} + +// verifySignature verifies that the given block is validly signed, and returns the signer. +func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, error) { + return openpgp.CheckDetachedSignature( + s.KeyRing, + bytes.NewBuffer(block.Bytes), + block.ArmoredSignature.Body, + ) +} + +func messageBlock(chartpath string) (*bytes.Buffer, error) { + var b *bytes.Buffer + // Checksum the archive + chash, err := sumArchive(chartpath) + if err != nil { + return b, err + } + + base := filepath.Base(chartpath) + sums := &SumCollection{ + Files: map[string]string{ + base: "sha256:" + chash, + }, + } + + // Load the archive into memory. + chart, err := chartutil.LoadFile(chartpath) + if err != nil { + return b, err + } + + // Buffer a hash + checksums YAML file + data, err := yaml.Marshal(chart.Metadata) + if err != nil { + return b, err + } + + // FIXME: YAML uses ---\n as a file start indicator, but this is not legal in a PGP + // clearsign block. So we use ...\n, which is the YAML document end marker. + // http://yaml.org/spec/1.2/spec.html#id2800168 + b = bytes.NewBuffer(data) + b.WriteString("\n...\n") + + data, err = yaml.Marshal(sums) + if err != nil { + return b, err + } + b.Write(data) + + return b, nil +} + +// parseMessageBlock +func parseMessageBlock(data []byte) (*hapi.Metadata, *SumCollection, error) { + // This sucks. + parts := bytes.Split(data, []byte("\n...\n")) + if len(parts) < 2 { + return nil, nil, errors.New("message block must have at least two parts") + } + + md := &hapi.Metadata{} + sc := &SumCollection{} + + if err := yaml.Unmarshal(parts[0], md); err != nil { + return md, sc, err + } + err := yaml.Unmarshal(parts[1], sc) + return md, sc, err +} + +// loadKey loads a GPG key found at a particular path. +func loadKey(keypath string) (*openpgp.Entity, error) { + f, err := os.Open(keypath) + if err != nil { + return nil, err + } + defer f.Close() + + pr := packet.NewReader(f) + return openpgp.ReadEntity(pr) +} + +func loadKeyRing(ringpath string) (openpgp.EntityList, error) { + f, err := os.Open(ringpath) + if err != nil { + return nil, err + } + defer f.Close() + return openpgp.ReadKeyRing(f) +} + +// sumArchive calculates a SHA256 hash (like Docker) for a given file. +// +// It takes the path to the archive file, and returns a string representation of +// the SHA256 sum. +// +// The intended use of this function is to generate a sum of a chart TGZ file. +func sumArchive(filename string) (string, error) { + f, err := os.Open(filename) + if err != nil { + return "", err + } + defer f.Close() + + hash := crypto.SHA256.New() + io.Copy(hash, f) + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go new file mode 100644 index 000000000..5dfd6bd68 --- /dev/null +++ b/pkg/provenance/sign_test.go @@ -0,0 +1,249 @@ +/* +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 provenance + +import ( + "io/ioutil" + "os" + "strings" + "testing" + + pgperrors "golang.org/x/crypto/openpgp/errors" +) + +const ( + // testKeyFile is the secret key. + // Generating keys should be done with `gpg --gen-key`. The current key + // was generated to match Go's defaults (RSA/RSA 2048). It has no pass + // phrase. Use `gpg --export-secret-keys helm-test` to export the secret. + testKeyfile = "testdata/helm-test-key.secret" + + // testPubfile is the public key file. + // Use `gpg --export helm-test` to export the public key. + testPubfile = "testdata/helm-test-key.pub" + + // Generated name for the PGP key in testKeyFile. + testKeyName = `Helm Testing (This key should only be used for testing. DO NOT TRUST.) ` + + testChartfile = "testdata/hashtest-1.2.3.tgz" + + // testSigBlock points to a signature generated by an external tool. + // This file was generated with GnuPG: + // gpg --clearsign -u helm-test --openpgp testdata/msgblock.yaml + testSigBlock = "testdata/msgblock.yaml.asc" + + // testTamperedSigBlock is a tampered copy of msgblock.yaml.asc + testTamperedSigBlock = "testdata/msgblock.yaml.tampered" + + // testSumfile points to a SHA256 sum generated by an external tool. + // We always want to validate against an external tool's representation to + // verify that we haven't done something stupid. This file was generated + // with shasum. + // shasum -a 256 hashtest-1.2.3.tgz > testdata/hashtest.sha256 + testSumfile = "testdata/hashtest.sha256" +) + +// testMessageBlock represents the expected message block for the testdata/hashtest chart. +const testMessageBlock = `description: Test chart versioning +name: hashtest +version: 1.2.3 + +... +files: + hashtest-1.2.3.tgz: sha256:8e90e879e2a04b1900570e1c198755e46e4706d70b0e79f5edabfac7900e4e75 +` + +func TestMessageBlock(t *testing.T) { + out, err := messageBlock(testChartfile) + if err != nil { + t.Fatal(err) + } + got := out.String() + + if got != testMessageBlock { + t.Errorf("Expected:\n%q\nGot\n%q\n", testMessageBlock, got) + } +} + +func TestParseMessageBlock(t *testing.T) { + md, sc, err := parseMessageBlock([]byte(testMessageBlock)) + if err != nil { + t.Fatal(err) + } + + if md.Name != "hashtest" { + t.Errorf("Expected name %q, got %q", "hashtest", md.Name) + } + + if lsc := len(sc.Files); lsc != 1 { + t.Errorf("Expected 1 file, got %d", lsc) + } + + if hash, ok := sc.Files["hashtest-1.2.3.tgz"]; !ok { + t.Errorf("hashtest file not found in Files") + } else if hash != "sha256:8e90e879e2a04b1900570e1c198755e46e4706d70b0e79f5edabfac7900e4e75" { + t.Errorf("Unexpected hash: %q", hash) + } +} + +func TestLoadKey(t *testing.T) { + k, err := loadKey(testKeyfile) + if err != nil { + t.Fatal(err) + } + + if _, ok := k.Identities[testKeyName]; !ok { + t.Errorf("Expected to load a key for user %q", testKeyName) + } +} + +func TestLoadKeyRing(t *testing.T) { + k, err := loadKeyRing(testPubfile) + if err != nil { + t.Fatal(err) + } + + if len(k) > 1 { + t.Errorf("Expected 1, got %d", len(k)) + } + + for _, e := range k { + if ii, ok := e.Identities[testKeyName]; !ok { + t.Errorf("Expected %s in %v", testKeyName, ii) + } + } +} + +func TestNewFromFiles(t *testing.T) { + s, err := NewFromFiles(testKeyfile, testPubfile) + if err != nil { + t.Fatal(err) + } + + if _, ok := s.Entity.Identities[testKeyName]; !ok { + t.Errorf("Expected to load a key for user %q", testKeyName) + } +} + +func TestSumArchive(t *testing.T) { + hash, err := sumArchive(testChartfile) + if err != nil { + t.Fatal(err) + } + + sig, err := readSumFile(testSumfile) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(sig, hash) { + t.Errorf("Expected %s to be in %s", hash, sig) + } +} + +func TestClearSign(t *testing.T) { + signer, err := NewFromFiles(testKeyfile, testPubfile) + if err != nil { + t.Fatal(err) + } + + sig, err := signer.ClearSign(testChartfile) + if err != nil { + t.Fatal(err) + } + t.Logf("Sig:\n%s", sig) + + if !strings.Contains(sig, testMessageBlock) { + t.Errorf("expected message block to be in sig: %s", sig) + } +} + +func TestDecodeSignature(t *testing.T) { + // Unlike other tests, this does a round-trip test, ensuring that a signature + // generated by the library can also be verified by the library. + + signer, err := NewFromFiles(testKeyfile, testPubfile) + if err != nil { + t.Fatal(err) + } + + sig, err := signer.ClearSign(testChartfile) + if err != nil { + t.Fatal(err) + } + + f, err := ioutil.TempFile("", "helm-test-sig-") + if err != nil { + t.Fatal(err) + } + + tname := f.Name() + defer func() { + os.Remove(tname) + }() + f.WriteString(sig) + f.Close() + + sig2, err := signer.decodeSignature(tname) + if err != nil { + t.Fatal(err) + } + + by, err := signer.verifySignature(sig2) + if err != nil { + t.Fatal(err) + } + + if _, ok := by.Identities[testKeyName]; !ok { + t.Errorf("Expected identity %q", testKeyName) + } +} + +func TestVerify(t *testing.T) { + signer, err := NewFromFiles(testKeyfile, testPubfile) + if err != nil { + t.Fatal(err) + } + + passed, err := signer.Verify(testChartfile, testSigBlock) + if !passed { + t.Errorf("Failed to pass verify. Err: %s", err) + } + + passed, err = signer.Verify(testChartfile, testTamperedSigBlock) + if passed { + t.Errorf("Expected %s to fail.", testTamperedSigBlock) + } + + switch err.(type) { + case pgperrors.SignatureError: + t.Logf("Tampered sig block error: %s (%T)", err, err) + default: + t.Errorf("Expected invalid signature error, got %q (%T)", err, err) + } +} + +// readSumFile reads a file containing a sum generated by the UNIX shasum tool. +func readSumFile(sumfile string) (string, error) { + data, err := ioutil.ReadFile(sumfile) + if err != nil { + return "", err + } + + sig := string(data) + parts := strings.SplitN(sig, " ", 2) + return parts[0], nil +} diff --git a/pkg/provenance/testdata/hashtest-1.2.3.tgz b/pkg/provenance/testdata/hashtest-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..1e89b524f4fc89e4a483ef8dc4fefdc79d64786f GIT binary patch literal 465 zcmV;?0WSU@iwG0|32ul0|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PL2*>(ek4$A9Pk6;F0i(Ac#6HrP$vQBl|~o+N8un_!xhB;DM9 z?@Oyirm(~2hL8_~X6Z|tviJF}Qg|8Ahqv#gaDkmfr=M<3POP4v$0Kom%z4h|@i@VvXo4LfQCsA40)0iCBgW!lV$4%hIjQL>+B z*1%c8%Iwh(khqH3|AWv2`hOAtm;5hydFFq~%Od%I4;HY&Mhu#a9~%G~>t@$kwt$^f z9_Sjxd=gI$juz=4|XJJ0SyFKmTjH^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/pkg/provenance/testdata/helm-test-key.secret b/pkg/provenance/testdata/helm-test-key.secret new file mode 100644 index 0000000000000000000000000000000000000000..a966aef93ed97d01d764f29940738df6df2d9d24 GIT binary patch literal 2545 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 hashtest.sha256 From d80df934147779b6337fa8026c8323767443c1b6 Mon Sep 17 00:00:00 2001 From: Matt Butcher Date: Wed, 27 Jul 2016 16:23:27 -0600 Subject: [PATCH 2/3] feat(helm): add signature support to 'helm package' --- cmd/helm/helm.go | 1 + cmd/helm/list_test.go | 4 +- cmd/helm/package.go | 93 ++++++++++++---- cmd/helm/package_test.go | 148 +++++++++++++++++++++++++ cmd/helm/testdata/helm-test-key.secret | Bin 0 -> 2545 bytes pkg/provenance/sign.go | 48 +++++++- 6 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 cmd/helm/package_test.go create mode 100644 cmd/helm/testdata/helm-test-key.secret diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go index ebe139fb6..42e7c6c26 100644 --- a/cmd/helm/helm.go +++ b/cmd/helm/helm.go @@ -94,6 +94,7 @@ func newRootCmd(out io.Writer) *cobra.Command { newStatusCmd(nil, out), newUpgradeCmd(nil, out), newRollbackCmd(nil, out), + newPackageCmd(nil, out), ) return cmd } diff --git a/cmd/helm/list_test.go b/cmd/helm/list_test.go index 84fe65a47..0a2e88f3f 100644 --- a/cmd/helm/list_test.go +++ b/cmd/helm/list_test.go @@ -56,9 +56,7 @@ func TestListCmd(t *testing.T) { rels: tt.resp, } cmd := newListCmd(c, &buf) - for flag, value := range tt.flags { - cmd.Flags().Set(flag, value) - } + setFlags(cmd, tt.flags) 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 beff30e31..81385224f 100644 --- a/cmd/helm/package.go +++ b/cmd/helm/package.go @@ -17,12 +17,18 @@ limitations under the License. package main import ( + "errors" "fmt" + "io" + "io/ioutil" "os" "path/filepath" "github.com/spf13/cobra" + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/helm" + "k8s.io/helm/pkg/provenance" "k8s.io/helm/pkg/repo" ) @@ -37,30 +43,56 @@ Chart.yaml file, and (if found) build the current directory into a chart. Versioned chart archives are used by Helm package repositories. ` -var save bool +const ( + envSigningKey = "HELM_SIGNING_KEY" + envKeyring = "HELM_KEYRING" +) -func init() { - packageCmd.Flags().BoolVar(&save, "save", true, "save packaged chart to local chart repository") - RootCommand.AddCommand(packageCmd) +type packageCmd struct { + save bool + sign bool + path string + key string + keyring string + out io.Writer } -var packageCmd = &cobra.Command{ - Use: "package [CHART_PATH]", - Short: "package a chart directory into a chart archive", - Long: packageDesc, - RunE: runPackage, -} +func newPackageCmd(client helm.Interface, out io.Writer) *cobra.Command { + pkg := &packageCmd{ + out: out, + } + cmd := &cobra.Command{ + Use: "package [CHART_PATH]", + Short: "package a chart directory into a chart archive", + Long: packageDesc, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("This command needs at least one argument, the path to the chart.") + } + pkg.path = args[0] + if pkg.sign { + if pkg.key == "" { + return errors.New("--key is required for signing a package") + } + if pkg.keyring == "" { + return errors.New("--keyring is required for signing a package") + } + } + return pkg.run(cmd, args) + }, + } -func runPackage(cmd *cobra.Command, args []string) error { - path := "." + f := cmd.Flags() + 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") - if len(args) > 0 { - path = args[0] - } else { - return fmt.Errorf("This command needs at least one argument, the path to the chart.") - } + return cmd +} - path, err := filepath.Abs(path) +func (p *packageCmd) run(cmd *cobra.Command, args []string) error { + path, err := filepath.Abs(p.path) if err != nil { return err } @@ -86,7 +118,7 @@ func runPackage(cmd *cobra.Command, args []string) error { // Save to $HELM_HOME/local directory. This is second, because we don't want // the case where we saved here, but didn't save to the default destination. - if save { + if p.save { if err := repo.AddChartToLocalRepo(ch, localRepoDirectory()); err != nil { return err } else if flagDebug { @@ -94,5 +126,28 @@ func runPackage(cmd *cobra.Command, args []string) error { } } + if p.sign { + err = p.clearsign(name) + } + return err } + +func (p *packageCmd) clearsign(filename string) error { + // Load keyring + signer, err := provenance.NewFromKeyring(p.keyring, p.key) + if err != nil { + return err + } + + sig, err := signer.ClearSign(filename) + if err != nil { + return err + } + + if flagDebug { + fmt.Fprintln(p.out, sig) + } + + return ioutil.WriteFile(filename+".prov", []byte(sig), 0755) +} diff --git a/cmd/helm/package_test.go b/cmd/helm/package_test.go new file mode 100644 index 000000000..b724ba2ea --- /dev/null +++ b/cmd/helm/package_test.go @@ -0,0 +1,148 @@ +/* +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" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/spf13/cobra" +) + +func TestPackage(t *testing.T) { + + tests := []struct { + name string + flags map[string]string + args []string + expect string + hasfile string + err bool + }{ + { + name: "package without chart path", + args: []string{}, + flags: map[string]string{}, + expect: "This command needs at least one argument, the path to the chart.", + err: true, + }, + { + name: "package --sign, no --key", + args: []string{"testdata/testcharts/alpine"}, + flags: map[string]string{"sign": "1"}, + expect: "key is required for signing a package", + err: true, + }, + { + name: "package --sign, no --keyring", + args: []string{"testdata/testcharts/alpine"}, + flags: map[string]string{"sign": "1", "key": "nosuchkey", "keyring": ""}, + expect: "keyring is required for signing a package", + err: true, + }, + { + name: "package testdata/testcharts/alpine", + args: []string{"testdata/testcharts/alpine"}, + expect: "", + hasfile: "alpine-0.1.0.tgz", + }, + { + name: "package --sign --key=KEY --keyring=KEYRING testdata/testcharts/alpine", + args: []string{"testdata/testcharts/alpine"}, + flags: map[string]string{"sign": "1", "keyring": "testdata/helm-test-key.secret", "key": "helm-test"}, + expect: "", + hasfile: "alpine-0.1.0.tgz", + }, + } + + // Because these tests are destructive, we run them in a tempdir. + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + tmp, err := ioutil.TempDir("", "helm-package-test-") + if err != nil { + t.Fatal(err) + } + + t.Logf("Running tests in %s", tmp) + if err := os.Chdir(tmp); err != nil { + t.Fatal(err) + } + + defer func() { + os.Chdir(origDir) + os.RemoveAll(tmp) + }() + + for _, tt := range tests { + buf := bytes.NewBuffer(nil) + c := newPackageCmd(nil, buf) + + // This is an unfortunate byproduct of the tmpdir + if v, ok := tt.flags["keyring"]; ok && len(v) > 0 { + tt.flags["keyring"] = filepath.Join(origDir, v) + } + + setFlags(c, tt.flags) + re := regexp.MustCompile(tt.expect) + + adjustedArgs := make([]string, len(tt.args)) + for i, f := range tt.args { + adjustedArgs[i] = filepath.Join(origDir, f) + } + + err := c.RunE(c, adjustedArgs) + if err != nil { + if tt.err && re.MatchString(err.Error()) { + continue + } + t.Errorf("%q: expected error %q, got %q", tt.name, tt.expect, err) + continue + } + + if !re.Match(buf.Bytes()) { + t.Errorf("%q: expected output %q, got %q", tt.name, tt.expect, buf.String()) + } + + if len(tt.hasfile) > 0 { + if fi, err := os.Stat(tt.hasfile); err != nil { + t.Errorf("%q: expected file %q, got err %q", tt.name, tt.hasfile, err) + } else if fi.Size() == 0 { + t.Errorf("%q: file %q has zero bytes.", tt.name, tt.hasfile) + } + } + + if v, ok := tt.flags["sign"]; ok && v == "1" { + if fi, err := os.Stat(tt.hasfile + ".prov"); err != nil { + t.Errorf("%q: expected provenance file", tt.name) + } else if fi.Size() == 0 { + t.Errorf("%q: provenance file is empty", tt.name) + } + } + } +} + +func setFlags(cmd *cobra.Command, flags map[string]string) { + dest := cmd.Flags() + for f, v := range flags { + dest.Set(f, v) + } +} diff --git a/cmd/helm/testdata/helm-test-key.secret b/cmd/helm/testdata/helm-test-key.secret new file mode 100644 index 0000000000000000000000000000000000000000..a966aef93ed97d01d764f29940738df6df2d9d24 GIT binary patch literal 2545 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 Date: Tue, 9 Aug 2016 16:36:00 -0600 Subject: [PATCH 3/3] 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) }