From 72faea17d1ce55518b276b2c3f58c41a35ddb87e Mon Sep 17 00:00:00 2001 From: sudoforge <9c001b67637a@sudoforge.com> Date: Fri, 12 May 2023 22:51:04 -0700 Subject: [PATCH] feat: add support for gopass as a credential store This change adds support for `gopass` as a credential store, based on the `pass` implementation. Closes: #138 Closes: #166 Signed-off-by: sudoforge <9c001b67637a@sudoforge.com> --- .github/workflows/build.yml | 6 + Dockerfile | 31 ++++- Makefile | 5 +- README.md | 25 ++-- gopass/cmd/main.go | 10 ++ gopass/gopass.go | 229 ++++++++++++++++++++++++++++++++++++ gopass/gopass_test.go | 87 ++++++++++++++ 7 files changed, 381 insertions(+), 12 deletions(-) create mode 100644 gopass/cmd/main.go create mode 100644 gopass/gopass.go create mode 100644 gopass/gopass_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 201281b7..7fda5158 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,6 +63,12 @@ jobs: run: | sudo apt-get update sudo apt-get install -y dbus-x11 gnome-keyring libsecret-1-dev pass + - + name: Install gopass + env: + GOPASS_VERSION: v1.15.5 + run: go install github.com/gopasspw/gopass@${{ env.GOPASS_VERSION }} + - name: GPG conf if: ${{ matrix.os == 'ubuntu-20.04' }} diff --git a/Dockerfile b/Dockerfile index 41432719..3078f386 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ ARG XX_VERSION=1.2.1 ARG OSXCROSS_VERSION=11.3-r7-debian ARG GOLANGCI_LINT_VERSION=v1.51.1 ARG DEBIAN_FRONTEND=noninteractive +ARG GOPASS_VERSION=v1.15.5 ARG PACKAGE=github.com/docker/docker-credential-helpers @@ -68,12 +69,19 @@ RUN xx-apt-get install -y binutils gcc libc6-dev libgcc-10-dev libsecret-1-dev p FROM base AS test ARG DEBIAN_FRONTEND +ARG GOPASS_VERSION RUN xx-apt-get install -y dbus-x11 gnome-keyring gpg-agent gpgconf libsecret-1-dev pass +RUN --mount=type=bind,target=. \ + --mount=type=cache,target=/root/.cache \ + --mount=type=cache,target=/go/pkg/mod \ + GOFLAGS='' go install github.com/gopasspw/gopass@${GOPASS_VERSION} RUN --mount=type=bind,target=. \ --mount=type=cache,target=/root/.cache \ --mount=type=cache,target=/go/pkg/mod </dev/null + gopass config core.autopush false 1>/dev/null + gopass config core.autosync false 1>/dev/null + gopass config core.exportkeys false 1>/dev/null + gopass config core.notifications false 1>/dev/null + gopass config core.color false 1>/dev/null + gopass config core.nopager true 1>/dev/null + gopass init --crypto gpgcli --storage fs 7D851EB72D73BDA0 + gpg -k mkdir /out @@ -106,7 +127,8 @@ RUN --mount=type=bind,target=. \ --mount=type=bind,source=/tmp/.revision,target=/tmp/.revision,from=version </dev/null") + if err != nil { + return fmt.Errorf("gopass is not initialized: %v", err) + } + gopassInitialized = true + return nil +} + +func (g Gopass) runGopass(stdinContent string, args ...string) (string, error) { + if err := g.checkInitialized(); err != nil { + return "", err + } + return g.runGopassHelper(stdinContent, args...) +} + +func (g Gopass) runGopassHelper(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; gopass 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 (g Gopass) Add(creds *credentials.Credentials) error { + if creds == nil { + return errors.New("missing credentials") + } + + encoded := base64.URLEncoding.EncodeToString([]byte(creds.ServerURL)) + + _, err := g.runGopass(creds.Secret, "insert", "-f", path.Join(GOPASS_FOLDER, encoded, creds.Username)) + return err +} + +// Delete removes credentials from the store. +func (g Gopass) Delete(serverURL string) error { + if serverURL == "" { + return errors.New("missing server url") + } + + encoded := base64.URLEncoding.EncodeToString([]byte(serverURL)) + _, err := g.runGopass("", "rm", "-rf", path.Join(GOPASS_FOLDER, encoded)) + return err +} + +func (g Gopass) getGopassDir() (string, error) { + gopassDir, err := g.runGopass("", "config", "mounts.path") + + if err != nil { + return "", fmt.Errorf("error getting gopass dir: %v", err) + } + + return gopassDir, nil +} + +// listGopassDir 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 (g Gopass) listGopassDir(args ...string) ([]os.FileInfo, error) { + gopassDir, err := g.getGopassDir() + if err != nil { + return nil, err + } + + p := os.ExpandEnv(path.Join(append([]string{gopassDir, GOPASS_FOLDER}, args...)...)) + + if strings.HasPrefix(p, "~/") { + d, err := os.UserHomeDir() + + if err != nil { + message := fmt.Sprintf("unable to get user home directory: %v", err.Error()) + return nil, errors.New(message) + } + + p = path.Join(d, p[2:]) + } + + entries, err := os.ReadDir(p) + if err != nil { + if os.IsNotExist(err) { + return []os.FileInfo{}, nil + } + return nil, err + } + + infos := make([]fs.FileInfo, 0, len(entries)) + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return nil, err + } + infos = append(infos, info) + } + return infos, nil +} + +// Get returns the username and secret to use for a given registry server URL. +func (g Gopass) Get(serverURL string) (string, string, error) { + if serverURL == "" { + return "", "", errors.New("missing server url") + } + + gopassDir, err := g.getGopassDir() + if err != nil { + return "", "", err + } + + encoded := base64.URLEncoding.EncodeToString([]byte(serverURL)) + + if _, err := os.Stat(path.Join(gopassDir, GOPASS_FOLDER, encoded)); err != nil { + if os.IsNotExist(err) { + return "", "", credentials.NewErrCredentialsNotFound() + } + + return "", "", err + } + + usernames, err := g.listGopassDir(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 := g.runGopass("", "show", "-o", path.Join(GOPASS_FOLDER, encoded, actual)) + + return actual, secret, err +} + +// List returns the stored URLs and corresponding usernames for a given credentials label +func (g Gopass) List() (map[string]string, error) { + servers, err := g.listGopassDir() + 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 := g.listGopassDir(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/gopass/gopass_test.go b/gopass/gopass_test.go new file mode 100644 index 00000000..373db037 --- /dev/null +++ b/gopass/gopass_test.go @@ -0,0 +1,87 @@ +package gopass + +import ( + "strings" + "testing" + + "github.com/docker/docker-credential-helpers/credentials" +) + +func TestGopassHelper(t *testing.T) { + helper := Gopass{} + + creds := &credentials.Credentials{ + ServerURL: "https://gopass.docker.io:2376/v1", + Username: "gopass-username", + Secret: "gopass-password", + } + + _ = helper.CheckInitialized() + + helper.Add(creds) + + creds.ServerURL = "https://gopass.docker.io:9999/v2" + helper.Add(creds) + + credsList, err := helper.List() + if err != nil { + t.Fatal(err) + } + + if len(credsList) == 0 { + t.Fatal("missing credentials from store") + } + + for server, username := range credsList { + + if !(strings.Contains(server, "2376") || + strings.Contains(server, "9999")) { + t.Fatalf("invalid url: %s", creds.ServerURL) + } + + if username != "gopass-username" { + 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 != "gopass-password" { + t.Fatalf("invalid secret: %s", s) + } + + err = helper.Delete(server) + if err != nil { + t.Fatal(err) + } + + _, _, err = helper.Get(server) + if !credentials.IsErrCredentialsNotFound(err) { + t.Fatalf("expected credentials not found, actual: %v", err) + } + } + + credsList, err = helper.List() + if err != nil { + t.Fatal(err) + } + + if len(credsList) != 0 { + t.Fatal("didn't delete all creds?") + } +} + +func TestMissingCred(t *testing.T) { + helper := Gopass{} + + _, _, err := helper.Get("garbage") + if !credentials.IsErrCredentialsNotFound(err) { + t.Fatalf("expected credentials not found, actual: %v", err) + } +}