diff --git a/Makefile b/Makefile index 1eb9f08c..55c48cd0 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,10 @@ pass: mkdir -p bin go build -o bin/docker-credential-pass pass/cmd/main_linux.go +gopass: + mkdir -p bin + go build -o bin/docker-credential-gopass gopass/cmd/main_linux.go + wincred: mkdir -p bin go build -o bin/docker-credential-wincred.exe wincred/cmd/main_windows.go @@ -36,6 +40,7 @@ wincred: linuxrelease: mkdir -p release cd bin && tar cvfz ../release/docker-credential-pass-v$(VERSION)-amd64.tar.gz docker-credential-pass + cd bin && tar cvfz ../release/docker-credential-gopass-v$(VERSION)-amd64.tar.gz docker-credential-gopass cd bin && tar cvfz ../release/docker-credential-secretservice-v$(VERSION)-amd64.tar.gz docker-credential-secretservice osxrelease: diff --git a/gopass/cmd/main_linux.go b/gopass/cmd/main_linux.go new file mode 100644 index 00000000..3a4a165e --- /dev/null +++ b/gopass/cmd/main_linux.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/docker/docker-credential-helpers/credentials" + "github.com/docker/docker-credential-helpers/gopass" +) + +func main() { + credentials.Serve(gopass.Gopass{}) +} diff --git a/gopass/gopass_linux.go b/gopass/gopass_linux.go new file mode 100644 index 00000000..d783113e --- /dev/null +++ b/gopass/gopass_linux.go @@ -0,0 +1,192 @@ +// A `gopass` based credential helper. Passwords are stored as arguments to gopass +// of the form: "$GOPASS_FOLDER/base64-url(serverURL)/username". We base64-url +// encode the serverURL, because under the hood gopass uses files and folders, so +// /s will get translated into additional folders. +package gopass + +import ( + "bytes" + // "encoding/base64" + "errors" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path" + "strings" + "sync" + + "github.com/docker/docker-credential-helpers/credentials" +) + +const GOPASS_FOLDER = "docker-credential-helpers" + +// Gopass handles secrets using Linux secret-service as a store. +type Gopass struct{} + +// Ideally these would be stored as members of Pass, but since all of Pass's +// methods have value receivers, not pointer receivers, and changing that is +// backwards incompatible, we assume that all Pass instances share the same configuration + +// initializationMutex is held while initializing so that only one 'gopass' +// round-tripping is done to check gopass is functioning. +var initializationMutex sync.Mutex +var gopassInitialized bool + +// CheckInitialized checks whether the password helper can be used. It +// internally caches and so may be safely called multiple times with no impact +// on performance, though the first call may take longer. +func (p Gopass) CheckInitialized() bool { + return p.checkInitialized() == nil +} + +func (p Gopass) checkInitialized() error { + initializationMutex.Lock() + defer initializationMutex.Unlock() + if gopassInitialized { + return nil + } + // We just run a `pass ls`, if it fails then pass is not initialized. + _, err := p.runPassHelper("", "ls") + if err != nil { + return fmt.Errorf("gopass not initialized: %v", err) + } + gopassInitialized = true + return nil +} + +func (p Gopass) runPass(stdinContent string, args ...string) (string, error) { + if err := p.checkInitialized(); err != nil { + return "", err + } + return p.runPassHelper(stdinContent, args...) +} + +func (p Gopass) runPassHelper(stdinContent string, args ...string) (string, error) { + var stdout, stderr bytes.Buffer + cmd := exec.Command("gopass", args...) + cmd.Stdin = strings.NewReader(stdinContent) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("%s: %s", err, stderr.String()) + } + + // trim newlines; pass v1.7.1+ includes a newline at the end of `show` output + return strings.TrimRight(stdout.String(), "\n\r"), nil +} + +// Add adds new credentials to the keychain. +func (h Gopass) Add(creds *credentials.Credentials) error { + if creds == nil { + return errors.New("missing credentials") + } + + // encoded := base64.URLEncoding.EncodeToString([]byte(creds.ServerURL)) + + _, err := h.runPass(creds.Secret, "insert", "-f", "-m", path.Join(GOPASS_FOLDER, creds.ServerURL, creds.Username)) + return err +} + +// Delete removes credentials from the store. +func (h Gopass) Delete(serverURL string) error { + if serverURL == "" { + return errors.New("missing server url") + } + + // encoded := base64.URLEncoding.EncodeToString([]byte(serverURL)) + _, err := h.runPass("", "rm", "-r", "-f", path.Join(GOPASS_FOLDER, serverURL)) + return err +} + +func getPassDir() string { + passDir := "$HOME/.password-store" + if envDir := os.Getenv("PASSWORD_STORE_DIR"); envDir != "" { + passDir = envDir + } + return os.ExpandEnv(passDir) +} + +// listPassDir lists all the contents of a directory in the password store. +// Gopass 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 := getPassDir() + p := path.Join(append([]string{passDir, GOPASS_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 Gopass) Get(serverURL string) (string, string, error) { + if serverURL == "" { + return "", "", errors.New("missing server url") + } + + // encoded := base64.URLEncoding.EncodeToString([]byte(serverURL)) + + if _, err := os.Stat(path.Join(getPassDir(), GOPASS_FOLDER, serverURL)); err != nil { + if os.IsNotExist(err) { + return "", "", nil + } + + return "", "", err + } + + usernames, err := listPassDir(serverURL) + 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 := h.runPass("", "show", path.Join(GOPASS_FOLDER, serverURL, actual)) + return actual, secret, err +} + +// List returns the stored URLs and corresponding usernames for a given credentials label +func (h Gopass) List() (map[string]string, error) { + 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", server.Name()) + } + + resp[string(server.Name())] = strings.TrimSuffix(usernames[0].Name(), ".gpg") + } + + return resp, nil +} diff --git a/gopass/gopass_linux_test.go b/gopass/gopass_linux_test.go new file mode 100644 index 00000000..0fdf9bc8 --- /dev/null +++ b/gopass/gopass_linux_test.go @@ -0,0 +1,75 @@ +package gopass + +import ( + "strings" + "testing" + + "github.com/docker/docker-credential-helpers/credentials" +) + +func TestGopassHelper(t *testing.T) { + helper := Gopass{} + + creds := &credentials.Credentials{ + ServerURL: "https://foobar.docker.io:2376/v1", + Username: "nothing", + Secret: "isthebestmeshuggahalbum", + } + + helper.Add(creds) + + creds.ServerURL = "https://foobar.docker.io:9999/v2" + helper.Add(creds) + + credsList, err := helper.List() + if err != nil { + t.Fatal(err) + } + + for server, username := range credsList { + if !(strings.Contains(server, "2376") || + strings.Contains(server, "9999")) { + t.Fatalf("invalid url: %s", creds.ServerURL) + } + + if username != "nothing" { + t.Fatalf("invalid username: %v", username) + } + + u, s, err := helper.Get(server) + if err != nil { + t.Fatal(err) + } + + if u != username { + t.Fatalf("invalid username %s", u) + } + + if s != "isthebestmeshuggahalbum" { + t.Fatalf("invalid secret: %s", s) + } + + err = helper.Delete(server) + if err != nil { + t.Fatal(err) + } + + username, _, err = helper.Get(server) + if err != nil { + t.Fatal(err) + } + + if username != "" { + t.Fatalf("%s shouldn't exist any more", username) + } + } + + credsList, err = helper.List() + if err != nil { + t.Fatal(err) + } + + if len(credsList) != 0 { + t.Fatal("didn't delete all creds?") + } +}