From 08786e973d9b401c1796bb653726a9ad1eb8e1a3 Mon Sep 17 00:00:00 2001 From: Andrea11 <10788630+andrea11@users.noreply.github.com> Date: Sun, 20 Aug 2023 06:08:09 +0200 Subject: [PATCH] support 1password as backend (#404) --- .github/workflows/test.yml | 2 +- README.md | 1 + config.go | 9 ++ go.mod | 7 +- go.sum | 11 ++ keyring.go | 2 + one_password.go | 196 ++++++++++++++++++++++++++++++++++ one_password_test.go | 208 +++++++++++++++++++++++++++++++++++++ 8 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 one_password.go create mode 100644 one_password_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d96c7ab..a020ba6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,6 @@ jobs: - uses: actions/setup-go@v2 with: go-version: 1.19 - - run: brew install pass gnupg + - run: brew install pass gnupg 1password-cli - uses: actions/checkout@v2 - run: go test -race ./... diff --git a/README.md b/README.md index 629a6aa..4b5769c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Currently Keyring supports the following backends * [Pass](https://www.passwordstore.org/) * [Encrypted file (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) * [KeyCtl](https://linux.die.net/man/1/keyctl) + * [1Password](https://1password.com) ## Usage diff --git a/config.go b/config.go index 590af7c..8614916 100644 --- a/config.go +++ b/config.go @@ -55,4 +55,13 @@ type Config struct { // WinCredPrefix is a string prefix to prepend to the key name WinCredPrefix string + + // OnePasswordAccount is the name of the 1Password account to use + OnePasswordAccount string + + // OnePasswordVault is the name of the 1Password vault to use + OnePasswordVault string + + // OnePasswordPrefix is a string prefix to prepend to the key name + OnePasswordPrefix string } diff --git a/go.mod b/go.mod index a9ebba4..ea39899 100644 --- a/go.mod +++ b/go.mod @@ -9,14 +9,15 @@ require ( github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c github.com/mtibben/percent v0.2.1 - github.com/stretchr/testify v1.7.0 + github.com/stretchr/testify v1.8.2 golang.org/x/sys v0.3.0 golang.org/x/term v0.3.0 ) require ( + github.com/1Password/connect-sdk-go v1.5.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.3.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + github.com/stretchr/objx v0.5.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b7726bf..3fdbd16 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/1Password/connect-sdk-go v1.5.3 h1:KyjJ+kCKj6BwB2Y8tPM1Ixg5uIS6HsB0uWA8U38p/Uk= +github.com/1Password/connect-sdk-go v1.5.3/go.mod h1:5rSymY4oIYtS4G3t0oMkGAXBeoYiukV3vkqlnEjIDJs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= @@ -23,9 +25,16 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -37,3 +46,5 @@ gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/keyring.go b/keyring.go index 12161b7..3975e55 100644 --- a/keyring.go +++ b/keyring.go @@ -20,6 +20,7 @@ const ( WinCredBackend BackendType = "wincred" FileBackend BackendType = "file" PassBackend BackendType = "pass" + OnePasswordBackend BackendType = "onepassword" ) // This order makes sure the OS-specific backends @@ -36,6 +37,7 @@ var backendOrder = []BackendType{ // General PassBackend, FileBackend, + OnePasswordBackend, } var supportedBackends = map[BackendType]opener{} diff --git a/one_password.go b/one_password.go new file mode 100644 index 0000000..9d5bdf2 --- /dev/null +++ b/one_password.go @@ -0,0 +1,196 @@ +//go:build darwin && cgo +// +build darwin,cgo + +package keyring + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +func init() { + supportedBackends[OnePasswordBackend] = opener(func(cfg Config) (Keyring, error) { + return &onePasswordKeyring{ + account: cfg.OnePasswordAccount, + vault: cfg.OnePasswordVault, + prefix: cfg.OnePasswordPrefix, + }, nil + }) +} + +type onePasswordKeyring struct { + account string + vault string + prefix string +} + +type onePasswordField struct { + Id string `json:"id"` + Value string `json:"value"` +} + +type onePasswordItem struct { + Id string `json:"id"` + Fields []onePasswordField `json:"fields,omitempty"` + Title string `json:"title"` + UpdatedAt time.Time `json:"updated_at"` +} + +const onePasswordKeyNotFoundFragmentMessage = "isn't an item" +const onePasswordItemCategory = "Secure Note" +const onePasswordItemField = "notesPlain" + +func (k *onePasswordKeyring) retrieveOnePasswordItem(key string) (onePasswordItem, error) { + args := []string{ + "item", + "get", + k.prefix + key, + "--format=json", + } + + if k.account != "" { + args = append(args, "--account", k.account) + } + + if k.vault != "" { + args = append(args, "--vault", k.vault) + } + + cmd := exec.Command("op", args...) + output, err := cmd.CombinedOutput() + + if err != nil { + if strings.Contains(string(output), onePasswordKeyNotFoundFragmentMessage) { + return onePasswordItem{}, ErrKeyNotFound + } + + return onePasswordItem{}, err + } + + var decoded onePasswordItem + err = json.Unmarshal(output, &decoded) + + return decoded, err +} + +func (k *onePasswordKeyring) Get(key string) (Item, error) { + onePasswordItem, err := k.retrieveOnePasswordItem(key) + + if err != nil { + return Item{}, err + } + + var value string + for _, field := range onePasswordItem.Fields { + if field.Id == onePasswordItemField { + value = field.Value + break + } + } + + item := Item{ + Key: strings.TrimPrefix(onePasswordItem.Title, k.prefix), + Data: []byte(fmt.Sprintf("%v", value)), + Label: strings.TrimPrefix(onePasswordItem.Title, k.prefix), + } + return item, nil +} + +func (k *onePasswordKeyring) GetMetadata(key string) (Metadata, error) { + onePasswordItem, err := k.retrieveOnePasswordItem(key) + + if err != nil { + return Metadata{}, err + } + + metadata := Metadata{ + ModificationTime: onePasswordItem.UpdatedAt, + } + + return metadata, nil +} + +func (k *onePasswordKeyring) Set(i Item) error { + k.Remove(i.Key) + + args := []string{ + "item", + "create", + } + + if k.account != "" { + args = append(args, "--account", k.account) + } + + if k.vault != "" { + args = append(args, "--vault", k.vault) + } + + args = append(args, "--category", onePasswordItemCategory) + args = append(args, "--title", k.prefix+i.Key) + + if i.Label != "" { + args = append(args, fmt.Sprintf("%s=%s", "label", i.Label)) + } + + if i.Description != "" { + args = append(args, fmt.Sprintf("%s=%s", "Description", i.Description)) + } + + args = append(args, fmt.Sprintf("%s=%s", onePasswordItemField, string(i.Data))) + + return exec.Command("op", args...).Run() +} + +func (k *onePasswordKeyring) Remove(key string) error { + output, err := exec.Command("op", "item", "delete", k.prefix+key).CombinedOutput() + + if err != nil && strings.Contains(string(output), onePasswordKeyNotFoundFragmentMessage) { + return ErrKeyNotFound + } + + return err +} + +func (k *onePasswordKeyring) Keys() ([]string, error) { + args := []string{ + "item", + "list", + "--format=json", + } + + if k.account != "" { + args = append(args, "--account", k.account) + } + + if k.vault != "" { + args = append(args, "--vault", k.vault) + } + + output, err := exec.Command("op", args...).CombinedOutput() + + if err != nil { + if strings.Contains(string(output), onePasswordKeyNotFoundFragmentMessage) { + return nil, ErrKeyNotFound + } + + return nil, err + } + + var decoded []onePasswordItem + err = json.Unmarshal(output, &decoded) + + if err != nil { + return nil, err + } + + keys := []string{} + for _, item := range decoded { + keys = append(keys, strings.TrimPrefix(item.Title, k.prefix)) + } + + return keys, nil +} diff --git a/one_password_test.go b/one_password_test.go new file mode 100644 index 0000000..6f4c7b3 --- /dev/null +++ b/one_password_test.go @@ -0,0 +1,208 @@ +//go:build darwin && cgo +// +build darwin,cgo + +package keyring_test + +import ( + "os/exec" + "reflect" + "sort" + "testing" + + "github.com/99designs/keyring" +) + +func deleteTestVault() { + exec.Command("op", "vault", "delete", "one_password_test").Run() +} + +func setup(t *testing.T) keyring.Keyring { + t.Helper() + t.Cleanup(deleteTestVault) + kr, err := keyring.Open(keyring.Config{ + AllowedBackends: []keyring.BackendType{keyring.OnePasswordBackend}, + OnePasswordPrefix: "Test", + OnePasswordVault: "one_password_test", + }) + + if err != nil { + t.Fatal(err) + } + + exec.Command("op", "vault", "create", "one_password_test").Run() + + return kr +} + +func TestOnePasswordKeyringSet(t *testing.T) { + kr := setup(t) + item := keyring.Item{Key: "llamas", Data: []byte("llamas are great")} + + if err := kr.Set(item); err != nil { + t.Fatal(err) + } + + foundItem, err := kr.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 TestOnePasswordKeyringOverwrite(t *testing.T) { + kr := setup(t) + + item1 := keyring.Item{ + Key: "llamas", + Label: "Arbitrary label", + Description: "A freetext description", + Data: []byte("llamas are ok"), + } + + if err := kr.Set(item1); err != nil { + t.Fatal(err) + } + + v1, err := kr.Get("llamas") + if err != nil { + t.Fatal(err) + } + + if string(v1.Data) != string(item1.Data) { + t.Fatalf("Data stored was not the data retrieved: %q vs %q", v1.Data, item1.Data) + } + + item2 := keyring.Item{ + Key: "llamas", + Label: "Arbitrary label", + Description: "A freetext description", + Data: []byte("llamas are great"), + } + + if err := kr.Set(item2); err != nil { + t.Fatal(err) + } + + v2, err := kr.Get("llamas") + if err != nil { + t.Fatal(err) + } + + if string(v2.Data) != string(item2.Data) { + t.Fatalf("Data stored was not the data retrieved: %q vs %q", v2.Data, item2.Data) + } +} + +func TestOnePasswordKeyringListKeysWhenEmpty(t *testing.T) { + kr := setup(t) + + keys, err := kr.Keys() + if err != nil { + t.Fatal(err) + } + if len(keys) != 0 { + t.Fatalf("Expected 0 keys, got %d", len(keys)) + } +} + +func TestOnePasswordKeyringListKeysWhenNotEmpty(t *testing.T) { + kr := setup(t) + + keys := []string{"key1", "key2", "key3"} + + for _, key := range keys { + item := keyring.Item{ + Key: key, + Data: []byte("llamas are great"), + } + + if err := kr.Set(item); err != nil { + t.Fatal(err) + } + } + + keys2, err := kr.Keys() + if err != nil { + t.Fatal(err) + } + + sort.Strings(keys) + sort.Strings(keys2) + + if !reflect.DeepEqual(keys, keys2) { + t.Fatalf("Retrieved keys weren't the same: %q vs %q", keys, keys2) + } +} + +func TestOnePasswordGetKeyWhenEmpty(t *testing.T) { + kr := setup(t) + + _, err := kr.Get("no-such-key") + if err != keyring.ErrKeyNotFound { + t.Fatal("expected ErrKeyNotFound") + } +} + +func TestOnePasswordGetKeyWhenNotEmpty(t *testing.T) { + kr := setup(t) + + item := keyring.Item{ + Key: "llamas", + Label: "Arbitrary label", + Description: "A freetext description", + Data: []byte("llamas are ok"), + } + + if err := kr.Set(item); err != nil { + t.Fatal(err) + } + + v1, err := kr.Get("llamas") + if err != nil { + t.Fatal(err) + } + if string(v1.Data) != string(item.Data) { + t.Fatalf("Data stored was not the data retrieved: %q vs %q", v1.Data, item.Data) + } +} + +func TestOnePasswordRemoveKeyWhenEmpty(t *testing.T) { + kr := setup(t) + + err := kr.Remove("no-such-key") + if err != keyring.ErrKeyNotFound { + t.Fatalf("expected ErrKeyNotFound, got: %v", err) + } +} + +func TestOnePasswordRemoveKeyWhenNotEmpty(t *testing.T) { + kr := setup(t) + + item := keyring.Item{ + Key: "llamas", + Label: "Arbitrary label", + Description: "A freetext description", + Data: []byte("llamas are ok"), + } + + if err := kr.Set(item); err != nil { + t.Fatal(err) + } + + _, err := kr.Get("llamas") + if err != nil { + t.Fatal(err) + } + + err = kr.Remove("llamas") + if err != nil { + t.Fatal(err) + } +}