mirror of https://github.com/helm/helm
Merge pull request #988 from technosophos/feat/sig-files
feat(pkg/provenance): add OpenPGP signaturespull/1099/head
commit
01e2d367dd
@ -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)
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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-----
|
@ -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
|
@ -0,0 +1,3 @@
|
||||
description: A Helm chart for Kubernetes
|
||||
name: signtest
|
||||
version: 0.1.0
|
@ -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
|
@ -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`.
|
@ -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"]
|
@ -0,0 +1,2 @@
|
||||
# The pod name
|
||||
name: my-alpine
|
@ -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"]
|
@ -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)
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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.) <helm-testing@helm.sh>" [ultimate]
|
||||
*/
|
||||
package provenance // import "k8s.io/helm/pkg/provenance"
|
@ -0,0 +1,351 @@
|
||||
/*
|
||||
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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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 collection 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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewFromKeyring reads a keyring file and creates a Signatory.
|
||||
//
|
||||
// If id is not the empty string, this will also try to find an Entity in the
|
||||
// keyring whose name matches, and set that as the signing entity. It will return
|
||||
// an error if the id is not empty and also not found.
|
||||
func NewFromKeyring(keyringfile, id string) (*Signatory, error) {
|
||||
ring, err := loadKeyRing(keyringfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Signatory{KeyRing: ring}
|
||||
|
||||
// If the ID is empty, we can return now.
|
||||
if id == "" {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// We're gonna go all GnuPG on this and look for a string that _contains_. If
|
||||
// two or more keys contain the string and none are a direct match, we error
|
||||
// out.
|
||||
var candidate *openpgp.Entity
|
||||
vague := false
|
||||
for _, e := range ring {
|
||||
for n := range e.Identities {
|
||||
if n == id {
|
||||
s.Entity = e
|
||||
return s, nil
|
||||
}
|
||||
if strings.Contains(n, id) {
|
||||
if candidate != nil {
|
||||
vague = true
|
||||
}
|
||||
candidate = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if vague {
|
||||
return s, fmt.Errorf("more than one key contain the id %q", id)
|
||||
}
|
||||
s.Entity = candidate
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ClearSign 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
|
||||
}
|
||||
|
||||
// Verify checks a signature and verifies that it is legit for a chart.
|
||||
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 ver, err
|
||||
} else if fi.IsDir() {
|
||||
return ver, fmt.Errorf("%s cannot be a directory", fname)
|
||||
}
|
||||
}
|
||||
|
||||
// First verify the signature
|
||||
sig, err := s.decodeSignature(sigpath)
|
||||
if err != nil {
|
||||
return ver, fmt.Errorf("failed to decode signature: %s", err)
|
||||
}
|
||||
|
||||
by, err := s.verifySignature(sig)
|
||||
if err != nil {
|
||||
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 ver, 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
|
||||
}
|
@ -0,0 +1,251 @@
|
||||
/*
|
||||
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.) <helm-testing@helm.sh>`
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
if _, err = signer.Verify(testChartfile, testTamperedSigBlock); err == nil {
|
||||
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
|
||||
}
|
Binary file not shown.
@ -0,0 +1 @@
|
||||
8e90e879e2a04b1900570e1c198755e46e4706d70b0e79f5edabfac7900e4e75 hashtest-1.2.3.tgz
|
@ -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
|
@ -0,0 +1,3 @@
|
||||
description: Test chart versioning
|
||||
name: hashtest
|
||||
version: 1.2.3
|
@ -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
|
Binary file not shown.
Binary file not shown.
@ -0,0 +1,7 @@
|
||||
description: Test chart versioning
|
||||
name: hashtest
|
||||
version: 1.2.3
|
||||
|
||||
...
|
||||
files:
|
||||
hashtest-1.2.3.tgz: sha256:8e90e879e2a04b1900570e1c198755e46e4706d70b0e79f5edabfac7900e4e75
|
@ -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-----
|
@ -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-----
|
@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
helm package hashtest
|
||||
shasum -a 256 hashtest-1.2.3.tgz > hashtest.sha256
|
Loading…
Reference in new issue