From 1ab10377076c7cd77d4ee210dc101f04486e1fbe Mon Sep 17 00:00:00 2001 From: Tycho Andersen Date: Thu, 10 Aug 2017 10:14:20 -0600 Subject: [PATCH] add a `pass` credential helper backend Signed-off-by: Tycho Andersen --- .travis.yml | 1 + Makefile | 6 +- ci/before_script_linux.sh | 18 ++++ pass/cmd/main_linux.go | 10 ++ pass/pass_linux.go | 208 ++++++++++++++++++++++++++++++++++++++ pass/pass_linux_test.go | 71 +++++++++++++ 6 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 pass/cmd/main_linux.go create mode 100644 pass/pass_linux.go create mode 100644 pass/pass_linux_test.go diff --git a/.travis.yml b/.travis.yml index 6011a4d..bf1ac60 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ apt: packages: - libsecret-1-dev + - pass before_script: - "export DISPLAY=:99.0" - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sh ci/before_script_linux.sh; fi diff --git a/Makefile b/Makefile index df5b72f..1f3549e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all deps osxkeychain secretservice test validate wincred +.PHONY: all deps osxkeychain secretservice test validate wincred pass TRAVIS_OS_NAME ?= linux VERSION := $(shell grep 'const Version' credentials/version.go | awk -F'"' '{ print $$2 }') @@ -30,6 +30,10 @@ secretservice: mkdir bin go build -o bin/docker-credential-secretservice secretservice/cmd/main_linux.go +pass: + mkdir -p bin + go build -o bin/docker-credential-pass pass/cmd/main_linux.go + wincred: mkdir bin go build -o bin/docker-credential-wincred.exe wincred/cmd/main_windows.go diff --git a/ci/before_script_linux.sh b/ci/before_script_linux.sh index eb3ac19..9fc0ea2 100644 --- a/ci/before_script_linux.sh +++ b/ci/before_script_linux.sh @@ -2,3 +2,21 @@ set -ex sh -e /etc/init.d/xvfb start sleep 3 # give xvfb some time to start + +# init key for pass +gpg --batch --gen-key <<-EOF +%echo Generating a standard key +Key-Type: DSA +Key-Length: 1024 +Subkey-Type: ELG-E +Subkey-Length: 1024 +Name-Real: Meshuggah Rocks +Name-Email: meshuggah@example.com +Expire-Date: 0 +# Do a commit here, so that we can later print "done" :-) +%commit +%echo done +EOF + +key=$(gpg --no-auto-check-trustdb --list-secret-keys | grep ^sec | cut -d/ -f2 | cut -d" " -f1) +pass init $key diff --git a/pass/cmd/main_linux.go b/pass/cmd/main_linux.go new file mode 100644 index 0000000..4fe7c1e --- /dev/null +++ b/pass/cmd/main_linux.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/docker/docker-credential-helpers/credentials" + "github.com/docker/docker-credential-helpers/pass" +) + +func main() { + credentials.Serve(pass.Pass{}) +} diff --git a/pass/pass_linux.go b/pass/pass_linux.go new file mode 100644 index 0000000..8a1fa81 --- /dev/null +++ b/pass/pass_linux.go @@ -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 +} diff --git a/pass/pass_linux_test.go b/pass/pass_linux_test.go new file mode 100644 index 0000000..1e6f0f8 --- /dev/null +++ b/pass/pass_linux_test.go @@ -0,0 +1,71 @@ +package pass + +import ( + "strings" + "testing" + + "github.com/docker/docker-credential-helpers/credentials" +) + +func TestPassHelper(t *testing.T) { + helper := Pass{} + + 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) + } + + _, _, err = helper.Get(server) + if err == nil { + t.Fatalf("%s shuldn't exist any more", server) + } + } + + credsList, err = helper.List() + if err != nil { + t.Fatal(err) + } + + if len(credsList) != 0 { + t.Fatal("didn't delete all creds?") + } +}