diff --git a/cli/command/trust/client_test.go b/cli/command/trust/client_test.go index 3343209673..028083591f 100644 --- a/cli/command/trust/client_test.go +++ b/cli/command/trust/client_test.go @@ -316,7 +316,39 @@ func (l LoadedNotaryRepository) ListRoles() ([]client.RoleWithSignatures, error) Name: data.CanonicalTargetsRole, } - return []client.RoleWithSignatures{{Role: rootRole}, {Role: targetsRole}}, nil + aliceRole := data.Role{ + RootRole: data.RootRole{ + KeyIDs: []string{"A"}, + Threshold: 1, + }, + Name: data.RoleName("targets/alice"), + } + + bobRole := data.Role{ + RootRole: data.RootRole{ + KeyIDs: []string{"B"}, + Threshold: 1, + }, + Name: data.RoleName("targets/bob"), + } + + releasesRole := data.Role{ + RootRole: data.RootRole{ + KeyIDs: []string{"A", "B"}, + Threshold: 1, + }, + Name: data.RoleName("targets/releases"), + } + // have releases only signed off by Alice last + releasesSig := []data.Signature{{KeyID: "A"}} + + return []client.RoleWithSignatures{ + {Role: rootRole}, + {Role: targetsRole}, + {Role: aliceRole}, + {Role: bobRole}, + {Role: releasesRole, Signatures: releasesSig}, + }, nil } func (l LoadedNotaryRepository) ListTargets(roles ...data.RoleName) ([]*client.TargetWithRole, error) { diff --git a/cli/command/trust/cmd.go b/cli/command/trust/cmd.go index 766c3dd4a6..cb8408d1e7 100644 --- a/cli/command/trust/cmd.go +++ b/cli/command/trust/cmd.go @@ -18,6 +18,8 @@ func NewTrustCommand(dockerCli command.Cli) *cobra.Command { newViewCommand(dockerCli), newRevokeCommand(dockerCli), newSignCommand(dockerCli), + newTrustKeyCommand(dockerCli), + newTrustSignerCommand(dockerCli), ) return cmd } diff --git a/cli/command/trust/helpers.go b/cli/command/trust/helpers.go index 7d9c184ba4..60d38815d3 100644 --- a/cli/command/trust/helpers.go +++ b/cli/command/trust/helpers.go @@ -9,13 +9,15 @@ import ( ) const releasedRoleName = "Repo Admin" +const releasesRoleTUFName = "targets/releases" -// check if a role name is "released": either targets/releases or targets TUF roles +// isReleasedTarget checks if a role name is "released": +// either targets/releases or targets TUF roles func isReleasedTarget(role data.RoleName) bool { return role == data.CanonicalTargetsRole || role == trust.ReleasesRole } -// convert TUF role name to a human-understandable signer name +// notaryRoleToSigner converts TUF role name to a human-understandable signer name func notaryRoleToSigner(tufRole data.RoleName) string { // don't show a signer for "targets" or "targets/releases" if isReleasedTarget(data.RoleName(tufRole.String())) { @@ -24,6 +26,7 @@ func notaryRoleToSigner(tufRole data.RoleName) string { return strings.TrimPrefix(tufRole.String(), "targets/") } +// clearChangelist clears the notary staging changelist. func clearChangeList(notaryRepo client.Repository) error { cl, err := notaryRepo.GetChangelist() if err != nil { @@ -31,3 +34,14 @@ func clearChangeList(notaryRepo client.Repository) error { } return cl.Clear("") } + +// getOrGenerateRootKeyAndInitRepo initializes the notary repository +// with a remotely managed snapshot key. The initialization will use +// an existing root key if one is found, else a new one will be generated. +func getOrGenerateRootKeyAndInitRepo(notaryRepo client.Repository) error { + rootKey, err := getOrGenerateNotaryKey(notaryRepo, data.CanonicalRootRole) + if err != nil { + return err + } + return notaryRepo.Initialize([]string{rootKey.ID()}, data.CanonicalSnapshotRole) +} diff --git a/cli/command/trust/helpers_test.go b/cli/command/trust/helpers_test.go new file mode 100644 index 0000000000..c3113c65ec --- /dev/null +++ b/cli/command/trust/helpers_test.go @@ -0,0 +1,25 @@ +package trust + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/docker/notary/client" + "github.com/docker/notary/passphrase" + "github.com/docker/notary/trustpinning" + + "github.com/stretchr/testify/assert" +) + +func TestGetOrGenerateNotaryKeyAndInitRepo(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "notary-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + notaryRepo, err := client.NewFileCachedRepository(tmpDir, "gun", "https://localhost", nil, passphrase.ConstantRetriever(passwd), trustpinning.TrustPinConfig{}) + assert.NoError(t, err) + + err = getOrGenerateRootKeyAndInitRepo(notaryRepo) + assert.EqualError(t, err, "client is offline") +} diff --git a/cli/command/trust/key.go b/cli/command/trust/key.go new file mode 100644 index 0000000000..b24a34c38a --- /dev/null +++ b/cli/command/trust/key.go @@ -0,0 +1,22 @@ +package trust + +import ( + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +// newTrustKeyCommand returns a cobra command for `trust key` subcommands +func newTrustKeyCommand(dockerCli command.Streams) *cobra.Command { + cmd := &cobra.Command{ + Use: "key", + Short: "Manage keys for signing Docker images (experimental)", + Args: cli.NoArgs, + RunE: command.ShowHelp(dockerCli.Err()), + } + cmd.AddCommand( + newKeyGenerateCommand(dockerCli), + newKeyLoadCommand(dockerCli), + ) + return cmd +} diff --git a/cli/command/trust/key_generate.go b/cli/command/trust/key_generate.go new file mode 100644 index 0000000000..d12f21ce77 --- /dev/null +++ b/cli/command/trust/key_generate.go @@ -0,0 +1,134 @@ +package trust + +import ( + "encoding/pem" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/trust" + "github.com/docker/notary" + "github.com/docker/notary/trustmanager" + "github.com/docker/notary/tuf/data" + tufutils "github.com/docker/notary/tuf/utils" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type keyGenerateOptions struct { + name string + directory string +} + +func newKeyGenerateCommand(dockerCli command.Streams) *cobra.Command { + options := keyGenerateOptions{} + cmd := &cobra.Command{ + Use: "generate NAME", + Short: "Generate and load a signing key-pair", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + options.name = args[0] + return setupPassphraseAndGenerateKeys(dockerCli, options) + }, + } + flags := cmd.Flags() + flags.StringVar(&options.directory, "dir", "", "Directory to generate key in, defaults to current directory") + return cmd +} + +// key names can use lowercase alphanumeric + _ + - characters +var validKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString + +// validate that all of the key names are unique and are alphanumeric + _ + - +// and that we do not already have public key files in the target dir on disk +func validateKeyArgs(keyName string, targetDir string) error { + if !validKeyName(keyName) { + return fmt.Errorf("key name \"%s\" must start with lowercase alphanumeric characters and can include \"-\" or \"_\" after the first character", keyName) + } + + pubKeyFileName := keyName + ".pub" + if _, err := os.Stat(targetDir); err != nil { + return fmt.Errorf("public key path does not exist: \"%s\"", targetDir) + } + targetPath := filepath.Join(targetDir, pubKeyFileName) + if _, err := os.Stat(targetPath); err == nil { + return fmt.Errorf("public key file already exists: \"%s\"", targetPath) + } + return nil +} + +func setupPassphraseAndGenerateKeys(streams command.Streams, opts keyGenerateOptions) error { + targetDir := opts.directory + if targetDir == "" { + cwd, err := os.Getwd() + if err != nil { + return err + } + targetDir = cwd + } + return validateAndGenerateKey(streams, opts.name, targetDir) +} + +func validateAndGenerateKey(streams command.Streams, keyName string, workingDir string) error { + freshPassRetGetter := func() notary.PassRetriever { return trust.GetPassphraseRetriever(streams.In(), streams.Out()) } + if err := validateKeyArgs(keyName, workingDir); err != nil { + return err + } + fmt.Fprintf(streams.Out(), "Generating key for %s...\n", keyName) + // Automatically load the private key to local storage for use + privKeyFileStore, err := trustmanager.NewKeyFileStore(trust.GetTrustDirectory(), freshPassRetGetter()) + if err != nil { + return err + } + + pubPEM, err := generateKeyAndOutputPubPEM(keyName, privKeyFileStore) + if err != nil { + fmt.Fprintf(streams.Out(), err.Error()) + return errors.Wrapf(err, "failed to generate key for %s", keyName) + } + + // Output the public key to a file in the CWD or specified dir + writtenPubFile, err := writePubKeyPEMToDir(pubPEM, keyName, workingDir) + if err != nil { + return err + } + fmt.Fprintf(streams.Out(), "Successfully generated and loaded private key. Corresponding public key available: %s\n", writtenPubFile) + + return nil +} + +func generateKeyAndOutputPubPEM(keyName string, privKeyStore trustmanager.KeyStore) (pem.Block, error) { + privKey, err := tufutils.GenerateKey(data.ECDSAKey) + if err != nil { + return pem.Block{}, err + } + + privKeyStore.AddKey(trustmanager.KeyInfo{Role: data.RoleName(keyName)}, privKey) + if err != nil { + return pem.Block{}, err + } + + pubKey := data.PublicKeyFromPrivate(privKey) + return pem.Block{ + Type: "PUBLIC KEY", + Headers: map[string]string{ + "role": keyName, + }, + Bytes: pubKey.Public(), + }, nil +} + +func writePubKeyPEMToDir(pubPEM pem.Block, keyName, workingDir string) (string, error) { + // Output the public key to a file in the CWD or specified dir + pubFileName := strings.Join([]string{keyName, "pub"}, ".") + pubFilePath := filepath.Join(workingDir, pubFileName) + if err := ioutil.WriteFile(pubFilePath, pem.EncodeToMemory(&pubPEM), notary.PrivNoExecPerms); err != nil { + return "", errors.Wrapf(err, "failed to write public key to %s", pubFilePath) + } + return pubFilePath, nil +} diff --git a/cli/command/trust/key_generate_test.go b/cli/command/trust/key_generate_test.go new file mode 100644 index 0000000000..4843f6af4c --- /dev/null +++ b/cli/command/trust/key_generate_test.go @@ -0,0 +1,138 @@ +package trust + +import ( + "encoding/pem" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/internal/test" + "github.com/docker/cli/internal/test/testutil" + "github.com/docker/notary" + "github.com/docker/notary/passphrase" + "github.com/docker/notary/trustmanager" + tufutils "github.com/docker/notary/tuf/utils" + "github.com/stretchr/testify/assert" +) + +func TestTrustKeyGenerateErrors(t *testing.T) { + testCases := []struct { + name string + args []string + expectedError string + }{ + { + name: "not-enough-args", + expectedError: "requires exactly 1 argument", + }, + { + name: "too-many-args", + args: []string{"key-1", "key-2"}, + expectedError: "requires exactly 1 argument", + }, + } + + tmpDir, err := ioutil.TempDir("", "docker-key-generate-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + config.SetDir(tmpDir) + + for _, tc := range testCases { + cli := test.NewFakeCli(&fakeClient{}) + cmd := newKeyGenerateCommand(cli) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + } +} + +func TestGenerateKeySuccess(t *testing.T) { + pubKeyCWD, err := ioutil.TempDir("", "pub-keys-") + assert.NoError(t, err) + defer os.RemoveAll(pubKeyCWD) + + privKeyStorageDir, err := ioutil.TempDir("", "priv-keys-") + assert.NoError(t, err) + defer os.RemoveAll(privKeyStorageDir) + + passwd := "password" + cannedPasswordRetriever := passphrase.ConstantRetriever(passwd) + // generate a single key + keyName := "alice" + privKeyFileStore, err := trustmanager.NewKeyFileStore(privKeyStorageDir, cannedPasswordRetriever) + assert.NoError(t, err) + + pubKeyPEM, err := generateKeyAndOutputPubPEM(keyName, privKeyFileStore) + assert.NoError(t, err) + + assert.Equal(t, keyName, pubKeyPEM.Headers["role"]) + // the default GUN is empty + assert.Equal(t, "", pubKeyPEM.Headers["gun"]) + // assert public key header + assert.Equal(t, "PUBLIC KEY", pubKeyPEM.Type) + + // check that an appropriate ~//private/.key file exists + expectedPrivKeyDir := filepath.Join(privKeyStorageDir, notary.PrivDir) + _, err = os.Stat(expectedPrivKeyDir) + assert.NoError(t, err) + + keyFiles, err := ioutil.ReadDir(expectedPrivKeyDir) + assert.NoError(t, err) + assert.Len(t, keyFiles, 1) + privKeyFilePath := filepath.Join(expectedPrivKeyDir, keyFiles[0].Name()) + + // verify the key content + privFrom, _ := os.OpenFile(privKeyFilePath, os.O_RDONLY, notary.PrivExecPerms) + defer privFrom.Close() + fromBytes, _ := ioutil.ReadAll(privFrom) + privKeyPEM, _ := pem.Decode(fromBytes) + assert.Equal(t, keyName, privKeyPEM.Headers["role"]) + // the default GUN is empty + assert.Equal(t, "", privKeyPEM.Headers["gun"]) + // assert encrypted header + assert.Equal(t, "ENCRYPTED PRIVATE KEY", privKeyPEM.Type) + // check that the passphrase matches + _, err = tufutils.ParsePKCS8ToTufKey(privKeyPEM.Bytes, []byte(passwd)) + assert.NoError(t, err) + + // check that the public key exists at the correct path if we use the helper: + returnedPath, err := writePubKeyPEMToDir(pubKeyPEM, keyName, pubKeyCWD) + assert.NoError(t, err) + expectedPubKeyPath := filepath.Join(pubKeyCWD, keyName+".pub") + assert.Equal(t, returnedPath, expectedPubKeyPath) + _, err = os.Stat(expectedPubKeyPath) + assert.NoError(t, err) + // check that the public key is the only file output in CWD + cwdKeyFiles, err := ioutil.ReadDir(pubKeyCWD) + assert.NoError(t, err) + assert.Len(t, cwdKeyFiles, 1) +} + +func TestValidateKeyArgs(t *testing.T) { + pubKeyCWD, err := ioutil.TempDir("", "pub-keys-") + assert.NoError(t, err) + defer os.RemoveAll(pubKeyCWD) + + err = validateKeyArgs("a", pubKeyCWD) + assert.NoError(t, err) + + err = validateKeyArgs("a/b", pubKeyCWD) + assert.Error(t, err) + assert.Equal(t, err.Error(), "key name \"a/b\" must start with lowercase alphanumeric characters and can include \"-\" or \"_\" after the first character") + + err = validateKeyArgs("-", pubKeyCWD) + assert.Error(t, err) + assert.Equal(t, err.Error(), "key name \"-\" must start with lowercase alphanumeric characters and can include \"-\" or \"_\" after the first character") + + assert.NoError(t, ioutil.WriteFile(filepath.Join(pubKeyCWD, "a.pub"), []byte("abc"), notary.PrivExecPerms)) + err = validateKeyArgs("a", pubKeyCWD) + assert.Error(t, err) + assert.Equal(t, err.Error(), fmt.Sprintf("public key file already exists: \"%s/a.pub\"", pubKeyCWD)) + + err = validateKeyArgs("a", "/random/dir/") + assert.Error(t, err) + assert.Equal(t, err.Error(), "public key path does not exist: \"/random/dir/\"") +} diff --git a/cli/command/trust/key_load.go b/cli/command/trust/key_load.go new file mode 100644 index 0000000000..86e9990fdb --- /dev/null +++ b/cli/command/trust/key_load.go @@ -0,0 +1,115 @@ +package trust + +import ( + "bytes" + "encoding/pem" + "fmt" + "io/ioutil" + "os" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/trust" + "github.com/docker/notary" + "github.com/docker/notary/storage" + "github.com/docker/notary/trustmanager" + tufutils "github.com/docker/notary/tuf/utils" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +const ( + nonOwnerReadWriteMask = 0077 +) + +type keyLoadOptions struct { + keyName string +} + +func newKeyLoadCommand(dockerCli command.Streams) *cobra.Command { + var options keyLoadOptions + cmd := &cobra.Command{ + Use: "load [OPTIONS] KEYFILE", + Short: "Load a private key file for signing", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return loadPrivKey(dockerCli, args[0], options) + }, + } + flags := cmd.Flags() + flags.StringVar(&options.keyName, "name", "signer", "Name for the loaded key") + return cmd +} + +func loadPrivKey(streams command.Streams, keyPath string, options keyLoadOptions) error { + // validate the key name if provided + if options.keyName != "" && !validKeyName(options.keyName) { + return fmt.Errorf("key name \"%s\" must start with lowercase alphanumeric characters and can include \"-\" or \"_\" after the first character", options.keyName) + } + trustDir := trust.GetTrustDirectory() + keyFileStore, err := storage.NewPrivateKeyFileStorage(trustDir, notary.KeyExtension) + if err != nil { + return err + } + privKeyImporters := []trustmanager.Importer{keyFileStore} + + fmt.Fprintf(streams.Out(), "Loading key from \"%s\"...\n", keyPath) + + // Always use a fresh passphrase retriever for each import + passRet := trust.GetPassphraseRetriever(streams.In(), streams.Out()) + keyBytes, err := getPrivKeyBytesFromPath(keyPath) + if err != nil { + return errors.Wrapf(err, "refusing to load key from %s", keyPath) + } + if err := loadPrivKeyBytesToStore(keyBytes, privKeyImporters, keyPath, options.keyName, passRet); err != nil { + return errors.Wrapf(err, "error importing key from %s", keyPath) + } + fmt.Fprintf(streams.Out(), "Successfully imported key from %s\n", keyPath) + return nil +} + +func getPrivKeyBytesFromPath(keyPath string) ([]byte, error) { + fileInfo, err := os.Stat(keyPath) + if err != nil { + return nil, err + } + if fileInfo.Mode()&nonOwnerReadWriteMask != 0 { + return nil, fmt.Errorf("private key file %s must not be readable or writable by others", keyPath) + } + + from, err := os.OpenFile(keyPath, os.O_RDONLY, notary.PrivExecPerms) + if err != nil { + return nil, err + } + defer from.Close() + + return ioutil.ReadAll(from) +} + +func loadPrivKeyBytesToStore(privKeyBytes []byte, privKeyImporters []trustmanager.Importer, keyPath, keyName string, passRet notary.PassRetriever) error { + var err error + if _, _, err = tufutils.ExtractPrivateKeyAttributes(privKeyBytes); err != nil { + return fmt.Errorf("provided file %s is not a supported private key - to add a signer's public key use docker trust signer add", keyPath) + } + if privKeyBytes, err = decodePrivKeyIfNecessary(privKeyBytes, passRet); err != nil { + return errors.Wrapf(err, "cannot load key from provided file %s", keyPath) + } + // Make a reader, rewind the file pointer + return trustmanager.ImportKeys(bytes.NewReader(privKeyBytes), privKeyImporters, keyName, "", passRet) +} + +func decodePrivKeyIfNecessary(privPemBytes []byte, passRet notary.PassRetriever) ([]byte, error) { + pemBlock, _ := pem.Decode(privPemBytes) + _, containsDEKInfo := pemBlock.Headers["DEK-Info"] + if containsDEKInfo || pemBlock.Type == "ENCRYPTED PRIVATE KEY" { + // if we do not have enough information to properly import, try to decrypt the key + if _, ok := pemBlock.Headers["path"]; !ok { + privKey, _, err := trustmanager.GetPasswdDecryptBytes(passRet, privPemBytes, "", "encrypted") + if err != nil { + return []byte{}, fmt.Errorf("could not decrypt key") + } + privPemBytes = privKey.Private() + } + } + return privPemBytes, nil +} diff --git a/cli/command/trust/key_load_test.go b/cli/command/trust/key_load_test.go new file mode 100644 index 0000000000..5959a81192 --- /dev/null +++ b/cli/command/trust/key_load_test.go @@ -0,0 +1,244 @@ +package trust + +import ( + "encoding/pem" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/internal/test" + "github.com/docker/cli/internal/test/testutil" + "github.com/docker/notary" + "github.com/docker/notary/passphrase" + "github.com/docker/notary/storage" + "github.com/docker/notary/trustmanager" + tufutils "github.com/docker/notary/tuf/utils" + "github.com/stretchr/testify/assert" +) + +func TestTrustKeyLoadErrors(t *testing.T) { + testCases := []struct { + name string + args []string + expectedError string + expectedOutput string + }{ + { + name: "not-enough-args", + expectedError: "exactly 1 argument", + expectedOutput: "", + }, + { + name: "too-many-args", + args: []string{"iamnotakey", "alsonotakey"}, + expectedError: "exactly 1 argument", + expectedOutput: "", + }, + { + name: "not-a-key", + args: []string{"iamnotakey"}, + expectedError: "refusing to load key from iamnotakey: stat iamnotakey: no such file or directory", + expectedOutput: "Loading key from \"iamnotakey\"...\n", + }, + { + name: "bad-key-name", + args: []string{"iamnotakey", "--name", "KEYNAME"}, + expectedError: "key name \"KEYNAME\" must start with lowercase alphanumeric characters and can include \"-\" or \"_\" after the first character", + expectedOutput: "", + }, + } + tmpDir, err := ioutil.TempDir("", "docker-key-load-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + config.SetDir(tmpDir) + + for _, tc := range testCases { + cli := test.NewFakeCli(&fakeClient{}) + cmd := newKeyLoadCommand(cli) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + assert.Contains(t, cli.OutBuffer().String(), tc.expectedOutput) + } +} + +var rsaPrivKeyFixture = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAs7yVMzCw8CBZPoN+QLdx3ZzbVaHnouHIKu+ynX60IZ3stpbb +6rowu78OWON252JcYJqe++2GmdIgbBhg+mZDwhX0ZibMVztJaZFsYL+Ch/2J9KqD +A5NtE1s/XdhYoX5hsv7W4ok9jLFXRYIMj+T4exJRlR4f4GP9p0fcqPWd9/enPnlJ +JFTmu0DXJTZUMVS1UrXUy5t/DPXdrwyl8pM7VCqO3bqK7jqE6mWawdTkEeiku1fJ +ydP0285uiYTbj1Q38VVhPwXzMuLbkaUgRJhCI4BcjfQIjtJLbWpS+VdhUEvtgMVx +XJMKxCVGG69qjXyj9TjI7pxanb/bWglhovJN9wIDAQABAoIBAQCSnMsLxbUfOxPx +RWuwOLN+NZxIvtfnastQEtSdWiRvo5Xa3zYmw5hLHa8DXRC57+cwug/jqr54LQpb +gotg1hiBck05In7ezTK2FXTVeoJskal91bUnLpP0DSOkVnz9xszFKNF6Wr7FTEfH +IC1FF16Fbcz0mW0hKg9X6+uYOzqPcKpQRwli5LAwhT18Alf9h4/3NCeKotiJyr2J +xvcEH1eY2m2c/jQZurBkys7qBC3+i8LJEOW8MBQt7mxajwfbU91wtP2YoqMcoYiS +zsPbYp7Ui2t4G9Yn+OJw+uj4RGP1Bo4nSyRxWDtg+8Zug/JYU6/s+8kVRpiGffd3 +T1GvoxUhAoGBAOnPDWG/g1xlJf65Rh71CxMs638zhYbIloU2K4Rqr05DHe7GryTS +9hLVrwhHddK+KwfVbR8HFMPo1DC/NVbuKt8StTAadAu3HsC088gWd28nOiGAWuvH +Bo3x/DYQGYwGFfoo4rzCOgMj6DJjXmcWEXNv3NDMoXoYpkxa0g6zZDyHAoGBAMTL +t7EUneJT+Mm7wyL1I5bmaT/HFwqoUQB2ccBPVD8p1el62NgLdfhOa8iNlBVhMrlh +2aTjrMlSPcjr9sCgKrLcenSWw+2qFsf4+SmV01ntB9kWes2phXpnB0ynXIcbeG05 ++BLxbqDTVV0Iqh4r/dGeplyV2WyL3mTpkT3hRq8RAoGAZ93degEUICWnHWO9LN97 +Dge0joua0+ekRoVsC6VBP6k9UOfewqMdQfy/hxQH2Zk1kINVuKTyqp1yNj2bOoUP +co3jA/2cc9/jv4QjkE26vRxWDK/ytC90T/aiLno0fyns9XbYUzaNgvuemVPfijgZ +hIi7Nd7SFWWB6wWlr3YuH10CgYEAwh7JVa2mh8iZEjVaKTNyJbmmfDjgq6yYKkKr +ti0KRzv3O9Xn7ERx27tPaobtWaGFLYQt8g57NCMhuv23aw8Sz1fYmwTUw60Rx7P5 +42FdF8lOAn/AJvpfJfxXIO+9v7ADPIr//3+TxqRwAdM4K4btWkaKh61wyTe26gfT +MxzyYmECgYAnlU5zsGyiZqwoXVktkhtZrE7Qu0SoztzFb8KpvFNmMTPF1kAAYmJY +GIhbizeGJ3h4cUdozKmt8ZWIt6uFDEYCqEA7XF4RH75dW25x86mpIPO7iRl9eisY +IsLeMYqTIwXAwGx6Ka9v5LOL1kzcHQ2iVj6+QX+yoptSft1dYa9jOA== +-----END RSA PRIVATE KEY-----`) + +const rsaPrivKeyID = "ee69e8e07a14756ad5ff0aca2336b37f86b0ac1710d1f3e94440081e080aecd7" + +var ecPrivKeyFixture = []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEINfxKtDH3ug7ZIQPDyeAzujCdhw36D+bf9ToPE1A7YEyoAoGCCqGSM49 +AwEHoUQDQgAEUIH9AYtrcDFzZrFJBdJZkn21d+4cH3nzy2O6Q/ct4BjOBKa+WCdR +tPo78bA+C/7t81ADQO8Jqaj59W50rwoqDQ== +-----END EC PRIVATE KEY-----`) + +const ecPrivKeyID = "46157cb0becf9c72c3219e11d4692424fef9bf4460812ccc8a71a3dfcafc7e60" + +var testKeys = map[string][]byte{ + ecPrivKeyID: ecPrivKeyFixture, + rsaPrivKeyID: rsaPrivKeyFixture, +} + +func TestLoadKeyFromPath(t *testing.T) { + for keyID, keyBytes := range testKeys { + t.Run(fmt.Sprintf("load-key-id-%s-from-path", keyID), func(t *testing.T) { + testLoadKeyFromPath(t, keyID, keyBytes) + }) + } +} + +func testLoadKeyFromPath(t *testing.T, privKeyID string, privKeyFixture []byte) { + privKeyDir, err := ioutil.TempDir("", "key-load-test-") + assert.NoError(t, err) + defer os.RemoveAll(privKeyDir) + privKeyFilepath := filepath.Join(privKeyDir, "privkey.pem") + assert.NoError(t, ioutil.WriteFile(privKeyFilepath, privKeyFixture, notary.PrivNoExecPerms)) + + keyStorageDir, err := ioutil.TempDir("", "loaded-keys-") + assert.NoError(t, err) + defer os.RemoveAll(keyStorageDir) + + passwd := "password" + cannedPasswordRetriever := passphrase.ConstantRetriever(passwd) + keyFileStore, err := storage.NewPrivateKeyFileStorage(keyStorageDir, notary.KeyExtension) + assert.NoError(t, err) + privKeyImporters := []trustmanager.Importer{keyFileStore} + + // get the privKeyBytes + privKeyBytes, err := getPrivKeyBytesFromPath(privKeyFilepath) + assert.NoError(t, err) + + // import the key to our keyStorageDir + assert.NoError(t, loadPrivKeyBytesToStore(privKeyBytes, privKeyImporters, privKeyFilepath, "signer-name", cannedPasswordRetriever)) + + // check that the appropriate ~//private/.key file exists + expectedImportKeyPath := filepath.Join(keyStorageDir, notary.PrivDir, privKeyID+"."+notary.KeyExtension) + _, err = os.Stat(expectedImportKeyPath) + assert.NoError(t, err) + + // verify the key content + from, _ := os.OpenFile(expectedImportKeyPath, os.O_RDONLY, notary.PrivExecPerms) + defer from.Close() + fromBytes, _ := ioutil.ReadAll(from) + keyPEM, _ := pem.Decode(fromBytes) + assert.Equal(t, "signer-name", keyPEM.Headers["role"]) + // the default GUN is empty + assert.Equal(t, "", keyPEM.Headers["gun"]) + // assert encrypted header + assert.Equal(t, "ENCRYPTED PRIVATE KEY", keyPEM.Type) + + decryptedKey, err := tufutils.ParsePKCS8ToTufKey(keyPEM.Bytes, []byte(passwd)) + assert.NoError(t, err) + fixturePEM, _ := pem.Decode(privKeyFixture) + assert.Equal(t, fixturePEM.Bytes, decryptedKey.Private()) +} + +func TestLoadKeyTooPermissive(t *testing.T) { + for keyID, keyBytes := range testKeys { + t.Run(fmt.Sprintf("load-key-id-%s-too-permissive", keyID), func(t *testing.T) { + testLoadKeyTooPermissive(t, keyBytes) + }) + } +} + +func testLoadKeyTooPermissive(t *testing.T, privKeyFixture []byte) { + privKeyDir, err := ioutil.TempDir("", "key-load-test-") + assert.NoError(t, err) + defer os.RemoveAll(privKeyDir) + privKeyFilepath := filepath.Join(privKeyDir, "privkey477.pem") + assert.NoError(t, ioutil.WriteFile(privKeyFilepath, privKeyFixture, 0477)) + + keyStorageDir, err := ioutil.TempDir("", "loaded-keys-") + assert.NoError(t, err) + defer os.RemoveAll(keyStorageDir) + + // import the key to our keyStorageDir + _, err = getPrivKeyBytesFromPath(privKeyFilepath) + assert.Error(t, err) + assert.Contains(t, fmt.Sprintf("private key file %s must not be readable or writable by others", privKeyFilepath), err.Error()) + + privKeyFilepath = filepath.Join(privKeyDir, "privkey667.pem") + assert.NoError(t, ioutil.WriteFile(privKeyFilepath, privKeyFixture, 0677)) + + _, err = getPrivKeyBytesFromPath(privKeyFilepath) + assert.Error(t, err) + assert.Contains(t, fmt.Sprintf("private key file %s must not be readable or writable by others", privKeyFilepath), err.Error()) + + privKeyFilepath = filepath.Join(privKeyDir, "privkey777.pem") + assert.NoError(t, ioutil.WriteFile(privKeyFilepath, privKeyFixture, 0777)) + + _, err = getPrivKeyBytesFromPath(privKeyFilepath) + assert.Error(t, err) + assert.Contains(t, fmt.Sprintf("private key file %s must not be readable or writable by others", privKeyFilepath), err.Error()) + + privKeyFilepath = filepath.Join(privKeyDir, "privkey400.pem") + assert.NoError(t, ioutil.WriteFile(privKeyFilepath, privKeyFixture, 0400)) + + _, err = getPrivKeyBytesFromPath(privKeyFilepath) + assert.NoError(t, err) + + privKeyFilepath = filepath.Join(privKeyDir, "privkey600.pem") + assert.NoError(t, ioutil.WriteFile(privKeyFilepath, privKeyFixture, 0600)) + + _, err = getPrivKeyBytesFromPath(privKeyFilepath) + assert.NoError(t, err) +} + +var pubKeyFixture = []byte(`-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUIH9AYtrcDFzZrFJBdJZkn21d+4c +H3nzy2O6Q/ct4BjOBKa+WCdRtPo78bA+C/7t81ADQO8Jqaj59W50rwoqDQ== +-----END PUBLIC KEY-----`) + +func TestLoadPubKeyFailure(t *testing.T) { + pubKeyDir, err := ioutil.TempDir("", "key-load-test-pubkey-") + assert.NoError(t, err) + defer os.RemoveAll(pubKeyDir) + pubKeyFilepath := filepath.Join(pubKeyDir, "pubkey.pem") + assert.NoError(t, ioutil.WriteFile(pubKeyFilepath, pubKeyFixture, notary.PrivNoExecPerms)) + keyStorageDir, err := ioutil.TempDir("", "loaded-keys-") + assert.NoError(t, err) + defer os.RemoveAll(keyStorageDir) + + passwd := "password" + cannedPasswordRetriever := passphrase.ConstantRetriever(passwd) + keyFileStore, err := storage.NewPrivateKeyFileStorage(keyStorageDir, notary.KeyExtension) + assert.NoError(t, err) + privKeyImporters := []trustmanager.Importer{keyFileStore} + + pubKeyBytes, err := getPrivKeyBytesFromPath(pubKeyFilepath) + assert.NoError(t, err) + + // import the key to our keyStorageDir - it should fail + err = loadPrivKeyBytesToStore(pubKeyBytes, privKeyImporters, pubKeyFilepath, "signer-name", cannedPasswordRetriever) + assert.Error(t, err) + assert.Contains(t, fmt.Sprintf("provided file %s is not a supported private key - to add a signer's public key use docker trust signer add", pubKeyFilepath), err.Error()) +} diff --git a/cli/command/trust/sign.go b/cli/command/trust/sign.go index e67373659a..af40e206e7 100644 --- a/cli/command/trust/sign.go +++ b/cli/command/trust/sign.go @@ -183,7 +183,9 @@ func initNotaryRepoWithSigners(notaryRepo client.Repository, newSigner data.Role if err != nil { return err } - addStagedSigner(notaryRepo, newSigner, []data.PublicKey{signerKey}) + if err := addStagedSigner(notaryRepo, newSigner, []data.PublicKey{signerKey}); err != nil { + return errors.Wrapf(err, "could not add signer to repo: %s", strings.TrimPrefix(newSigner.String(), "targets/")) + } return notaryRepo.Publish() } @@ -216,12 +218,21 @@ func getOrGenerateNotaryKey(notaryRepo client.Repository, role data.RoleName) (d } // stages changes to add a signer with the specified name and key(s). Adds to targets/ and targets/releases -func addStagedSigner(notaryRepo client.Repository, newSigner data.RoleName, signerKeys []data.PublicKey) { +func addStagedSigner(notaryRepo client.Repository, newSigner data.RoleName, signerKeys []data.PublicKey) error { // create targets/ - notaryRepo.AddDelegationRoleAndKeys(newSigner, signerKeys) - notaryRepo.AddDelegationPaths(newSigner, []string{""}) + if err := notaryRepo.AddDelegationRoleAndKeys(newSigner, signerKeys); err != nil { + return err + } + if err := notaryRepo.AddDelegationPaths(newSigner, []string{""}); err != nil { + return err + } // create targets/releases - notaryRepo.AddDelegationRoleAndKeys(trust.ReleasesRole, signerKeys) - notaryRepo.AddDelegationPaths(trust.ReleasesRole, []string{""}) + if err := notaryRepo.AddDelegationRoleAndKeys(trust.ReleasesRole, signerKeys); err != nil { + return err + } + if err := notaryRepo.AddDelegationPaths(trust.ReleasesRole, []string{""}); err != nil { + return err + } + return nil } diff --git a/cli/command/trust/sign_test.go b/cli/command/trust/sign_test.go index ec9a5dc59f..5334004397 100644 --- a/cli/command/trust/sign_test.go +++ b/cli/command/trust/sign_test.go @@ -140,7 +140,8 @@ func TestAddStageSigners(t *testing.T) { // stage targets/user userRole := data.RoleName("targets/user") userKey := data.NewPublicKey("algoA", []byte("a")) - addStagedSigner(notaryRepo, userRole, []data.PublicKey{userKey}) + err = addStagedSigner(notaryRepo, userRole, []data.PublicKey{userKey}) + assert.NoError(t, err) // check the changelist for four total changes: two on targets/releases and two on targets/user cl, err := notaryRepo.GetChangelist() assert.NoError(t, err) diff --git a/cli/command/trust/signer.go b/cli/command/trust/signer.go new file mode 100644 index 0000000000..c0111ef3f3 --- /dev/null +++ b/cli/command/trust/signer.go @@ -0,0 +1,22 @@ +package trust + +import ( + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +// newTrustSignerCommand returns a cobra command for `trust signer` subcommands +func newTrustSignerCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "signer", + Short: "Manage entities who can sign Docker images (experimental)", + Args: cli.NoArgs, + RunE: command.ShowHelp(dockerCli.Err()), + } + cmd.AddCommand( + newSignerAddCommand(dockerCli), + newSignerRemoveCommand(dockerCli), + ) + return cmd +} diff --git a/cli/command/trust/signer_add.go b/cli/command/trust/signer_add.go new file mode 100644 index 0000000000..8a9418f02b --- /dev/null +++ b/cli/command/trust/signer_add.go @@ -0,0 +1,141 @@ +package trust + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "regexp" + "strings" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/image" + "github.com/docker/cli/cli/trust" + "github.com/docker/cli/opts" + "github.com/docker/notary/client" + "github.com/docker/notary/tuf/data" + tufutils "github.com/docker/notary/tuf/utils" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type signerAddOptions struct { + keys opts.ListOpts + signer string + repos []string +} + +func newSignerAddCommand(dockerCli command.Cli) *cobra.Command { + var options signerAddOptions + cmd := &cobra.Command{ + Use: "add OPTIONS NAME REPOSITORY [REPOSITORY...] ", + Short: "Add a signer", + Args: cli.RequiresMinArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + options.signer = args[0] + options.repos = args[1:] + return addSigner(dockerCli, options) + }, + } + flags := cmd.Flags() + options.keys = opts.NewListOpts(nil) + flags.Var(&options.keys, "key", "Path to the signer's public key file") + return cmd +} + +var validSignerName = regexp.MustCompile(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString + +func addSigner(cli command.Cli, options signerAddOptions) error { + signerName := options.signer + if !validSignerName(signerName) { + return fmt.Errorf("signer name \"%s\" must start with lowercase alphanumeric characters and can include \"-\" or \"_\" after the first character", signerName) + } + if signerName == "releases" { + return fmt.Errorf("releases is a reserved keyword, please use a different signer name") + } + + if options.keys.Len() == 0 { + return fmt.Errorf("path to a public key must be provided using the `--key` flag") + } + signerPubKeys, err := ingestPublicKeys(options.keys.GetAll()) + if err != nil { + return err + } + var errRepos []string + for _, repoName := range options.repos { + fmt.Fprintf(cli.Out(), "Adding signer \"%s\" to %s...\n", signerName, repoName) + if err := addSignerToRepo(cli, signerName, repoName, signerPubKeys); err != nil { + fmt.Fprintln(cli.Err(), err.Error()+"\n") + errRepos = append(errRepos, repoName) + } else { + fmt.Fprintf(cli.Out(), "Successfully added signer: %s to %s\n\n", signerName, repoName) + } + } + if len(errRepos) > 0 { + return fmt.Errorf("Failed to add signer to: %s", strings.Join(errRepos, ", ")) + } + return nil +} + +func addSignerToRepo(cli command.Cli, signerName string, repoName string, signerPubKeys []data.PublicKey) error { + ctx := context.Background() + imgRefAndAuth, err := trust.GetImageReferencesAndAuth(ctx, image.AuthResolver(cli), repoName) + if err != nil { + return err + } + + notaryRepo, err := cli.NotaryClient(imgRefAndAuth, trust.ActionsPushAndPull) + if err != nil { + return trust.NotaryError(imgRefAndAuth.Reference().Name(), err) + } + + if _, err = notaryRepo.ListTargets(); err != nil { + switch err.(type) { + case client.ErrRepoNotInitialized, client.ErrRepositoryNotExist: + fmt.Fprintf(cli.Out(), "Initializing signed repository for %s...\n", repoName) + if err := getOrGenerateRootKeyAndInitRepo(notaryRepo); err != nil { + return trust.NotaryError(repoName, err) + } + fmt.Fprintf(cli.Out(), "Successfully initialized %q\n", repoName) + default: + return trust.NotaryError(repoName, err) + } + } + + newSignerRoleName := data.RoleName(path.Join(data.CanonicalTargetsRole.String(), signerName)) + + if err := addStagedSigner(notaryRepo, newSignerRoleName, signerPubKeys); err != nil { + return errors.Wrapf(err, "could not add signer to repo: %s", strings.TrimPrefix(newSignerRoleName.String(), "targets/")) + } + + return notaryRepo.Publish() +} + +func ingestPublicKeys(pubKeyPaths []string) ([]data.PublicKey, error) { + pubKeys := []data.PublicKey{} + for _, pubKeyPath := range pubKeyPaths { + // Read public key bytes from PEM file, limit to 1 KiB + pubKeyFile, err := os.OpenFile(pubKeyPath, os.O_RDONLY, 0666) + if err != nil { + return nil, errors.Wrap(err, "unable to read public key from file") + } + defer pubKeyFile.Close() + // limit to + l := io.LimitReader(pubKeyFile, 1<<20) + pubKeyBytes, err := ioutil.ReadAll(l) + if err != nil { + return nil, errors.Wrap(err, "unable to read public key from file") + } + + // Parse PEM bytes into type PublicKey + pubKey, err := tufutils.ParsePEMPublicKey(pubKeyBytes) + if err != nil { + return nil, errors.Wrapf(err, "could not parse public key from file: %s", pubKeyPath) + } + pubKeys = append(pubKeys, pubKey) + } + return pubKeys, nil +} diff --git a/cli/command/trust/signer_add_test.go b/cli/command/trust/signer_add_test.go new file mode 100644 index 0000000000..075613f84b --- /dev/null +++ b/cli/command/trust/signer_add_test.go @@ -0,0 +1,137 @@ +package trust + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/internal/test" + "github.com/docker/cli/internal/test/testutil" + "github.com/docker/notary" + "github.com/stretchr/testify/assert" +) + +func TestTrustSignerAddErrors(t *testing.T) { + testCases := []struct { + name string + args []string + expectedError string + }{ + { + name: "not-enough-args", + expectedError: "requires at least 2 argument", + }, + { + name: "no-key", + args: []string{"foo", "bar"}, + expectedError: "path to a public key must be provided using the `--key` flag", + }, + { + name: "reserved-releases-signer-add", + args: []string{"releases", "my-image", "--key", "/path/to/key"}, + expectedError: "releases is a reserved keyword, please use a different signer name", + }, + { + name: "disallowed-chars", + args: []string{"ali/ce", "my-image", "--key", "/path/to/key"}, + expectedError: "signer name \"ali/ce\" must start with lowercase alphanumeric characters and can include \"-\" or \"_\" after the first character", + }, + { + name: "no-upper-case", + args: []string{"Alice", "my-image", "--key", "/path/to/key"}, + expectedError: "signer name \"Alice\" must start with lowercase alphanumeric characters and can include \"-\" or \"_\" after the first character", + }, + { + name: "start-with-letter", + args: []string{"_alice", "my-image", "--key", "/path/to/key"}, + expectedError: "signer name \"_alice\" must start with lowercase alphanumeric characters and can include \"-\" or \"_\" after the first character", + }, + } + tmpDir, err := ioutil.TempDir("", "docker-sign-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + config.SetDir(tmpDir) + + for _, tc := range testCases { + cli := test.NewFakeCli(&fakeClient{}) + cli.SetNotaryClient(getOfflineNotaryRepository) + cmd := newSignerAddCommand(cli) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + } +} + +func TestSignerAddCommandNoTargetsKey(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "docker-sign-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + config.SetDir(tmpDir) + + tmpfile, err := ioutil.TempFile("", "pemfile") + assert.NoError(t, err) + defer os.Remove(tmpfile.Name()) + + cli := test.NewFakeCli(&fakeClient{}) + cli.SetNotaryClient(getEmptyTargetsNotaryRepository) + cmd := newSignerAddCommand(cli) + cmd.SetArgs([]string{"--key", tmpfile.Name(), "alice", "alpine", "linuxkit/alpine"}) + + cmd.SetOutput(ioutil.Discard) + assert.EqualError(t, cmd.Execute(), fmt.Sprintf("could not parse public key from file: %s: no valid public key found", tmpfile.Name())) +} + +func TestSignerAddCommandBadKeyPath(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "docker-sign-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + config.SetDir(tmpDir) + + cli := test.NewFakeCli(&fakeClient{}) + cli.SetNotaryClient(getEmptyTargetsNotaryRepository) + cmd := newSignerAddCommand(cli) + cmd.SetArgs([]string{"--key", "/path/to/key.pem", "alice", "alpine"}) + + cmd.SetOutput(ioutil.Discard) + assert.EqualError(t, cmd.Execute(), "unable to read public key from file: open /path/to/key.pem: no such file or directory") +} + +func TestSignerAddCommandInvalidRepoName(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "docker-sign-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + config.SetDir(tmpDir) + + pubKeyDir, err := ioutil.TempDir("", "key-load-test-pubkey-") + assert.NoError(t, err) + defer os.RemoveAll(pubKeyDir) + pubKeyFilepath := filepath.Join(pubKeyDir, "pubkey.pem") + assert.NoError(t, ioutil.WriteFile(pubKeyFilepath, pubKeyFixture, notary.PrivNoExecPerms)) + + cli := test.NewFakeCli(&fakeClient{}) + cli.SetNotaryClient(getUninitializedNotaryRepository) + cmd := newSignerAddCommand(cli) + imageName := "870d292919d01a0af7e7f056271dc78792c05f55f49b9b9012b6d89725bd9abd" + cmd.SetArgs([]string{"--key", pubKeyFilepath, "alice", imageName}) + + cmd.SetOutput(ioutil.Discard) + assert.EqualError(t, cmd.Execute(), "Failed to add signer to: 870d292919d01a0af7e7f056271dc78792c05f55f49b9b9012b6d89725bd9abd") + expectedErr := fmt.Sprintf("invalid repository name (%s), cannot specify 64-byte hexadecimal strings\n\n", imageName) + + assert.Equal(t, expectedErr, cli.ErrBuffer().String()) +} + +func TestIngestPublicKeys(t *testing.T) { + // Call with a bad path + _, err := ingestPublicKeys([]string{"foo", "bar"}) + assert.EqualError(t, err, "unable to read public key from file: open foo: no such file or directory") + // Call with real file path + tmpfile, err := ioutil.TempFile("", "pemfile") + assert.NoError(t, err) + defer os.Remove(tmpfile.Name()) + _, err = ingestPublicKeys([]string{tmpfile.Name()}) + assert.EqualError(t, err, fmt.Sprintf("could not parse public key from file: %s: no valid public key found", tmpfile.Name())) +} diff --git a/cli/command/trust/signer_remove.go b/cli/command/trust/signer_remove.go new file mode 100644 index 0000000000..2ac4224434 --- /dev/null +++ b/cli/command/trust/signer_remove.go @@ -0,0 +1,138 @@ +package trust + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/image" + "github.com/docker/cli/cli/trust" + "github.com/docker/notary/client" + "github.com/docker/notary/tuf/data" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type signerRemoveOptions struct { + signer string + repos []string + forceYes bool +} + +func newSignerRemoveCommand(dockerCli command.Cli) *cobra.Command { + options := signerRemoveOptions{} + cmd := &cobra.Command{ + Use: "remove [OPTIONS] NAME REPOSITORY [REPOSITORY...]", + Short: "Remove a signer", + Args: cli.RequiresMinArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + options.signer = args[0] + options.repos = args[1:] + return removeSigner(dockerCli, options) + }, + } + flags := cmd.Flags() + flags.BoolVarP(&options.forceYes, "force", "f", false, "Do not prompt for confirmation before removing the most recent signer") + return cmd +} + +func removeSigner(cli command.Cli, options signerRemoveOptions) error { + var errRepos []string + for _, repo := range options.repos { + fmt.Fprintf(cli.Out(), "Removing signer \"%s\" from %s...\n", options.signer, repo) + if err := removeSingleSigner(cli, repo, options.signer, options.forceYes); err != nil { + fmt.Fprintln(cli.Err(), err.Error()+"\n") + errRepos = append(errRepos, repo) + } else { + fmt.Fprintf(cli.Out(), "Successfully removed %s from %s\n\n", options.signer, repo) + } + } + if len(errRepos) > 0 { + return fmt.Errorf("Error removing signer from: %s", strings.Join(errRepos, ", ")) + } + return nil +} + +func isLastSignerForReleases(roleWithSig data.Role, allRoles []client.RoleWithSignatures) (bool, error) { + var releasesRoleWithSigs client.RoleWithSignatures + for _, role := range allRoles { + if role.Name == releasesRoleTUFName { + releasesRoleWithSigs = role + break + } + } + counter := len(releasesRoleWithSigs.Signatures) + if counter == 0 { + return false, fmt.Errorf("All signed tags are currently revoked, use docker trust sign to fix") + } + for _, signature := range releasesRoleWithSigs.Signatures { + for _, key := range roleWithSig.KeyIDs { + if signature.KeyID == key { + counter-- + } + } + } + return counter < releasesRoleWithSigs.Threshold, nil +} + +func removeSingleSigner(cli command.Cli, repoName, signerName string, forceYes bool) error { + ctx := context.Background() + imgRefAndAuth, err := trust.GetImageReferencesAndAuth(ctx, image.AuthResolver(cli), repoName) + if err != nil { + return err + } + + signerDelegation := data.RoleName("targets/" + signerName) + if signerDelegation == releasesRoleTUFName { + return fmt.Errorf("releases is a reserved keyword and cannot be removed") + } + notaryRepo, err := cli.NotaryClient(imgRefAndAuth, trust.ActionsPushAndPull) + if err != nil { + return trust.NotaryError(imgRefAndAuth.Reference().Name(), err) + } + delegationRoles, err := notaryRepo.GetDelegationRoles() + if err != nil { + return errors.Wrapf(err, "error retrieving signers for %s", repoName) + } + var role data.Role + for _, delRole := range delegationRoles { + if delRole.Name == signerDelegation { + role = delRole + break + } + } + if role.Name == "" { + return fmt.Errorf("No signer %s for repository %s", signerName, repoName) + } + allRoles, err := notaryRepo.ListRoles() + if err != nil { + return err + } + if ok, err := isLastSignerForReleases(role, allRoles); ok && !forceYes { + removeSigner := command.PromptForConfirmation(os.Stdin, cli.Out(), fmt.Sprintf("The signer \"%s\" signed the last released version of %s. "+ + "Removing this signer will make %s unpullable. "+ + "Are you sure you want to continue?", + signerName, repoName, repoName, + )) + + if !removeSigner { + fmt.Fprintf(cli.Out(), "\nAborting action.\n") + return nil + } + } else if err != nil { + return err + } + if err = notaryRepo.RemoveDelegationKeys(releasesRoleTUFName, role.KeyIDs); err != nil { + return err + } + if err = notaryRepo.RemoveDelegationRole(signerDelegation); err != nil { + return err + } + if err = notaryRepo.Publish(); err != nil { + return err + } + return nil +} diff --git a/cli/command/trust/signer_remove_test.go b/cli/command/trust/signer_remove_test.go new file mode 100644 index 0000000000..f0ab695c89 --- /dev/null +++ b/cli/command/trust/signer_remove_test.go @@ -0,0 +1,124 @@ +package trust + +import ( + "io/ioutil" + "testing" + + "github.com/docker/cli/internal/test" + "github.com/docker/cli/internal/test/testutil" + "github.com/docker/notary/client" + "github.com/docker/notary/tuf/data" + "github.com/stretchr/testify/assert" +) + +func TestTrustSignerRemoveErrors(t *testing.T) { + testCases := []struct { + name string + args []string + expectedError string + }{ + { + name: "not-enough-args-0", + expectedError: "requires at least 2 arguments", + }, + { + name: "not-enough-args-1", + args: []string{"user"}, + expectedError: "requires at least 2 arguments", + }, + } + for _, tc := range testCases { + cmd := newSignerRemoveCommand( + test.NewFakeCli(&fakeClient{})) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + } + testCasesWithOutput := []struct { + name string + args []string + expectedError string + }{ + { + name: "not-an-image", + args: []string{"user", "notanimage"}, + expectedError: "error retrieving signers for notanimage", + }, + { + name: "sha-reference", + args: []string{"user", "870d292919d01a0af7e7f056271dc78792c05f55f49b9b9012b6d89725bd9abd"}, + expectedError: "invalid repository name", + }, + { + name: "invalid-img-reference", + args: []string{"user", "ALPINE"}, + expectedError: "invalid reference format", + }, + } + for _, tc := range testCasesWithOutput { + cli := test.NewFakeCli(&fakeClient{}) + cli.SetNotaryClient(getOfflineNotaryRepository) + cmd := newSignerRemoveCommand(cli) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + cmd.Execute() + assert.Contains(t, cli.ErrBuffer().String(), tc.expectedError) + } + +} + +func TestRemoveSingleSigner(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{}) + cli.SetNotaryClient(getLoadedNotaryRepository) + err := removeSingleSigner(cli, "signed-repo", "test", true) + assert.EqualError(t, err, "No signer test for repository signed-repo") + err = removeSingleSigner(cli, "signed-repo", "releases", true) + assert.EqualError(t, err, "releases is a reserved keyword and cannot be removed") +} + +func TestRemoveMultipleSigners(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{}) + cli.SetNotaryClient(getLoadedNotaryRepository) + err := removeSigner(cli, signerRemoveOptions{signer: "test", repos: []string{"signed-repo", "signed-repo"}, forceYes: true}) + assert.EqualError(t, err, "Error removing signer from: signed-repo, signed-repo") + assert.Contains(t, cli.ErrBuffer().String(), + "No signer test for repository signed-repo") + assert.Contains(t, cli.OutBuffer().String(), "Removing signer \"test\" from signed-repo...\n") +} +func TestRemoveLastSignerWarning(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{}) + cli.SetNotaryClient(getLoadedNotaryRepository) + + err := removeSigner(cli, signerRemoveOptions{signer: "alice", repos: []string{"signed-repo"}, forceYes: false}) + assert.NoError(t, err) + assert.Contains(t, cli.OutBuffer().String(), + "The signer \"alice\" signed the last released version of signed-repo. "+ + "Removing this signer will make signed-repo unpullable. "+ + "Are you sure you want to continue? [y/N]") +} + +func TestIsLastSignerForReleases(t *testing.T) { + role := data.Role{} + releaserole := client.RoleWithSignatures{} + releaserole.Name = releasesRoleTUFName + releaserole.Threshold = 1 + allrole := []client.RoleWithSignatures{releaserole} + lastsigner, _ := isLastSignerForReleases(role, allrole) + assert.Equal(t, false, lastsigner) + + role.KeyIDs = []string{"deadbeef"} + sig := data.Signature{} + sig.KeyID = "deadbeef" + releaserole.Signatures = []data.Signature{sig} + releaserole.Threshold = 1 + allrole = []client.RoleWithSignatures{releaserole} + lastsigner, _ = isLastSignerForReleases(role, allrole) + assert.Equal(t, true, lastsigner) + + sig.KeyID = "8badf00d" + releaserole.Signatures = []data.Signature{sig} + releaserole.Threshold = 1 + allrole = []client.RoleWithSignatures{releaserole} + lastsigner, _ = isLastSignerForReleases(role, allrole) + assert.Equal(t, false, lastsigner) +} diff --git a/cli/trust/trust.go b/cli/trust/trust.go index d392153e5c..c87ad7be8e 100644 --- a/cli/trust/trust.go +++ b/cli/trust/trust.go @@ -43,7 +43,8 @@ var ( ActionsPushAndPull = []string{"pull", "push"} ) -func trustDirectory() string { +// GetTrustDirectory returns the base trust directory name +func GetTrustDirectory() string { return filepath.Join(cliconfig.Dir(), "trust") } @@ -172,15 +173,16 @@ func GetNotaryRepository(in io.Reader, out io.Writer, userAgent string, repoInfo tr := transport.NewTransport(base, modifiers...) return client.NewFileCachedRepository( - trustDirectory(), + GetTrustDirectory(), data.GUN(repoInfo.Name.Name()), server, tr, - getPassphraseRetriever(in, out), + GetPassphraseRetriever(in, out), trustpinning.TrustPinConfig{}) } -func getPassphraseRetriever(in io.Reader, out io.Writer) notary.PassRetriever { +// GetPassphraseRetriever returns a passphrase retriever that utilizes Content Trust env vars +func GetPassphraseRetriever(in io.Reader, out io.Writer) notary.PassRetriever { aliasMap := map[string]string{ "root": "root", "snapshot": "repository", diff --git a/docs/reference/commandline/trust_key_generate.md b/docs/reference/commandline/trust_key_generate.md new file mode 100644 index 0000000000..ad2758d0b1 --- /dev/null +++ b/docs/reference/commandline/trust_key_generate.md @@ -0,0 +1,69 @@ +--- +title: "key generate" +description: "The key generate command description and usage" +keywords: "Key, notary, trust" +--- + + + +# trust key generate + +```markdown +Usage: docker trust key generate NAME + +Generate and load a signing key-pair + +Options: + --dir string Directory to generate key in, defaults to current directory + --help Print usage +``` + +## Description + +`docker trust key generate` generates a key-pair to be used with signing, + and loads the private key into the local docker trust keystore. + +`docker trust key generate` is currently experimental. + +## Examples + +### Generate a key-pair + +```bash +$ docker trust key generate alice + +Generating key for alice... +Enter passphrase for new alice key with ID 17acf3c: +Repeat passphrase for new alice key with ID 17acf3c: +Successfully generated and loaded private key. Corresponding public key available: alice.pub +$ ls +alice.pub + +``` + +The private signing key is encrypted by the passphrase and loaded into the docker trust keystore. +All passphrase requests to sign with the key will be referred to by the provided `NAME`. + +The public key component `alice.pub` will be available in the current working directory, and can +be used directly by `docker trust signer add`. + +Provide the `--dir` argument to specify a directory to generate the key in: + +```bash +$ docker trust key generate alice --dir /foo + +Generating key for alice... +Enter passphrase for new alice key with ID 17acf3c: +Repeat passphrase for new alice key with ID 17acf3c: +Successfully generated and loaded private key. Corresponding public key available: alice.pub +$ ls /foo +alice.pub + +``` diff --git a/docs/reference/commandline/trust_key_load.md b/docs/reference/commandline/trust_key_load.md new file mode 100644 index 0000000000..6a29dd8874 --- /dev/null +++ b/docs/reference/commandline/trust_key_load.md @@ -0,0 +1,59 @@ +--- +title: "key load" +description: "The key load command description and usage" +keywords: "Key, notary, trust" +--- + + + +# trust key load + +```markdown +Usage: docker trust key load [OPTIONS] KEYFILE + +Load a private key file for signing + +Options: + --help Print usage + --name string Name for the loaded key (default "signer") +``` + +## Description + +`docker trust key load` adds private keys to the local docker trust keystore. To add a signer to a repository use `docker trust signer add`. + +`docker trust key load` is currently experimental. + +## Examples + +### Load a single private key + +For a private key `alice.pem` with permissions `-rw-------` + +```bash +$ docker trust key load alice.pem + +Loading key from "alice.pem"... +Enter passphrase for new signer key with ID f8097df: +Repeat passphrase for new signer key with ID f8097df: +Successfully imported key from alice.pem + +``` +to specify a name use the `--name` flag + +```bash +$ docker trust key load --name alice-key alice.pem + +Loading key from "alice.pem"... +Enter passphrase for new alice-key key with ID f8097df: +Repeat passphrase for new alice-key key with ID f8097df: +Successfully imported key from alice.pem + +``` diff --git a/docs/reference/commandline/trust_signer_add.md b/docs/reference/commandline/trust_signer_add.md new file mode 100644 index 0000000000..bda45196f7 --- /dev/null +++ b/docs/reference/commandline/trust_signer_add.md @@ -0,0 +1,213 @@ +--- +title: "signer add" +description: "The signer add command description and usage" +keywords: "signer, notary, trust" +--- + + + +# trust signer add + +```markdown +Usage: docker trust signer add [OPTIONS] NAME REPOSITORY [REPOSITORY...] + +Add a signer + +Options: + --help Print usage + -k, --key list Path to the signer's public key file +``` + +## Description + +`docker trust signer add` adds signers to signed repositories. + +`docker trust signer add` is currently experimental. + +## Examples + +### Add a signer to a repo + +To add a new signer, `alice`, to this repository: + +```bash +$ docker trust view example/trust-demo + +No signatures for example/trust-demo + + +List of signers and their keys: + +SIGNER KEYS +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo: +Repository Key: 642692c14c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4555b3c6ab02f71e +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` + +Add `alice` with `docker trust signer add`: + +```bash +$ docker trust signer add alice example/trust-demo --key alice.crt + Adding signer "alice" to example/trust-demo... + Enter passphrase for repository key with ID 642692c: + Successfully added signer: alice to example/trust-demo +``` + +`docker trust view` now lists `alice` as a valid signer: + +```bash +$ docker trust view example/trust-demo + +No signatures for example/trust-demo + + +List of signers and their keys: + +SIGNER KEYS +alice 05e87edcaecb +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo: +Repository Key: 642692c14c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4555b3c6ab02f71e +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` + +## Initialize a new repo and add a signer + +When adding a signer on a repo for the first time, `docker trust signer add` sets up a new repo if it doesn't exist. + +```bash +$ docker trust view example/trust-demo +No signatures or cannot access example/trust-demo +``` + +```bash +$ docker trust signer add alice example/trust-demo --key alice.crt + Initializing signed repository for example/trust-demo... + Enter passphrase for root key with ID 748121c: + Enter passphrase for new repository key with ID 95b9e55: + Repeat passphrase for new repository key with ID 95b9e55: + Successfully initialized "example/trust-demo" + + Adding signer "alice" to example/trust-demo... + Successfully added signer: alice to example/trust-demo +``` + +```bash +$ docker trust view example/trust-demo + +No signatures for example/trust-demo + + +SIGNED TAG DIGEST SIGNERS + +List of signers and their keys: + +SIGNER KEYS +alice 6d52b29d940f + +Administrative keys for example/trust-demo: +Repository Key: 95b9e5565eac3ef5ec01406801bdfb70feb40c17808d2222427c18046eb63beb +Root Key: 748121c14bd1461f6c58cb3ef39087c8fdc7633bb11a98af844fd9a04e208103 +``` + +## Add a signer to multiple repos +To add a signer, `alice`, to multiple repositories: + +```bash +$ docker trust view example/trust-demo +SIGNED TAG DIGEST SIGNERS +v1 74d4bfa917d55d53c7df3d2ab20a8d926874d61c3da5ef6de15dd2654fc467c4 bob + +List of signers and their keys: + +SIGNER KEYS +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo: +Repository Key: ecc457614c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4555b3c6ab02f71e +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` +```bash +$ docker trust view example/trust-demo2 +SIGNED TAG DIGEST SIGNERS +v1 74d4bfa917d55d53c7df3d2ab20a8d926874d61c3da5ef6de15dd2654fc467c4 bob + +List of signers and their keys: + +SIGNER KEYS +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo2: +Repository Key: ece554f14c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4553d2ab20a8d9268 +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` +Add `alice` to both repositories with a single `docker trust signer add` command: + +```bash +$ docker trust signer add alice example/trust-demo example/trust-demo2 --key alice.crt +Adding signer "alice" to example/trust-demo... +Enter passphrase for repository key with ID 95b9e55: +Successfully added signer: alice to example/trust-demo + +Adding signer "alice" to example/trust-demo2... +Enter passphrase for repository key with ID ece554f: +Successfully added signer: alice to example/trust-demo2 +``` +`docker trust view` now lists `alice` as a valid signer of both `example/trust-demo` and `example/trust-demo2`: + + +```bash +$ docker trust view example/trust-demo +SIGNED TAG DIGEST SIGNERS +v1 74d4bfa917d55d53c7df3d2ab20a8d926874d61c3da5ef6de15dd2654fc467c4 bob + +List of signers and their keys: + +SIGNER KEYS +alice 05e87edcaecb +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo: +Repository Key: 95b9e5514c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4555b3c6ab02f71e +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` +```bash +$ docker trust view example/trust-demo2 +SIGNED TAG DIGEST SIGNERS +v1 74d4bfa917d55d53c7df3d2ab20a8d926874d61c3da5ef6de15dd2654fc467c4 bob + +List of signers and their keys: + +SIGNER KEYS +alice 05e87edcaecb +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo2: +Repository Key: ece554f14c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4553d2ab20a8d9268 +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` + + +`docker trust signer add` adds signers to repositories on a best effort basis, so it will continue to add the signer to subsequent repositories if one attempt fails: + +```bash +$ docker trust signer add alice example/unauthorized example/authorized --key alice.crt +Adding signer "alice" to example/unauthorized... +you are not authorized to perform this operation: server returned 401. + +Adding signer "alice" to example/authorized... +Enter passphrase for repository key with ID c6772a0: +Successfully added signer: alice to example/authorized + +Failed to add signer to: example/unauthorized +``` diff --git a/docs/reference/commandline/trust_signer_remove.md b/docs/reference/commandline/trust_signer_remove.md new file mode 100644 index 0000000000..6afe160040 --- /dev/null +++ b/docs/reference/commandline/trust_signer_remove.md @@ -0,0 +1,174 @@ +--- +title: "signer remove" +description: "The signer remove command description and usage" +keywords: "signer, notary, trust" +--- + + + +# trust signer remove + +```markdown +Usage: docker trust signer remove [OPTIONS] NAME REPOSITORY [REPOSITORY...] + +Remove a signer + +Options: + -f, --force Do not prompt for confirmation before removing the most recent signer + --help Print usage +``` + +## Description + +`docker trust signer remove` removes signers from signed repositories. + +`docker trust signer remove` is currently experimental. + +## Examples + +### Remove a signer from a repo + +To remove an existing signer, `alice`, from this repository: + +```bash +$ docker trust view example/trust-demo + +No signatures for example/trust-demo + + +List of signers and their keys: + +SIGNER KEYS +alice 05e87edcaecb +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo: +Repository Key: ecc457614c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4555b3c6ab02f71e +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` + +Remove `alice` with `docker trust signer remove`: + +```bash +$ docker trust signer remove alice example/trust-demo + Removing signer "alice" from image example/trust-demo... + Enter passphrase for repository key with ID 642692c: + Successfully removed alice from example/trust-demo + +``` + +`docker trust view` now does not list `alice` as a valid signer: + +```bash +$ docker trust view example/trust-demo + +No signatures for example/trust-demo + + +List of signers and their keys: + +SIGNER KEYS +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo: +Repository Key: ecc457614c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4555b3c6ab02f71e +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` + +### Remove a signer from multiple repos + +To remove an existing signer, `alice`, from multiple repositories: + +```bash +$ docker trust view example/trust-demo +SIGNED TAG DIGEST SIGNERS +v1 74d4bfa917d55d53c7df3d2ab20a8d926874d61c3da5ef6de15dd2654fc467c4 alice, bob + +List of signers and their keys: + +SIGNER KEYS +alice 05e87edcaecb +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo: +Repository Key: 95b9e5514c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4555b3c6ab02f71e +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` +```bash +$ docker trust view example/trust-demo2 +SIGNED TAG DIGEST SIGNERS +v1 74d4bfa917d55d53c7df3d2ab20a8d926874d61c3da5ef6de15dd2654fc467c4 alice, bob + +List of signers and their keys: + +SIGNER KEYS +alice 05e87edcaecb +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo2: +Repository Key: ece554f14c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4553d2ab20a8d9268 +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` +Remove `alice` from both images with a single `docker trust signer remove` command: + +```bash +$ docker trust signer remove alice example/trust-demo example/trust-demo2 +Removing signer "alice" from image example/trust-demo... +Enter passphrase for repository key with ID 95b9e55: +Successfully removed alice from example/trust-demo + +Removing signer "alice" from image example/trust-demo2... +Enter passphrase for repository key with ID ece554f: +Successfully removed alice from example/trust-demo2 +``` +`docker trust view` no longer lists `alice` as a valid signer of either `example/trust-demo` or `example/trust-demo2`: +```bash +$ docker trust view example/trust-demo +SIGNED TAG DIGEST SIGNERS +v1 74d4bfa917d55d53c7df3d2ab20a8d926874d61c3da5ef6de15dd2654fc467c4 bob + +List of signers and their keys: + +SIGNER KEYS +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo: +Repository Key: ecc457614c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4555b3c6ab02f71e +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` +```bash +$ docker trust view example/trust-demo2 +SIGNED TAG DIGEST SIGNERS +v1 74d4bfa917d55d53c7df3d2ab20a8d926874d61c3da5ef6de15dd2654fc467c4 bob + +List of signers and their keys: + +SIGNER KEYS +bob 5600f5ab76a2 + +Administrative keys for example/trust-demo2: +Repository Key: ece554f14c9fc399da523a5f4e24fe306a0a6ee1cc79a10e4553d2ab20a8d9268 +Root Key: 3cb2228f6561e58f46dbc4cda4fcaff9d5ef22e865a94636f82450d1d2234949 +``` + +`docker trust signer remove` removes signers to repositories on a best effort basis, so it will continue to remove the signer from subsequent repositories if one attempt fails: + +```bash +$ docker trust signer remove alice example/unauthorized example/authorized +Removing signer "alice" from image example/unauthorized... +No signer alice for image example/unauthorized + +Removing signer "alice" from image example/authorized... +Enter passphrase for repository key with ID c6772a0: +Successfully removed alice from example/authorized + +Error removing signer from: example/unauthorized +``` + diff --git a/vendor.conf b/vendor.conf index 527d10b2ef..7c8a85e891 100755 --- a/vendor.conf +++ b/vendor.conf @@ -14,7 +14,7 @@ github.com/docker/go d30aec9fd63c35133f8f79c3412ad91a3b08be06 github.com/docker/go-connections 3ede32e2033de7505e6500d6c868c2b9ed9f169d github.com/docker/go-events 9461782956ad83b30282bf90e31fa6a70c255ba9 github.com/docker/go-units 9e638d38cf6977a37a8ea0078f3ee75a7cdb2dd1 -github.com/docker/notary 8a1de3cfc3f1408e54d6364fc949214a4883a9f3 +github.com/docker/notary 5d55a30c1bec010a8c6df4c09889acfb4e0a7942 github.com/docker/swarmkit 872861d2ae46958af7ead1d5fffb092c73afbaf0 github.com/flynn-archive/go-shlex 3f9db97f856818214da2e1057f8ad84803971cff github.com/gogo/protobuf v0.4 diff --git a/vendor/github.com/docker/notary/client/client.go b/vendor/github.com/docker/notary/client/client.go index c7e473b43a..81411d2440 100644 --- a/vendor/github.com/docker/notary/client/client.go +++ b/vendor/github.com/docker/notary/client/client.go @@ -1,3 +1,88 @@ +/* +Package client implements everything required for interacting with a Notary repository. + +Usage + +Use this package by creating a new repository object and calling methods on it. + + package main + + import ( + "encoding/hex" + "fmt" + "net/http" + "os" + "time" + + "github.com/docker/distribution/registry/client/auth" + "github.com/docker/distribution/registry/client/auth/challenge" + "github.com/docker/distribution/registry/client/transport" + notary "github.com/docker/notary/client" + "github.com/docker/notary/trustpinning" + "github.com/docker/notary/tuf/data" + ) + + func main() { + rootDir := ".trust" + if err := os.MkdirAll(rootDir, 0700); err != nil { + panic(err) + } + + server := "https://notary.docker.io" + image := "docker.io/library/alpine" + repo, err := notary.NewFileCachedNotaryRepository( + rootDir, + data.GUN(image), + server, + makeHubTransport(server, image), + nil, + trustpinning.TrustPinConfig{}, + ) + + targets, err := repo.ListTargets() + if err != nil { + panic(err) + } + + for _, tgt := range targets { + fmt.Printf("%s\t%s\n", tgt.Name, hex.EncodeToString(tgt.Hashes["sha256"])) + } + } + + func makeHubTransport(server, image string) http.RoundTripper { + base := http.DefaultTransport + modifiers := []transport.RequestModifier{ + transport.NewHeaderRequestModifier(http.Header{ + "User-Agent": []string{"my-client"}, + }), + } + + 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 { + panic(err) + } + + challengeManager := challenge.NewSimpleManager() + resp, err := pingClient.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + if err := challengeManager.AddResponse(resp); err != nil { + panic(err) + } + tokenHandler := auth.NewTokenHandler(base, nil, image, "pull") + modifiers = append(modifiers, auth.NewAuthorizer(challengeManager, tokenHandler, auth.NewBasicHandler(nil))) + + return transport.NewTransport(base, modifiers...) + } + +*/ package client import ( diff --git a/vendor/github.com/docker/notary/storage/filestore.go b/vendor/github.com/docker/notary/storage/filestore.go index d7dc6bfb95..32b35eda1f 100644 --- a/vendor/github.com/docker/notary/storage/filestore.go +++ b/vendor/github.com/docker/notary/storage/filestore.go @@ -206,10 +206,7 @@ func (f *FilesystemStore) Set(name string, meta []byte) error { os.RemoveAll(fp) // Write the file to disk - if err = ioutil.WriteFile(fp, meta, notary.PrivNoExecPerms); err != nil { - return err - } - return nil + return ioutil.WriteFile(fp, meta, notary.PrivNoExecPerms) } // RemoveAll clears the existing filestore by removing its base directory diff --git a/vendor/github.com/docker/notary/trustmanager/keys.go b/vendor/github.com/docker/notary/trustmanager/keys.go new file mode 100644 index 0000000000..e90e4c9a38 --- /dev/null +++ b/vendor/github.com/docker/notary/trustmanager/keys.go @@ -0,0 +1,240 @@ +package trustmanager + +import ( + "encoding/pem" + "errors" + "io" + "io/ioutil" + "path/filepath" + "sort" + "strings" + + "github.com/docker/notary" + tufdata "github.com/docker/notary/tuf/data" + "github.com/docker/notary/tuf/utils" + "github.com/sirupsen/logrus" +) + +// Exporter is a simple interface for the two functions we need from the Storage interface +type Exporter interface { + Get(string) ([]byte, error) + ListFiles() []string +} + +// Importer is a simple interface for the one function we need from the Storage interface +type Importer interface { + Set(string, []byte) error +} + +// ExportKeysByGUN exports all keys filtered to a GUN +func ExportKeysByGUN(to io.Writer, s Exporter, gun string) error { + keys := s.ListFiles() + sort.Strings(keys) // ensure consistency. ListFiles has no order guarantee + for _, loc := range keys { + keyFile, err := s.Get(loc) + if err != nil { + logrus.Warn("Could not parse key file at ", loc) + continue + } + block, _ := pem.Decode(keyFile) + keyGun := block.Headers["gun"] + if keyGun == gun { // must be full GUN match + if err := ExportKeys(to, s, loc); err != nil { + return err + } + } + } + return nil +} + +// ExportKeysByID exports all keys matching the given ID +func ExportKeysByID(to io.Writer, s Exporter, ids []string) error { + want := make(map[string]struct{}) + for _, id := range ids { + want[id] = struct{}{} + } + keys := s.ListFiles() + for _, k := range keys { + id := filepath.Base(k) + if _, ok := want[id]; ok { + if err := ExportKeys(to, s, k); err != nil { + return err + } + } + } + return nil +} + +// ExportKeys copies a key from the store to the io.Writer +func ExportKeys(to io.Writer, s Exporter, from string) error { + // get PEM block + k, err := s.Get(from) + if err != nil { + return err + } + + // parse PEM blocks if there are more than one + for block, rest := pem.Decode(k); block != nil; block, rest = pem.Decode(rest) { + // add from path in a header for later import + block.Headers["path"] = from + // write serialized PEM + err = pem.Encode(to, block) + if err != nil { + return err + } + } + return nil +} + +// ImportKeys expects an io.Reader containing one or more PEM blocks. +// It reads PEM blocks one at a time until pem.Decode returns a nil +// block. +// Each block is written to the subpath indicated in the "path" PEM +// header. If the file already exists, the file is truncated. Multiple +// adjacent PEMs with the same "path" header are appended together. +func ImportKeys(from io.Reader, to []Importer, fallbackRole string, fallbackGUN string, passRet notary.PassRetriever) error { + // importLogic.md contains a small flowchart I made to clear up my understand while writing the cases in this function + // it is very rough, but it may help while reading this piece of code + data, err := ioutil.ReadAll(from) + if err != nil { + return err + } + var ( + writeTo string + toWrite []byte + ) + for block, rest := pem.Decode(data); block != nil; block, rest = pem.Decode(rest) { + handleLegacyPath(block) + setFallbacks(block, fallbackGUN, fallbackRole) + + loc, err := checkValidity(block) + if err != nil { + // already logged in checkValidity + continue + } + + // the path header is not of any use once we've imported the key so strip it away + delete(block.Headers, "path") + + // we are now all set for import but let's first encrypt the key + blockBytes := pem.EncodeToMemory(block) + // check if key is encrypted, note: if it is encrypted at this point, it will have had a path header + if privKey, err := utils.ParsePEMPrivateKey(blockBytes, ""); err == nil { + // Key is not encrypted- ask for a passphrase and encrypt this key + var chosenPassphrase string + for attempts := 0; ; attempts++ { + var giveup bool + chosenPassphrase, giveup, err = passRet(loc, block.Headers["role"], true, attempts) + if err == nil { + break + } + if giveup || attempts > 10 { + return errors.New("maximum number of passphrase attempts exceeded") + } + } + blockBytes, err = utils.ConvertPrivateKeyToPKCS8(privKey, tufdata.RoleName(block.Headers["role"]), tufdata.GUN(block.Headers["gun"]), chosenPassphrase) + if err != nil { + return errors.New("failed to encrypt key with given passphrase") + } + } + + if loc != writeTo { + // next location is different from previous one. We've finished aggregating + // data for the previous file. If we have data, write the previous file, + // clear toWrite and set writeTo to the next path we're going to write + if toWrite != nil { + if err = importToStores(to, writeTo, toWrite); err != nil { + return err + } + } + // set up for aggregating next file's data + toWrite = nil + writeTo = loc + } + + toWrite = append(toWrite, blockBytes...) + } + if toWrite != nil { // close out final iteration if there's data left + return importToStores(to, writeTo, toWrite) + } + return nil +} + +func handleLegacyPath(block *pem.Block) { + // if there is a legacy path then we set the gun header from this path + // this is the case when a user attempts to import a key bundle generated by an older client + if rawPath := block.Headers["path"]; rawPath != "" && rawPath != filepath.Base(rawPath) { + // this is a legacy filepath and we should try to deduce the gun name from it + pathWOFileName := filepath.Dir(rawPath) + if strings.HasPrefix(pathWOFileName, notary.NonRootKeysSubdir) { + // remove the notary keystore-specific segment of the path, and any potential leading or trailing slashes + gunName := strings.Trim(strings.TrimPrefix(pathWOFileName, notary.NonRootKeysSubdir), "/") + if gunName != "" { + block.Headers["gun"] = gunName + } + } + block.Headers["path"] = filepath.Base(rawPath) + } +} + +func setFallbacks(block *pem.Block, fallbackGUN, fallbackRole string) { + if block.Headers["gun"] == "" { + if fallbackGUN != "" { + block.Headers["gun"] = fallbackGUN + } + } + + if block.Headers["role"] == "" { + if fallbackRole == "" { + block.Headers["role"] = notary.DefaultImportRole + } else { + block.Headers["role"] = fallbackRole + } + } +} + +// checkValidity ensures the fields in the pem headers are valid and parses out the location. +// While importing a collection of keys, errors from this function should result in only the +// current pem block being skipped. +func checkValidity(block *pem.Block) (string, error) { + // A root key or a delegations key should not have a gun + // Note that a key that is not any of the canonical roles (except root) is a delegations key and should not have a gun + switch block.Headers["role"] { + case tufdata.CanonicalSnapshotRole.String(), tufdata.CanonicalTargetsRole.String(), tufdata.CanonicalTimestampRole.String(): + // check if the key is missing a gun header or has an empty gun and error out since we don't know what gun it belongs to + if block.Headers["gun"] == "" { + logrus.Warnf("failed to import key (%s) to store: Cannot have canonical role key without a gun, don't know what gun it belongs to", block.Headers["path"]) + return "", errors.New("invalid key pem block") + } + default: + delete(block.Headers, "gun") + } + + loc, ok := block.Headers["path"] + // only if the path isn't specified do we get into this parsing path logic + if !ok || loc == "" { + // if the path isn't specified, we will try to infer the path rel to trust dir from the role (and then gun) + // parse key for the keyID which we will save it by. + // if the key is encrypted at this point, we will generate an error and continue since we don't know the ID to save it by + + decodedKey, err := utils.ParsePEMPrivateKey(pem.EncodeToMemory(block), "") + if err != nil { + logrus.Warn("failed to import key to store: Invalid key generated, key may be encrypted and does not contain path header") + return "", errors.New("invalid key pem block") + } + loc = decodedKey.ID() + } + return loc, nil +} + +func importToStores(to []Importer, path string, bytes []byte) error { + var err error + for _, i := range to { + if err = i.Set(path, bytes); err != nil { + logrus.Errorf("failed to import key to store: %s", err.Error()) + continue + } + break + } + return err +} diff --git a/vendor/github.com/docker/notary/trustpinning/trustpin.go b/vendor/github.com/docker/notary/trustpinning/trustpin.go index e1777a16de..acad5a1369 100644 --- a/vendor/github.com/docker/notary/trustpinning/trustpin.go +++ b/vendor/github.com/docker/notary/trustpinning/trustpin.go @@ -12,9 +12,24 @@ import ( // TrustPinConfig represents the configuration under the trust_pinning section of the config file // This struct represents the preferred way to bootstrap trust for this repository +// This is fully optional. If left at the default, uninitialized value Notary will use TOFU over +// HTTPS. +// You can use this to provide certificates or a CA to pin to as a root of trust for a GUN. +// These are used with the following precedence: +// +// 1. Certs +// 2. CA +// 3. TOFUS (TOFU over HTTPS) +// +// Only one trust pinning option will be used to validate a particular GUN. type TrustPinConfig struct { - CA map[string]string - Certs map[string][]string + // CA maps a GUN prefix to file paths containing the root CA. + // This file can contain multiple root certificates, bundled in separate PEM blocks. + CA map[string]string + // Certs maps a GUN to a list of certificate IDs + Certs map[string][]string + // DisableTOFU, when true, disables "Trust On First Use" of new key data + // This is false by default, which means new key data will always be trusted the first time it is seen. DisableTOFU bool } diff --git a/vendor/github.com/docker/notary/tuf/data/types.go b/vendor/github.com/docker/notary/tuf/data/types.go index 5c480d4837..ba973e1f9d 100644 --- a/vendor/github.com/docker/notary/tuf/data/types.go +++ b/vendor/github.com/docker/notary/tuf/data/types.go @@ -19,7 +19,9 @@ import ( "github.com/sirupsen/logrus" ) -// GUN type for specifying gun +// GUN is a Globally Unique Name. It is used to identify trust collections. +// An example usage of this is for container image repositories. +// For example: myregistry.io/myuser/myimage type GUN string func (g GUN) String() string { diff --git a/vendor/github.com/docker/notary/vendor.conf b/vendor/github.com/docker/notary/vendor.conf index d10edba040..73e12db5de 100644 --- a/vendor/github.com/docker/notary/vendor.conf +++ b/vendor/github.com/docker/notary/vendor.conf @@ -24,7 +24,7 @@ github.com/prometheus/common 4fdc91a58c9d3696b982e8a680f4997403132d44 github.com/golang/protobuf c3cefd437628a0b7d31b34fe44b3a7a540e98527 github.com/spf13/cobra f368244301305f414206f889b1735a54cfc8bde8 github.com/spf13/viper be5ff3e4840cf692388bde7a057595a474ef379e -golang.org/x/crypto 5bcd134fee4dd1475da17714aac19c0aa0142e2f +golang.org/x/crypto 76eec36fa14229c4b25bb894c2d0e591527af429 golang.org/x/net 6a513affb38dc9788b449d59ffed099b8de18fa0 golang.org/x/sys 739734461d1c916b6c72a63d7efda2b27edb369f google.golang.org/grpc 708a7f9f3283aa2d4f6132d287d78683babe55c8 # v1.0.5