diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dce21ff..6f82569 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,12 @@ jobs: #go-version-file: 'go.mod' check-latest: true - run: sudo apt-get update - - run: sudo apt-get install pass gnome-keyring dbus-x11 + - run: sudo apt-get install pass gnome-keyring dbus-x11 age + - run: | + git clone https://github.com/FiloSottile/passage /tmp/passage + cd /tmp/passage + sudo make install + - run: go test -race ./... mac: runs-on: macos-latest @@ -35,5 +40,11 @@ jobs: #go-version: 1.19 go-version-file: 'go.mod' check-latest: true - - run: brew install pass + - run: brew install pass age + - run: | + git clone https://github.com/FiloSottile/passage /tmp/passage + cd /tmp/passage + make install PREFIX="$(brew --cellar)/passage/$(git describe --tags)" + brew link passage + - run: go test -race ./... diff --git a/README.md b/README.md index b8445ac..02fbba1 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Currently Keyring supports the following backends * Secret Service ([Gnome Keyring](https://wiki.gnome.org/Projects/GnomeKeyring), [KWallet](https://kde.org/applications/system/org.kde.kwalletmanager5)) * [KWallet](https://kde.org/applications/system/org.kde.kwalletmanager5) * [Pass](https://www.passwordstore.org/) + * [Passage](https://github.com/FiloSottile/passage) * [Encrypted file (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) * [KeyCtl](https://linux.die.net/man/1/keyctl) * [1Password Connect](https://developer.1password.com/docs/connect/) diff --git a/keyring.go b/keyring.go index 8f55dcb..ca5865c 100644 --- a/keyring.go +++ b/keyring.go @@ -20,6 +20,7 @@ const ( WinCredBackend BackendType = "wincred" FileBackend BackendType = "file" PassBackend BackendType = "pass" + PassageBackend BackendType = "passage" OPBackend BackendType = "op" OPConnectBackend BackendType = "op-connect" OPDesktopBackend BackendType = "op-desktop" @@ -42,6 +43,7 @@ var backendOrder = []BackendType{ KeyCtlBackend, // General PassBackend, + PassageBackend, FileBackend, // 1Password OPConnectBackend, diff --git a/passage.go b/passage.go new file mode 100644 index 0000000..7399b13 --- /dev/null +++ b/passage.go @@ -0,0 +1,165 @@ +//go:build !windows + +package keyring + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func init() { + supportedBackends[PassageBackend] = opener(func(cfg Config) (Keyring, error) { + var err error + + passage := &passageKeyring{ + passcmd: cfg.PassCmd, + dir: cfg.PassDir, + prefix: cfg.PassPrefix, + } + + if passage.passcmd == "" { + passage.passcmd = "passage" + } + + if passage.dir == "" { + if passDir, found := os.LookupEnv("PASSAGE_DIR"); found { + passage.dir = passDir + } else { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + passage.dir = filepath.Join(homeDir, ".passage/store") + } + } + + passage.dir, err = ExpandTilde(passage.dir) + if err != nil { + return nil, err + } + + // fail if the pass program is not available + _, err = exec.LookPath(passage.passcmd) + if err != nil { + return nil, errors.New("The passage program is not available") + } + + return passage, nil + }) +} + +type passageKeyring struct { + dir string + passcmd string + prefix string +} + +func (k *passageKeyring) pass(args ...string) *exec.Cmd { + cmd := exec.Command(k.passcmd, args...) + if k.dir != "" { + cmd.Env = append(os.Environ(), fmt.Sprintf("PASSAGE_DIR=%s", k.dir)) + } + cmd.Stderr = os.Stderr + + return cmd +} + +func (k *passageKeyring) Get(key string) (Item, error) { + if !k.itemExists(key) { + return Item{}, ErrKeyNotFound + } + + name := filepath.Join(k.prefix, key) + cmd := k.pass("show", name) + output, err := cmd.Output() + if err != nil { + return Item{}, err + } + + var decoded Item + err = json.Unmarshal(output, &decoded) + + return decoded, err +} + +func (k *passageKeyring) GetMetadata(key string) (Metadata, error) { + return Metadata{}, nil +} + +func (k *passageKeyring) Set(i Item) error { + bytes, err := json.Marshal(i) + if err != nil { + return err + } + + name := filepath.Join(k.prefix, i.Key) + cmd := k.pass("insert", "-m", "-f", name) + cmd.Stdin = strings.NewReader(string(bytes)) + + err = cmd.Run() + if err != nil { + return err + } + + return nil +} + +func (k *passageKeyring) Remove(key string) error { + if !k.itemExists(key) { + return ErrKeyNotFound + } + + name := filepath.Join(k.prefix, key) + cmd := k.pass("rm", "-f", name) + err := cmd.Run() + if err != nil { + return err + } + + return nil +} + +func (k *passageKeyring) itemExists(key string) bool { + var path = filepath.Join(k.dir, k.prefix, key+".age") + _, err := os.Stat(path) + + return err == nil +} + +func (k *passageKeyring) Keys() ([]string, error) { + var keys = []string{} + var path = filepath.Join(k.dir, k.prefix) + + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return keys, nil + } + return keys, err + } + if !info.IsDir() { + return keys, fmt.Errorf("%s is not a directory", path) + } + + err = filepath.Walk(path, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && filepath.Ext(p) == ".age" { + name := strings.TrimPrefix(p, path) + if name[0] == os.PathSeparator { + name = name[1:] + } + keys = append(keys, name[:len(name)-4]) + } + return nil + }) + + return keys, err +} diff --git a/passage_test.go b/passage_test.go new file mode 100644 index 0000000..d4fa5cf --- /dev/null +++ b/passage_test.go @@ -0,0 +1,222 @@ +//go:build !windows + +package keyring + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "testing" +) + +// we use runCmd defined in pass_test.go +func passageSetup(t *testing.T) (*passageKeyring, func(t *testing.T)) { + t.Helper() + + tmpdir, err := os.MkdirTemp("/tmp", "keyring-passage-test-*") + if err != nil { + t.Fatal(err) + } + + // Initialise a passage homedir; create a test identity + passagehome := filepath.Join(tmpdir, ".passage") + err = os.MkdirAll(filepath.Join(passagehome, "store"), os.FileMode(int(0700))) + if err != nil { + t.Fatal(err) + } + identityFile := filepath.Join(passagehome, "identities") + runCmd(t, "age-keygen", "--output", identityFile) + t.Setenv("PASSAGE_IDENTITIES_FILE", identityFile) + + passdir := filepath.Join(passagehome, "store") + k := &passageKeyring{ + dir: passdir, + passcmd: "passage", + prefix: "keyring", + } + + return k, func(t *testing.T) { + t.Helper() + os.RemoveAll(tmpdir) + } +} + +func TestPassageKeyringSetWhenEmpty(t *testing.T) { + k, teardown := passageSetup(t) + defer teardown(t) + + item := Item{Key: "llamas", Data: []byte("llamas are great")} + + if err := k.Set(item); err != nil { + t.Fatal(err) + } + + foundItem, err := k.Get("llamas") + if err != nil { + t.Fatal(err) + } + + if string(foundItem.Data) != "llamas are great" { + t.Fatalf("Value stored was not the value retrieved: %q", foundItem.Data) + } + + if foundItem.Key != "llamas" { + t.Fatalf("Key wasn't persisted: %q", foundItem.Key) + } +} + +func TestPassageKeyringKeysWhenEmpty(t *testing.T) { + k, teardown := passageSetup(t) + defer teardown(t) + + keys, err := k.Keys() + if err != nil { + t.Fatal(err) + } + if len(keys) != 0 { + t.Fatalf("Expected 0 keys, got %d", len(keys)) + } +} + +func TestPassageKeyringKeysWhenNotEmpty(t *testing.T) { + k, teardown := passageSetup(t) + defer teardown(t) + + items := []Item{ + {Key: "llamas", Data: []byte("llamas are great")}, + {Key: "alpacas", Data: []byte("alpacas are better")}, + {Key: "africa/elephants", Data: []byte("who doesn't like elephants")}, + } + + for _, item := range items { + if err := k.Set(item); err != nil { + t.Fatal(err) + } + } + + keys, err := k.Keys() + if err != nil { + t.Fatal(err) + } + + if len(keys) != len(items) { + t.Fatalf("Expected %d keys, got %d", len(items), len(keys)) + } + + expectedKeys := []string{ + "africa/elephants", + "alpacas", + "llamas", + } + + if !reflect.DeepEqual(keys, expectedKeys) { + t.Fatalf("Expected keys %v, got %v", expectedKeys, keys) + } +} + +func TestPassageKeyringRemoveWhenEmpty(t *testing.T) { + k, teardown := passageSetup(t) + defer teardown(t) + + err := k.Remove("no-such-key") + if err != ErrKeyNotFound { + t.Fatalf("expected ErrKeyNotFound, got: %v", err) + } +} + +func TestPassageKeyringRemoveWhenNotEmpty(t *testing.T) { + k, teardown := passageSetup(t) + defer teardown(t) + + item := Item{Key: "llamas", Data: []byte("llamas are great")} + + if err := k.Set(item); err != nil { + t.Fatal(err) + } + + if err := k.Remove(item.Key); err != nil { + t.Fatal(err) + } + + keys, err := k.Keys() + if err != nil { + t.Fatal(err) + } + + if len(keys) != 0 { + t.Fatalf("Expected 0 keys, got %d", len(keys)) + } +} + +func TestPassageKeyringGetWhenEmpty(t *testing.T) { + k, teardown := passageSetup(t) + defer teardown(t) + + _, err := k.Get("no-such-key") + if err != ErrKeyNotFound { + t.Fatalf("expected ErrKeyNotFound, got: %v", err) + } +} + +func TestPassageKeyringGetWhenNotEmpty(t *testing.T) { + k, teardown := passageSetup(t) + defer teardown(t) + + item := Item{Key: "llamas", Data: []byte("llamas are great")} + + if err := k.Set(item); err != nil { + t.Fatal(err) + } + + v1, err := k.Get(item.Key) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(v1.Data, item.Data) { + t.Fatal("Expected item not returned") + } +} + +func TestPassageKeyringKeysWithSymlink(t *testing.T) { + k, teardown := passageSetup(t) + defer teardown(t) + + items := []Item{ + {Key: "llamas", Data: []byte("llamas are great")}, + {Key: "alpacas", Data: []byte("alpacas are better")}, + {Key: "africa/elephants", Data: []byte("who doesn't like elephants")}, + } + + for _, item := range items { + if err := k.Set(item); err != nil { + t.Fatal(err) + } + } + + s := filepath.Join(t.TempDir(), "newsymlink") + err := os.Symlink(k.dir, s) + if err != nil { + t.Fatal(err) + } + k.dir = s + + keys, err := k.Keys() + if err != nil { + t.Fatal(err) + } + + if len(keys) != len(items) { + t.Fatalf("Expected %d keys, got %d", len(items), len(keys)) + } + + expectedKeys := []string{ + "africa/elephants", + "alpacas", + "llamas", + } + + if !reflect.DeepEqual(keys, expectedKeys) { + t.Fatalf("Expected keys %v, got %v", expectedKeys, keys) + } +}