Merge pull request #451 from tych0/use-pass-backend

Use pass backend
This commit is contained in:
Sebastiaan van Stijn 2017-09-26 16:40:32 +02:00 committed by GitHub
commit be8dab26a3
15 changed files with 376 additions and 36 deletions

View File

@ -7,13 +7,15 @@ import (
// DetectDefaultStore return the default credentials store for the platform if // DetectDefaultStore return the default credentials store for the platform if
// the store executable is available. // the store executable is available.
func DetectDefaultStore(store string) string { func DetectDefaultStore(store string) string {
platformDefault := defaultCredentialsStore()
// user defined or no default for platform // user defined or no default for platform
if store != "" || defaultCredentialsStore == "" { if store != "" || platformDefault == "" {
return store return store
} }
if _, err := exec.LookPath(remoteCredentialsPrefix + defaultCredentialsStore); err == nil { if _, err := exec.LookPath(remoteCredentialsPrefix + platformDefault); err == nil {
return defaultCredentialsStore return platformDefault
} }
return "" return ""
} }

View File

@ -1,3 +1,5 @@
package credentials package credentials
const defaultCredentialsStore = "osxkeychain" func defaultCredentialsStore() string {
return "osxkeychain"
}

View File

@ -1,3 +1,13 @@
package credentials package credentials
const defaultCredentialsStore = "secretservice" import (
"github.com/docker/docker-credential-helpers/pass"
)
func defaultCredentialsStore() string {
if pass.PassInitialized {
return "pass"
}
return "secretservice"
}

View File

@ -1,3 +1,5 @@
package credentials package credentials
const defaultCredentialsStore = "wincred" func defaultCredentialsStore() string {
return "wincred"
}

View File

@ -63,8 +63,9 @@ $ cat ~/my_password.txt | docker login --username foo --password-stdin
2. user is added to the `docker` group. This will impact the security of your system; the `docker` group is `root` equivalent. See [Docker Daemon Attack Surface](https://docs.docker.com/security/security/#docker-daemon-attack-surface) for details. 2. user is added to the `docker` group. This will impact the security of your system; the `docker` group is `root` equivalent. See [Docker Daemon Attack Surface](https://docs.docker.com/security/security/#docker-daemon-attack-surface) for details.
You can log into any public or private repository for which you have You can log into any public or private repository for which you have
credentials. When you log in, the command stores encoded credentials in credentials. When you log in, the command stores credentials in
`$HOME/.docker/config.json` on Linux or `%USERPROFILE%/.docker/config.json` on Windows. `$HOME/.docker/config.json` on Linux or `%USERPROFILE%/.docker/config.json` on
Windows, via the procedure described below.
### Credentials store ### Credentials store
@ -82,6 +83,7 @@ you can download them from:
- D-Bus Secret Service: https://github.com/docker/docker-credential-helpers/releases - D-Bus Secret Service: https://github.com/docker/docker-credential-helpers/releases
- Apple macOS keychain: https://github.com/docker/docker-credential-helpers/releases - Apple macOS keychain: https://github.com/docker/docker-credential-helpers/releases
- Microsoft Windows Credential Manager: https://github.com/docker/docker-credential-helpers/releases - Microsoft Windows Credential Manager: https://github.com/docker/docker-credential-helpers/releases
- [pass](https://www.passwordstore.org/): https://github.com/docker/docker-credential-helpers/releases
You need to specify the credentials store in `$HOME/.docker/config.json` You need to specify the credentials store in `$HOME/.docker/config.json`
to tell the docker engine to use it. The value of the config property should be to tell the docker engine to use it. The value of the config property should be
@ -97,6 +99,15 @@ For example, to use `docker-credential-osxkeychain`:
If you are currently logged in, run `docker logout` to remove If you are currently logged in, run `docker logout` to remove
the credentials from the file and run `docker login` again. the credentials from the file and run `docker login` again.
### Default behavior
By default, Docker looks for the native binary on each of the platforms, i.e.
"osxkeychain" on macOS, "wincred" on windows, and "pass" on Linux. A special
case is that on Linux, Docker will fall back to the "secretservice" binary if
it cannot find the "pass" binary. If none of these binaries are present, it
stores the credentials (i.e. password) in base64 encoding in the config files
described above.
### Credential helper protocol ### Credential helper protocol
Credential helpers can be any program or script that follows a very simple protocol. Credential helpers can be any program or script that follows a very simple protocol.

View File

@ -5,7 +5,7 @@ github.com/cpuguy83/go-md2man a65d4d2de4d5f7c74868dfa9b202a3c8be315aaa
github.com/davecgh/go-spew 346938d642f2ec3594ed81d874461961cd0faa76 github.com/davecgh/go-spew 346938d642f2ec3594ed81d874461961cd0faa76
github.com/docker/distribution edc3ab29cdff8694dd6feb85cfeb4b5f1b38ed9c github.com/docker/distribution edc3ab29cdff8694dd6feb85cfeb4b5f1b38ed9c
github.com/docker/docker 84144a8c66c1bb2af8fa997288f51ef2719971b4 github.com/docker/docker 84144a8c66c1bb2af8fa997288f51ef2719971b4
github.com/docker/docker-credential-helpers v0.5.1 github.com/docker/docker-credential-helpers 3c90bd29a46b943b2a9842987b58fb91a7c1819b
# the docker/go package contains a customized version of canonical/json # the docker/go package contains a customized version of canonical/json
# and is used by Notary. The package is periodically rebased on current Go versions. # and is used by Notary. The package is periodically rebased on current Go versions.

View File

@ -1,6 +1,7 @@
package client package client
import ( import (
"fmt"
"io" "io"
"os" "os"
"os/exec" "os/exec"
@ -17,15 +18,26 @@ type ProgramFunc func(args ...string) Program
// NewShellProgramFunc creates programs that are executed in a Shell. // NewShellProgramFunc creates programs that are executed in a Shell.
func NewShellProgramFunc(name string) ProgramFunc { func NewShellProgramFunc(name string) ProgramFunc {
return NewShellProgramFuncWithEnv(name, nil)
}
// NewShellProgramFuncWithEnv creates programs that are executed in a Shell with environment variables
func NewShellProgramFuncWithEnv(name string, env *map[string]string) ProgramFunc {
return func(args ...string) Program { return func(args ...string) Program {
return &Shell{cmd: newCmdRedirectErr(name, args)} return &Shell{cmd: createProgramCmdRedirectErr(name, args, env)}
} }
} }
func newCmdRedirectErr(name string, args []string) *exec.Cmd { func createProgramCmdRedirectErr(commandName string, args []string, env *map[string]string) *exec.Cmd {
newCmd := exec.Command(name, args...) programCmd := exec.Command(commandName, args...)
newCmd.Stderr = os.Stderr programCmd.Env = os.Environ()
return newCmd if env != nil {
for k, v := range *env {
programCmd.Env = append(programCmd.Env, fmt.Sprintf("%s=%s", k, v))
}
}
programCmd.Stderr = os.Stderr
return programCmd
} }
// Shell invokes shell commands to talk with a remote credentials helper. // Shell invokes shell commands to talk with a remote credentials helper.

View File

@ -33,11 +33,12 @@ func (c *Credentials) isValid() (bool, error) {
return true, nil return true, nil
} }
// Docker credentials should be labeled as such in credentials stores that allow labelling. // CredsLabel holds the way Docker credentials should be labeled as such in credentials stores that allow labelling.
// That label allows to filter out non-Docker credentials too at lookup/search in macOS keychain, // That label allows to filter out non-Docker credentials too at lookup/search in macOS keychain,
// Windows credentials manager and Linux libsecret. Default value is "Docker Credentials" // Windows credentials manager and Linux libsecret. Default value is "Docker Credentials"
var CredsLabel = "Docker Credentials" var CredsLabel = "Docker Credentials"
// SetCredsLabel is a simple setter for CredsLabel
func SetCredsLabel(label string) { func SetCredsLabel(label string) {
CredsLabel = label CredsLabel = label
} }
@ -50,7 +51,7 @@ func SetCredsLabel(label string) {
func Serve(helper Helper) { func Serve(helper Helper) {
var err error var err error
if len(os.Args) != 2 { if len(os.Args) != 2 {
err = fmt.Errorf("Usage: %s <store|get|erase|list>", os.Args[0]) err = fmt.Errorf("Usage: %s <store|get|erase|list|version>", os.Args[0])
} }
if err == nil { if err == nil {
@ -74,6 +75,8 @@ func HandleCommand(helper Helper, key string, in io.Reader, out io.Writer) error
return Erase(helper, in) return Erase(helper, in)
case "list": case "list":
return List(helper, out) return List(helper, out)
case "version":
return PrintVersion(out)
} }
return fmt.Errorf("Unknown credential action `%s`", key) return fmt.Errorf("Unknown credential action `%s`", key)
} }
@ -131,8 +134,8 @@ func Get(helper Helper, reader io.Reader, writer io.Writer) error {
resp := Credentials{ resp := Credentials{
ServerURL: serverURL, ServerURL: serverURL,
Username: username, Username: username,
Secret: secret, Secret: secret,
} }
buffer.Reset() buffer.Reset()
@ -175,3 +178,9 @@ func List(helper Helper, writer io.Writer) error {
} }
return json.NewEncoder(writer).Encode(accts) return json.NewEncoder(writer).Encode(accts)
} }
//PrintVersion outputs the current version.
func PrintVersion(writer io.Writer) error {
fmt.Fprintln(writer, Version)
return nil
}

View File

@ -8,7 +8,7 @@ const (
// ErrCredentialsMissingServerURL and ErrCredentialsMissingUsername standardize // ErrCredentialsMissingServerURL and ErrCredentialsMissingUsername standardize
// invalid credentials or credentials management operations // invalid credentials or credentials management operations
errCredentialsMissingServerURLMessage = "no credentials server URL" errCredentialsMissingServerURLMessage = "no credentials server URL"
errCredentialsMissingUsernameMessage = "no credentials username" errCredentialsMissingUsernameMessage = "no credentials username"
) )
// errCredentialsNotFound represents an error // errCredentialsNotFound represents an error
@ -43,7 +43,6 @@ func IsErrCredentialsNotFoundMessage(err string) bool {
return err == errCredentialsNotFoundMessage return err == errCredentialsNotFoundMessage
} }
// errCredentialsMissingServerURL represents an error raised // errCredentialsMissingServerURL represents an error raised
// when the credentials object has no server URL or when no // when the credentials object has no server URL or when no
// server URL is provided to a credentials operation requiring // server URL is provided to a credentials operation requiring
@ -64,7 +63,6 @@ func (errCredentialsMissingUsername) Error() string {
return errCredentialsMissingUsernameMessage return errCredentialsMissingUsernameMessage
} }
// NewErrCredentialsMissingServerURL creates a new error for // NewErrCredentialsMissingServerURL creates a new error for
// errCredentialsMissingServerURL. // errCredentialsMissingServerURL.
func NewErrCredentialsMissingServerURL() error { func NewErrCredentialsMissingServerURL() error {
@ -77,7 +75,6 @@ func NewErrCredentialsMissingUsername() error {
return errCredentialsMissingUsername{} return errCredentialsMissingUsername{}
} }
// IsCredentialsMissingServerURL returns true if the error // IsCredentialsMissingServerURL returns true if the error
// was an errCredentialsMissingServerURL. // was an errCredentialsMissingServerURL.
func IsCredentialsMissingServerURL(err error) bool { func IsCredentialsMissingServerURL(err error) bool {

View File

@ -0,0 +1,4 @@
package credentials
// Version holds a string describing the current version
const Version = "0.5.2"

View File

@ -135,30 +135,27 @@ func (h Osxkeychain) List() (map[string]string, error) {
} }
func splitServer(serverURL string) (*C.struct_Server, error) { func splitServer(serverURL string) (*C.struct_Server, error) {
u, err := url.Parse(serverURL) u, err := parseURL(serverURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
hostAndPort := strings.Split(u.Host, ":") proto := C.kSecProtocolTypeHTTPS
host := hostAndPort[0] if u.Scheme == "http" {
proto = C.kSecProtocolTypeHTTP
}
var port int var port int
if len(hostAndPort) == 2 { p := getPort(u)
p, err := strconv.Atoi(hostAndPort[1]) if p != "" {
port, err = strconv.Atoi(p)
if err != nil { if err != nil {
return nil, err return nil, err
} }
port = p
}
proto := C.kSecProtocolTypeHTTPS
if u.Scheme != "https" {
proto = C.kSecProtocolTypeHTTP
} }
return &C.struct_Server{ return &C.struct_Server{
proto: C.SecProtocolType(proto), proto: C.SecProtocolType(proto),
host: C.CString(host), host: C.CString(getHostname(u)),
port: C.uint(port), port: C.uint(port),
path: C.CString(u.Path), path: C.CString(u.Path),
}, nil }, nil
@ -168,3 +165,32 @@ func freeServer(s *C.struct_Server) {
C.free(unsafe.Pointer(s.host)) C.free(unsafe.Pointer(s.host))
C.free(unsafe.Pointer(s.path)) C.free(unsafe.Pointer(s.path))
} }
// parseURL parses and validates a given serverURL to an url.URL, and
// returns an error if validation failed. Querystring parameters are
// omitted in the resulting URL, because they are not used in the helper.
//
// If serverURL does not have a valid scheme, `//` is used as scheme
// before parsing. This prevents the hostname being used as path,
// and the credentials being stored without host.
func parseURL(serverURL string) (*url.URL, error) {
// Check if serverURL has a scheme, otherwise add `//` as scheme.
if !strings.Contains(serverURL, "://") && !strings.HasPrefix(serverURL, "//") {
serverURL = "//" + serverURL
}
u, err := url.Parse(serverURL)
if err != nil {
return nil, err
}
if u.Scheme != "" && u.Scheme != "https" && u.Scheme != "http" {
return nil, errors.New("unsupported scheme: " + u.Scheme)
}
if getHostname(u) == "" {
return nil, errors.New("no hostname in URL")
}
u.RawQuery = ""
return u, nil
}

View File

@ -0,0 +1,13 @@
//+build go1.8
package osxkeychain
import "net/url"
func getHostname(u *url.URL) string {
return u.Hostname()
}
func getPort(u *url.URL) string {
return u.Port()
}

View File

@ -0,0 +1,41 @@
//+build !go1.8
package osxkeychain
import (
"net/url"
"strings"
)
func getHostname(u *url.URL) string {
return stripPort(u.Host)
}
func getPort(u *url.URL) string {
return portOnly(u.Host)
}
func stripPort(hostport string) string {
colon := strings.IndexByte(hostport, ':')
if colon == -1 {
return hostport
}
if i := strings.IndexByte(hostport, ']'); i != -1 {
return strings.TrimPrefix(hostport[:i], "[")
}
return hostport[:colon]
}
func portOnly(hostport string) string {
colon := strings.IndexByte(hostport, ':')
if colon == -1 {
return ""
}
if i := strings.Index(hostport, "]:"); i != -1 {
return hostport[i+len("]:"):]
}
if strings.Contains(hostport, "]") {
return ""
}
return hostport[colon+len(":"):]
}

View File

@ -0,0 +1,208 @@
// A `pass` based credential helper. Passwords are stored as arguments to pass
// of the form: "$PASS_FOLDER/base64-url(serverURL)/username". We base64-url
// encode the serverURL, because under the hood pass uses files and folders, so
// /s will get translated into additional folders.
package pass
import (
"encoding/base64"
"errors"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path"
"strings"
"github.com/docker/docker-credential-helpers/credentials"
)
const PASS_FOLDER = "docker-credential-helpers"
var (
PassInitialized bool
)
func init() {
PassInitialized = exec.Command("pass").Run() == nil
}
func runPass(stdinContent string, args ...string) (string, error) {
cmd := exec.Command("pass", args...)
stdin, err := cmd.StdinPipe()
if err != nil {
return "", err
}
defer stdin.Close()
stderr, err := cmd.StderrPipe()
if err != nil {
return "", err
}
defer stderr.Close()
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", err
}
defer stdout.Close()
err = cmd.Start()
if err != nil {
return "", err
}
_, err = stdin.Write([]byte(stdinContent))
if err != nil {
return "", err
}
stdin.Close()
errContent, err := ioutil.ReadAll(stderr)
if err != nil {
return "", fmt.Errorf("error reading stderr: %s", err)
}
result, err := ioutil.ReadAll(stdout)
if err != nil {
return "", fmt.Errorf("Error reading stdout: %s", err)
}
cmdErr := cmd.Wait()
if cmdErr != nil {
return "", fmt.Errorf("%s: %s", cmdErr, errContent)
}
return string(result), nil
}
// Pass handles secrets using Linux secret-service as a store.
type Pass struct{}
// Add adds new credentials to the keychain.
func (h Pass) Add(creds *credentials.Credentials) error {
if !PassInitialized {
return errors.New("pass store is uninitialized")
}
if creds == nil {
return errors.New("missing credentials")
}
encoded := base64.URLEncoding.EncodeToString([]byte(creds.ServerURL))
_, err := runPass(creds.Secret, "insert", "-f", "-m", path.Join(PASS_FOLDER, encoded, creds.Username))
return err
}
// Delete removes credentials from the store.
func (h Pass) Delete(serverURL string) error {
if !PassInitialized {
return errors.New("pass store is uninitialized")
}
if serverURL == "" {
return errors.New("missing server url")
}
encoded := base64.URLEncoding.EncodeToString([]byte(serverURL))
_, err := runPass("", "rm", "-rf", path.Join(PASS_FOLDER, encoded))
return err
}
// listPassDir lists all the contents of a directory in the password store.
// Pass uses fancy unicode to emit stuff to stdout, so rather than try
// and parse this, let's just look at the directory structure instead.
func listPassDir(args ...string) ([]os.FileInfo, error) {
passDir := os.ExpandEnv("$HOME/.password-store")
for _, e := range os.Environ() {
parts := strings.SplitN(e, "=", 2)
if len(parts) < 2 {
continue
}
if parts[0] != "PASSWORD_STORE_DIR" {
continue
}
passDir = parts[1]
break
}
p := path.Join(append([]string{passDir, PASS_FOLDER}, args...)...)
contents, err := ioutil.ReadDir(p)
if err != nil {
if os.IsNotExist(err) {
return []os.FileInfo{}, nil
}
return nil, err
}
return contents, nil
}
// Get returns the username and secret to use for a given registry server URL.
func (h Pass) Get(serverURL string) (string, string, error) {
if !PassInitialized {
return "", "", errors.New("pass store is uninitialized")
}
if serverURL == "" {
return "", "", errors.New("missing server url")
}
encoded := base64.URLEncoding.EncodeToString([]byte(serverURL))
usernames, err := listPassDir(encoded)
if err != nil {
return "", "", err
}
if len(usernames) < 1 {
return "", "", fmt.Errorf("no usernames for %s", serverURL)
}
actual := strings.TrimSuffix(usernames[0].Name(), ".gpg")
secret, err := runPass("", "show", path.Join(PASS_FOLDER, encoded, actual))
return actual, secret, err
}
// List returns the stored URLs and corresponding usernames for a given credentials label
func (h Pass) List() (map[string]string, error) {
if !PassInitialized {
return nil, errors.New("pass store is uninitialized")
}
servers, err := listPassDir()
if err != nil {
return nil, err
}
resp := map[string]string{}
for _, server := range servers {
if !server.IsDir() {
continue
}
serverURL, err := base64.URLEncoding.DecodeString(server.Name())
if err != nil {
return nil, err
}
usernames, err := listPassDir(server.Name())
if err != nil {
return nil, err
}
if len(usernames) < 1 {
return nil, fmt.Errorf("no usernames for %s", serverURL)
}
resp[string(serverURL)] = strings.TrimSuffix(usernames[0].Name(), ".gpg")
}
return resp, nil
}

View File

@ -105,8 +105,11 @@ func (h Secretservice) List() (map[string]string, error) {
if listLen == 0 { if listLen == 0 {
return resp, nil return resp, nil
} }
pathTmp := (*[1 << 30]*C.char)(unsafe.Pointer(pathsC))[:listLen:listLen] // The maximum capacity of the following two slices is limited to (2^29)-1 to remain compatible
acctTmp := (*[1 << 30]*C.char)(unsafe.Pointer(acctsC))[:listLen:listLen] // with 32-bit platforms. The size of a `*C.char` (a pointer) is 4 Byte on a 32-bit system
// and (2^29)*4 == math.MaxInt32 + 1. -- See issue golang/go#13656
pathTmp := (*[(1 << 29) - 1]*C.char)(unsafe.Pointer(pathsC))[:listLen:listLen]
acctTmp := (*[(1 << 29) - 1]*C.char)(unsafe.Pointer(acctsC))[:listLen:listLen]
for i := 0; i < listLen; i++ { for i := 0; i < listLen; i++ {
resp[C.GoString(pathTmp[i])] = C.GoString(acctTmp[i]) resp[C.GoString(pathTmp[i])] = C.GoString(acctTmp[i])
} }