github.com/stefanmcshane/helm@v0.0.0-20221213002717-88a4a2c6e77d/pkg/provenance/sign.go (about)

     1  /*
     2  Copyright The Helm Authors.
     3  Licensed under the Apache License, Version 2.0 (the "License");
     4  you may not use this file except in compliance with the License.
     5  You may obtain a copy of the License at
     6  
     7  http://www.apache.org/licenses/LICENSE-2.0
     8  
     9  Unless required by applicable law or agreed to in writing, software
    10  distributed under the License is distributed on an "AS IS" BASIS,
    11  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  See the License for the specific language governing permissions and
    13  limitations under the License.
    14  */
    15  
    16  package provenance
    17  
    18  import (
    19  	"bytes"
    20  	"crypto"
    21  	"encoding/hex"
    22  	"io"
    23  	"io/ioutil"
    24  	"os"
    25  	"path/filepath"
    26  	"strings"
    27  
    28  	"github.com/pkg/errors"
    29  	"golang.org/x/crypto/openpgp"           //nolint
    30  	"golang.org/x/crypto/openpgp/clearsign" //nolint
    31  	"golang.org/x/crypto/openpgp/packet"    //nolint
    32  	"sigs.k8s.io/yaml"
    33  
    34  	hapi "github.com/stefanmcshane/helm/pkg/chart"
    35  	"github.com/stefanmcshane/helm/pkg/chart/loader"
    36  )
    37  
    38  var defaultPGPConfig = packet.Config{
    39  	DefaultHash: crypto.SHA512,
    40  }
    41  
    42  // SumCollection represents a collection of file and image checksums.
    43  //
    44  // Files are of the form:
    45  //	FILENAME: "sha256:SUM"
    46  // Images are of the form:
    47  //	"IMAGE:TAG": "sha256:SUM"
    48  // Docker optionally supports sha512, and if this is the case, the hash marker
    49  // will be 'sha512' instead of 'sha256'.
    50  type SumCollection struct {
    51  	Files  map[string]string `json:"files"`
    52  	Images map[string]string `json:"images,omitempty"`
    53  }
    54  
    55  // Verification contains information about a verification operation.
    56  type Verification struct {
    57  	// SignedBy contains the entity that signed a chart.
    58  	SignedBy *openpgp.Entity
    59  	// FileHash is the hash, prepended with the scheme, for the file that was verified.
    60  	FileHash string
    61  	// FileName is the name of the file that FileHash verifies.
    62  	FileName string
    63  }
    64  
    65  // Signatory signs things.
    66  //
    67  // Signatories can be constructed from a PGP private key file using NewFromFiles
    68  // or they can be constructed manually by setting the Entity to a valid
    69  // PGP entity.
    70  //
    71  // The same Signatory can be used to sign or validate multiple charts.
    72  type Signatory struct {
    73  	// The signatory for this instance of Helm. This is used for signing.
    74  	Entity *openpgp.Entity
    75  	// The keyring for this instance of Helm. This is used for verification.
    76  	KeyRing openpgp.EntityList
    77  }
    78  
    79  // NewFromFiles constructs a new Signatory from the PGP key in the given filename.
    80  //
    81  // This will emit an error if it cannot find a valid GPG keyfile (entity) at the
    82  // given location.
    83  //
    84  // Note that the keyfile may have just a public key, just a private key, or
    85  // both. The Signatory methods may have different requirements of the keys. For
    86  // example, ClearSign must have a valid `openpgp.Entity.PrivateKey` before it
    87  // can sign something.
    88  func NewFromFiles(keyfile, keyringfile string) (*Signatory, error) {
    89  	e, err := loadKey(keyfile)
    90  	if err != nil {
    91  		return nil, err
    92  	}
    93  
    94  	ring, err := loadKeyRing(keyringfile)
    95  	if err != nil {
    96  		return nil, err
    97  	}
    98  
    99  	return &Signatory{
   100  		Entity:  e,
   101  		KeyRing: ring,
   102  	}, nil
   103  }
   104  
   105  // NewFromKeyring reads a keyring file and creates a Signatory.
   106  //
   107  // If id is not the empty string, this will also try to find an Entity in the
   108  // keyring whose name matches, and set that as the signing entity. It will return
   109  // an error if the id is not empty and also not found.
   110  func NewFromKeyring(keyringfile, id string) (*Signatory, error) {
   111  	ring, err := loadKeyRing(keyringfile)
   112  	if err != nil {
   113  		return nil, err
   114  	}
   115  
   116  	s := &Signatory{KeyRing: ring}
   117  
   118  	// If the ID is empty, we can return now.
   119  	if id == "" {
   120  		return s, nil
   121  	}
   122  
   123  	// We're gonna go all GnuPG on this and look for a string that _contains_. If
   124  	// two or more keys contain the string and none are a direct match, we error
   125  	// out.
   126  	var candidate *openpgp.Entity
   127  	vague := false
   128  	for _, e := range ring {
   129  		for n := range e.Identities {
   130  			if n == id {
   131  				s.Entity = e
   132  				return s, nil
   133  			}
   134  			if strings.Contains(n, id) {
   135  				if candidate != nil {
   136  					vague = true
   137  				}
   138  				candidate = e
   139  			}
   140  		}
   141  	}
   142  	if vague {
   143  		return s, errors.Errorf("more than one key contain the id %q", id)
   144  	}
   145  
   146  	s.Entity = candidate
   147  	return s, nil
   148  }
   149  
   150  // PassphraseFetcher returns a passphrase for decrypting keys.
   151  //
   152  // This is used as a callback to read a passphrase from some other location. The
   153  // given name is the Name field on the key, typically of the form:
   154  //
   155  //	USER_NAME (COMMENT) <EMAIL>
   156  type PassphraseFetcher func(name string) ([]byte, error)
   157  
   158  // DecryptKey decrypts a private key in the Signatory.
   159  //
   160  // If the key is not encrypted, this will return without error.
   161  //
   162  // If the key does not exist, this will return an error.
   163  //
   164  // If the key exists, but cannot be unlocked with the passphrase returned by
   165  // the PassphraseFetcher, this will return an error.
   166  //
   167  // If the key is successfully unlocked, it will return nil.
   168  func (s *Signatory) DecryptKey(fn PassphraseFetcher) error {
   169  	if s.Entity == nil {
   170  		return errors.New("private key not found")
   171  	} else if s.Entity.PrivateKey == nil {
   172  		return errors.New("provided key is not a private key. Try providing a keyring with secret keys")
   173  	}
   174  
   175  	// Nothing else to do if key is not encrypted.
   176  	if !s.Entity.PrivateKey.Encrypted {
   177  		return nil
   178  	}
   179  
   180  	fname := "Unknown"
   181  	for i := range s.Entity.Identities {
   182  		if i != "" {
   183  			fname = i
   184  			break
   185  		}
   186  	}
   187  
   188  	p, err := fn(fname)
   189  	if err != nil {
   190  		return err
   191  	}
   192  
   193  	return s.Entity.PrivateKey.Decrypt(p)
   194  }
   195  
   196  // ClearSign signs a chart with the given key.
   197  //
   198  // This takes the path to a chart archive file and a key, and it returns a clear signature.
   199  //
   200  // The Signatory must have a valid Entity.PrivateKey for this to work. If it does
   201  // not, an error will be returned.
   202  func (s *Signatory) ClearSign(chartpath string) (string, error) {
   203  	if s.Entity == nil {
   204  		return "", errors.New("private key not found")
   205  	} else if s.Entity.PrivateKey == nil {
   206  		return "", errors.New("provided key is not a private key. Try providing a keyring with secret keys")
   207  	}
   208  
   209  	if fi, err := os.Stat(chartpath); err != nil {
   210  		return "", err
   211  	} else if fi.IsDir() {
   212  		return "", errors.New("cannot sign a directory")
   213  	}
   214  
   215  	out := bytes.NewBuffer(nil)
   216  
   217  	b, err := messageBlock(chartpath)
   218  	if err != nil {
   219  		return "", err
   220  	}
   221  
   222  	// Sign the buffer
   223  	w, err := clearsign.Encode(out, s.Entity.PrivateKey, &defaultPGPConfig)
   224  	if err != nil {
   225  		return "", err
   226  	}
   227  
   228  	_, err = io.Copy(w, b)
   229  
   230  	if err != nil {
   231  		// NB: We intentionally don't call `w.Close()` here! `w.Close()` is the method which
   232  		// actually does the PGP signing, and therefore is the part which uses the private key.
   233  		// In other words, if we call Close here, there's a risk that there's an attempt to use the
   234  		// private key to sign garbage data (since we know that io.Copy failed, `w` won't contain
   235  		// anything useful).
   236  		return "", errors.Wrap(err, "failed to write to clearsign encoder")
   237  	}
   238  
   239  	err = w.Close()
   240  	if err != nil {
   241  		return "", errors.Wrap(err, "failed to either sign or armor message block")
   242  	}
   243  
   244  	return out.String(), nil
   245  }
   246  
   247  // Verify checks a signature and verifies that it is legit for a chart.
   248  func (s *Signatory) Verify(chartpath, sigpath string) (*Verification, error) {
   249  	ver := &Verification{}
   250  	for _, fname := range []string{chartpath, sigpath} {
   251  		if fi, err := os.Stat(fname); err != nil {
   252  			return ver, err
   253  		} else if fi.IsDir() {
   254  			return ver, errors.Errorf("%s cannot be a directory", fname)
   255  		}
   256  	}
   257  
   258  	// First verify the signature
   259  	sig, err := s.decodeSignature(sigpath)
   260  	if err != nil {
   261  		return ver, errors.Wrap(err, "failed to decode signature")
   262  	}
   263  
   264  	by, err := s.verifySignature(sig)
   265  	if err != nil {
   266  		return ver, err
   267  	}
   268  	ver.SignedBy = by
   269  
   270  	// Second, verify the hash of the tarball.
   271  	sum, err := DigestFile(chartpath)
   272  	if err != nil {
   273  		return ver, err
   274  	}
   275  	_, sums, err := parseMessageBlock(sig.Plaintext)
   276  	if err != nil {
   277  		return ver, err
   278  	}
   279  
   280  	sum = "sha256:" + sum
   281  	basename := filepath.Base(chartpath)
   282  	if sha, ok := sums.Files[basename]; !ok {
   283  		return ver, errors.Errorf("provenance does not contain a SHA for a file named %q", basename)
   284  	} else if sha != sum {
   285  		return ver, errors.Errorf("sha256 sum does not match for %s: %q != %q", basename, sha, sum)
   286  	}
   287  	ver.FileHash = sum
   288  	ver.FileName = basename
   289  
   290  	// TODO: when image signing is added, verify that here.
   291  
   292  	return ver, nil
   293  }
   294  
   295  func (s *Signatory) decodeSignature(filename string) (*clearsign.Block, error) {
   296  	data, err := ioutil.ReadFile(filename)
   297  	if err != nil {
   298  		return nil, err
   299  	}
   300  
   301  	block, _ := clearsign.Decode(data)
   302  	if block == nil {
   303  		// There was no sig in the file.
   304  		return nil, errors.New("signature block not found")
   305  	}
   306  
   307  	return block, nil
   308  }
   309  
   310  // verifySignature verifies that the given block is validly signed, and returns the signer.
   311  func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, error) {
   312  	return openpgp.CheckDetachedSignature(
   313  		s.KeyRing,
   314  		bytes.NewBuffer(block.Bytes),
   315  		block.ArmoredSignature.Body,
   316  	)
   317  }
   318  
   319  func messageBlock(chartpath string) (*bytes.Buffer, error) {
   320  	var b *bytes.Buffer
   321  	// Checksum the archive
   322  	chash, err := DigestFile(chartpath)
   323  	if err != nil {
   324  		return b, err
   325  	}
   326  
   327  	base := filepath.Base(chartpath)
   328  	sums := &SumCollection{
   329  		Files: map[string]string{
   330  			base: "sha256:" + chash,
   331  		},
   332  	}
   333  
   334  	// Load the archive into memory.
   335  	chart, err := loader.LoadFile(chartpath)
   336  	if err != nil {
   337  		return b, err
   338  	}
   339  
   340  	// Buffer a hash + checksums YAML file
   341  	data, err := yaml.Marshal(chart.Metadata)
   342  	if err != nil {
   343  		return b, err
   344  	}
   345  
   346  	// FIXME: YAML uses ---\n as a file start indicator, but this is not legal in a PGP
   347  	// clearsign block. So we use ...\n, which is the YAML document end marker.
   348  	// http://yaml.org/spec/1.2/spec.html#id2800168
   349  	b = bytes.NewBuffer(data)
   350  	b.WriteString("\n...\n")
   351  
   352  	data, err = yaml.Marshal(sums)
   353  	if err != nil {
   354  		return b, err
   355  	}
   356  	b.Write(data)
   357  
   358  	return b, nil
   359  }
   360  
   361  // parseMessageBlock
   362  func parseMessageBlock(data []byte) (*hapi.Metadata, *SumCollection, error) {
   363  	// This sucks.
   364  	parts := bytes.Split(data, []byte("\n...\n"))
   365  	if len(parts) < 2 {
   366  		return nil, nil, errors.New("message block must have at least two parts")
   367  	}
   368  
   369  	md := &hapi.Metadata{}
   370  	sc := &SumCollection{}
   371  
   372  	if err := yaml.Unmarshal(parts[0], md); err != nil {
   373  		return md, sc, err
   374  	}
   375  	err := yaml.Unmarshal(parts[1], sc)
   376  	return md, sc, err
   377  }
   378  
   379  // loadKey loads a GPG key found at a particular path.
   380  func loadKey(keypath string) (*openpgp.Entity, error) {
   381  	f, err := os.Open(keypath)
   382  	if err != nil {
   383  		return nil, err
   384  	}
   385  	defer f.Close()
   386  
   387  	pr := packet.NewReader(f)
   388  	return openpgp.ReadEntity(pr)
   389  }
   390  
   391  func loadKeyRing(ringpath string) (openpgp.EntityList, error) {
   392  	f, err := os.Open(ringpath)
   393  	if err != nil {
   394  		return nil, err
   395  	}
   396  	defer f.Close()
   397  	return openpgp.ReadKeyRing(f)
   398  }
   399  
   400  // DigestFile calculates a SHA256 hash (like Docker) for a given file.
   401  //
   402  // It takes the path to the archive file, and returns a string representation of
   403  // the SHA256 sum.
   404  //
   405  // The intended use of this function is to generate a sum of a chart TGZ file.
   406  func DigestFile(filename string) (string, error) {
   407  	f, err := os.Open(filename)
   408  	if err != nil {
   409  		return "", err
   410  	}
   411  	defer f.Close()
   412  	return Digest(f)
   413  }
   414  
   415  // Digest hashes a reader and returns a SHA256 digest.
   416  //
   417  // Helm uses SHA256 as its default hash for all non-cryptographic applications.
   418  func Digest(in io.Reader) (string, error) {
   419  	hash := crypto.SHA256.New()
   420  	if _, err := io.Copy(hash, in); err != nil {
   421  		return "", nil
   422  	}
   423  	return hex.EncodeToString(hash.Sum(nil)), nil
   424  }