From 7278ac5effb3cc180b26308b2a064a3a2848066f Mon Sep 17 00:00:00 2001 From: Trishank K Kuppusamy Date: Wed, 12 Feb 2020 14:26:33 -0500 Subject: [PATCH 1/3] let Notary signer mng snapshot keys too Signed-off-by: Trishank K Kuppusamy --- pkg/tuf/helpers.go | 33 ++------------------------------- pkg/tuf/sign.go | 3 ++- scripts/live-reload.sh | 1 + scripts/signy-env.sh | 3 +-- 4 files changed, 6 insertions(+), 34 deletions(-) diff --git a/pkg/tuf/helpers.go b/pkg/tuf/helpers.go index a9cb262..fed8b74 100644 --- a/pkg/tuf/helpers.go +++ b/pkg/tuf/helpers.go @@ -118,6 +118,7 @@ func clearChangeList(notaryRepo client.Repository) error { // importRootKey imports the root key from path then adds the key to repo // returns key ids +// https://github.com/theupdateframework/notary/blob/f255ae779066dc28ae4aee196061e58bb38a2b49/cmd/notary/tuf.go#L413 func importRootKey(rootKey string, nRepo client.Repository, retriever notary.PassRetriever) ([]string, error) { var rootKeyList []string @@ -140,7 +141,7 @@ func importRootKey(rootKey string, nRepo client.Repository, retriever notary.Pas // Chooses the first root key available, which is initialization specific // but should return the HW one first. rootKeyID := rootKeyList[0] - log.Infof("Root key found, using: %s\n", rootKeyID) + log.Infof("SIGNY: Root key found, using: %s\n", rootKeyID) return []string{rootKeyID}, nil } @@ -148,35 +149,6 @@ func importRootKey(rootKey string, nRepo client.Repository, retriever notary.Pas return []string{}, nil } -// // importRootCert imports the base64 encoded public certificate corresponding to the root key -// // returns empty slice if path is empty -// func importRootCert(certFilePath string) ([]data.PublicKey, error) { -// publicKeys := make([]data.PublicKey, 0, 1) - -// if certFilePath == "" { -// return publicKeys, nil -// } - -// // read certificate from file -// certPEM, err := ioutil.ReadFile(certFilePath) -// if err != nil { -// return nil, fmt.Errorf("error reading certificate file: %v", err) -// } -// block, _ := pem.Decode([]byte(certPEM)) -// if block == nil { -// return nil, fmt.Errorf("the provided file does not contain a valid PEM certificate %v", err) -// } - -// // convert the file to data.PublicKey -// cert, err := x509.ParseCertificate(block.Bytes) -// if err != nil { -// return nil, fmt.Errorf("Parsing certificate PEM bytes to x509 certificate: %v", err) -// } -// publicKeys = append(publicKeys, utils.CertToKey(cert)) - -// return publicKeys, nil -// } - // Attempt to read a role key from a file, and return it as a data.PrivateKey // If key is for the Root role, it must be encrypted func readKey(role data.RoleName, keyFilename string, retriever notary.PassRetriever) (data.PrivateKey, error) { @@ -209,7 +181,6 @@ func getPassphraseRetriever() notary.PassRetriever { env := map[string]string{ "root": os.Getenv("SIGNY_ROOT_PASSPHRASE"), "targets": os.Getenv("SIGNY_TARGETS_PASSPHRASE"), - "snapshot": os.Getenv("SIGNY_SNAPSHOT_PASSPHRASE"), "delegation": os.Getenv("SIGNY_DELEGATION_PASSPHRASE"), } diff --git a/pkg/tuf/sign.go b/pkg/tuf/sign.go index 80da6ab..a325f4d 100644 --- a/pkg/tuf/sign.go +++ b/pkg/tuf/sign.go @@ -52,7 +52,8 @@ func SignAndPublish(trustDir, trustServer, ref, file, tlscacert, rootKey, timeou return nil, err } - if err = repo.Initialize(rootKeyIDs); err != nil { + // 2nd variadic argument is to indicate that snapshot is managed remotely. + if err = repo.Initialize(rootKeyIDs, data.CanonicalSnapshotRole); err != nil { return nil, fmt.Errorf("cannot initialize repo: %v", err) } diff --git a/scripts/live-reload.sh b/scripts/live-reload.sh index b22fafa..257c721 100755 --- a/scripts/live-reload.sh +++ b/scripts/live-reload.sh @@ -1,6 +1,7 @@ #!/bin/bash brew install fswatch +make install # https://emcrisostomo.github.io/fswatch/doc/1.14.0/fswatch.html/Tutorial-Introduction-to-fswatch.html#Detecting-File-System-Changes # NOTE: We exclude bin/* to avoid infinite loop. diff --git a/scripts/signy-env.sh b/scripts/signy-env.sh index 21652ac..65b8aa8 100644 --- a/scripts/signy-env.sh +++ b/scripts/signy-env.sh @@ -3,5 +3,4 @@ PASSPHRASE=0xdeadbeef export SIGNY_ROOT_PASSPHRASE=$PASSPHRASE export SIGNY_TARGETS_PASSPHRASE=$PASSPHRASE -export SIGNY_SNAPSHOT_PASSPHRASE=$PASSPHRASE -export SIGNY_DELEGATION_PASSPHRASE=$PASSPHRASE \ No newline at end of file +export SIGNY_DELEGATION_PASSPHRASE=$PASSPHRASE From 6179f001289fc15f963cf29133b5fad2a0b32eee Mon Sep 17 00:00:00 2001 From: Trishank K Kuppusamy Date: Thu, 13 Feb 2020 17:55:57 -0500 Subject: [PATCH 2/3] reuse a single targets key across repos Signed-off-by: Trishank K Kuppusamy --- pkg/tuf/helpers.go | 61 ++++++++++++++++++++++++++++++++++++++++- pkg/tuf/sign.go | 10 ++++++- scripts/live-reload.sh | 7 ++++- scripts/notary-start.sh | 2 ++ scripts/signy-start.sh | 2 ++ 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/pkg/tuf/helpers.go b/pkg/tuf/helpers.go index fed8b74..3ce97ff 100644 --- a/pkg/tuf/helpers.go +++ b/pkg/tuf/helpers.go @@ -141,7 +141,7 @@ func importRootKey(rootKey string, nRepo client.Repository, retriever notary.Pas // Chooses the first root key available, which is initialization specific // but should return the HW one first. rootKeyID := rootKeyList[0] - log.Infof("SIGNY: Root key found, using: %s\n", rootKeyID) + log.Debugf("Signy found root key, using: %s\n", rootKeyID) return []string{rootKeyID}, nil } @@ -176,6 +176,65 @@ func readKey(role data.RoleName, keyFilename string, retriever notary.PassRetrie return privKey, nil } +// Try to reuse a single targets key across repositories. +// FIXME: Unfortunately, short of forking Notary or sending a PR upstream, there isn't an easy way to prevent it +// from automagically creating a new, local targets key per TUF metadata repository. We fix this here by undoing +// more than one new, local targets key, and reusing any existing local targets key, just like the way Notary +// reuses the root key. +func reuseTargetsKey(r client.Repository) error { + var ( + err error + thisTargetsKeyID, thatTargetsKeyID string + ) + + // Get all known targets keys. + targetsKeyList := r.GetCryptoService().ListKeys(data.CanonicalTargetsRole) + // Try to extract a single targets key we can reuse. + switch len(targetsKeyList) { + case 0: + err = fmt.Errorf("No targets key despite having initialized a repo!") + case 1: + log.Debug("Nothing to do, only one targets key available") + case 2: + // Get the current top-level roles. + roleWithSigs, listRolesErr := r.ListRoles() + if listRolesErr != nil { + err = listRolesErr + break + } + + // Get the current targets key. + // NOTE: We do not delete it, in case the user wants to keep it. + for _, roleWithSig := range roleWithSigs { + role := roleWithSig.Role + if role.Name == data.CanonicalTargetsRole { + if len(role.KeyIDs) == 1 { + thisTargetsKeyID = role.KeyIDs[0] + log.Debugf("This targets keyid: %s", thisTargetsKeyID) + } else { + return fmt.Errorf("This targets role has more than 1 key!") + } + } + } + + // Get and reuse the other targets key. + for _, keyID := range targetsKeyList { + if keyID != thisTargetsKeyID { + thatTargetsKeyID = keyID + log.Debugf("That targets keyID: %s", thatTargetsKeyID) + break + } + log.Debugf("Before rotating targets key from %s to %s", thisTargetsKeyID, thatTargetsKeyID) + err = r.RotateKey(data.CanonicalTargetsRole, false, []string{thatTargetsKeyID}) + log.Debugf("After targets key rotation") + } + default: + err = fmt.Errorf("There are more than 2 targets keys!") + } + + return err +} + func getPassphraseRetriever() notary.PassRetriever { baseRetriever := passphrase.PromptRetriever() env := map[string]string{ diff --git a/pkg/tuf/sign.go b/pkg/tuf/sign.go index a325f4d..9a34b2c 100644 --- a/pkg/tuf/sign.go +++ b/pkg/tuf/sign.go @@ -47,16 +47,24 @@ func SignAndPublish(trustDir, trustServer, ref, file, tlscacert, rootKey, timeou if _, err = repo.ListTargets(); err != nil { switch err.(type) { case client.ErrRepoNotInitialized, client.ErrRepositoryNotExist: + // Reuse root key. rootKeyIDs, err := importRootKey(rootKey, repo, getPassphraseRetriever()) if err != nil { return nil, err } - // 2nd variadic argument is to indicate that snapshot is managed remotely. + // NOTE: 2nd variadic argument is to indicate that snapshot is managed remotely. + // The impact of a timestamp + snapshot key compromise is not terrible: + // https://docs.docker.com/notary/service_architecture/#threat-model if err = repo.Initialize(rootKeyIDs, data.CanonicalSnapshotRole); err != nil { return nil, fmt.Errorf("cannot initialize repo: %v", err) } + // Reuse targets key. + if err = reuseTargetsKey(repo); err != nil { + return nil, fmt.Errorf("cannot reuse targets keys %v", err) + } + default: return nil, fmt.Errorf("cannot list targets: %v", err) } diff --git a/scripts/live-reload.sh b/scripts/live-reload.sh index 257c721..70032c8 100755 --- a/scripts/live-reload.sh +++ b/scripts/live-reload.sh @@ -1,10 +1,15 @@ #!/bin/bash +echo "Installing fswatch..." brew install fswatch +echo + +echo "Building..." make install +echo # https://emcrisostomo.github.io/fswatch/doc/1.14.0/fswatch.html/Tutorial-Introduction-to-fswatch.html#Detecting-File-System-Changes # NOTE: We exclude bin/* to avoid infinite loop. # TODO: Exclude *.sh, *.md, and other non-source files. # FIXME: Sometimes fswatch fires a few times in a row. It is what it is. -fswatch -o . -e "bin/*" | (while read; do make install; date; echo; done) \ No newline at end of file +fswatch -o . -e "bin/*" | (while read; echo "Building..."; do make install; date; echo; done) \ No newline at end of file diff --git a/scripts/notary-start.sh b/scripts/notary-start.sh index ba74f45..360e3e9 100755 --- a/scripts/notary-start.sh +++ b/scripts/notary-start.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -e -x + NOTARY=$GOPATH/src/github.com/theupdateframework/notary (cd $NOTARY; docker-compose up -d) diff --git a/scripts/signy-start.sh b/scripts/signy-start.sh index b166956..c9b0fc5 100755 --- a/scripts/signy-start.sh +++ b/scripts/signy-start.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -e -x + NOTARY=$GOPATH/src/github.com/theupdateframework/notary (cd $NOTARY; docker-compose up -d) From dad7da074214da181f4bbf7758924d87e0f8ecac Mon Sep 17 00:00:00 2001 From: Trishank Karthik Kuppusamy Date: Wed, 19 Feb 2020 14:58:56 -0500 Subject: [PATCH 3/3] refactor tuf pkg to ease development Signed-off-by: Trishank Karthik Kuppusamy --- README.md | 7 +- cmd/main.go | 13 +- cmd/sign.go | 3 +- pkg/tuf/common.go | 67 ++++ pkg/tuf/{helpers_test.go => common_test.go} | 0 pkg/tuf/helpers.go | 329 -------------------- pkg/tuf/keys.go | 165 ++++++++++ pkg/tuf/list.go | 2 +- pkg/tuf/sign.go | 13 +- pkg/tuf/transport.go | 127 ++++++++ scripts/live-reload.sh | 3 +- scripts/reset.sh | 4 +- scripts/signy-env.sh | 8 +- scripts/signy-list.sh | 4 +- scripts/signy-sign.sh | 4 +- scripts/signy-verify.sh | 5 +- 16 files changed, 398 insertions(+), 356 deletions(-) create mode 100644 pkg/tuf/common.go rename pkg/tuf/{helpers_test.go => common_test.go} (100%) delete mode 100644 pkg/tuf/helpers.go create mode 100644 pkg/tuf/keys.go create mode 100644 pkg/tuf/transport.go diff --git a/README.md b/README.md index 8b51b9a..8754da2 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ $ signy --tlscacert=$NOTARY_CA --server https://localhost:4443 - Computing the SHA256 digest of a canonical CNAB bundle, pushing it to the trust server, then pushing the bundle using `cnab-to-oci`: ```bash -$ ./scripts/signy-sign.sh +$ ./scripts/signy-sign.sh testdata/cnab/bundle.json localhost:5000/cnab/thin-bundle:v1 INFO[0000] Starting to copy image cnab/helloworld:0.1.1 INFO[0000] Completed image cnab/helloworld:0.1.1 copy INFO[0000] Generated relocation map: relocation.ImageRelocationMap{"cnab/helloworld:0.1.1":"localhost:5000/cnab/thin-bundle@sha256:a59a4e74d9cc89e4e75dfb2cc7ea5c108e4236ba6231b53081a9e2506d1197b6"} @@ -65,7 +65,7 @@ INFO[0000] Pushed trust data for localhost:5000/cnab/thin-bundle:v1: c7e92bd51f0 - Verifying the metadata in the trusted collection for a CNAB bundle against the bundle pushed to an OCI registry ``` -$ signy --tlscacert=$NOTARY_CA --server https://localhost:4443 verify localhost:5000/thin-bundle:v1 +$ ./scripts/signy-verify.sh localhost:5000/cnab/thin-bundle:v1 INFO[0000] Pulled trust data for localhost:5000/thin-bundle:v1, with role targets - SHA256: c7e92bd51f059d60b15ad456edf194648997d739f60799b37e08edafd88a81b5 INFO[0000] Pulling bundle from registry: localhost:5000/thin-bundle:v1 INFO[0000] Computed SHA: c7e92bd51f059d60b15ad456edf194648997d739f60799b37e08edafd88a81b5 @@ -194,8 +194,7 @@ On the first push to a repository, Signy generates the signing keys (using Notar ``` $ export SIGNY_ROOT_PASSPHRASE=PassPhrase#123 $ export SIGNY_TARGETS_PASSPHRASE=PassPhrase#123 -$ export SIGNY_SNAPSHOT_PASSPHRASE=PassPhrase#123 -$ export SIGNY_DELEGATION_PASSPHRASE=PassPhrase#123 +$ export SIGNY_RELEASES_PASSPHRASE=PassPhrase#123 ``` ## Contributing diff --git a/cmd/main.go b/cmd/main.go index 7d26aae..6f6a615 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,8 +3,6 @@ package main import ( "fmt" "os" - "path/filepath" - "runtime" "github.com/cnabio/signy/pkg/tuf" log "github.com/sirupsen/logrus" @@ -40,7 +38,7 @@ func init() { rootCmd.PersistentFlags().StringVarP(&trustServer, "server", "", tuf.DockerNotaryServer, "The trust server used") rootCmd.PersistentFlags().StringVarP(&tlscacert, "tlscacert", "", "", "Trust certs signed only by this CA") - rootCmd.PersistentFlags().StringVarP(&trustDir, "dir", "d", defaultTrustDir(), "Directory where the trust data is persisted to") + rootCmd.PersistentFlags().StringVarP(&trustDir, "dir", "d", tuf.DefaultTrustDir(), "Directory where the trust data is persisted to") rootCmd.PersistentFlags().StringVar(&logLevel, "log", "info", `Set the logging level ("debug"|"info"|"warn"|"error"|"fatal")`) rootCmd.PersistentFlags().StringVarP(&timeout, "timeout", "t", "5s", `Timeout for the trust server`) } @@ -51,12 +49,3 @@ func main() { os.Exit(1) } } - -func defaultTrustDir() string { - homeEnvPath := os.Getenv("HOME") - if homeEnvPath == "" && runtime.GOOS == "windows" { - homeEnvPath = os.Getenv("USERPROFILE") - } - - return filepath.Join(homeEnvPath, ".signy") -} diff --git a/cmd/sign.go b/cmd/sign.go index 72e6015..0bde4cf 100644 --- a/cmd/sign.go +++ b/cmd/sign.go @@ -35,8 +35,7 @@ To avoid introducing the passphrases every time, set the following environment v export SIGNY_ROOT_PASSPHRASE export SIGNY_TARGETS_PASSPHRASE -export SIGNY_SNAPSHOT_PASSPHRASE -export SIGNY_DELEGATION_PASSPHRASE +export SIGNY_RELEASES_PASSPHRASE For more info on managing the signing keys, see https://docs.docker.com/notary/advanced_usage/ diff --git a/pkg/tuf/common.go b/pkg/tuf/common.go new file mode 100644 index 0000000..1f4b17a --- /dev/null +++ b/pkg/tuf/common.go @@ -0,0 +1,67 @@ +// Most of the helper functions are adapted from github.com/theupdateframework/notary +// +// Figure out the proper way of making sure we are respecting the licensing from Notary +// While we are also vendoring Notary directly (see LICENSE in vendor/github.com/theupdateframework/notary/LICENSE), +// copying unexported functions could fall under different licensing, so we need to make sure. + +package tuf + +import ( + "os" + "path/filepath" + "runtime" + + "github.com/docker/distribution/reference" + "github.com/docker/docker/registry" +) + +const ( + dockerConfigDir = ".docker" +) + +func DefaultTrustDir() string { + homeEnvPath := os.Getenv("HOME") + if homeEnvPath == "" && runtime.GOOS == "windows" { + homeEnvPath = os.Getenv("USERPROFILE") + } + + return filepath.Join(homeEnvPath, ".signy") +} + +func DefaultDockerCfgDir() string { + homeEnvPath := os.Getenv("HOME") + if homeEnvPath == "" && runtime.GOOS == "windows" { + homeEnvPath = os.Getenv("USERPROFILE") + } + + return filepath.Join(homeEnvPath, dockerConfigDir) +} + +// ensures the trust directory exists +func EnsureTrustDir(trustDir string) error { + return os.MkdirAll(trustDir, 0700) +} + +func getRepoAndTag(name string) (*registry.RepositoryInfo, string, error) { + r, err := reference.ParseNormalizedNamed(name) + if err != nil { + return nil, "", err + } + repo, err := registry.ParseRepositoryInfo(r) + if err != nil { + return nil, "", err + } + + return repo, getTag(r), nil +} + +func getTag(ref reference.Named) string { + switch x := ref.(type) { + case reference.Canonical, reference.Digested: + return "" + case reference.NamedTagged: + return x.Tag() + default: + return "" + } +} diff --git a/pkg/tuf/helpers_test.go b/pkg/tuf/common_test.go similarity index 100% rename from pkg/tuf/helpers_test.go rename to pkg/tuf/common_test.go diff --git a/pkg/tuf/helpers.go b/pkg/tuf/helpers.go deleted file mode 100644 index 3ce97ff..0000000 --- a/pkg/tuf/helpers.go +++ /dev/null @@ -1,329 +0,0 @@ -// Most of the helper functions are adapted from github.com/theupdateframework/notary -// -// Figure out the proper way of making sure we are respecting the licensing from Notary -// While we are also vendoring Notary directly (see LICENSE in vendor/github.com/theupdateframework/notary/LICENSE), -// copying unexported functions could fall under different licensing, so we need to make sure. - -package tuf - -import ( - "crypto/tls" - "crypto/x509" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "os" - "path/filepath" - "runtime" - "time" - - "github.com/docker/cli/cli/config" - configtypes "github.com/docker/cli/cli/config/types" - "github.com/docker/distribution/reference" - "github.com/docker/distribution/registry/client/auth" - "github.com/docker/distribution/registry/client/auth/challenge" - "github.com/docker/distribution/registry/client/transport" - "github.com/docker/docker/registry" - log "github.com/sirupsen/logrus" - "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/tuf/data" - "github.com/theupdateframework/notary/tuf/utils" -) - -const ( - // DockerNotaryServer is the default Notary server associated with Docker Hub - DockerNotaryServer = "https://notary.docker.io" - - configFileDir = ".docker" - defaultIndexServer = "https://index.docker.io/v1/" -) - -func makeTransport(server, gun, tlsCaCert, timeout 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, - }, - } - } - - t, err := time.ParseDuration(timeout) - if err != nil { - return nil, err - } - - authTransport := transport.NewTransport(base, modifiers...) - pingClient := &http.Client{ - Transport: authTransport, - Timeout: t * 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 := getAuth(server) - if err != nil { - log.Debug(fmt.Errorf("cannot get default credentials: %v", err)) - } else { - 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 -} - -// ensureTrustDir ensures the trust directory exists -func ensureTrustDir(trustDir string) error { - return os.MkdirAll(trustDir, 0700) -} - -// clearChangelist clears the notary staging changelist -func clearChangeList(notaryRepo client.Repository) error { - cl, err := notaryRepo.GetChangelist() - if err != nil { - return err - } - return cl.Clear("") -} - -// importRootKey imports the root key from path then adds the key to repo -// returns key ids -// https://github.com/theupdateframework/notary/blob/f255ae779066dc28ae4aee196061e58bb38a2b49/cmd/notary/tuf.go#L413 -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] - log.Debugf("Signy found root key, using: %s\n", rootKeyID) - - return []string{rootKeyID}, nil - } - - return []string{}, nil -} - -// Attempt to read a role key from a file, and return it as a data.PrivateKey -// If key is for the Root role, it must be encrypted -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 -} - -// Try to reuse a single targets key across repositories. -// FIXME: Unfortunately, short of forking Notary or sending a PR upstream, there isn't an easy way to prevent it -// from automagically creating a new, local targets key per TUF metadata repository. We fix this here by undoing -// more than one new, local targets key, and reusing any existing local targets key, just like the way Notary -// reuses the root key. -func reuseTargetsKey(r client.Repository) error { - var ( - err error - thisTargetsKeyID, thatTargetsKeyID string - ) - - // Get all known targets keys. - targetsKeyList := r.GetCryptoService().ListKeys(data.CanonicalTargetsRole) - // Try to extract a single targets key we can reuse. - switch len(targetsKeyList) { - case 0: - err = fmt.Errorf("No targets key despite having initialized a repo!") - case 1: - log.Debug("Nothing to do, only one targets key available") - case 2: - // Get the current top-level roles. - roleWithSigs, listRolesErr := r.ListRoles() - if listRolesErr != nil { - err = listRolesErr - break - } - - // Get the current targets key. - // NOTE: We do not delete it, in case the user wants to keep it. - for _, roleWithSig := range roleWithSigs { - role := roleWithSig.Role - if role.Name == data.CanonicalTargetsRole { - if len(role.KeyIDs) == 1 { - thisTargetsKeyID = role.KeyIDs[0] - log.Debugf("This targets keyid: %s", thisTargetsKeyID) - } else { - return fmt.Errorf("This targets role has more than 1 key!") - } - } - } - - // Get and reuse the other targets key. - for _, keyID := range targetsKeyList { - if keyID != thisTargetsKeyID { - thatTargetsKeyID = keyID - log.Debugf("That targets keyID: %s", thatTargetsKeyID) - break - } - log.Debugf("Before rotating targets key from %s to %s", thisTargetsKeyID, thatTargetsKeyID) - err = r.RotateKey(data.CanonicalTargetsRole, false, []string{thatTargetsKeyID}) - log.Debugf("After targets key rotation") - } - default: - err = fmt.Errorf("There are more than 2 targets keys!") - } - - return err -} - -func getPassphraseRetriever() notary.PassRetriever { - baseRetriever := passphrase.PromptRetriever() - env := map[string]string{ - "root": os.Getenv("SIGNY_ROOT_PASSPHRASE"), - "targets": os.Getenv("SIGNY_TARGETS_PASSPHRASE"), - "delegation": os.Getenv("SIGNY_DELEGATION_PASSPHRASE"), - } - - return func(keyName string, alias string, createNew bool, numAttempts int) (string, bool, error) { - if v := env[alias]; v != "" { - return v, numAttempts > 1, nil - } - // For delegation roles, we can also try the "delegation" alias if it is specified - // Note that we don't check if the role name is for a delegation to allow for names like "user" - // since delegation keys can be shared across repositories - // This cannot be a base role or imported key, though. - if v := env["delegation"]; !data.IsBaseRole(data.RoleName(alias)) && v != "" { - return v, numAttempts > 1, nil - } - return baseRetriever(keyName, alias, createNew, numAttempts) - } -} - -func getAuth(server string) (configtypes.AuthConfig, error) { - s, err := url.Parse(server) - if err != nil { - return configtypes.AuthConfig{}, fmt.Errorf("cannot parse trust server URL: %v", err) - } - - cfg, err := config.Load(defaultCfgDir()) - if err != nil { - return configtypes.AuthConfig{}, err - } - - auth, ok := cfg.AuthConfigs[s.Hostname()] - if !ok { - if s.String() == DockerNotaryServer { - return cfg.AuthConfigs[defaultIndexServer], nil - } - return configtypes.AuthConfig{}, fmt.Errorf("authentication not found for trust server %v", server) - } - - return auth, nil -} - -func getRepoAndTag(name string) (*registry.RepositoryInfo, string, error) { - r, err := reference.ParseNormalizedNamed(name) - if err != nil { - return nil, "", err - } - repo, err := registry.ParseRepositoryInfo(r) - if err != nil { - return nil, "", err - } - - return repo, getTag(r), nil -} - -func getTag(ref reference.Named) string { - switch x := ref.(type) { - case reference.Canonical, reference.Digested: - return "" - case reference.NamedTagged: - return x.Tag() - default: - return "" - } -} - -func defaultCfgDir() string { - homeEnvPath := os.Getenv("HOME") - if homeEnvPath == "" && runtime.GOOS == "windows" { - homeEnvPath = os.Getenv("USERPROFILE") - } - - return filepath.Join(homeEnvPath, configFileDir) -} - -type simpleCredentialStore struct { - auth configtypes.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) { -} diff --git a/pkg/tuf/keys.go b/pkg/tuf/keys.go new file mode 100644 index 0000000..2edcc43 --- /dev/null +++ b/pkg/tuf/keys.go @@ -0,0 +1,165 @@ +// Most of the helper functions are adapted from github.com/theupdateframework/notary +// +// Figure out the proper way of making sure we are respecting the licensing from Notary +// While we are also vendoring Notary directly (see LICENSE in vendor/github.com/theupdateframework/notary/LICENSE), +// copying unexported functions could fall under different licensing, so we need to make sure. + +package tuf + +import ( + "fmt" + "io/ioutil" + "os" + + log "github.com/sirupsen/logrus" + "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/tuf/data" + "github.com/theupdateframework/notary/tuf/utils" +) + +func getPassphraseRetriever() notary.PassRetriever { + baseRetriever := passphrase.PromptRetriever() + env := map[string]string{ + "root": os.Getenv("SIGNY_ROOT_PASSPHRASE"), + "targets": os.Getenv("SIGNY_TARGETS_PASSPHRASE"), + "releases": os.Getenv("SIGNY_RELEASES_PASSPHRASE"), + } + + return func(keyName string, alias string, createNew bool, numAttempts int) (string, bool, error) { + if v := env[alias]; v != "" { + return v, numAttempts > 1, nil + } + return baseRetriever(keyName, alias, createNew, numAttempts) + } +} + +// Attempt to read a role key from a file, and return it as a data.PrivateKey +// If key is for the Root role, it must be encrypted +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 +} + +// importRootKey imports the root key from path then adds the key to repo +// returns key ids +// https://github.com/theupdateframework/notary/blob/f255ae779066dc28ae4aee196061e58bb38a2b49/cmd/notary/tuf.go#L413 +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] + log.Debugf("Signy found root key, using: %s\n", rootKeyID) + + return []string{rootKeyID}, nil + } + + return []string{}, nil +} + +// Try to reuse a single targets key across repositories. +// FIXME: Unfortunately, short of forking Notary or sending a PR upstream, there isn't an easy way to prevent it +// from automagically creating a new, local targets key per TUF metadata repository. We fix this here by undoing +// more than one new, local targets key, and reusing any existing local targets key, just like the way Notary +// reuses the root key. +func reuseTargetsKey(r client.Repository) error { + var ( + err error + thisTargetsKeyID, thatTargetsKeyID string + ) + + // Get all known targets keys. + targetsKeyList := r.GetCryptoService().ListKeys(data.CanonicalTargetsRole) + // Try to extract a single targets key we can reuse. + switch len(targetsKeyList) { + case 0: + err = fmt.Errorf("no targets key despite having initialized a repo") + case 1: + log.Debug("Nothing to do, only one targets key available") + case 2: + // First, we publish current changes to repository in order to list roles. + // FIXME: Find a find better way to list roles w/o publishing changes first. + publishErr := r.Publish() + if publishErr != nil { + err = publishErr + break + } + + // Get the current top-level roles. + roleWithSigs, listRolesErr := r.ListRoles() + if listRolesErr != nil { + err = listRolesErr + break + } + + // Get the current targets key. + // NOTE: We do not delete it, in case the user wants to keep it. + for _, roleWithSig := range roleWithSigs { + role := roleWithSig.Role + if role.Name == data.CanonicalTargetsRole { + if len(role.KeyIDs) == 1 { + thisTargetsKeyID = role.KeyIDs[0] + log.Debugf("This targets keyid: %s", thisTargetsKeyID) + } else { + return fmt.Errorf("this targets role has more than 1 key") + } + } + } + + // Get and reuse the other targets key. + for _, keyID := range targetsKeyList { + if keyID != thisTargetsKeyID { + thatTargetsKeyID = keyID + break + } + } + log.Debugf("That targets keyID: %s", thatTargetsKeyID) + log.Debugf("Before rotating targets key from %s to %s", thisTargetsKeyID, thatTargetsKeyID) + err = r.RotateKey(data.CanonicalTargetsRole, false, []string{thatTargetsKeyID}) + log.Debugf("After targets key rotation") + default: + err = fmt.Errorf("there are more than 2 targets keys") + } + + return err +} diff --git a/pkg/tuf/list.go b/pkg/tuf/list.go index ad35099..3f4f5d5 100644 --- a/pkg/tuf/list.go +++ b/pkg/tuf/list.go @@ -40,7 +40,7 @@ func GetTargetWithRole(gun, name, trustServer, tlscacert, trustDir, timeout stri // GetTargets returns all targets for a given gun from the trusted collection func GetTargets(gun, trustServer, tlscacert, trustDir, timeout string) ([]*client.TargetWithRole, error) { - if err := ensureTrustDir(trustDir); err != nil { + if err := EnsureTrustDir(trustDir); err != nil { return nil, fmt.Errorf("cannot ensure trust directory: %v", err) } diff --git a/pkg/tuf/sign.go b/pkg/tuf/sign.go index 9a34b2c..9fce829 100644 --- a/pkg/tuf/sign.go +++ b/pkg/tuf/sign.go @@ -9,9 +9,18 @@ import ( "github.com/theupdateframework/notary/tuf/data" ) +// clearChangelist clears the notary staging changelist +func clearChangeList(notaryRepo client.Repository) error { + cl, err := notaryRepo.GetChangelist() + if err != nil { + return err + } + return cl.Clear("") +} + // SignAndPublish signs an artifact, then publishes the metadata to a trust server func SignAndPublish(trustDir, trustServer, ref, file, tlscacert, rootKey, timeout string, custom *canonicaljson.RawMessage) (*client.Target, error) { - if err := ensureTrustDir(trustDir); err != nil { + if err := EnsureTrustDir(trustDir); err != nil { return nil, fmt.Errorf("cannot ensure trust directory: %v", err) } @@ -62,7 +71,7 @@ func SignAndPublish(trustDir, trustServer, ref, file, tlscacert, rootKey, timeou // Reuse targets key. if err = reuseTargetsKey(repo); err != nil { - return nil, fmt.Errorf("cannot reuse targets keys %v", err) + return nil, fmt.Errorf("cannot reuse targets keys: %v", err) } default: diff --git a/pkg/tuf/transport.go b/pkg/tuf/transport.go new file mode 100644 index 0000000..8224402 --- /dev/null +++ b/pkg/tuf/transport.go @@ -0,0 +1,127 @@ +// Most of the helper functions are adapted from github.com/theupdateframework/notary +// +// Figure out the proper way of making sure we are respecting the licensing from Notary +// While we are also vendoring Notary directly (see LICENSE in vendor/github.com/theupdateframework/notary/LICENSE), +// copying unexported functions could fall under different licensing, so we need to make sure. + +package tuf + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" + + "github.com/docker/cli/cli/config" + configtypes "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" + log "github.com/sirupsen/logrus" +) + +const ( + // DockerNotaryServer is the default Notary server associated with Docker Hub + DockerNotaryServer = "https://notary.docker.io" + + defaultIndexServer = "https://index.docker.io/v1/" +) + +func makeTransport(server, gun, tlsCaCert, timeout 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, + }, + } + } + + t, err := time.ParseDuration(timeout) + if err != nil { + return nil, err + } + + authTransport := transport.NewTransport(base, modifiers...) + pingClient := &http.Client{ + Transport: authTransport, + Timeout: t * 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 := getAuth(server) + if err != nil { + log.Debug(fmt.Errorf("cannot get default credentials: %v", err)) + } else { + 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 +} + +func getAuth(server string) (configtypes.AuthConfig, error) { + s, err := url.Parse(server) + if err != nil { + return configtypes.AuthConfig{}, fmt.Errorf("cannot parse trust server URL: %v", err) + } + + cfg, err := config.Load(DefaultDockerCfgDir()) + if err != nil { + return configtypes.AuthConfig{}, err + } + + auth, ok := cfg.AuthConfigs[s.Hostname()] + if !ok { + if s.Hostname() == DockerNotaryServer { + return cfg.AuthConfigs[defaultIndexServer], nil + } + return configtypes.AuthConfig{}, fmt.Errorf("authentication not found for trust server %v", server) + } + + return auth, nil +} + +type simpleCredentialStore struct { + auth configtypes.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) { +} diff --git a/scripts/live-reload.sh b/scripts/live-reload.sh index 70032c8..fca4f9d 100755 --- a/scripts/live-reload.sh +++ b/scripts/live-reload.sh @@ -6,10 +6,11 @@ echo echo "Building..." make install +echo "...done." echo # https://emcrisostomo.github.io/fswatch/doc/1.14.0/fswatch.html/Tutorial-Introduction-to-fswatch.html#Detecting-File-System-Changes # NOTE: We exclude bin/* to avoid infinite loop. # TODO: Exclude *.sh, *.md, and other non-source files. # FIXME: Sometimes fswatch fires a few times in a row. It is what it is. -fswatch -o . -e "bin/*" | (while read; echo "Building..."; do make install; date; echo; done) \ No newline at end of file +fswatch -o . -e "bin/*" | (while read; echo "Building..."; do make install; date; echo "...done."; echo; done) \ No newline at end of file diff --git a/scripts/reset.sh b/scripts/reset.sh index cd4f165..0ac1022 100755 --- a/scripts/reset.sh +++ b/scripts/reset.sh @@ -1,6 +1,8 @@ #!/bin/bash docker rm registry -docker volume prune +docker volume ls +# set $1="--force" to force prune +docker volume prune $1 rm -rf ~/.signy rm -rf ~/.docker/trust/tuf/localhost:5000 diff --git a/scripts/signy-env.sh b/scripts/signy-env.sh index 65b8aa8..31c4e85 100644 --- a/scripts/signy-env.sh +++ b/scripts/signy-env.sh @@ -3,4 +3,10 @@ PASSPHRASE=0xdeadbeef export SIGNY_ROOT_PASSPHRASE=$PASSPHRASE export SIGNY_TARGETS_PASSPHRASE=$PASSPHRASE -export SIGNY_DELEGATION_PASSPHRASE=$PASSPHRASE +export SIGNY_RELEASES_PASSPHRASE=$PASSPHRASE + +# Get the GPG keyid using the given homedir. +function run_signy { + # https://linuxize.com/post/bash-functions/ + signy --tlscacert=$GOPATH/src/github.com/theupdateframework/notary/cmd/notary/root-ca.crt --server=https://localhost:4443 --log=debug $* +} diff --git a/scripts/signy-list.sh b/scripts/signy-list.sh index 1040e78..4b489c5 100755 --- a/scripts/signy-list.sh +++ b/scripts/signy-list.sh @@ -1,4 +1,6 @@ #!/bin/bash +source scripts/signy-env.sh + # FIXME: list does not seem to work right now -signy --tlscacert=$GOPATH/src/github.com/theupdateframework/notary/cmd/notary/root-ca.crt --server=https://localhost:4443 --log=info list localhost:5000/thin-bundle:v1 \ No newline at end of file +run_signy list \ No newline at end of file diff --git a/scripts/signy-sign.sh b/scripts/signy-sign.sh index ce07317..1ab013b 100755 --- a/scripts/signy-sign.sh +++ b/scripts/signy-sign.sh @@ -2,4 +2,6 @@ source scripts/signy-env.sh -signy --tlscacert=$GOPATH/src/github.com/theupdateframework/notary/cmd/notary/root-ca.crt --server=https://localhost:4443 --log=info sign testdata/cnab/bundle.json localhost:5000/thin-bundle:v1 \ No newline at end of file +# $1: bundle.json +# $2: GUN +run_signy sign $1 $2 \ No newline at end of file diff --git a/scripts/signy-verify.sh b/scripts/signy-verify.sh index 2a2f897..3395788 100755 --- a/scripts/signy-verify.sh +++ b/scripts/signy-verify.sh @@ -1,3 +1,6 @@ #!/bin/bash -signy --tlscacert=$GOPATH/src/github.com/theupdateframework/notary/cmd/notary/root-ca.crt --server=https://localhost:4443 --log=info verify localhost:5000/thin-bundle:v1 \ No newline at end of file +source scripts/signy-env.sh + +# $1: GUN +run_signy verify $1 \ No newline at end of file