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 000000000..1e89b524f Binary files /dev/null and b/pkg/provenance/testdata/hashtest-1.2.3.tgz differ diff --git a/pkg/provenance/testdata/hashtest.sha256 b/pkg/provenance/testdata/hashtest.sha256 new file mode 100644 index 000000000..829031f9d --- /dev/null +++ b/pkg/provenance/testdata/hashtest.sha256 @@ -0,0 +1 @@ +8e90e879e2a04b1900570e1c198755e46e4706d70b0e79f5edabfac7900e4e75 hashtest-1.2.3.tgz diff --git a/pkg/provenance/testdata/hashtest/.helmignore b/pkg/provenance/testdata/hashtest/.helmignore new file mode 100644 index 000000000..435b756d8 --- /dev/null +++ b/pkg/provenance/testdata/hashtest/.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/pkg/provenance/testdata/hashtest/Chart.yaml b/pkg/provenance/testdata/hashtest/Chart.yaml new file mode 100755 index 000000000..342631ef8 --- /dev/null +++ b/pkg/provenance/testdata/hashtest/Chart.yaml @@ -0,0 +1,3 @@ +description: Test chart versioning +name: hashtest +version: 1.2.3 diff --git a/pkg/provenance/testdata/hashtest/values.yaml b/pkg/provenance/testdata/hashtest/values.yaml new file mode 100644 index 000000000..0827a01fb --- /dev/null +++ b/pkg/provenance/testdata/hashtest/values.yaml @@ -0,0 +1,4 @@ +# Default values for hashtest. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/pkg/provenance/testdata/helm-test-key.pub b/pkg/provenance/testdata/helm-test-key.pub new file mode 100644 index 000000000..38714f25a Binary files /dev/null and b/pkg/provenance/testdata/helm-test-key.pub differ diff --git a/pkg/provenance/testdata/helm-test-key.secret b/pkg/provenance/testdata/helm-test-key.secret new file mode 100644 index 000000000..a966aef93 Binary files /dev/null and b/pkg/provenance/testdata/helm-test-key.secret differ diff --git a/pkg/provenance/testdata/msgblock.yaml b/pkg/provenance/testdata/msgblock.yaml new file mode 100644 index 000000000..0fdbda8ce --- /dev/null +++ b/pkg/provenance/testdata/msgblock.yaml @@ -0,0 +1,7 @@ +description: Test chart versioning +name: hashtest +version: 1.2.3 + +... +files: + hashtest-1.2.3.tgz: sha256:8e90e879e2a04b1900570e1c198755e46e4706d70b0e79f5edabfac7900e4e75 diff --git a/pkg/provenance/testdata/msgblock.yaml.asc b/pkg/provenance/testdata/msgblock.yaml.asc new file mode 100644 index 000000000..5a34d6c52 --- /dev/null +++ b/pkg/provenance/testdata/msgblock.yaml.asc @@ -0,0 +1,21 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +description: Test chart versioning +name: hashtest +version: 1.2.3 + +... +files: + hashtest-1.2.3.tgz: sha256:8e90e879e2a04b1900570e1c198755e46e4706d70b0e79f5edabfac7900e4e75 +-----BEGIN PGP SIGNATURE----- +Comment: GPGTools - https://gpgtools.org + +iQEcBAEBCgAGBQJXlp8KAAoJEIQ7v5gfwYdiE7sIAJYDiza+asekeooSXLvQiK+G +PKnveqQpx49EZ6L7Y7UlW25SyH8EjXXHeJysDywCXF3w4luxN9n56ffU0KEW11IY +F+JSjmgIWLS6ti7ZAGEi6JInQ/30rOAIpTEBRBL2IueW3m63mezrGK6XkBlGqpor +C9WKeqLi+DWlMoBtsEy3Uk0XP6pn/qBFICYAbLQQU0sCCUT8CBA8f8aidxi7aw9t +i404yYF+Dvc6i4JlSG77SV0ZJBWllUvsWoCd9Jli0NAuaMqmE7mzcEt/dE+Fm2Ql +Bx3tr1WS4xTRiFQdcOttOl93H+OaHTh+Y0qqLTzzpCvqmttG0HfI6lMeCs7LeyA= +=vEK+ +-----END PGP SIGNATURE----- diff --git a/pkg/provenance/testdata/msgblock.yaml.tampered b/pkg/provenance/testdata/msgblock.yaml.tampered new file mode 100644 index 000000000..f15811bb2 --- /dev/null +++ b/pkg/provenance/testdata/msgblock.yaml.tampered @@ -0,0 +1,21 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +description: Test chart versioning +name: hashtest +version: 1.2.3+tampered + +... +files: + hashtest-1.2.3.tgz: sha256:8e90e879e2a04b1900570e1c198755e46e4706d70b0e79f5edabfac7900e4e75 +-----BEGIN PGP SIGNATURE----- +Comment: GPGTools - https://gpgtools.org + +iQEcBAEBCgAGBQJXlp8KAAoJEIQ7v5gfwYdiE7sIAJYDiza+asekeooSXLvQiK+G +PKnveqQpx49EZ6L7Y7UlW25SyH8EjXXHeJysDywCXF3w4luxN9n56ffU0KEW11IY +F+JSjmgIWLS6ti7ZAGEi6JInQ/30rOAIpTEBRBL2IueW3m63mezrGK6XkBlGqpor +C9WKeqLi+DWlMoBtsEy3Uk0XP6pn/qBFICYAbLQQU0sCCUT8CBA8f8aidxi7aw9t +i404yYF+Dvc6i4JlSG77SV0ZJBWllUvsWoCd9Jli0NAuaMqmE7mzcEt/dE+Fm2Ql +Bx3tr1WS4xTRiFQdcOttOl93H+OaHTh+Y0qqLTzzpCvqmttG0HfI6lMeCs7LeyA= +=vEK+ +-----END PGP SIGNATURE----- diff --git a/pkg/provenance/testdata/regen-hashtest.sh b/pkg/provenance/testdata/regen-hashtest.sh new file mode 100755 index 000000000..4381fd0b1 --- /dev/null +++ b/pkg/provenance/testdata/regen-hashtest.sh @@ -0,0 +1,3 @@ +#!/bin/sh +helm package hashtest +shasum -a 256 hashtest-1.2.3.tgz > hashtest.sha256