Skip to content

Commit

Permalink
Migrate keychain to use non-CGO libraries
Browse files Browse the repository at this point in the history
  • Loading branch information
tylercreller committed Feb 26, 2024
1 parent 6848726 commit 34e0663
Show file tree
Hide file tree
Showing 11 changed files with 452 additions and 17 deletions.
14 changes: 13 additions & 1 deletion .github/workflows/check-pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,27 @@ jobs:

test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
go:
- "1.21"
platform:
- ubuntu-latest
- macos-latest
- windows-latest
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout the source
uses: actions/checkout@v2

- name: Install Keyrings (macOS-only)
if: ${{ contains(fromJSON('["macos-latest"]'), matrix.platform) }}
run: brew install pass gnupg

- name: Install Keyrings (linux)
if: ${{ contains(fromJSON('["ubuntu-latest"]'), matrix.platform) }}
run: sudo apt-get install pass

- name: Setup Go
uses: actions/setup-go@v2
with:
Expand Down
70 changes: 70 additions & 0 deletions authentication/securestore/keychain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//go:build darwin
// +build darwin

/*
Copyright (c) 2024 Red Hat, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package securestore

import (
"time"

. "github.com/onsi/ginkgo/v2" // nolint
. "github.com/onsi/gomega" // nolint

. "github.com/openshift-online/ocm-sdk-go/testing" // nolint
)

var _ = Describe("Keychain", func() {
const backend = "keychain"

BeforeEach(func() {
RemoveConfigFromKeyring(backend)
})

When("Listing Keyrings", func() {
It("Lists keychain as a valid keyring", func() {
backends := AvailableBackends()
Expect(backends).To(ContainElement(backend))
})
})

When("Using Keychain", func() {
It("Stores/Removes via Keychain", func() {
// Create the token
accessToken := MakeTokenString("Bearer", 15*time.Minute)

// Run insert
err := UpsertConfigToKeyring(backend, []byte(accessToken))

Expect(err).To(BeNil())

// Check the content of the keyring
result, err := GetConfigFromKeyring(backend)
Expect(result).To(Equal([]byte(accessToken)))
Expect(err).To(BeNil())

// Remove the configuration from the keyring
err = RemoveConfigFromKeyring(backend)
Expect(err).To(BeNil())

// Ensure the keyring is empty
result, err = GetConfigFromKeyring(backend)
Expect(result).To(Equal([]byte("")))
Expect(err).To(BeNil())
})
})
})
80 changes: 64 additions & 16 deletions authentication/securestore/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"compress/gzip"
"fmt"
"io"
"runtime"
"strings"

"github.com/99designs/keyring"
gokeyring "github.com/zalando/go-keyring"
)

const (
Expand Down Expand Up @@ -46,8 +48,6 @@ func getKeyringConfig(backend string) keyring.Config {
}

// IsBackendAvailable provides validation that the desired backend is available on the current OS.
//
// Note: CGO_ENABLED=1 is required for darwin builds (enables OSX Keychain)
func IsBackendAvailable(backend string) (isAvailable bool) {
if backend == "" {
return false
Expand All @@ -64,11 +64,14 @@ func IsBackendAvailable(backend string) (isAvailable bool) {
}

// AvailableBackends provides a slice of all available backend keys on the current OS.
//
// Note: CGO_ENABLED=1 is required for darwin builds (enables OSX Keychain)
func AvailableBackends() []string {
b := []string{}

if runtime.GOOS == "darwin" {
// Assume Keychain is always available on Darwin. It will not return from keyring.AvailableBackends()
b = append(b, "keychain")
}

// Intersection between available backends from OS and allowed backends
for _, avail := range keyring.AvailableBackends() {
for _, allowed := range AllowedBackends {
Expand All @@ -82,13 +85,15 @@ func AvailableBackends() []string {
}

// UpsertConfigToKeyring will upsert the provided credentials to the desired OS secure store.
//
// Note: CGO_ENABLED=1 is required for darwin builds (enables OSX Keychain)
func UpsertConfigToKeyring(backend string, creds []byte) error {
if err := ValidateBackend(backend); err != nil {
return err
}

if runtime.GOOS == "darwin" && backend == "keychain" {
return keychainUpsert(creds)
}

ring, err := keyring.Open(getKeyringConfig(backend))
if err != nil {
return err
Expand Down Expand Up @@ -116,13 +121,15 @@ func UpsertConfigToKeyring(backend string, creds []byte) error {
}

// RemoveConfigFromKeyring will remove the credentials from the first priority OS secure store.
//
// Note: CGO_ENABLED=1 is required for OSX Keychain and darwin builds
func RemoveConfigFromKeyring(backend string) error {
if err := ValidateBackend(backend); err != nil {
return err
}

if runtime.GOOS == "darwin" && backend == "keychain" {
return keychainRemove()
}

ring, err := keyring.Open(getKeyringConfig(backend))
if err != nil {
return err
Expand All @@ -134,23 +141,21 @@ func RemoveConfigFromKeyring(backend string) error {
// Ignore not found errors, key is already removed
return nil
}

if strings.Contains(err.Error(), "Keychain Error. (-25244)") {
return fmt.Errorf("%s\nThis application may not have permission to delete from the Keychain. Please check the permissions in the Keychain and try again", err.Error())
}
}

return err
}

// GetConfigFromKeyring will retrieve the credentials from the first priority OS secure store.
//
// Note: CGO_ENABLED=1 is required for darwin builds (enables OSX Keychain)
func GetConfigFromKeyring(backend string) ([]byte, error) {
if err := ValidateBackend(backend); err != nil {
return nil, err
}

if runtime.GOOS == "darwin" && backend == "keychain" {
return keychainGet()
}

credentials := []byte("")

ring, err := keyring.Open(getKeyringConfig(backend))
Expand Down Expand Up @@ -182,8 +187,6 @@ func GetConfigFromKeyring(backend string) ([]byte, error) {
}

// Validates that the requested backend is valid and available, returns an error if not.
//
// Note: CGO_ENABLED=1 is required for darwin builds (enables OSX Keychain)
func ValidateBackend(backend string) error {
if backend == "" {
return ErrKeyringInvalid
Expand All @@ -207,6 +210,51 @@ func ValidateBackend(backend string) error {
return nil
}

func keychainGet() ([]byte, error) {
credentials, err := gokeyring.Get(ItemKey, ItemKey)
if err != nil && err != gokeyring.ErrNotFound {
return []byte(credentials), err
} else if err == gokeyring.ErrNotFound {
return []byte(""), nil
}

if len(credentials) == 0 {
// No creds to decompress, return early
return []byte(""), nil
}

creds, err := decompressConfig([]byte(credentials))
if err != nil {
return nil, err
}
return creds, nil
}

func keychainUpsert(creds []byte) error {
compressed, err := compressConfig(creds)
if err != nil {
return err
}

err = gokeyring.Set(ItemKey, ItemKey, string(compressed))
if err != nil {
return err
}

return nil
}

func keychainRemove() error {
err := gokeyring.Delete(ItemKey, ItemKey)
if err != nil {
if strings.Contains(err.Error(), "Keychain Error. (-25244)") {
return fmt.Errorf("%s\nThis application may not have permission to delete from the Keychain. Please check the permissions in the Keychain and try again", err.Error())
}
}

return err
}

// Compresses credential bytes to help ensure all OS secure stores can store the data.
// Windows Credential Manager has a 2500 byte limit.
func compressConfig(creds []byte) ([]byte, error) {
Expand Down
130 changes: 130 additions & 0 deletions authentication/securestore/pass_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//go:build !windows
// +build !windows

/*
Copyright (c) 2024 Red Hat, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package securestore

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"

. "github.com/onsi/ginkgo/v2" // nolint
. "github.com/onsi/gomega" // nolint

. "github.com/openshift-online/ocm-sdk-go/testing" // nolint
)

// This test requires `pass` to be installed.
// macOS: `brew install pass`
// linux: `sudo apt-get install pass` or `sudo yum install pass`

const keyring_dir = "keyring-pass-test-*"

func runCmd(cmds ...string) {
cmd := exec.Command(cmds[0], cmds[1:]...) //nolint:gosec
out, err := cmd.CombinedOutput()
if err != nil {
fmt.Println(cmd)
fmt.Println(string(out))
Fail(err.Error())
}
}

var _ = Describe("Pass Keyring", Ordered, func() {
const backend = "pass"

BeforeAll(func() {
pwd, err := os.Getwd()
if err != nil {
Fail(err.Error())
}
pwdParent := filepath.Dir(pwd)

// the default temp directory can't be used because gpg-agent complains with "socket name too long"
tmpdir, err := os.MkdirTemp("/tmp", keyring_dir)
if err != nil {
Fail(err.Error())

}
tmpdirPass, err := os.MkdirTemp("/tmp", ".password-store-*")
if err != nil {
Fail(err.Error())
}

// Initialise a blank GPG homedir; import & trust the test key
gnupghome := filepath.Join(tmpdir, ".gnupg")
err = os.Mkdir(gnupghome, os.FileMode(int(0700)))
if err != nil {
Fail(err.Error())
}
os.Setenv("GNUPGHOME", gnupghome)
os.Setenv("PASSWORD_STORE_DIR", tmpdirPass)
os.Unsetenv("GPG_AGENT_INFO")
os.Unsetenv("GPG_TTY")

runCmd("gpg", "--batch", "--import", filepath.Join(pwdParent, "testdata", "test-gpg.key"))
runCmd("gpg", "--batch", "--import-ownertrust", filepath.Join(pwdParent, "testdata", "test-ownertrust-gpg.txt"))
runCmd("pass", "init", "[email protected]")

DeferCleanup(func() {
os.Unsetenv("GNUPGHOME")
os.Unsetenv("PASSWORD_STORE_DIR")
os.RemoveAll(filepath.Join("/tmp", keyring_dir))
})
})

BeforeEach(func() {
RemoveConfigFromKeyring(backend)

Check failure on line 95 in authentication/securestore/pass_test.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value is not checked (errcheck)
})

When("Listing Keyrings", func() {
It("Lists pass as a valid keyring", func() {
backends := AvailableBackends()
Expect(backends).To(ContainElement(backend))
})
})

When("Using Pass", func() {
It("Stores/Removes configuration in Pass", func() {
// Create the token
accessToken := MakeTokenString("Bearer", 15*time.Minute)

// Run insert
err := UpsertConfigToKeyring(backend, []byte(accessToken))

Expect(err).To(BeNil())

// Check the content of the keyring
result, err := GetConfigFromKeyring(backend)
Expect(result).To(Equal([]byte(accessToken)))
Expect(err).To(BeNil())

// Remove the configuration from the keyring
err = RemoveConfigFromKeyring(backend)
Expect(err).To(BeNil())

// Ensure the keyring is empty
result, err = GetConfigFromKeyring(backend)
Expect(result).To(Equal([]byte("")))
Expect(err).To(BeNil())
})
})
})
Loading

0 comments on commit 34e0663

Please sign in to comment.