Skip to content

Commit

Permalink
feat: Add keychain provider for reading text/yaml/json secrets (#585)
Browse files Browse the repository at this point in the history
* feat: Add keychain provider for reading text/yaml/json secrets

Signed-off-by: Dmitry K. Anisimov <mail@anisimov.dk>

* fix: use security cmd for keychain secret

Signed-off-by: Dmitry K. Anisimov <mail@anisimov.dk>

* fix: add some tests

Signed-off-by: Dmitry K. Anisimov <mail@anisimov.dk>

* fix: fix test

Signed-off-by: Dmitry K. Anisimov <mail@anisimov.dk>

---------

Signed-off-by: Dmitry K. Anisimov <mail@anisimov.dk>
  • Loading branch information
anisimovdk authored Dec 4, 2024
1 parent 258e43d commit ab686ca
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 1 deletion.
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ It supports various backends including:
- HCP Vault Secrets
- Bitwarden
- HTTP JSON
- Keychain

- Use `vals eval -f refs.yaml` to replace all the `ref`s in the file to actual values and secrets.
- Use `vals exec -f env.yaml -- <COMMAND>` to populate envvars and execute the command.
Expand Down Expand Up @@ -216,6 +217,7 @@ Please see the [relevant unit test cases](https://github.com/helmfile/vals/blob/
- [Google GCS](#google-gcs)
- [SOPS](#sops) powered by [sops](https://github.com/getsops/sops)
- [Terraform (tfstate)](#terraform-tfstate) powered by [tfstate-lookup](https://github.com/fujiwara/tfstate-lookup)
- [Keychain](#keychain)
- [Echo](#echo)
- [File](#file)
- [Azure Key Vault](#azure-key-vault)
Expand Down Expand Up @@ -592,6 +594,18 @@ Examples:
- `ref+sops://path/to/file#/foo/bar` reads `path/to/file` as a `yaml` file and returns the value at `foo.bar`.
- `ref+sops://path/to/file?format=json#/foo/bar` reads `path/to/file` as a `json` file and returns the value at `foo.bar`.
### Keychain
Keychain provider is going to be available on macOS only. It reads a secret from the macOS Keychain.
- `ref+keychain://KEY1/[#/path/to/the/value]`
Examples:
- `security add-generic-password -U -a ${USER} -s "secret-name" -D "vals-secret" -w '{"foo":{"bar":"baz"}}'` - will create a secret in the Keychain with the name `secret-name` and the value `{"foo":{"bar":"baz"}}`, `vals-secret` is required to be able to find the secret in the Keychain.
- `echo 'foo: ref+keychain://secret-name' | vals eval -f -` - will read the secret from the Keychain with the name `secret-name` and replace the `foo` with the secret value.
- `echo 'foo: ref+keychain://secret-name#/foo/bar' | vals eval -f -` - will read the secret from the Keychain with the name `secret-name` and replace the `foo` with the value at the path `$.foo.bar`.
### Echo
Echo provider echoes the string for testing purpose. Please read [the original proposal](https://github.com/roboll/helmfile/pull/920#issuecomment-548213738) to get why we might need this.
Expand All @@ -603,7 +617,6 @@ Examples:
- `ref+echo://foo/bar` generates `foo/bar`
- `ref+echo://foo/bar/baz#/foo/bar` generates `baz`. This works by the host and the path part `foo/bar/baz` generating an object `{"foo":{"bar":"baz"}}` and the fragment part `#/foo/bar` results in digging the object to obtain the value at `$.foo.bar`.
### File
File provider reads a local text file, or the value for the specific path in a YAML/JSON file.
Expand Down
96 changes: 96 additions & 0 deletions pkg/providers/keychain/keychain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package keychain

import (
"encoding/hex"
"errors"
"os/exec"
"runtime"
"strings"

"gopkg.in/yaml.v3"

"github.com/helmfile/vals/pkg/api"
)

const keychainKind = "vals-secret"

type provider struct {
}

func New(cfg api.StaticConfig) *provider {
p := &provider{}
return p
}

// isHex checks if a string is a valid hexadecimal string
func isHex(s string) bool {
// Check if the string length is even
if len(s)%2 != 0 {
return false
}

// Attempt to decode the string
_, err := hex.DecodeString(s)
return err == nil // If no error, it's valid hex
}

// isDarwin checks if the current OS is macOS
func isDarwin() bool {
return runtime.GOOS == "darwin"
}

// getKeychainSecret retrieves a secret from the macOS keychain with security find-generic-password
func getKeychainSecret(key string) ([]byte, error) {
if !isDarwin() {
return nil, errors.New("keychain provider is only supported on macOS")
}

// Get the secret from the keychain with 'security find-generic-password' command
getKeyCmd := exec.Command("security", "find-generic-password", "-w", "-D", keychainKind, "-s", key)

result, err := getKeyCmd.Output()
if err != nil {
return nil, err
}

stringResult := string(result)
stringResult = strings.TrimSpace(stringResult)

// If the result is a hexadecimal string, decode it.
if isHex(stringResult) {
result, err = hex.DecodeString(stringResult)
if err != nil {
return nil, err
}
}

return result, nil
}

func (p *provider) GetString(key string) (string, error) {
key = strings.TrimSuffix(key, "/")
key = strings.TrimSpace(key)

secret, err := getKeychainSecret(key)
if err != nil {
return "", err
}

return string(secret), err
}

func (p *provider) GetStringMap(key string) (map[string]interface{}, error) {
key = strings.TrimSuffix(key, "/")
key = strings.TrimSpace(key)

secret, err := getKeychainSecret(key)
if err != nil {
return nil, err
}

m := map[string]interface{}{}
if err := yaml.Unmarshal(secret, &m); err != nil {
return nil, err
}
return m, nil
}
35 changes: 35 additions & 0 deletions pkg/providers/keychain/keychain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package keychain

import (
"testing"
)

func Test_isHex(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"a1b2c3", true},
{"A1B2C3", true},
{"1234567890abcdef", true},
{"12345", false}, // Odd length
{"g1h2", false}, // Non-hex characters
{"!@#$", false}, // Special characters
{"abcdefa", false}, // Odd length with valid hex characters
{"ABCDEF", true},
{"abcdef", true},
{"1234abcd", true},
{"1234abcg", false}, // Contains 'g'
{"12 34", false}, // Contains space
{"", true}, // Empty string
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := isHex(tt.input)
if result != tt.expected {
t.Errorf("isHex(%q) = %v; want %v", tt.input, result, tt.expected)
}
})
}
}
6 changes: 6 additions & 0 deletions vals.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/helmfile/vals/pkg/providers/hcpvaultsecrets"
"github.com/helmfile/vals/pkg/providers/httpjson"
"github.com/helmfile/vals/pkg/providers/k8s"
"github.com/helmfile/vals/pkg/providers/keychain"
"github.com/helmfile/vals/pkg/providers/onepassword"
"github.com/helmfile/vals/pkg/providers/onepasswordconnect"
"github.com/helmfile/vals/pkg/providers/pulumi"
Expand Down Expand Up @@ -91,6 +92,7 @@ const (
ProviderTFStateRemote = "tfstateremote"
ProviderAzureKeyVault = "azurekeyvault"
ProviderEnvSubst = "envsubst"
ProviderKeychain = "keychain"
ProviderOnePassword = "op"
ProviderOnePasswordConnect = "onepasswordconnect"
ProviderDoppler = "doppler"
Expand Down Expand Up @@ -245,6 +247,9 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) {
case ProviderKms:
p := awskms.New(conf)
return p, nil
case ProviderKeychain:
p := keychain.New(conf)
return p, nil
case ProviderEnvSubst:
p := envsubst.New(conf)
return p, nil
Expand Down Expand Up @@ -491,6 +496,7 @@ var KnownValuesTypes = []string{
ProviderTFState,
ProviderFile,
ProviderEcho,
ProviderKeychain,
ProviderEnvSubst,
ProviderPulumiStateAPI,
}
Expand Down

0 comments on commit ab686ca

Please sign in to comment.