diff --git a/Makefile b/Makefile index 6eda46d3..071e9def 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all deps osxkeychain secretservice test validate wincred pass deb +.PHONY: all deps osxkeychain secretservice test validate wincred pass keyctl deb TRAVIS_OS_NAME ?= linux VERSION := $(shell grep 'const Version' credentials/version.go | awk -F'"' '{ print $$2 }') @@ -6,7 +6,7 @@ VERSION := $(shell grep 'const Version' credentials/version.go | awk -F'"' '{ pr all: test deps: - go get -u golang.org/x/lint/golint + go get -d golang.org/x/lint/golint clean: rm -rf bin @@ -29,14 +29,21 @@ pass: mkdir -p bin go build -o bin/docker-credential-pass pass/cmd/main.go +keyctl: + set -x + mkdir -p bin + go build -o bin/docker-credential-keyctl keyctl/cmd/main.go + wincred: mkdir -p bin go build -o bin/docker-credential-wincred.exe wincred/cmd/main_windows.go linuxrelease: + set -x 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-secretservice-v$(VERSION)-amd64.tar.gz docker-credential-secretservice + cd bin && tar cvfz ../release/docker-credential-keyctl-v$(VERSION)-amd64.tar.gz docker-credential-keyctl osxrelease: mkdir -p release @@ -61,6 +68,8 @@ vet_osx: vet_linux: go vet ./secretservice + go vet ./keyctl + go vet ./pass lint: for p in `go list ./... | grep -v /vendor/`; do \ diff --git a/README.md b/README.md index c469c944..06be52f9 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,15 @@ You can see examples of each function in the [client](https://godoc.org/github.c 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. +5. keyctl: Provides a kernel keyring based helper as credential store. It is a purely non-file based credential store. #### Note `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`. +`keyctl` does not need any configuration except that kernel should be compiled with CONFIG_KEYS enabled, which is default in most distro kernels. + ## Development A credential helper can be any program that can read values from the standard input. We use the first argument in the command line to differentiate the kind of command to execute. There are four valid values: diff --git a/go.mod b/go.mod index 6db1efbb..2270013f 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,7 @@ go 1.13 require ( github.com/danieljoos/wincred v1.1.2 + github.com/jsipprell/keyctl v1.0.0 + github.com/pkg/errors v0.9.1 golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c ) diff --git a/go.sum b/go.sum index 6b172106..43c50bbc 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuA github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jsipprell/keyctl v1.0.0 h1:eMMJGk3UEsCXQegACLlfIjnfPlYHV61qfGXZ9/axBn4= +github.com/jsipprell/keyctl v1.0.0/go.mod h1:64s6WpBtruURX3w8W/vhWj1/uh+nOm7vUXSJlK5+KMs= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= diff --git a/keyctl/cmd/main.go b/keyctl/cmd/main.go new file mode 100644 index 00000000..b0c161b2 --- /dev/null +++ b/keyctl/cmd/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/docker/docker-credential-helpers/credentials" + "github.com/docker/docker-credential-helpers/keyctl" +) + +func main() { + credentials.Serve(keyctl.Keyctl{}) +} diff --git a/keyctl/keyctl.go b/keyctl/keyctl.go new file mode 100644 index 00000000..d139e71f --- /dev/null +++ b/keyctl/keyctl.go @@ -0,0 +1,250 @@ +// Package keyctl implements a `keyctl` based credential helper. Passwords are stored +// in linux kernel keyring. +package keyctl + +import ( + "bytes" + "encoding/base64" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/docker/docker-credential-helpers/credentials" + "github.com/jsipprell/keyctl" + "github.com/pkg/errors" +) + +// Keyctl based credential helper looks for a default keyring inside +// session keyring. It does all operations inside the default keyring + +const defaultKeyringName string = "keyctlCredsStore" +const persistent int = 1 + +// Keyctl handles secrets using Linux Kernel keyring mechanism +type Keyctl struct{} + +func (k Keyctl) createDefaultPersistentKeyring() (string, error) { + /* Create default persistent keyring. If the keyring for the user + * already exists, then it returns the id of the existing keyring + */ + var errout, out bytes.Buffer + uid := os.Getuid() + cmd := exec.Command("keyctl", "get_persistent", "@u", strconv.Itoa(uid)) + cmd.Stderr = &errout + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + return "", errors.Wrapf(err, "cannot run keyctl command to create persistent keyring %+v: %s", err, errout.String()) + } + persistentKeyringID := out.String() + if err != nil { + return "", errors.Wrapf(err, "cannot create or read persistent keyring %+v", err) + } + return persistentKeyringID, nil +} + +func (k Keyctl) getDefaultCredsStoreFromPersistent() (keyctl.NamedKeyring, error) { + var out, errout bytes.Buffer + persistentKeyringID, err := k.createDefaultPersistentKeyring() + if err != nil { + return nil, errors.Wrap(err, "default persistent keyring cannot be created") + } + + defaultSessionKeyring, err := keyctl.SessionKeyring() + if err != nil { + return nil, errors.New("errors getting session keyring") + } + + defaultKeyring, err := keyctl.OpenKeyring(defaultSessionKeyring, defaultKeyringName) + /* if already does not exist we create */ + if err != nil || defaultKeyring == nil { + cmd := exec.Command("keyctl", "newring", defaultKeyringName, strings.TrimSuffix(persistentKeyringID, "\n")) + cmd.Stdout = &out + cmd.Stderr = &errout + err := cmd.Run() + if err != nil { + return nil, errors.Wrapf(err, "cannot run keyctl command to created credstore keyring %s %s %s", cmd.String(), errout.String(), out.String()) + } + } + /* Search for it again and return the default keyring*/ + defaultKeyring, err = keyctl.OpenKeyring(defaultSessionKeyring, defaultKeyringName) + if err != nil { + return nil, errors.Wrap(err, "failed to lookup default session keyring") + } + + return defaultKeyring, nil +} + +// getDefaultCredsStore is a helper function to get the default credsStore keyring +func (k Keyctl) getDefaultCredsStore() (keyctl.NamedKeyring, error) { + if persistent == 1 { + return k.getDefaultCredsStoreFromPersistent() + } + defaultSessionKeyring, err := keyctl.SessionKeyring() + if err != nil { + return nil, errors.Wrap(err, "error getting session keyring") + } + + defaultKeyring, err := keyctl.OpenKeyring(defaultSessionKeyring, defaultKeyringName) + if err != nil || defaultKeyring == nil { + if defaultKeyring == nil { + defaultKeyring, err = keyctl.CreateKeyring(defaultSessionKeyring, defaultKeyringName) + if err != nil { + return nil, errors.Wrap(err, "failed to create default credsStore keyring") + } + } + } + + if defaultKeyring == nil { + return nil, errors.Wrap(errors.New(""), " nil credstore") + } + + return defaultKeyring, nil +} + +// Add adds new credentials to the keychain. +func (k Keyctl) Add(creds *credentials.Credentials) error { + defaultKeyring, err := k.getDefaultCredsStore() + if err != nil || defaultKeyring == nil { + return errors.Wrapf(err, "failed to create credsStore entry for %s", creds.ServerURL) + } + + // create a child keyring under default for given url + encoded := base64.URLEncoding.EncodeToString([]byte(strings.TrimSuffix(creds.ServerURL, "\n"))) + urlKeyring, err := keyctl.CreateKeyring(defaultKeyring, encoded) + if err != nil { + return errors.Wrapf(err, "failed to create keyring for %s", creds.ServerURL) + } + + _, err = urlKeyring.Add(creds.Username, []byte(creds.Secret)) + if err != nil { + return errors.Wrapf(err, "failed to add creds to keryring for %s with error: %+v", creds.ServerURL, err) + } + return err +} + +// searchHelper function searches for an url inside the default keyring. +func (k Keyctl) searchHelper(serverURL string) (keyctl.NamedKeyring, string, error) { + defaultKeyring, err := k.getDefaultCredsStore() + if err != nil || defaultKeyring == nil { + return nil, "", fmt.Errorf("searchHelper failed: cannot read defaultCredsStore") + } + + encoded := base64.URLEncoding.EncodeToString([]byte(strings.TrimSuffix(serverURL, "\n"))) + urlKeyring, err := keyctl.OpenKeyring(defaultKeyring, encoded) + if err != nil { + return nil, "", fmt.Errorf("error in reading credsStore for url %s", serverURL) + } + if urlKeyring == nil { + return nil, "", fmt.Errorf("credsStore entry for suplied url %s not found", serverURL) + } + + refs, err := keyctl.ListKeyring(urlKeyring) + if err != nil { + return nil, "", fmt.Errorf("key for server url not found") + } + if len(refs) < 1 { + return nil, "", fmt.Errorf("no keys in keyring %s", urlKeyring.Name()) + } + + obj := refs[0] + id, err := obj.Get() + if err != nil { + return nil, "", fmt.Errorf("key for server url not found") + } + + info, err := id.Info() + if err != nil { + return nil, "", fmt.Errorf("cannot read info for url key") + } + + return urlKeyring, info.Name, err +} + +// Get returns the username and secret to use for a given registry server URL. +func (k Keyctl) Get(serverURL string) (string, string, error) { + if serverURL == "" { + return "", "", errors.New("missing server url") + } + + serverURL = strings.TrimSuffix(serverURL, "\n") + urlKeyring, searchData, err := k.searchHelper(serverURL) + if err != nil { + return "", "", errors.Wrapf(err, "url not found by searchHelper: %s err: %v", serverURL, err) + } + key, err := urlKeyring.Search(searchData) + if err != nil { + return "", "", errors.Wrapf(err, "url not found in %+v", urlKeyring) + } + secret, err := key.Get() + if err != nil { + return "", "", errors.Wrapf(err, "failed to read credentials for %s:%s", serverURL, searchData) + } + + return searchData, string(secret), nil +} + +// Delete removes credentials from the store. +func (k Keyctl) Delete(serverURL string) error { + serverURL = strings.TrimSuffix(serverURL, "\n") + urlKeyring, searchData, err := k.searchHelper(serverURL) + if err != nil { + return errors.Wrapf(err, "cannot find server url %s", serverURL) + } + + key, err := urlKeyring.Search(searchData) + if err != nil { + return err + } + + err = key.Unlink() + if err != nil { + return err + } + + refs, err := keyctl.ListKeyring(urlKeyring) + if err != nil { + fmt.Printf("cannot list keyring %s", urlKeyring.Name()) + } + if len(refs) == 0 { + keyctl.UnlinkKeyring(urlKeyring) + } else { + return errors.Wrapf(err, "Canot remove keyring as its not empty %s", urlKeyring.Name()) + } + + return err +} + +// List returns the stored URLs and corresponding usernames for a given credentials label +func (k Keyctl) List() (map[string]string, error) { + defaultKeyring, err := k.getDefaultCredsStore() + if err != nil || defaultKeyring == nil { + return nil, errors.Wrap(err, "List() failed: cannot read default credStore") + } + + resp := map[string]string{} + + refs, err := keyctl.ListKeyring(defaultKeyring) + if err != nil { + return nil, err + } + + for _, r := range refs { + id, _ := r.Get() + info, _ := id.Info() + url, _ := base64.URLEncoding.DecodeString(info.Name) + + key, _ := keyctl.OpenKeyring(defaultKeyring, info.Name) + innerRefs, _ := keyctl.ListKeyring(key) + + if len(innerRefs) < 1 { + continue + } + k, _ := innerRefs[0].Get() + i, _ := k.Info() + resp[string(url)] = i.Name + } + return resp, nil +} diff --git a/keyctl/keyctl_test.go b/keyctl/keyctl_test.go new file mode 100644 index 00000000..22505f8e --- /dev/null +++ b/keyctl/keyctl_test.go @@ -0,0 +1,74 @@ +package keyctl + +import ( + "strings" + "testing" + + "github.com/docker/docker-credential-helpers/credentials" + "github.com/pkg/errors" +) + +func TestKeyctlHelper(t *testing.T) { + helper := Keyctl{} + + // remove old stale values from previous failed run if any + credsList, err := helper.List() + if err != nil { + t.Fatal(err) + } + + for s, u := range credsList { + if strings.Contains(s, "amazonecr") || + strings.Contains(s, "docker") { + t.Logf("removing stale test entry for %s:%s", s, u) + helper.Delete(s) + } + } + + creds := &credentials.Credentials{ + ServerURL: "https://foobar.docker.io/v1:tag1", + Username: "nothing", + Secret: "mysecret", + } + helper.Add(creds) + + creds0 := &credentials.Credentials{ + ServerURL: "https://amazonecr.com/v1:tag2", + Username: "nothing0", + Secret: "mysecret0", + } + + creds1 := &credentials.Credentials{ + ServerURL: "https://foobar.docker1.io/v1:tag3", + Username: "nothing1", + Secret: "mysecret1", + } + helper.Add(creds) + helper.Add(creds0) + helper.Add(creds1) + + credsList, err = helper.List() + if err != nil { + t.Fatal(err) + } + + for s, u := range credsList { + if !strings.Contains(s, "amazonecr") && + !strings.Contains(s, "docker") { + t.Fatalf("unrecognized server name found Server: %s Username: %s ", s, u) + } + err = helper.Delete(s) + if err != nil { + errors.Wrapf(err, "error in deleting %s", s) + } + } + + /* Read the list of credentials again */ + credsList, err = helper.List() + if err != nil { + t.Fatal(err) + } + if len(credsList) != 0 { + t.Fatalf("didn't delete all creds? %d", len(credsList)) + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 46632f0f..66a0ed24 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,5 +1,9 @@ # github.com/danieljoos/wincred v1.1.2 github.com/danieljoos/wincred +# github.com/jsipprell/keyctl v1.0.0 +github.com/jsipprell/keyctl +# github.com/pkg/errors v0.9.1 +github.com/pkg/errors # golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c golang.org/x/sys/execabs golang.org/x/sys/internal/unsafeheader