Skip to content

Commit

Permalink
feat: add support for gopass as a credential store
Browse files Browse the repository at this point in the history
This change adds support for `gopass` as a credential store, based on
the `pass` implementation.

Closes: docker#138
Closes: docker#166
Signed-off-by: sudoforge <9c001b67637a@sudoforge.com>
  • Loading branch information
sudoforge committed May 28, 2023
1 parent 4ede49c commit 2deead8
Show file tree
Hide file tree
Showing 7 changed files with 391 additions and 12 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down
31 changes: 28 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <<EOT
set -e

cp -r .github/workflows/fixtures /root/.gnupg
chmod 0400 /root/.gnupg
gpg-connect-agent "RELOADAGENT" /bye
gpg --import --batch --yes /root/.gnupg/7D851EB72D73BDA0.key
gpg --update-trustdb
Expand All @@ -82,7 +90,20 @@ RUN --mount=type=bind,target=. \
gpg-connect-agent "KEYINFO 3E2D1142AA59E08E16B7E2C64BA6DDC773B1A627" /bye
gpg-connect-agent "PRESET_PASSPHRASE BA83FC8947213477F28ADC019F6564A956456163 -1 77697468207374757069642070617373706872617365" /bye
gpg-connect-agent "KEYINFO BA83FC8947213477F28ADC019F6564A956456163" /bye

# initialize password store for `pass`
pass init 7D851EB72D73BDA0

# initialize password store for `gopass`
gopass config mounts.path /root/.gopass-password-store 1>/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
Expand All @@ -106,7 +127,8 @@ RUN --mount=type=bind,target=. \
--mount=type=bind,source=/tmp/.revision,target=/tmp/.revision,from=version <<EOT
set -ex
xx-go --wrap
make build-pass build-secretservice PACKAGE=$PACKAGE VERSION=$(cat /tmp/.version) REVISION=$(cat /tmp/.revision) DESTDIR=/out
make build-gopass build-pass build-secretservice PACKAGE=$PACKAGE VERSION=$(cat /tmp/.version) REVISION=$(cat /tmp/.revision) DESTDIR=/out
xx-verify /out/docker-credential-gopass
xx-verify /out/docker-credential-pass
xx-verify /out/docker-credential-secretservice
EOT
Expand All @@ -122,7 +144,8 @@ RUN --mount=type=bind,target=. \
set -ex
xx-go --wrap
go install std
make build-osxkeychain build-pass PACKAGE=$PACKAGE VERSION=$(cat /tmp/.version) REVISION=$(cat /tmp/.revision) DESTDIR=/out
make build-gopass build-osxkeychain build-pass PACKAGE=$PACKAGE VERSION=$(cat /tmp/.version) REVISION=$(cat /tmp/.revision) DESTDIR=/out
xx-verify /out/docker-credential-gopass
xx-verify /out/docker-credential-osxkeychain
xx-verify /out/docker-credential-pass
EOT
Expand All @@ -136,7 +159,9 @@ RUN --mount=type=bind,target=. \
--mount=type=bind,source=/tmp/.revision,target=/tmp/.revision,from=version <<EOT
set -ex
xx-go --wrap
make build-wincred PACKAGE=$PACKAGE VERSION=$(cat /tmp/.version) REVISION=$(cat /tmp/.revision) DESTDIR=/out
make build-gopass build-wincred PACKAGE=$PACKAGE VERSION=$(cat /tmp/.version) REVISION=$(cat /tmp/.revision) DESTDIR=/out
mv /out/docker-credential-gopass /out/docker-credential-gopass.exe
xx-verify /out/docker-credential-gopass.exe
mv /out/docker-credential-wincred /out/docker-credential-wincred.exe
xx-verify /out/docker-credential-wincred.exe
EOT
Expand Down
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ clean:
rm -rf bin

.PHONY: build-%
build-%: # build, can be one of build-osxkeychain build-pass build-secretservice build-wincred
build-%: # build, can be one of build-gopass build-osxkeychain build-pass build-secretservice build-wincred
go build -trimpath -ldflags="$(GO_LDFLAGS) -X ${GO_PKG}/credentials.Name=docker-credential-$*" -o "$(DESTDIR)/docker-credential-$*" ./$*/cmd/

# aliases for build-* targets
.PHONY: osxkeychain secretservice pass wincred
.PHONY: gopass osxkeychain secretservice pass wincred
gopass: build-gopass
osxkeychain: build-osxkeychain
secretservice: build-secretservice
pass: build-pass
Expand Down
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,26 @@ You can see examples of each function in the [client](https://godoc.org/github.c

### Available programs

1. osxkeychain: Provides a helper to use the OS X keychain as credentials store.
2. secretservice: Provides a helper to use the D-Bus secret service as credentials store.
3. wincred: Provides a helper to use Windows credentials manager as store.
4. pass: Provides a helper to use `pass` as credentials store.
- gopass: Provides a helper to use `gopass` as credentials store.
- osxkeychain: Provides a helper to use the OS X keychain as credentials store.
- pass: Provides a helper to use `pass` as credentials store.
- secretservice: Provides a helper to use the D-Bus secret service as credentials store.
- wincred: Provides a helper to use Windows credentials manager as store.

#### Note
#### Note regarding `gopass`

`pass` needs to be configured for `docker-credential-pass` to work properly.
It must be initialized with a `gpg2` key ID. Make sure your GPG key exists is in `gpg2` keyring as `pass` uses `gpg2` instead of the regular `gpg`.
`gopass` requires manual intervention in order for `docker-credential-gopass` to
work properly: a password store must be initialized. Please ensure to review the
upstream [quick start guide][gopass-quick-start] for more information.

[gopass-quick-start]: https://github.com/gopasspw/gopass#quick-start-guide

#### Note regarding `pass`

`pass` requires manual interview in order for `docker-credential-pass` to
work properly. It must be initialized with a `gpg2` key ID. Make sure your GPG
key exists is in `gpg2` keyring as `pass` uses `gpg2` instead of the regular
`gpg`.

## Development

Expand Down
10 changes: 10 additions & 0 deletions gopass/cmd/main.go
Original file line number Diff line number Diff line change
@@ -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{})
}
239 changes: 239 additions & 0 deletions gopass/gopass.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
// Package gopass implements 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/fs"
"os"
"os/exec"
"path"
"strings"
"sync"

"github.com/docker/docker-credential-helpers/credentials"
)

// GOPASS_FOLDER contains the directory where credentials are stored
const GOPASS_FOLDER = "docker-credential-helpers" //nolint:revive

// Gopass handles secrets using gopass as a store.
type Gopass struct{}

// Ideally these would be stored as members of Gopass, but since all of Gopass's
// methods have value receivers, not pointer receivers, and changing that is
// backwards incompatible, we assume that all Gopass instances share the same
// configuration

// initializationMutex is held while initializing so that only one 'gopass'
// round-tripping is done to check that 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 (g Gopass) CheckInitialized() bool {
return g.checkInitialized() == nil
}

func (g Gopass) checkInitialized() error {
initializationMutex.Lock()
defer initializationMutex.Unlock()
if gopassInitialized {
return nil
}

// We just run a `gopass ls`, if it fails then gopass is not initialized.
_, err := g.runGopassHelper("", "ls", "--flat")
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)
}

gopassDir = os.ExpandEnv(gopassDir)

if strings.HasPrefix(gopassDir, "~/") {
d, err := os.UserHomeDir()

if err != nil {
message := fmt.Sprintf("unable to get user home directory: %v", err.Error())
return "", errors.New(message)
}

gopassDir = path.Join(d, gopassDir[2:])
}

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...)...))

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))
search := path.Join(gopassDir, GOPASS_FOLDER, encoded)

stat, _ := os.Stat(search)

fmt.Printf("search: %s\n", search)
fmt.Printf("stat: %v\n", stat)

if _, err := os.Stat(search); 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))

fmt.Printf("secret: %s\n", secret)

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
}
Loading

0 comments on commit 2deead8

Please sign in to comment.