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
pull/988/head
Matt Butcher 8 years ago
parent 64b73081ee
commit ce83a8a777

15
glide.lock generated

@ -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:

@ -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

@ -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,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
}

@ -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.) <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)
}
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
}

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…
Cancel
Save