Skip to content

Commit 1ab1037

Browse files
author
Tycho Andersen
committed
add a pass credential helper backend
Signed-off-by: Tycho Andersen <[email protected]>
1 parent 4fbc86d commit 1ab1037

File tree

6 files changed

+313
-1
lines changed

6 files changed

+313
-1
lines changed

.travis.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
apt:
1616
packages:
1717
- libsecret-1-dev
18+
- pass
1819
before_script:
1920
- "export DISPLAY=:99.0"
2021
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sh ci/before_script_linux.sh; fi

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: all deps osxkeychain secretservice test validate wincred
1+
.PHONY: all deps osxkeychain secretservice test validate wincred pass
22

33
TRAVIS_OS_NAME ?= linux
44
VERSION := $(shell grep 'const Version' credentials/version.go | awk -F'"' '{ print $$2 }')
@@ -30,6 +30,10 @@ secretservice:
3030
mkdir bin
3131
go build -o bin/docker-credential-secretservice secretservice/cmd/main_linux.go
3232

33+
pass:
34+
mkdir -p bin
35+
go build -o bin/docker-credential-pass pass/cmd/main_linux.go
36+
3337
wincred:
3438
mkdir bin
3539
go build -o bin/docker-credential-wincred.exe wincred/cmd/main_windows.go

ci/before_script_linux.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,21 @@ set -ex
22

33
sh -e /etc/init.d/xvfb start
44
sleep 3 # give xvfb some time to start
5+
6+
# init key for pass
7+
gpg --batch --gen-key <<-EOF
8+
%echo Generating a standard key
9+
Key-Type: DSA
10+
Key-Length: 1024
11+
Subkey-Type: ELG-E
12+
Subkey-Length: 1024
13+
Name-Real: Meshuggah Rocks
14+
Name-Email: [email protected]
15+
Expire-Date: 0
16+
# Do a commit here, so that we can later print "done" :-)
17+
%commit
18+
%echo done
19+
EOF
20+
21+
key=$(gpg --no-auto-check-trustdb --list-secret-keys | grep ^sec | cut -d/ -f2 | cut -d" " -f1)
22+
pass init $key

pass/cmd/main_linux.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package main
2+
3+
import (
4+
"github.com/docker/docker-credential-helpers/credentials"
5+
"github.com/docker/docker-credential-helpers/pass"
6+
)
7+
8+
func main() {
9+
credentials.Serve(pass.Pass{})
10+
}

pass/pass_linux.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// A `pass` based credential helper. Passwords are stored as arguments to pass
2+
// of the form: "$PASS_FOLDER/base64-url(serverURL)/username". We base64-url
3+
// encode the serverURL, because under the hood pass uses files and folders, so
4+
// /s will get translated into additional folders.
5+
package pass
6+
7+
import (
8+
"encoding/base64"
9+
"errors"
10+
"fmt"
11+
"io/ioutil"
12+
"os"
13+
"os/exec"
14+
"path"
15+
"strings"
16+
17+
"github.com/docker/docker-credential-helpers/credentials"
18+
)
19+
20+
const PASS_FOLDER = "docker-credential-helpers"
21+
22+
var (
23+
passInitialized bool
24+
)
25+
26+
func init() {
27+
passInitialized = exec.Command("pass").Run() == nil
28+
}
29+
30+
func runPass(stdinContent string, args ...string) (string, error) {
31+
cmd := exec.Command("pass", args...)
32+
33+
stdin, err := cmd.StdinPipe()
34+
if err != nil {
35+
return "", err
36+
}
37+
defer stdin.Close()
38+
39+
stderr, err := cmd.StderrPipe()
40+
if err != nil {
41+
return "", err
42+
}
43+
defer stderr.Close()
44+
45+
stdout, err := cmd.StdoutPipe()
46+
if err != nil {
47+
return "", err
48+
}
49+
defer stdout.Close()
50+
51+
err = cmd.Start()
52+
if err != nil {
53+
return "", err
54+
}
55+
56+
_, err = stdin.Write([]byte(stdinContent))
57+
if err != nil {
58+
return "", err
59+
}
60+
stdin.Close()
61+
62+
errContent, err := ioutil.ReadAll(stderr)
63+
if err != nil {
64+
return "", fmt.Errorf("error reading stderr: %s", err)
65+
}
66+
67+
result, err := ioutil.ReadAll(stdout)
68+
if err != nil {
69+
return "", fmt.Errorf("Error reading stdout: %s", err)
70+
}
71+
72+
cmdErr := cmd.Wait()
73+
if cmdErr != nil {
74+
return "", fmt.Errorf("%s: %s", cmdErr, errContent)
75+
}
76+
77+
return string(result), nil
78+
}
79+
80+
// Pass handles secrets using Linux secret-service as a store.
81+
type Pass struct{}
82+
83+
// Add adds new credentials to the keychain.
84+
func (h Pass) Add(creds *credentials.Credentials) error {
85+
if !passInitialized {
86+
return errors.New("pass store is uninitialized")
87+
}
88+
89+
if creds == nil {
90+
return errors.New("missing credentials")
91+
}
92+
93+
encoded := base64.URLEncoding.EncodeToString([]byte(creds.ServerURL))
94+
95+
_, err := runPass(creds.Secret, "insert", "-f", "-m", path.Join(PASS_FOLDER, encoded, creds.Username))
96+
return err
97+
}
98+
99+
// Delete removes credentials from the store.
100+
func (h Pass) Delete(serverURL string) error {
101+
if !passInitialized {
102+
return errors.New("pass store is uninitialized")
103+
}
104+
105+
if serverURL == "" {
106+
return errors.New("missing server url")
107+
}
108+
109+
encoded := base64.URLEncoding.EncodeToString([]byte(serverURL))
110+
_, err := runPass("", "rm", "-rf", path.Join(PASS_FOLDER, encoded))
111+
return err
112+
}
113+
114+
// listPassDir lists all the contents of a directory in the password store.
115+
// Pass uses fancy unicode to emit stuff to stdout, so rather than try
116+
// and parse this, let's just look at the directory structure instead.
117+
func listPassDir(args ...string) ([]os.FileInfo, error) {
118+
passDir := os.ExpandEnv("$HOME/.password-store")
119+
for _, e := range os.Environ() {
120+
parts := strings.SplitN(e, "=", 2)
121+
if len(parts) < 2 {
122+
continue
123+
}
124+
125+
if parts[0] != "PASSWORD_STORE_DIR" {
126+
continue
127+
}
128+
129+
passDir = parts[1]
130+
break
131+
}
132+
133+
p := path.Join(append([]string{passDir, PASS_FOLDER}, args...)...)
134+
contents, err := ioutil.ReadDir(p)
135+
if err != nil {
136+
if os.IsNotExist(err) {
137+
return []os.FileInfo{}, nil
138+
}
139+
140+
return nil, err
141+
}
142+
143+
return contents, nil
144+
}
145+
146+
// Get returns the username and secret to use for a given registry server URL.
147+
func (h Pass) Get(serverURL string) (string, string, error) {
148+
if !passInitialized {
149+
return "", "", errors.New("pass store is uninitialized")
150+
}
151+
152+
if serverURL == "" {
153+
return "", "", errors.New("missing server url")
154+
}
155+
156+
encoded := base64.URLEncoding.EncodeToString([]byte(serverURL))
157+
158+
usernames, err := listPassDir(encoded)
159+
if err != nil {
160+
return "", "", err
161+
}
162+
163+
if len(usernames) < 1 {
164+
return "", "", fmt.Errorf("no usernames for %s", serverURL)
165+
}
166+
167+
actual := strings.TrimSuffix(usernames[0].Name(), ".gpg")
168+
secret, err := runPass("", "show", path.Join(PASS_FOLDER, encoded, actual))
169+
return actual, secret, err
170+
}
171+
172+
// List returns the stored URLs and corresponding usernames for a given credentials label
173+
func (h Pass) List() (map[string]string, error) {
174+
if !passInitialized {
175+
return nil, errors.New("pass store is uninitialized")
176+
}
177+
178+
servers, err := listPassDir()
179+
if err != nil {
180+
return nil, err
181+
}
182+
183+
resp := map[string]string{}
184+
185+
for _, server := range servers {
186+
if !server.IsDir() {
187+
continue
188+
}
189+
190+
serverURL, err := base64.URLEncoding.DecodeString(server.Name())
191+
if err != nil {
192+
return nil, err
193+
}
194+
195+
usernames, err := listPassDir(server.Name())
196+
if err != nil {
197+
return nil, err
198+
}
199+
200+
if len(usernames) < 1 {
201+
return nil, fmt.Errorf("no usernames for %s", serverURL)
202+
}
203+
204+
resp[string(serverURL)] = strings.TrimSuffix(usernames[0].Name(), ".gpg")
205+
}
206+
207+
return resp, nil
208+
}

pass/pass_linux_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package pass
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/docker/docker-credential-helpers/credentials"
8+
)
9+
10+
func TestPassHelper(t *testing.T) {
11+
helper := Pass{}
12+
13+
creds := &credentials.Credentials{
14+
ServerURL: "https://foobar.docker.io:2376/v1",
15+
Username: "nothing",
16+
Secret: "isthebestmeshuggahalbum",
17+
}
18+
19+
helper.Add(creds)
20+
21+
creds.ServerURL = "https://foobar.docker.io:9999/v2"
22+
helper.Add(creds)
23+
24+
credsList, err := helper.List()
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
29+
for server, username := range credsList {
30+
if !(strings.Contains(server, "2376") ||
31+
strings.Contains(server, "9999")) {
32+
t.Fatalf("invalid url: %s", creds.ServerURL)
33+
}
34+
35+
if username != "nothing" {
36+
t.Fatalf("invalid username: %v", username)
37+
}
38+
39+
u, s, err := helper.Get(server)
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
44+
if u != username {
45+
t.Fatalf("invalid username %s", u)
46+
}
47+
48+
if s != "isthebestmeshuggahalbum" {
49+
t.Fatalf("invalid secret: %s", s)
50+
}
51+
52+
err = helper.Delete(server)
53+
if err != nil {
54+
t.Fatal(err)
55+
}
56+
57+
_, _, err = helper.Get(server)
58+
if err == nil {
59+
t.Fatalf("%s shuldn't exist any more", server)
60+
}
61+
}
62+
63+
credsList, err = helper.List()
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
68+
if len(credsList) != 0 {
69+
t.Fatal("didn't delete all creds?")
70+
}
71+
}

0 commit comments

Comments
 (0)