From 4a83bb5c3b0145dac8ede10042cd8c28a4194821 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Wed, 18 Mar 2020 11:18:08 +0530 Subject: [PATCH] Experimental Notary Support for Helm Notary v1 is the initial implementation of theUpdateFramework. This framework basically helps understand the security issues, that come up when updating a repo and addresses them by adding multilevel signature checking. Using Notary OCI Artifacts can also be provenance checked. and Helm Charts can also benefit from it. With that in mind, there needs to be an interface between pushing and pulling the charts, to and from the remote registries, to check if the right chart is being downloaded. Here --sign is used with helm chart pull/push to verify/set signatures in Notary. --ca-cert is given to set the CA Cert --trust-server would be the Notary Server --trust-dir would be the directory used instead of the trust server Co-authored-by: Radu Matei Co-authored-by: Vibhav Bobade Signed-off-by: Vibhav Bobade --- cmd/helm/chart_pull.go | 115 ++++++++++++++- cmd/helm/chart_push.go | 35 ++++- go.mod | 4 + go.sum | 7 + pkg/action/chart_sign.go | 302 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 pkg/action/chart_sign.go diff --git a/cmd/helm/chart_pull.go b/cmd/helm/chart_pull.go index 760ff3e2c..9b411be30 100644 --- a/cmd/helm/chart_pull.go +++ b/cmd/helm/chart_pull.go @@ -17,7 +17,17 @@ limitations under the License. package main import ( + "encoding/hex" + "fmt" + "github.com/theupdateframework/notary/client" + "github.com/theupdateframework/notary/trustpinning" + "github.com/theupdateframework/notary/tuf/data" + "helm.sh/helm/v3/internal/experimental/registry" + "helm.sh/helm/v3/pkg/helmpath" "io" + "os" + "path/filepath" + "strings" "github.com/spf13/cobra" @@ -32,7 +42,8 @@ This will store the chart in the local registry cache to be used later. ` func newChartPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { - return &cobra.Command{ + signOpts := &signatureOptions{} + cmd := &cobra.Command{ Use: "pull [ref]", Short: "pull a chart from remote", Long: chartPullDesc, @@ -40,7 +51,107 @@ func newChartPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { Hidden: !FeatureGateOCI.IsEnabled(), RunE: func(cmd *cobra.Command, args []string) error { ref := args[0] - return action.NewChartPull(cfg).Run(out, ref) + err := action.NewChartPull(cfg).Run(out, ref) + if err != nil { + return err + } + + if signOpts.Sign { + sha, err := GetSHA(signOpts.trustDir, signOpts.trustServer, ref, signOpts.caCert, signOpts.rootKey) + if err != nil { + return err + } + + r, err := registry.ParseReference(ref) + if err != nil { + return err + } + + c, err := registry.NewCache( + registry.CacheOptWriter(out), + registry.CacheOptRoot(filepath.Join(helmpath.CachePath(), "registry", registry.CacheRootDir))) + + cs, err := c.FetchReference(r) + if err != nil { + return err + } + + if cs.Digest.Hex() != sha { + fmt.Fprintf(out, "digests do not match: %v and %v", cs.Digest.Hex(), sha) + _, err = c.DeleteReference(r) + return err + } + } + return nil }, } + td := filepath.Join(helmpath.ConfigPath(), ".trust") + cmd.Flags().StringVarP(&signOpts.trustServer, "trust-server", "", "", "The trust server to use for signature verification") + cmd.Flags().StringVarP(&signOpts.trustDir, "trust-dir", "", td, "Location where trust data is stored") + cmd.Flags().StringVarP(&signOpts.rootKey, "root-key", "", "", "Root Key to initialize repository with") + cmd.Flags().StringVarP(&signOpts.caCert, "ca-cert", "", "", "Trust certs signed only by this CA will be considered") + cmd.Flags().BoolVarP(&signOpts.Sign, "sign", "", true, "Enable signature checking") + + return cmd +} + +func GetSHA(trustDir, trustServer, ref, tlscacert, rootKey string) (string, error) { + r, tag := GetRepoAndTag(ref) + target, err := GetTargetWithRole(r, tag, trustServer, tlscacert, trustDir) + if err != nil { + return "", err + } + + return hex.EncodeToString(target.Hashes["sha256"]), nil } + +func GetRepoAndTag(ref string) (string, string) { + parts := strings.Split(ref, "/") + return strings.Split(parts[1], ":")[0], strings.Split(parts[1], ":")[1] +} + +func GetTargetWithRole(gun, name, trustServer, tlscacert, trustDir string) (*client.TargetWithRole, error) { + targets, err := GetTargets(gun, trustServer, tlscacert, trustDir) + if err != nil { + return nil, fmt.Errorf("cannot list targets:%v", err) + } + + for _, target := range targets { + if target.Name == name { + return target, nil + } + } + + return nil, fmt.Errorf("cannot find target %v in trusted collection %v", name, gun) +} + +// GetTargets returns all targets for a given gun from the trusted collection +func GetTargets(gun, trustServer, tlscacert, trustDir string) ([]*client.TargetWithRole, error) { + if err := ensureTrustDir(trustDir); err != nil { + return nil, fmt.Errorf("cannot ensure trust directory: %v", err) + } + + transport, err := action.MakeTransport(trustServer, gun, tlscacert) + if err != nil { + return nil, fmt.Errorf("cannot make transport: %v", err) + } + + repo, err := client.NewFileCachedRepository( + trustDir, + data.GUN(gun), + trustServer, + transport, + nil, + trustpinning.TrustPinConfig{}, + ) + if err != nil { + return nil, fmt.Errorf("cannot create new file cached repository: %v", err) + } + + return repo.ListTargets() +} + +// ensureTrustDir ensures the trust directory exists +func ensureTrustDir(trustDir string) error { + return os.MkdirAll(trustDir, 0700) +} \ No newline at end of file diff --git a/cmd/helm/chart_push.go b/cmd/helm/chart_push.go index ff34632b1..86e62fa4d 100644 --- a/cmd/helm/chart_push.go +++ b/cmd/helm/chart_push.go @@ -17,7 +17,9 @@ limitations under the License. package main import ( + "helm.sh/helm/v3/pkg/helmpath" "io" + "path/filepath" "github.com/spf13/cobra" @@ -33,8 +35,18 @@ Note: the ref must already exist in the local registry cache. Must first run "helm chart save" or "helm chart pull". ` +// Used if --check-signature flag is used +type signatureOptions struct { + Sign bool + trustServer string + trustDir string + caCert string + rootKey string +} + func newChartPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { - return &cobra.Command{ + signOpts := &signatureOptions{} + cmd := &cobra.Command{ Use: "push [ref]", Short: "push a chart to remote", Long: chartPushDesc, @@ -42,7 +54,28 @@ func newChartPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { Hidden: !FeatureGateOCI.IsEnabled(), RunE: func(cmd *cobra.Command, args []string) error { ref := args[0] + + if signOpts.Sign { + err := action.NewChartSign( + cfg, + signOpts.trustServer, + signOpts.trustDir, + signOpts.caCert, + signOpts.rootKey).Run(out, ref) + if err != nil { + return err + } + } + return action.NewChartPush(cfg).Run(out, ref) }, } + td := filepath.Join(helmpath.ConfigPath(), ".trust") + cmd.Flags().StringVarP(&signOpts.trustServer, "trust-server", "", "", "The trust server to use for signature verification") + cmd.Flags().StringVarP(&signOpts.trustDir, "trust-dir", "", td, "Location where trust data is stored") + cmd.Flags().StringVarP(&signOpts.rootKey, "root-key", "", "", "Root Key to initialize repository with") + cmd.Flags().StringVarP(&signOpts.caCert, "ca-cert", "", "", "Trust certs signed only by this CA will be considered") + cmd.Flags().BoolVarP(&signOpts.Sign, "sign", "", true, "Enable signature checking") + + return cmd } diff --git a/go.mod b/go.mod index 3413112cd..a77aa58c7 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,10 @@ require ( github.com/containerd/containerd v1.3.2 github.com/cyphar/filepath-securejoin v0.2.2 github.com/deislabs/oras v0.8.1 + github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492 github.com/docker/distribution v2.7.1+incompatible github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce + github.com/docker/go v1.5.1-1 // indirect github.com/docker/go-units v0.4.0 github.com/evanphx/json-patch v4.5.0+incompatible github.com/gobwas/glob v0.2.3 @@ -27,6 +29,7 @@ require ( github.com/spf13/cobra v0.0.5 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.4.0 + github.com/theupdateframework/notary v0.6.1 github.com/xeipuuv/gojsonschema v1.1.0 golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 k8s.io/api v0.18.0 @@ -37,6 +40,7 @@ require ( k8s.io/klog v1.0.0 k8s.io/kubectl v0.18.0 sigs.k8s.io/yaml v1.2.0 + github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 ) replace ( diff --git a/go.sum b/go.sum index 3c08dba4b..8b44a3682 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,9 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= +github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 h1:iPf1jQ8yKTms6k6L5vYSE7RZJpjEe5vLTOmzRZdpnKc= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -129,6 +132,8 @@ github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce h1:KXS1Jg+ddGcWA8e github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ= github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= +github.com/docker/go v1.5.1-1 h1:hr4w35acWBPhGBXlzPoHpmZ/ygPjnmFVxGxxGnMyP7k= +github.com/docker/go v1.5.1-1/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916 h1:yWHOI+vFjEsAakUTSrtqc/SAHrhSkmn48pqjidZX3QA= @@ -459,6 +464,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/theupdateframework/notary v0.6.1 h1:7wshjstgS9x9F5LuB1L5mBI2xNMObWqjz+cjWoom6l0= +github.com/theupdateframework/notary v0.6.1/go.mod h1:MOfgIfmox8s7/7fduvB2xyPPMJCrjRLRizA8OFwpnKY= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= diff --git a/pkg/action/chart_sign.go b/pkg/action/chart_sign.go new file mode 100644 index 000000000..48cc2cbbb --- /dev/null +++ b/pkg/action/chart_sign.go @@ -0,0 +1,302 @@ +/* +Copyright The Helm Authors. + +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 action + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/types" + "github.com/docker/distribution/registry/client/auth" + "github.com/docker/distribution/registry/client/auth/challenge" + "github.com/docker/distribution/registry/client/transport" + "github.com/theupdateframework/notary" + "github.com/theupdateframework/notary/client" + "github.com/theupdateframework/notary/cryptoservice" + "github.com/theupdateframework/notary/passphrase" + "github.com/theupdateframework/notary/trustmanager" + "github.com/theupdateframework/notary/trustpinning" + "github.com/theupdateframework/notary/tuf/data" + "github.com/theupdateframework/notary/tuf/utils" + "helm.sh/helm/v3/internal/experimental/registry" + "helm.sh/helm/v3/pkg/helmpath" +) + +// ChartPush performs a chart sign operation +type ChartSign struct { + cfg *Configuration + trustDir string + trustServer string + ref string + caCert string + rootKey string +} + +// NewChartPush creates a new ChartPush object with the given configuration. +func NewChartSign(cfg *Configuration, trustServer, ref, caCert, rootKey string) *ChartSign { + return &ChartSign{ + cfg: cfg, + trustServer: trustServer, + ref: ref, + caCert: caCert, + rootKey: rootKey, + } +} + +// Run executes the chart push operation +func (a *ChartSign) Run(out io.Writer, ref string) error { + + // Init Registry Cache + cacheDir := filepath.Join(helmpath.CachePath(), "registry", registry.CacheRootDir) + cache, err := registry.NewCache(registry.CacheOptWriter(out), registry.CacheOptRoot(cacheDir)) + r, err := registry.ParseReference(ref) + if err != nil { + return err + } + + cacheSummary, err := cache.FetchReference(r) + if err != nil { + return err + } + + cachedChart := filepath.Join(cacheDir, "blobs", "sha256", strings.Split(cacheSummary.Digest.String(), ":")[1]) + + /// Export to action and tuf experimental + + transport, err := MakeTransport(a.trustServer, r.Repo, a.caCert) + if err != nil { + return fmt.Errorf("cannot make transport: %v", err) + } + + passphraseRetriever := passphrase.PromptRetriever() + + repo, err := client.NewFileCachedRepository( + a.trustDir, + data.GUN(r.Repo), + a.trustServer, + transport, + passphraseRetriever, + trustpinning.TrustPinConfig{}, + ) + + if err != nil { + return fmt.Errorf("cannot create new file cached repository: %v", err) + } + + err = clearChangeList(repo) + if err != nil { + return fmt.Errorf("cannot clear change list: %v", err) + } + + if _, err = repo.ListTargets(); err != nil { + switch err.(type) { + case client.ErrRepoNotInitialized, client.ErrRepositoryNotExist: + rootKeyIDs, err := importRootKey(a.rootKey, repo, passphraseRetriever) + if err != nil { + return err + } + + if err = repo.Initialize(rootKeyIDs); err != nil { + return fmt.Errorf("cannot initialize repo: %v", err) + } + + default: + return fmt.Errorf("cannot list targets: %v", err) + } + } + + target, err := client.NewTarget(r.Tag, cachedChart, nil) + if err != nil { + return err + } + + // TODO - Radu M + // decide whether to allow actually passing roles as flags + + // If roles is empty, we default to adding to targets + if err = repo.AddTarget(target, data.NewRoleList([]string{})...); err != nil { + return err + } + + err = repo.Publish() + + defer clearChangeList(repo) + + return err +} + +func MakeTransport(server, gun, tlsCaCert string) (http.RoundTripper, error) { + modifiers := []transport.RequestModifier{ + transport.NewHeaderRequestModifier(http.Header{ + "User-Agent": []string{"signy"}, + }), + } + + base := http.DefaultTransport + if tlsCaCert != "" { + caCert, err := ioutil.ReadFile(tlsCaCert) + if err != nil { + return nil, fmt.Errorf("cannot read cert file: %v", err) + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + base = &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + }, + } + } + + authTransport := transport.NewTransport(base, modifiers...) + pingClient := &http.Client{ + Transport: authTransport, + Timeout: 5 * time.Second, + } + req, err := http.NewRequest("GET", server+"/v2/", nil) + if err != nil { + return nil, fmt.Errorf("cannot create HTTP request: %v", err) + } + + challengeManager := challenge.NewSimpleManager() + resp, err := pingClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot get response from ping client: %v", err) + } + defer resp.Body.Close() + if err := challengeManager.AddResponse(resp); err != nil { + return nil, fmt.Errorf("cannot add response to challenge manager: %v", err) + } + + defaultAuth, err := getDefaultAuth() + if err != nil { + return nil, fmt.Errorf("cannot get default credentials: %v", err) + } + + creds := simpleCredentialStore{auth: defaultAuth} + tokenHandler := auth.NewTokenHandler(base, creds, gun, "push", "pull") + modifiers = append(modifiers, auth.NewAuthorizer(challengeManager, tokenHandler)) + + return transport.NewTransport(base, modifiers...), nil +} + +// clearChangelist clears the notary staging changelist +func clearChangeList(notaryRepo client.Repository) error { + cl, err := notaryRepo.GetChangelist() + if err != nil { + return err + } + return cl.Clear("") +} + +func getDefaultAuth() (types.AuthConfig, error) { + cfg, err := config.Load(defaultCfgDir()) + if err != nil { + return types.AuthConfig{}, err + } + + return cfg.AuthConfigs["https://index.docker.io/v1/"], nil +} + +type simpleCredentialStore struct { + auth types.AuthConfig +} + +func (scs simpleCredentialStore) Basic(u *url.URL) (string, string) { + return scs.auth.Username, scs.auth.Password +} + +func (scs simpleCredentialStore) RefreshToken(u *url.URL, service string) string { + return scs.auth.IdentityToken +} + +func (scs simpleCredentialStore) SetRefreshToken(*url.URL, string, string) { +} + +func defaultCfgDir() string { + homeEnvPath := os.Getenv("HOME") + if homeEnvPath == "" && runtime.GOOS == "windows" { + homeEnvPath = os.Getenv("USERPROFILE") + } + + return filepath.Join(homeEnvPath, ".docker") +} + +func importRootKey(rootKey string, nRepo client.Repository, retriever notary.PassRetriever) ([]string, error) { + var rootKeyList []string + + if rootKey != "" { + privKey, err := readKey(data.CanonicalRootRole, rootKey, retriever) + if err != nil { + return nil, err + } + // add root key to repo + err = nRepo.GetCryptoService().AddKey(data.CanonicalRootRole, "", privKey) + if err != nil { + return nil, fmt.Errorf("Error importing key: %v", err) + } + rootKeyList = []string{privKey.ID()} + } else { + rootKeyList = nRepo.GetCryptoService().ListKeys(data.CanonicalRootRole) + } + + if len(rootKeyList) > 0 { + // Chooses the first root key available, which is initialization specific + // but should return the HW one first. + rootKeyID := rootKeyList[0] + fmt.Printf("Root key found, using: %s\n", rootKeyID) + + return []string{rootKeyID}, nil + } + + return []string{}, nil +} + +func readKey(role data.RoleName, keyFilename string, retriever notary.PassRetriever) (data.PrivateKey, error) { + pemBytes, err := ioutil.ReadFile(keyFilename) + if err != nil { + return nil, fmt.Errorf("Error reading input root key file: %v", err) + } + isEncrypted := true + if err = cryptoservice.CheckRootKeyIsEncrypted(pemBytes); err != nil { + if role == data.CanonicalRootRole { + return nil, err + } + isEncrypted = false + } + var privKey data.PrivateKey + if isEncrypted { + privKey, _, err = trustmanager.GetPasswdDecryptBytes(retriever, pemBytes, "", data.CanonicalRootRole.String()) + } else { + privKey, err = utils.ParsePEMPrivateKey(pemBytes, "") + } + if err != nil { + return nil, err + } + + return privKey, nil +}