Browse code

Client credentials store.

This change implements communication with an external credentials store,
ala git-credential-helper. The client falls back the plain text store,
what we're currently using, if there is no remote store configured.

It shells out to helper program when a credential store is
configured. Those programs can be implemented with any language as long as they
follow the convention to pass arguments and information.

There is an implementation for the OS X keychain in https://github.com/calavera/docker-credential-helpers.
That package also provides basic structure to create other helpers.

Signed-off-by: David Calavera <david.calavera@gmail.com>

David Calavera authored on 2016/02/08 09:55:17
Showing 20 changed files
... ...
@@ -11,6 +11,7 @@ import (
11 11
 	"github.com/docker/docker/api"
12 12
 	"github.com/docker/docker/cli"
13 13
 	"github.com/docker/docker/cliconfig"
14
+	"github.com/docker/docker/cliconfig/credentials"
14 15
 	"github.com/docker/docker/dockerversion"
15 16
 	"github.com/docker/docker/opts"
16 17
 	"github.com/docker/docker/pkg/term"
... ...
@@ -125,6 +126,9 @@ func NewDockerCli(in io.ReadCloser, out, err io.Writer, clientFlags *cli.ClientF
125 125
 		if e != nil {
126 126
 			fmt.Fprintf(cli.err, "WARNING: Error loading config file:%v\n", e)
127 127
 		}
128
+		if !configFile.ContainsAuth() {
129
+			credentials.DetectDefaultStore(configFile)
130
+		}
128 131
 		cli.configFile = configFile
129 132
 
130 133
 		host, err := getServerHost(clientFlags.Common.Hosts, clientFlags.Common.TLSOptions)
... ...
@@ -42,7 +42,7 @@ func (cli *DockerCli) pullImageCustomOut(image string, out io.Writer) error {
42 42
 		return err
43 43
 	}
44 44
 
45
-	authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index)
45
+	authConfig := cli.resolveAuthConfig(repoInfo.Index)
46 46
 	encodedAuth, err := encodeAuthToBase64(authConfig)
47 47
 	if err != nil {
48 48
 		return err
... ...
@@ -9,6 +9,8 @@ import (
9 9
 	"strings"
10 10
 
11 11
 	Cli "github.com/docker/docker/cli"
12
+	"github.com/docker/docker/cliconfig"
13
+	"github.com/docker/docker/cliconfig/credentials"
12 14
 	flag "github.com/docker/docker/pkg/mflag"
13 15
 	"github.com/docker/docker/pkg/term"
14 16
 	"github.com/docker/engine-api/client"
... ...
@@ -50,18 +52,16 @@ func (cli *DockerCli) CmdLogin(args ...string) error {
50 50
 	response, err := cli.client.RegistryLogin(authConfig)
51 51
 	if err != nil {
52 52
 		if client.IsErrUnauthorized(err) {
53
-			delete(cli.configFile.AuthConfigs, serverAddress)
54
-			if err2 := cli.configFile.Save(); err2 != nil {
55
-				fmt.Fprintf(cli.out, "WARNING: could not save config file: %v\n", err2)
53
+			if err2 := eraseCredentials(cli.configFile, authConfig.ServerAddress); err2 != nil {
54
+				fmt.Fprintf(cli.out, "WARNING: could not save credentials: %v\n", err2)
56 55
 			}
57 56
 		}
58 57
 		return err
59 58
 	}
60 59
 
61
-	if err := cli.configFile.Save(); err != nil {
62
-		return fmt.Errorf("Error saving config file: %v", err)
60
+	if err := storeCredentials(cli.configFile, authConfig); err != nil {
61
+		return fmt.Errorf("Error saving credentials: %v", err)
63 62
 	}
64
-	fmt.Fprintf(cli.out, "WARNING: login credentials saved in %s\n", cli.configFile.Filename())
65 63
 
66 64
 	if response.Status != "" {
67 65
 		fmt.Fprintf(cli.out, "%s\n", response.Status)
... ...
@@ -78,10 +78,11 @@ func (cli *DockerCli) promptWithDefault(prompt string, configDefault string) {
78 78
 }
79 79
 
80 80
 func (cli *DockerCli) configureAuth(flUser, flPassword, flEmail, serverAddress string) (types.AuthConfig, error) {
81
-	authconfig, ok := cli.configFile.AuthConfigs[serverAddress]
82
-	if !ok {
83
-		authconfig = types.AuthConfig{}
81
+	authconfig, err := getCredentials(cli.configFile, serverAddress)
82
+	if err != nil {
83
+		return authconfig, err
84 84
 	}
85
+
85 86
 	authconfig.Username = strings.TrimSpace(authconfig.Username)
86 87
 
87 88
 	if flUser = strings.TrimSpace(flUser); flUser == "" {
... ...
@@ -133,11 +134,12 @@ func (cli *DockerCli) configureAuth(flUser, flPassword, flEmail, serverAddress s
133 133
 			flEmail = authconfig.Email
134 134
 		}
135 135
 	}
136
+
136 137
 	authconfig.Username = flUser
137 138
 	authconfig.Password = flPassword
138 139
 	authconfig.Email = flEmail
139 140
 	authconfig.ServerAddress = serverAddress
140
-	cli.configFile.AuthConfigs[serverAddress] = authconfig
141
+
141 142
 	return authconfig, nil
142 143
 }
143 144
 
... ...
@@ -150,3 +152,33 @@ func readInput(in io.Reader, out io.Writer) string {
150 150
 	}
151 151
 	return string(line)
152 152
 }
153
+
154
+// getCredentials loads the user credentials from a credentials store.
155
+// The store is determined by the config file settings.
156
+func getCredentials(c *cliconfig.ConfigFile, serverAddress string) (types.AuthConfig, error) {
157
+	s := loadCredentialsStore(c)
158
+	return s.Get(serverAddress)
159
+}
160
+
161
+// storeCredentials saves the user credentials in a credentials store.
162
+// The store is determined by the config file settings.
163
+func storeCredentials(c *cliconfig.ConfigFile, auth types.AuthConfig) error {
164
+	s := loadCredentialsStore(c)
165
+	return s.Store(auth)
166
+}
167
+
168
+// eraseCredentials removes the user credentials from a credentials store.
169
+// The store is determined by the config file settings.
170
+func eraseCredentials(c *cliconfig.ConfigFile, serverAddress string) error {
171
+	s := loadCredentialsStore(c)
172
+	return s.Erase(serverAddress)
173
+}
174
+
175
+// loadCredentialsStore initializes a new credentials store based
176
+// in the settings provided in the configuration file.
177
+func loadCredentialsStore(c *cliconfig.ConfigFile) credentials.Store {
178
+	if c.CredentialsStore != "" {
179
+		return credentials.NewNativeStore(c)
180
+	}
181
+	return credentials.NewFileStore(c)
182
+}
... ...
@@ -56,7 +56,7 @@ func (cli *DockerCli) CmdPull(args ...string) error {
56 56
 		return err
57 57
 	}
58 58
 
59
-	authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index)
59
+	authConfig := cli.resolveAuthConfig(repoInfo.Index)
60 60
 	requestPrivilege := cli.registryAuthenticationPrivilegedFunc(repoInfo.Index, "pull")
61 61
 
62 62
 	if isTrusted() && !ref.HasDigest() {
... ...
@@ -44,7 +44,7 @@ func (cli *DockerCli) CmdPush(args ...string) error {
44 44
 		return err
45 45
 	}
46 46
 	// Resolve the Auth config relevant for this server
47
-	authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index)
47
+	authConfig := cli.resolveAuthConfig(repoInfo.Index)
48 48
 
49 49
 	requestPrivilege := cli.registryAuthenticationPrivilegedFunc(repoInfo.Index, "push")
50 50
 	if isTrusted() {
... ...
@@ -36,7 +36,7 @@ func (cli *DockerCli) CmdSearch(args ...string) error {
36 36
 		return err
37 37
 	}
38 38
 
39
-	authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, indexInfo)
39
+	authConfig := cli.resolveAuthConfig(indexInfo)
40 40
 	requestPrivilege := cli.registryAuthenticationPrivilegedFunc(indexInfo, "search")
41 41
 
42 42
 	encodedAuth, err := encodeAuthToBase64(authConfig)
... ...
@@ -235,7 +235,7 @@ func (cli *DockerCli) trustedReference(ref reference.NamedTagged) (reference.Can
235 235
 	}
236 236
 
237 237
 	// Resolve the Auth config relevant for this server
238
-	authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index)
238
+	authConfig := cli.resolveAuthConfig(repoInfo.Index)
239 239
 
240 240
 	notaryRepo, err := cli.getNotaryRepository(repoInfo, authConfig)
241 241
 	if err != nil {
... ...
@@ -10,7 +10,6 @@ import (
10 10
 	gosignal "os/signal"
11 11
 	"path/filepath"
12 12
 	"runtime"
13
-	"strings"
14 13
 	"time"
15 14
 
16 15
 	"github.com/Sirupsen/logrus"
... ...
@@ -185,38 +184,12 @@ func copyToFile(outfile string, r io.Reader) error {
185 185
 // resolveAuthConfig is like registry.ResolveAuthConfig, but if using the
186 186
 // default index, it uses the default index name for the daemon's platform,
187 187
 // not the client's platform.
188
-func (cli *DockerCli) resolveAuthConfig(authConfigs map[string]types.AuthConfig, index *registrytypes.IndexInfo) types.AuthConfig {
188
+func (cli *DockerCli) resolveAuthConfig(index *registrytypes.IndexInfo) types.AuthConfig {
189 189
 	configKey := index.Name
190 190
 	if index.Official {
191 191
 		configKey = cli.electAuthServer()
192 192
 	}
193 193
 
194
-	// First try the happy case
195
-	if c, found := authConfigs[configKey]; found || index.Official {
196
-		return c
197
-	}
198
-
199
-	convertToHostname := func(url string) string {
200
-		stripped := url
201
-		if strings.HasPrefix(url, "http://") {
202
-			stripped = strings.Replace(url, "http://", "", 1)
203
-		} else if strings.HasPrefix(url, "https://") {
204
-			stripped = strings.Replace(url, "https://", "", 1)
205
-		}
206
-
207
-		nameParts := strings.SplitN(stripped, "/", 2)
208
-
209
-		return nameParts[0]
210
-	}
211
-
212
-	// Maybe they have a legacy config file, we will iterate the keys converting
213
-	// them to the new format and testing
214
-	for registry, ac := range authConfigs {
215
-		if configKey == convertToHostname(registry) {
216
-			return ac
217
-		}
218
-	}
219
-
220
-	// When all else fails, return an empty auth config
221
-	return types.AuthConfig{}
194
+	a, _ := getCredentials(cli.configFile, configKey)
195
+	return a
222 196
 }
... ...
@@ -47,12 +47,13 @@ func SetConfigDir(dir string) {
47 47
 
48 48
 // ConfigFile ~/.docker/config.json file info
49 49
 type ConfigFile struct {
50
-	AuthConfigs  map[string]types.AuthConfig `json:"auths"`
51
-	HTTPHeaders  map[string]string           `json:"HttpHeaders,omitempty"`
52
-	PsFormat     string                      `json:"psFormat,omitempty"`
53
-	ImagesFormat string                      `json:"imagesFormat,omitempty"`
54
-	DetachKeys   string                      `json:"detachKeys,omitempty"`
55
-	filename     string                      // Note: not serialized - for internal use only
50
+	AuthConfigs      map[string]types.AuthConfig `json:"auths"`
51
+	HTTPHeaders      map[string]string           `json:"HttpHeaders,omitempty"`
52
+	PsFormat         string                      `json:"psFormat,omitempty"`
53
+	ImagesFormat     string                      `json:"imagesFormat,omitempty"`
54
+	DetachKeys       string                      `json:"detachKeys,omitempty"`
55
+	CredentialsStore string                      `json:"credsStore,omitempty"`
56
+	filename         string                      // Note: not serialized - for internal use only
56 57
 }
57 58
 
58 59
 // NewConfigFile initializes an empty configuration file for the given filename 'fn'
... ...
@@ -126,6 +127,13 @@ func (configFile *ConfigFile) LoadFromReader(configData io.Reader) error {
126 126
 	return nil
127 127
 }
128 128
 
129
+// ContainsAuth returns whether there is authentication configured
130
+// in this file or not.
131
+func (configFile *ConfigFile) ContainsAuth() bool {
132
+	return configFile.CredentialsStore != "" ||
133
+		(configFile.AuthConfigs != nil && len(configFile.AuthConfigs) > 0)
134
+}
135
+
129 136
 // LegacyLoadFromReader is a convenience function that creates a ConfigFile object from
130 137
 // a non-nested reader
131 138
 func LegacyLoadFromReader(configData io.Reader) (*ConfigFile, error) {
... ...
@@ -249,6 +257,10 @@ func (configFile *ConfigFile) Filename() string {
249 249
 
250 250
 // encodeAuth creates a base64 encoded string to containing authorization information
251 251
 func encodeAuth(authConfig *types.AuthConfig) string {
252
+	if authConfig.Username == "" && authConfig.Password == "" {
253
+		return ""
254
+	}
255
+
252 256
 	authStr := authConfig.Username + ":" + authConfig.Password
253 257
 	msg := []byte(authStr)
254 258
 	encoded := make([]byte, base64.StdEncoding.EncodedLen(len(msg)))
... ...
@@ -258,6 +270,10 @@ func encodeAuth(authConfig *types.AuthConfig) string {
258 258
 
259 259
 // decodeAuth decodes a base64 encoded string and returns username and password
260 260
 func decodeAuth(authStr string) (string, string, error) {
261
+	if authStr == "" {
262
+		return "", "", nil
263
+	}
264
+
261 265
 	decLen := base64.StdEncoding.DecodedLen(len(authStr))
262 266
 	decoded := make([]byte, decLen)
263 267
 	authByte := []byte(authStr)
264 268
new file mode 100644
... ...
@@ -0,0 +1,15 @@
0
+package credentials
1
+
2
+import (
3
+	"github.com/docker/engine-api/types"
4
+)
5
+
6
+// Store is the interface that any credentials store must implement.
7
+type Store interface {
8
+	// Erase removes credentials from the store for a given server.
9
+	Erase(serverAddress string) error
10
+	// Get retrieves credentials from the store for a given server.
11
+	Get(serverAddress string) (types.AuthConfig, error)
12
+	// Store saves credentials in the store.
13
+	Store(authConfig types.AuthConfig) error
14
+}
0 15
new file mode 100644
... ...
@@ -0,0 +1,22 @@
0
+package credentials
1
+
2
+import (
3
+	"os/exec"
4
+
5
+	"github.com/docker/docker/cliconfig"
6
+)
7
+
8
+const defaultCredentialsStore = "osxkeychain"
9
+
10
+// DetectDefaultStore sets the default credentials store
11
+// if the host includes the default store helper program.
12
+func DetectDefaultStore(c *cliconfig.ConfigFile) {
13
+	if c.CredentialsStore != "" {
14
+		// user defined
15
+		return
16
+	}
17
+
18
+	if _, err := exec.LookPath(remoteCredentialsPrefix + c.CredentialsStore); err == nil {
19
+		c.CredentialsStore = defaultCredentialsStore
20
+	}
21
+}
0 22
new file mode 100644
... ...
@@ -0,0 +1,11 @@
0
+// +build !darwin
1
+
2
+package credentials
3
+
4
+import "github.com/docker/docker/cliconfig"
5
+
6
+// DetectDefaultStore sets the default credentials store
7
+// if the host includes the default store helper program.
8
+// This operation is only supported in Darwin.
9
+func DetectDefaultStore(c *cliconfig.ConfigFile) {
10
+}
0 11
new file mode 100644
... ...
@@ -0,0 +1,63 @@
0
+package credentials
1
+
2
+import (
3
+	"strings"
4
+
5
+	"github.com/docker/docker/cliconfig"
6
+	"github.com/docker/engine-api/types"
7
+)
8
+
9
+// fileStore implements a credentials store using
10
+// the docker configuration file to keep the credentials in plain text.
11
+type fileStore struct {
12
+	file *cliconfig.ConfigFile
13
+}
14
+
15
+// NewFileStore creates a new file credentials store.
16
+func NewFileStore(file *cliconfig.ConfigFile) Store {
17
+	return &fileStore{
18
+		file: file,
19
+	}
20
+}
21
+
22
+// Erase removes the given credentials from the file store.
23
+func (c *fileStore) Erase(serverAddress string) error {
24
+	delete(c.file.AuthConfigs, serverAddress)
25
+	return c.file.Save()
26
+}
27
+
28
+// Get retrieves credentials for a specific server from the file store.
29
+func (c *fileStore) Get(serverAddress string) (types.AuthConfig, error) {
30
+	authConfig, ok := c.file.AuthConfigs[serverAddress]
31
+	if !ok {
32
+		// Maybe they have a legacy config file, we will iterate the keys converting
33
+		// them to the new format and testing
34
+		for registry, ac := range c.file.AuthConfigs {
35
+			if serverAddress == convertToHostname(registry) {
36
+				return ac, nil
37
+			}
38
+		}
39
+
40
+		authConfig = types.AuthConfig{}
41
+	}
42
+	return authConfig, nil
43
+}
44
+
45
+// Store saves the given credentials in the file store.
46
+func (c *fileStore) Store(authConfig types.AuthConfig) error {
47
+	c.file.AuthConfigs[authConfig.ServerAddress] = authConfig
48
+	return c.file.Save()
49
+}
50
+
51
+func convertToHostname(url string) string {
52
+	stripped := url
53
+	if strings.HasPrefix(url, "http://") {
54
+		stripped = strings.Replace(url, "http://", "", 1)
55
+	} else if strings.HasPrefix(url, "https://") {
56
+		stripped = strings.Replace(url, "https://", "", 1)
57
+	}
58
+
59
+	nameParts := strings.SplitN(stripped, "/", 2)
60
+
61
+	return nameParts[0]
62
+}
0 63
new file mode 100644
... ...
@@ -0,0 +1,100 @@
0
+package credentials
1
+
2
+import (
3
+	"io/ioutil"
4
+	"testing"
5
+
6
+	"github.com/docker/docker/cliconfig"
7
+	"github.com/docker/engine-api/types"
8
+)
9
+
10
+func newConfigFile(auths map[string]types.AuthConfig) *cliconfig.ConfigFile {
11
+	tmp, _ := ioutil.TempFile("", "docker-test")
12
+	name := tmp.Name()
13
+	tmp.Close()
14
+
15
+	c := cliconfig.NewConfigFile(name)
16
+	c.AuthConfigs = auths
17
+	return c
18
+}
19
+
20
+func TestFileStoreAddCredentials(t *testing.T) {
21
+	f := newConfigFile(make(map[string]types.AuthConfig))
22
+
23
+	s := NewFileStore(f)
24
+	err := s.Store(types.AuthConfig{
25
+		Auth:          "super_secret_token",
26
+		Email:         "foo@example.com",
27
+		ServerAddress: "https://example.com",
28
+	})
29
+
30
+	if err != nil {
31
+		t.Fatal(err)
32
+	}
33
+
34
+	if len(f.AuthConfigs) != 1 {
35
+		t.Fatalf("expected 1 auth config, got %d", len(f.AuthConfigs))
36
+	}
37
+
38
+	a, ok := f.AuthConfigs["https://example.com"]
39
+	if !ok {
40
+		t.Fatalf("expected auth for https://example.com, got %v", f.AuthConfigs)
41
+	}
42
+	if a.Auth != "super_secret_token" {
43
+		t.Fatalf("expected auth `super_secret_token`, got %s", a.Auth)
44
+	}
45
+	if a.Email != "foo@example.com" {
46
+		t.Fatalf("expected email `foo@example.com`, got %s", a.Email)
47
+	}
48
+}
49
+
50
+func TestFileStoreGet(t *testing.T) {
51
+	f := newConfigFile(map[string]types.AuthConfig{
52
+		"https://example.com": {
53
+			Auth:          "super_secret_token",
54
+			Email:         "foo@example.com",
55
+			ServerAddress: "https://example.com",
56
+		},
57
+	})
58
+
59
+	s := NewFileStore(f)
60
+	a, err := s.Get("https://example.com")
61
+	if err != nil {
62
+		t.Fatal(err)
63
+	}
64
+	if a.Auth != "super_secret_token" {
65
+		t.Fatalf("expected auth `super_secret_token`, got %s", a.Auth)
66
+	}
67
+	if a.Email != "foo@example.com" {
68
+		t.Fatalf("expected email `foo@example.com`, got %s", a.Email)
69
+	}
70
+}
71
+
72
+func TestFileStoreErase(t *testing.T) {
73
+	f := newConfigFile(map[string]types.AuthConfig{
74
+		"https://example.com": {
75
+			Auth:          "super_secret_token",
76
+			Email:         "foo@example.com",
77
+			ServerAddress: "https://example.com",
78
+		},
79
+	})
80
+
81
+	s := NewFileStore(f)
82
+	err := s.Erase("https://example.com")
83
+	if err != nil {
84
+		t.Fatal(err)
85
+	}
86
+
87
+	// file store never returns errors, check that the auth config is empty
88
+	a, err := s.Get("https://example.com")
89
+	if err != nil {
90
+		t.Fatal(err)
91
+	}
92
+
93
+	if a.Auth != "" {
94
+		t.Fatalf("expected empty auth token, got %s", a.Auth)
95
+	}
96
+	if a.Email != "" {
97
+		t.Fatalf("expected empty email, got %s", a.Email)
98
+	}
99
+}
0 100
new file mode 100644
... ...
@@ -0,0 +1,166 @@
0
+package credentials
1
+
2
+import (
3
+	"bytes"
4
+	"encoding/json"
5
+	"errors"
6
+	"fmt"
7
+	"io"
8
+	"strings"
9
+
10
+	"github.com/Sirupsen/logrus"
11
+	"github.com/docker/docker/cliconfig"
12
+	"github.com/docker/engine-api/types"
13
+)
14
+
15
+const remoteCredentialsPrefix = "docker-credential-"
16
+
17
+// Standarize the not found error, so every helper returns
18
+// the same message and docker can handle it properly.
19
+var errCredentialsNotFound = errors.New("credentials not found in native keychain")
20
+
21
+// command is an interface that remote executed commands implement.
22
+type command interface {
23
+	Output() ([]byte, error)
24
+	Input(in io.Reader)
25
+}
26
+
27
+// credentialsRequest holds information shared between docker and a remote credential store.
28
+type credentialsRequest struct {
29
+	ServerURL string
30
+	Username  string
31
+	Password  string
32
+}
33
+
34
+// credentialsGetResponse is the information serialized from a remote store
35
+// when the plugin sends requests to get the user credentials.
36
+type credentialsGetResponse struct {
37
+	Username string
38
+	Password string
39
+}
40
+
41
+// nativeStore implements a credentials store
42
+// using native keychain to keep credentials secure.
43
+// It piggybacks into a file store to keep users' emails.
44
+type nativeStore struct {
45
+	commandFn func(args ...string) command
46
+	fileStore Store
47
+}
48
+
49
+// NewNativeStore creates a new native store that
50
+// uses a remote helper program to manage credentials.
51
+func NewNativeStore(file *cliconfig.ConfigFile) Store {
52
+	return &nativeStore{
53
+		commandFn: shellCommandFn(file.CredentialsStore),
54
+		fileStore: NewFileStore(file),
55
+	}
56
+}
57
+
58
+// Erase removes the given credentials from the native store.
59
+func (c *nativeStore) Erase(serverAddress string) error {
60
+	if err := c.eraseCredentialsFromStore(serverAddress); err != nil {
61
+		return err
62
+	}
63
+
64
+	// Fallback to plain text store to remove email
65
+	return c.fileStore.Erase(serverAddress)
66
+}
67
+
68
+// Get retrieves credentials for a specific server from the native store.
69
+func (c *nativeStore) Get(serverAddress string) (types.AuthConfig, error) {
70
+	// load user email if it exist or an empty auth config.
71
+	auth, _ := c.fileStore.Get(serverAddress)
72
+
73
+	creds, err := c.getCredentialsFromStore(serverAddress)
74
+	if err != nil {
75
+		return auth, err
76
+	}
77
+	auth.Username = creds.Username
78
+	auth.Password = creds.Password
79
+
80
+	return auth, nil
81
+}
82
+
83
+// Store saves the given credentials in the file store.
84
+func (c *nativeStore) Store(authConfig types.AuthConfig) error {
85
+	if err := c.storeCredentialsInStore(authConfig); err != nil {
86
+		return err
87
+	}
88
+	authConfig.Username = ""
89
+	authConfig.Password = ""
90
+
91
+	// Fallback to old credential in plain text to save only the email
92
+	return c.fileStore.Store(authConfig)
93
+}
94
+
95
+// storeCredentialsInStore executes the command to store the credentials in the native store.
96
+func (c *nativeStore) storeCredentialsInStore(config types.AuthConfig) error {
97
+	cmd := c.commandFn("store")
98
+	creds := &credentialsRequest{
99
+		ServerURL: config.ServerAddress,
100
+		Username:  config.Username,
101
+		Password:  config.Password,
102
+	}
103
+
104
+	buffer := new(bytes.Buffer)
105
+	if err := json.NewEncoder(buffer).Encode(creds); err != nil {
106
+		return err
107
+	}
108
+	cmd.Input(buffer)
109
+
110
+	out, err := cmd.Output()
111
+	if err != nil {
112
+		t := strings.TrimSpace(string(out))
113
+		logrus.Debugf("error adding credentials - err: %v, out: `%s`", err, t)
114
+		return fmt.Errorf(t)
115
+	}
116
+
117
+	return nil
118
+}
119
+
120
+// getCredentialsFromStore executes the command to get the credentials from the native store.
121
+func (c *nativeStore) getCredentialsFromStore(serverAddress string) (types.AuthConfig, error) {
122
+	var ret types.AuthConfig
123
+
124
+	cmd := c.commandFn("get")
125
+	cmd.Input(strings.NewReader(serverAddress))
126
+
127
+	out, err := cmd.Output()
128
+	if err != nil {
129
+		t := strings.TrimSpace(string(out))
130
+
131
+		// do not return an error if the credentials are not
132
+		// in the keyckain. Let docker ask for new credentials.
133
+		if t == errCredentialsNotFound.Error() {
134
+			return ret, nil
135
+		}
136
+
137
+		logrus.Debugf("error adding credentials - err: %v, out: `%s`", err, t)
138
+		return ret, fmt.Errorf(t)
139
+	}
140
+
141
+	var resp credentialsGetResponse
142
+	if err := json.NewDecoder(bytes.NewReader(out)).Decode(&resp); err != nil {
143
+		return ret, err
144
+	}
145
+
146
+	ret.Username = resp.Username
147
+	ret.Password = resp.Password
148
+	ret.ServerAddress = serverAddress
149
+	return ret, nil
150
+}
151
+
152
+// eraseCredentialsFromStore executes the command to remove the server redentails from the native store.
153
+func (c *nativeStore) eraseCredentialsFromStore(serverURL string) error {
154
+	cmd := c.commandFn("erase")
155
+	cmd.Input(strings.NewReader(serverURL))
156
+
157
+	out, err := cmd.Output()
158
+	if err != nil {
159
+		t := strings.TrimSpace(string(out))
160
+		logrus.Debugf("error adding credentials - err: %v, out: `%s`", err, t)
161
+		return fmt.Errorf(t)
162
+	}
163
+
164
+	return nil
165
+}
0 166
new file mode 100644
... ...
@@ -0,0 +1,264 @@
0
+package credentials
1
+
2
+import (
3
+	"encoding/json"
4
+	"fmt"
5
+	"io"
6
+	"io/ioutil"
7
+	"strings"
8
+	"testing"
9
+
10
+	"github.com/docker/engine-api/types"
11
+)
12
+
13
+const (
14
+	validServerAddress   = "https://index.docker.io/v1"
15
+	invalidServerAddress = "https://foobar.example.com"
16
+	missingCredsAddress  = "https://missing.docker.io/v1"
17
+)
18
+
19
+var errCommandExited = fmt.Errorf("exited 1")
20
+
21
+// mockCommand simulates interactions between the docker client and a remote
22
+// credentials helper.
23
+// Unit tests inject this mocked command into the remote to control execution.
24
+type mockCommand struct {
25
+	arg   string
26
+	input io.Reader
27
+}
28
+
29
+// Output returns responses from the remote credentials helper.
30
+// It mocks those reponses based in the input in the mock.
31
+func (m *mockCommand) Output() ([]byte, error) {
32
+	in, err := ioutil.ReadAll(m.input)
33
+	if err != nil {
34
+		return nil, err
35
+	}
36
+	inS := string(in)
37
+
38
+	switch m.arg {
39
+	case "erase":
40
+		switch inS {
41
+		case validServerAddress:
42
+			return nil, nil
43
+		default:
44
+			return []byte("error erasing credentials"), errCommandExited
45
+		}
46
+	case "get":
47
+		switch inS {
48
+		case validServerAddress:
49
+			return []byte(`{"Username": "foo", "Password": "bar"}`), nil
50
+		case missingCredsAddress:
51
+			return []byte(errCredentialsNotFound.Error()), errCommandExited
52
+		case invalidServerAddress:
53
+			return []byte("error getting credentials"), errCommandExited
54
+		}
55
+	case "store":
56
+		var c credentialsRequest
57
+		err := json.NewDecoder(strings.NewReader(inS)).Decode(&c)
58
+		if err != nil {
59
+			return []byte("error storing credentials"), errCommandExited
60
+		}
61
+		switch c.ServerURL {
62
+		case validServerAddress:
63
+			return nil, nil
64
+		default:
65
+			return []byte("error storing credentials"), errCommandExited
66
+		}
67
+	}
68
+
69
+	return []byte("unknown argument"), errCommandExited
70
+}
71
+
72
+// Input sets the input to send to a remote credentials helper.
73
+func (m *mockCommand) Input(in io.Reader) {
74
+	m.input = in
75
+}
76
+
77
+func mockCommandFn(args ...string) command {
78
+	return &mockCommand{
79
+		arg: args[0],
80
+	}
81
+}
82
+
83
+func TestNativeStoreAddCredentials(t *testing.T) {
84
+	f := newConfigFile(make(map[string]types.AuthConfig))
85
+	f.CredentialsStore = "mock"
86
+
87
+	s := &nativeStore{
88
+		commandFn: mockCommandFn,
89
+		fileStore: NewFileStore(f),
90
+	}
91
+	err := s.Store(types.AuthConfig{
92
+		Username:      "foo",
93
+		Password:      "bar",
94
+		Email:         "foo@example.com",
95
+		ServerAddress: validServerAddress,
96
+	})
97
+
98
+	if err != nil {
99
+		t.Fatal(err)
100
+	}
101
+
102
+	if len(f.AuthConfigs) != 1 {
103
+		t.Fatalf("expected 1 auth config, got %d", len(f.AuthConfigs))
104
+	}
105
+
106
+	a, ok := f.AuthConfigs[validServerAddress]
107
+	if !ok {
108
+		t.Fatalf("expected auth for %s, got %v", validServerAddress, f.AuthConfigs)
109
+	}
110
+	if a.Auth != "" {
111
+		t.Fatalf("expected auth to be empty, got %s", a.Auth)
112
+	}
113
+	if a.Username != "" {
114
+		t.Fatalf("expected username to be empty, got %s", a.Username)
115
+	}
116
+	if a.Password != "" {
117
+		t.Fatalf("expected password to be empty, got %s", a.Password)
118
+	}
119
+	if a.Email != "foo@example.com" {
120
+		t.Fatalf("expected email `foo@example.com`, got %s", a.Email)
121
+	}
122
+}
123
+
124
+func TestNativeStoreAddInvalidCredentials(t *testing.T) {
125
+	f := newConfigFile(make(map[string]types.AuthConfig))
126
+	f.CredentialsStore = "mock"
127
+
128
+	s := &nativeStore{
129
+		commandFn: mockCommandFn,
130
+		fileStore: NewFileStore(f),
131
+	}
132
+	err := s.Store(types.AuthConfig{
133
+		Username:      "foo",
134
+		Password:      "bar",
135
+		Email:         "foo@example.com",
136
+		ServerAddress: invalidServerAddress,
137
+	})
138
+
139
+	if err == nil {
140
+		t.Fatal("expected error, got nil")
141
+	}
142
+
143
+	if err.Error() != "error storing credentials" {
144
+		t.Fatalf("expected `error storing credentials`, got %v", err)
145
+	}
146
+
147
+	if len(f.AuthConfigs) != 0 {
148
+		t.Fatalf("expected 0 auth config, got %d", len(f.AuthConfigs))
149
+	}
150
+}
151
+
152
+func TestNativeStoreGet(t *testing.T) {
153
+	f := newConfigFile(map[string]types.AuthConfig{
154
+		validServerAddress: {
155
+			Email: "foo@example.com",
156
+		},
157
+	})
158
+	f.CredentialsStore = "mock"
159
+
160
+	s := &nativeStore{
161
+		commandFn: mockCommandFn,
162
+		fileStore: NewFileStore(f),
163
+	}
164
+	a, err := s.Get(validServerAddress)
165
+	if err != nil {
166
+		t.Fatal(err)
167
+	}
168
+
169
+	if a.Username != "foo" {
170
+		t.Fatalf("expected username `foo`, got %s", a.Username)
171
+	}
172
+	if a.Password != "bar" {
173
+		t.Fatalf("expected password `bar`, got %s", a.Password)
174
+	}
175
+	if a.Email != "foo@example.com" {
176
+		t.Fatalf("expected email `foo@example.com`, got %s", a.Email)
177
+	}
178
+}
179
+
180
+func TestNativeStoreGetMissingCredentials(t *testing.T) {
181
+	f := newConfigFile(map[string]types.AuthConfig{
182
+		validServerAddress: {
183
+			Email: "foo@example.com",
184
+		},
185
+	})
186
+	f.CredentialsStore = "mock"
187
+
188
+	s := &nativeStore{
189
+		commandFn: mockCommandFn,
190
+		fileStore: NewFileStore(f),
191
+	}
192
+	_, err := s.Get(missingCredsAddress)
193
+	if err != nil {
194
+		// missing credentials do not produce an error
195
+		t.Fatal(err)
196
+	}
197
+}
198
+
199
+func TestNativeStoreGetInvalidAddress(t *testing.T) {
200
+	f := newConfigFile(map[string]types.AuthConfig{
201
+		validServerAddress: {
202
+			Email: "foo@example.com",
203
+		},
204
+	})
205
+	f.CredentialsStore = "mock"
206
+
207
+	s := &nativeStore{
208
+		commandFn: mockCommandFn,
209
+		fileStore: NewFileStore(f),
210
+	}
211
+	_, err := s.Get(invalidServerAddress)
212
+	if err == nil {
213
+		t.Fatal("expected error, got nil")
214
+	}
215
+
216
+	if err.Error() != "error getting credentials" {
217
+		t.Fatalf("expected `error getting credentials`, got %v", err)
218
+	}
219
+}
220
+
221
+func TestNativeStoreErase(t *testing.T) {
222
+	f := newConfigFile(map[string]types.AuthConfig{
223
+		validServerAddress: {
224
+			Email: "foo@example.com",
225
+		},
226
+	})
227
+	f.CredentialsStore = "mock"
228
+
229
+	s := &nativeStore{
230
+		commandFn: mockCommandFn,
231
+		fileStore: NewFileStore(f),
232
+	}
233
+	err := s.Erase(validServerAddress)
234
+	if err != nil {
235
+		t.Fatal(err)
236
+	}
237
+
238
+	if len(f.AuthConfigs) != 0 {
239
+		t.Fatalf("expected 0 auth configs, got %d", len(f.AuthConfigs))
240
+	}
241
+}
242
+
243
+func TestNativeStoreEraseInvalidAddress(t *testing.T) {
244
+	f := newConfigFile(map[string]types.AuthConfig{
245
+		validServerAddress: {
246
+			Email: "foo@example.com",
247
+		},
248
+	})
249
+	f.CredentialsStore = "mock"
250
+
251
+	s := &nativeStore{
252
+		commandFn: mockCommandFn,
253
+		fileStore: NewFileStore(f),
254
+	}
255
+	err := s.Erase(invalidServerAddress)
256
+	if err == nil {
257
+		t.Fatal("expected error, got nil")
258
+	}
259
+
260
+	if err.Error() != "error erasing credentials" {
261
+		t.Fatalf("expected `error erasing credentials`, got %v", err)
262
+	}
263
+}
0 264
new file mode 100644
... ...
@@ -0,0 +1,28 @@
0
+package credentials
1
+
2
+import (
3
+	"io"
4
+	"os/exec"
5
+)
6
+
7
+func shellCommandFn(storeName string) func(args ...string) command {
8
+	name := remoteCredentialsPrefix + storeName
9
+	return func(args ...string) command {
10
+		return &shell{cmd: exec.Command(name, args...)}
11
+	}
12
+}
13
+
14
+// shell invokes shell commands to talk with a remote credentials helper.
15
+type shell struct {
16
+	cmd *exec.Cmd
17
+}
18
+
19
+// Output returns responses from the remote credentials helper.
20
+func (s *shell) Output() ([]byte, error) {
21
+	return s.cmd.Output()
22
+}
23
+
24
+// Input sets the input to send to a remote credentials helper.
25
+func (s *shell) Input(in io.Reader) {
26
+	s.cmd.Stdin = in
27
+}
... ...
@@ -38,3 +38,77 @@ credentials.  When you log in, the command stores encoded credentials in
38 38
 
39 39
 > **Note**:  When running `sudo docker login` credentials are saved in `/root/.docker/config.json`.
40 40
 >
41
+
42
+## Credentials store
43
+
44
+The Docker Engine can keep user credentials in an external credentials store,
45
+such as the native keychain of the operating system. Using an external store
46
+is more secure than storing credentials in the Docker configuration file.
47
+
48
+To use a credentials store, you need an external helper program to interact
49
+with a specific keychain or external store. Docker requires the helper
50
+program to be in the client's host `$PATH`.
51
+
52
+This is the list of currently available credentials helpers and where
53
+you can download them from:
54
+
55
+- Apple OS X keychain: https://github.com/docker/docker-credential-helpers/releases
56
+- Microsoft Windows Credential Manager: https://github.com/docker/docker-credential-helpers/releases
57
+
58
+### Usage
59
+
60
+You need to speficy the credentials store in `HOME/.docker/config.json`
61
+to tell the docker engine to use it:
62
+
63
+```json
64
+{
65
+	"credsStore": "osxkeychain"
66
+}
67
+```
68
+
69
+If you are currently logged in, run `docker logout` to remove
70
+the credentials from the file and run `docker login` again.
71
+
72
+### Protocol
73
+
74
+Credential helpers can be any program or script that follows a very simple protocol.
75
+This protocol is heavily inspired by Git, but it differs in the information shared.
76
+
77
+The helpers always use the first argument in the command to identify the action.
78
+There are only three possible values for that argument: `store`, `get`, and `erase`.
79
+
80
+The `store` command takes a JSON payload from the standard input. That payload carries
81
+the server address, to identify the credential, the user name and the password.
82
+This is an example of that payload:
83
+
84
+```json
85
+{
86
+	"ServerURL": "https://index.docker.io/v1",
87
+	"Username": "david",
88
+	"Password": "passw0rd1"
89
+}
90
+```
91
+
92
+The `store` command can write error messages to `STDOUT` that the docker engine
93
+will show if there was an issue.
94
+
95
+The `get` command takes a string payload from the standard input. That payload carries
96
+the server address that the docker engine needs credentials for. This is
97
+an example of that payload: `https://index.docker.io/v1`.
98
+
99
+The `get` command writes a JSON payload to `STDOUT`. Docker reads the user name
100
+and password from this payload:
101
+
102
+```json
103
+{
104
+	"Username": "david",
105
+	"Password": "passw0rd1"
106
+}
107
+```
108
+
109
+The `erase` command takes a string payload from `STDIN`. That payload carries
110
+the server address that the docker engine wants to remove credentials for. This is
111
+an example of that payload: `https://index.docker.io/v1`.
112
+
113
+The `erase` command can write error messages to `STDOUT` that the docker engine
114
+will show if there was an issue.
... ...
@@ -361,3 +361,39 @@ func (s *DockerRegistrySuite) TestPullManifestList(c *check.C) {
361 361
 
362 362
 	dockerCmd(c, "rmi", repoName)
363 363
 }
364
+
365
+func (s *DockerRegistryAuthSuite) TestPullWithExternalAuth(c *check.C) {
366
+	osPath := os.Getenv("PATH")
367
+	defer os.Setenv("PATH", osPath)
368
+
369
+	workingDir, err := os.Getwd()
370
+	c.Assert(err, checker.IsNil)
371
+	absolute, err := filepath.Abs(filepath.Join(workingDir, "fixtures", "auth"))
372
+	c.Assert(err, checker.IsNil)
373
+	testPath := fmt.Sprintf("%s%c%s", osPath, filepath.ListSeparator, absolute)
374
+
375
+	os.Setenv("PATH", testPath)
376
+
377
+	repoName := fmt.Sprintf("%v/dockercli/busybox:authtest", privateRegistryURL)
378
+
379
+	tmp, err := ioutil.TempDir("", "integration-cli-")
380
+	c.Assert(err, checker.IsNil)
381
+
382
+	externalAuthConfig := `{ "credsStore": "shell-test" }`
383
+
384
+	configPath := filepath.Join(tmp, "config.json")
385
+	err = ioutil.WriteFile(configPath, []byte(externalAuthConfig), 0644)
386
+	c.Assert(err, checker.IsNil)
387
+
388
+	dockerCmd(c, "--config", tmp, "login", "-u", s.reg.username, "-p", s.reg.password, "-e", s.reg.email, privateRegistryURL)
389
+
390
+	b, err := ioutil.ReadFile(configPath)
391
+	c.Assert(err, checker.IsNil)
392
+	c.Assert(string(b), checker.Not(checker.Contains), "\"auth\":")
393
+	c.Assert(string(b), checker.Contains, "email")
394
+
395
+	dockerCmd(c, "--config", tmp, "tag", "busybox", repoName)
396
+	dockerCmd(c, "--config", tmp, "push", repoName)
397
+
398
+	dockerCmd(c, "--config", tmp, "pull", repoName)
399
+}
364 400
new file mode 100755
... ...
@@ -0,0 +1,33 @@
0
+#!/bin/bash
1
+
2
+set -e
3
+
4
+case $1 in
5
+	"store")
6
+		in=$(</dev/stdin)
7
+		server=$(echo "$in" | jq --raw-output ".ServerURL" | sha1sum - | awk '{print $1}')
8
+
9
+		username=$(echo "$in" | jq --raw-output ".Username")
10
+		password=$(echo "$in" | jq --raw-output ".Password")
11
+		echo "{ \"Username\": \"${username}\", \"Password\": \"${password}\" }" > $TEMP/$server
12
+		;;
13
+	"get")
14
+		in=$(</dev/stdin)
15
+		server=$(echo "$in" | sha1sum - | awk '{print $1}')
16
+		if [[ ! -f $TEMP/$server ]]; then
17
+			echo "credentials not found in native keychain"
18
+			exit 1
19
+		fi
20
+		payload=$(<$TEMP/$server)
21
+		echo "$payload"
22
+		;;
23
+	"erase")
24
+		in=$(</dev/stdin)
25
+		server=$(echo "$in" | sha1sum - | awk '{print $1}')
26
+		rm -f $TEMP/$server
27
+		;;
28
+	*)
29
+		echo "unknown credential option"
30
+		exit 1
31
+		;;
32
+esac