commit ac609a54c2f3de531b88b4651e1b9caf1dd762f8 Author: maze Date: Thu Sep 4 14:14:02 2025 +0200 Initial import diff --git a/aws.go b/aws.go new file mode 100644 index 0000000..44de596 --- /dev/null +++ b/aws.go @@ -0,0 +1,69 @@ +package secret + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/ssm" +) + +type awskms struct { + service *kms.Client +} + +// AWSKeyManagement uses AWS KMS for decrypting blobs. +// +// The keys passed in GetSecret are the encrypted blobs and will be converted with [ToBinary]. +func AWSKeyManagement(options ...func(*config.LoadOptions) error) (Provider, error) { + config, err := config.LoadDefaultConfig(context.TODO(), options...) + if err != nil { + return nil, err + } + + return awskms{kms.NewFromConfig(config)}, nil +} + +func (p awskms) GetSecret(key string) (value []byte, err error) { + input := new(kms.DecryptInput) + input.CiphertextBlob = ToBinary(key) + + var output *kms.DecryptOutput + if output, err = p.service.Decrypt(context.TODO(), input); err != nil { + return + } + + return output.Plaintext, nil +} + +type awsps struct { + service *ssm.Client +} + +// AWSParameterStorage uses AWS Session Manager Parameter Storage for obtaining secrets. +func AWSParameterStorage(options ...func(*config.LoadOptions) error) (Provider, error) { + config, err := config.LoadDefaultConfig(context.TODO(), options...) + if err != nil { + return nil, err + } + + return awsps{service: ssm.NewFromConfig(config)}, nil +} + +func (p awsps) GetSecret(key string) (value []byte, err error) { + var yesPlease = true + input := new(ssm.GetParameterInput) + input.Name = &key + input.WithDecryption = &yesPlease + + var output *ssm.GetParameterOutput + if output, err = p.service.GetParameter(context.TODO(), input); err != nil { + return + } + + if output.Parameter == nil { + return nil, NotFound{Key: key} + } + + return []byte(*output.Parameter.Value), nil +} diff --git a/decryption.go b/decryption.go new file mode 100644 index 0000000..ea98741 --- /dev/null +++ b/decryption.go @@ -0,0 +1,76 @@ +package secret + +import ( + "crypto/aes" + "crypto/cipher" + "errors" + + "golang.org/x/crypto/chacha20poly1305" +) + +type aead struct { + Provider + aead cipher.AEAD +} + +// WithChaCha20Poly1305 wraps the returned value and decrypts it using ChaCha20-Poly1305 AEAD. +// +// The used nonce size is 12 bytes. +func WithChaCha20Poly1305(p Provider, key []byte) (Provider, error) { + cipher, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + return aead{ + Provider: p, + aead: cipher, + }, nil +} + +// WithChaCha20Poly1305X wraps the returned value and decrypts it using ChaCha20-Poly1305 AEAD. +// +// The used nonce size is 24 bytes. +func WithChaCha20Poly1305X(p Provider, key []byte) (Provider, error) { + cipher, err := chacha20poly1305.NewX(key) + if err != nil { + return nil, err + } + return aead{ + Provider: p, + aead: cipher, + }, nil +} + +// WithAESGCM wraps the returned value and decrypts it using AES GCM AEAD. +// +// The accepted key sizes are 16 bytes for AES-128 and 32 bytes for AES-256. +func WithAESGCM(p Provider, key []byte) (Provider, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + return aead{ + Provider: p, + aead: gcm, + }, nil +} + +func (p aead) GetSecret(key string) (value []byte, err error) { + if value, err = p.Provider.GetSecret(key); err != nil { + return + } + + nonceSize := p.aead.NonceSize() + if len(value) < nonceSize { + return nil, errors.New("secret: ciphertext is too short") + } + + nonce, ciphertext := value[:nonceSize], value[nonceSize:] + return p.aead.Open(nil, nonce, ciphertext, nil) +} diff --git a/decryption_test.go b/decryption_test.go new file mode 100644 index 0000000..54784ff --- /dev/null +++ b/decryption_test.go @@ -0,0 +1,152 @@ +package secret + +import ( + "bytes" + "encoding/hex" + "testing" +) + +func TestWithAES128GCM(t *testing.T) { + key := []byte{ + 0x37, 0xe4, 0x62, 0x59, 0xa6, 0xef, 0xe9, 0x96, + 0xb8, 0x5d, 0x2c, 0x35, 0xb5, 0x33, 0x3e, 0xff, + } + env := environment{"test": []byte{ + 0x1c, 0x74, 0x37, 0xe9, 0x9e, 0x37, 0xb8, 0x9e, + 0xf0, 0x21, 0xc0, 0xec, 0xad, 0x9d, 0xdf, 0x67, + 0x75, 0xfd, 0x00, 0x48, 0x20, 0x46, 0x2e, 0x14, + 0xfb, 0x9d, 0x17, 0x9a, 0xe7, 0x9d, 0x6f, 0x39, + 0x19, 0xd5, 0x3f, 0x22, 0xa1, 0xac, 0xdb, 0xff, + 0x2c, 0x1f, 0x57, 0x0e, 0xbb, 0x5d, 0xff, 0x89, + 0x62, 0x55, 0x2b, 0x3b, 0x5e, 0xb5, 0x67, 0xb4, + 0x32, 0x92, 0x68, 0xcc, 0x55, 0x8e, 0xd3, 0xc7, + 0xce, + }} + + p, err := WithAESGCM(env, key) + if err != nil { + t.Fatal(err) + return + } + + msg := []byte("Gophers, gophers, gophers everywhere!") + v, err := p.GetSecret("test") + if err != nil { + t.Fatal(err) + return + } + if !bytes.Equal(v, msg) { + t.Errorf("expected:\n%s\n\ngot:\n%s", hex.Dump(msg), hex.Dump(v)) + } +} + +func TestWithAES256GCM(t *testing.T) { + key := []byte{ + 0x50, 0x0a, 0x33, 0xf2, 0xe5, 0x85, 0xc1, 0xd3, + 0x65, 0x6d, 0x32, 0xbe, 0xea, 0x7b, 0xce, 0x7e, + 0x12, 0xbf, 0x7a, 0x47, 0x4d, 0x21, 0x09, 0xa0, + 0x3e, 0x3f, 0x65, 0xc7, 0xae, 0x94, 0x6c, 0xe3, + } + env := environment{"test": []byte{ + 0x62, 0x97, 0x6b, 0xc1, 0x78, 0xef, 0x41, 0xa0, + 0xd4, 0xdc, 0x05, 0x05, 0x66, 0xf6, 0x5f, 0x62, + 0xca, 0x91, 0xae, 0xd7, 0x7c, 0xff, 0xad, 0xc1, + 0xbf, 0xd5, 0x61, 0xe3, 0x09, 0x8d, 0x9c, 0xce, + 0xac, 0x88, 0x55, 0x8c, 0x02, 0xd4, 0x30, 0xc9, + 0x42, 0x38, 0xdf, 0xf9, 0x8a, 0x4c, 0x92, 0x34, + 0xc7, 0x82, 0x24, 0xb4, 0x9e, 0x9e, 0xdc, 0x10, + 0x96, 0x60, 0xa2, 0x92, 0x2a, 0x94, 0x9c, 0x3a, + 0xd8, + }} + + p, err := WithAESGCM(env, key) + if err != nil { + t.Fatal(err) + return + } + + msg := []byte("Gophers, gophers, gophers everywhere!") + v, err := p.GetSecret("test") + if err != nil { + t.Fatal(err) + return + } + if !bytes.Equal(v, msg) { + t.Errorf("expected:\n%s\n\ngot:\n%s", hex.Dump(msg), hex.Dump(v)) + } +} + +func TestWithChaCha20Poly1305(t *testing.T) { + key := []byte{ + 0x01, 0x94, 0x99, 0xd6, 0x89, 0x82, 0x7f, 0xa1, + 0x27, 0x82, 0x36, 0x11, 0x78, 0x22, 0xe5, 0x16, + 0x21, 0xe2, 0xc6, 0x29, 0x0c, 0x14, 0x6c, 0xb8, + 0x12, 0x46, 0x2f, 0xae, 0x9e, 0xa7, 0x17, 0x09, + } + + env := environment{"test": []byte{ + 0x76, 0x7c, 0x7d, 0x5d, 0x36, 0xd1, 0x27, 0xca, + 0x0b, 0x64, 0x28, 0x82, 0xc3, 0x3a, 0xd3, 0x24, + 0x30, 0x77, 0x8f, 0xef, 0xf1, 0x3a, 0x9f, 0xa7, + 0x41, 0x49, 0x62, 0x79, 0x9f, 0x65, 0x12, 0x4f, + 0x29, 0xe6, 0x04, 0x83, 0xab, 0x5c, 0xbc, 0x0b, + 0xe2, 0x82, 0xcc, 0x3c, 0x47, 0x7d, 0xaa, 0x6d, + 0x4c, 0x71, 0x6b, 0x24, 0x01, 0xd0, 0xf9, 0x88, + 0xcf, 0x88, 0xbe, 0xee, 0xe2, 0x77, 0x07, 0x18, + 0xf1, + }} + + p, err := WithChaCha20Poly1305(env, key) + if err != nil { + t.Fatal(err) + return + } + + msg := []byte("Gophers, gophers, gophers everywhere!") + v, err := p.GetSecret("test") + if err != nil { + t.Fatal(err) + return + } + if !bytes.Equal(v, msg) { + t.Errorf("expected:\n%s\n\ngot:\n%s", hex.Dump(msg), hex.Dump(v)) + } +} + +func TestWithChaCha20Poly1305X(t *testing.T) { + key := []byte{ + 0x25, 0x20, 0xda, 0x7c, 0x97, 0x60, 0x20, 0xf5, + 0x09, 0xa7, 0x42, 0x31, 0x08, 0x50, 0x76, 0xf6, + 0x79, 0xc5, 0x38, 0x5b, 0xfb, 0xf4, 0x98, 0x56, + 0xa1, 0x92, 0xd1, 0xa0, 0x08, 0x3e, 0xf5, 0xfd, + } + + env := environment{"test": []byte{ + 0x59, 0xaf, 0xc4, 0x65, 0x60, 0x3f, 0x02, 0xd3, + 0x87, 0xd3, 0x15, 0xac, 0xf3, 0x6b, 0x4a, 0x4f, + 0xe8, 0x40, 0xe5, 0x4d, 0x80, 0x30, 0x7b, 0x3d, + 0x8a, 0xf4, 0x18, 0x96, 0x89, 0x05, 0x4e, 0x31, + 0x44, 0x8c, 0xb2, 0x84, 0x9d, 0x25, 0xce, 0x3b, + 0xb4, 0x66, 0x36, 0x0f, 0xd2, 0xad, 0xb3, 0x78, + 0xf8, 0x02, 0x1e, 0x6c, 0xf9, 0x6c, 0x1f, 0x71, + 0x3c, 0x2b, 0x59, 0x6f, 0xc7, 0x92, 0x3b, 0x40, + 0x89, 0x13, 0x93, 0x6b, 0xa0, 0x35, 0x4e, 0x6f, + 0xd8, 0x31, 0x67, 0xee, 0xa2, + }} + + p, err := WithChaCha20Poly1305X(env, key) + if err != nil { + t.Fatal(err) + return + } + + msg := []byte("Gophers, gophers, gophers everywhere!") + v, err := p.GetSecret("test") + if err != nil { + t.Fatal(err) + return + } + if !bytes.Equal(v, msg) { + t.Errorf("expected:\n%s\n\ngot:\n%s", hex.Dump(msg), hex.Dump(v)) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..9bf12c2 --- /dev/null +++ b/doc.go @@ -0,0 +1,8 @@ +// Package secret implements secret storage providers. +// +// # Providers +// +// Providers of secrets accept a string as key and return a byte slice as result +// for the secret. It is up to the consumers of this package how to interpret +// the bytes. In most cases, the byte slice can be converted to a string. +package secret diff --git a/env.go b/env.go new file mode 100644 index 0000000..16d7d4b --- /dev/null +++ b/env.go @@ -0,0 +1,93 @@ +package secret + +import ( + "bufio" + "io" + "os" + "strings" + "syscall" +) + +type environment map[string][]byte + +// Environment provides environment variables as secrets. +func Environment() Provider { + return EnvironmentPrefix("") +} + +// EnvironmentPrefix provides environment variables with a prefix as secrets. +// +// The prefix is stripped from the final secret key name. +func EnvironmentPrefix(prefix string) Provider { + env := make(environment) + for _, line := range syscall.Environ() { + if !strings.HasPrefix(line, prefix) { + continue + } + kv := strings.SplitN(line[len(prefix):], "=", 2) + if len(kv) == 2 { + env[kv[0]] = []byte(strings.TrimSpace(kv[1])) + } + } + return env +} + +// EnvironmentFile reads a file containing key-value pairs. +// +// Line starting with `#` or `//` are ignored. +// +// Example: +// +// key=value +// # This comment is ignored +func EnvironmentFile(name string) (Provider, error) { + f, err := os.Open(name) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + return EnvironmentReader(f) +} + +// EnvironmentReader reads key-value pairs separated by newlines. +// +// Line starting with `#` or `//` are ignored. +// +// Example: +// +// key=value +// # This comment is ignored +func EnvironmentReader(r io.Reader) (Provider, error) { + s := bufio.NewScanner(r) + s.Split(bufio.ScanLines) + + env := make(environment) + for s.Scan() { + if err := s.Err(); err != nil { + if err == io.EOF { + break + } + return nil, err + } + + line := strings.TrimSpace(s.Text()) + if strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + + kv := strings.SplitN(line, "=", 2) + if len(kv) == 2 { + env[strings.TrimSpace(kv[0])] = []byte(strings.TrimSpace(kv[1])) + } + } + + return env, nil +} + +func (env environment) GetSecret(key string) (value []byte, err error) { + var ok bool + if value, ok = env[key]; ok { + return + } + return nil, NotFound{key} +} diff --git a/env_test.go b/env_test.go new file mode 100644 index 0000000..b6d4a3c --- /dev/null +++ b/env_test.go @@ -0,0 +1,60 @@ +package secret + +import ( + "bytes" + "path/filepath" + "testing" +) + +func TestEnvironment(t *testing.T) { + testProvider(t, Environment(), + testProviderCase{ + Key: "USER", + Test: testNotEmpty, + }) +} + +func TestEnvironmentPrefix(t *testing.T) { + testProvider(t, EnvironmentPrefix("US"), + testProviderCase{ + Key: "ER", + Test: testNotEmpty, + }) +} + +var testEnvironmentFileCases = []testProviderCase{ + { + Key: "test", + Test: testEqualString("case"), + }, + { + Key: "spaces", + Test: testEqualString("yeah"), + }, + { + Key: "ignored", + Err: NotFound{"ignored"}, + }, +} + +func TestEnvironmentFile(t *testing.T) { + p, err := EnvironmentFile(filepath.Join("testdata", "env")) + if err != nil { + t.Fatal(err) + return + } + testProvider(t, p, testEnvironmentFileCases...) +} + +func TestEnvironmentReader(t *testing.T) { + p, err := EnvironmentReader(bytes.NewBufferString(` +#ignored=true +test=case + spaces = yeah +`)) + if err != nil { + t.Fatal(err) + return + } + testProvider(t, p, testEnvironmentFileCases...) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..da1627c --- /dev/null +++ b/go.mod @@ -0,0 +1,47 @@ +module git.maze.io/go/secret + +go 1.24.0 + +require ( + github.com/aws/aws-sdk-go-v2/config v1.31.6 + github.com/aws/aws-sdk-go-v2/service/ssm v1.64.2 + github.com/hashicorp/vault/api v1.20.0 + golang.org/x/crypto v0.41.0 +) + +require ( + github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.45.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect + github.com/aws/smithy-go v1.23.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/keybase/go-keychain v0.0.1 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d8bbde5 --- /dev/null +++ b/go.sum @@ -0,0 +1,115 @@ +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= +github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo= +github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ= +github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg= +github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI= +github.com/aws/aws-sdk-go-v2/service/kms v1.45.1 h1:NhkI4kfcZYmcIM34a+q9drh3aMG1BthkyziOr7sRTv4= +github.com/aws/aws-sdk-go-v2/service/kms v1.45.1/go.mod h1:elyXIFqx79eHvd0cRAzYDYHajeoJEygkBjJto4HJddc= +github.com/aws/aws-sdk-go-v2/service/ssm v1.64.2 h1:6P4W42RUTZixRG6TgfRB8KlsqNzHtvBhs6sTbkVPZvk= +github.com/aws/aws-sdk-go-v2/service/ssm v1.64.2/go.mod h1:wtxdacy3oO5sHO03uOtk8HMGfgo1gBHKwuJdYM220i0= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c= +github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= +github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.20.0 h1:KQMHElgudOsr+IbJgmbjHnCTxEpKs9LnozA1D3nozU4= +github.com/hashicorp/vault/api v1.20.0/go.mod h1:GZ4pcjfzoOWpkJ3ijHNpEoAxKEsBJnVljyTe3jM2Sms= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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 new file mode 100644 index 0000000..f6a4f65 --- /dev/null +++ b/keyring.go @@ -0,0 +1,5 @@ +package secret + +func Keyring(service string) (Provider, error) { + return keyringProvider(service) +} diff --git a/keyring_darwin.go b/keyring_darwin.go new file mode 100644 index 0000000..c3691c2 --- /dev/null +++ b/keyring_darwin.go @@ -0,0 +1,22 @@ +package secret + +import "github.com/keybase/go-keychain" + +type keyring struct { + service string +} + +func keyringProvider(service string) (Provider, error) { + return keyring{ + service: service, + }, nil +} + +func (p keyring) GetSecret(key string) (value []byte, err error) { + return keychain.GetGenericPassword( + p.service, // service + key, // account + "", // label + "", // accessgroup + ) +} diff --git a/keyring_darwin_test.go b/keyring_darwin_test.go new file mode 100644 index 0000000..ba996ba --- /dev/null +++ b/keyring_darwin_test.go @@ -0,0 +1,40 @@ +package secret + +import ( + "testing" + + "github.com/keybase/go-keychain" +) + +const ( + testKeyringService = "io.maze.git.go.secret" + testKeyringKey = "test" +) + +func TestKeyring(t *testing.T) { + item := keychain.NewGenericPassword(testKeyringService, testKeyringKey, "", []byte(testKeyringKey), "") + if err := keychain.AddItem(item); err != nil { + t.Skip(err) + } + + defer func() { + if err := keychain.DeleteGenericPasswordItem(testKeyringService, testKeyringKey); err != nil { + t.Error(err) + } + }() + + p, err := Keyring(testKeyringService) + if err != nil { + t.Fatal(err) + return + } + + v, err := p.GetSecret(testKeyringKey) + if err != nil { + t.Fatal(err) + return + } + if string(v) != testKeyringKey { + t.Errorf("expected %q, got %q", testKeyringKey, v) + } +} diff --git a/keyring_stub.go b/keyring_stub.go new file mode 100644 index 0000000..28fdf51 --- /dev/null +++ b/keyring_stub.go @@ -0,0 +1,20 @@ +//go:build !darwin && !windows && !(dragonfly && cgo) && !(freebsd && cgo) && !linux && !netbsd && !openbsd + +package secret + +import ( + "errors" + "runtime" +) + +var errKeyringNotSupported = errors.New("secret: keyring is not supported on " + runtime.GOOS) + +type keyring struct{} + +func keyringProvider(_ string) (Provider, error) { + return nil, errKeyringNotSupported +} + +func (keyring) GetSecret(_ string) (_ []byte, err error) { + return nil, errKeyringNotSupported +} diff --git a/keyring_unix.go b/keyring_unix.go new file mode 100644 index 0000000..77ea7ae --- /dev/null +++ b/keyring_unix.go @@ -0,0 +1,177 @@ +//go:build (dragonfly && cgo) || (freebsd && cgo) || linux || netbsd || openbsd + +package secret + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +const ( + dbusServiceName = "org.freedesktop.secrets" + dbusServicePath = "/org/freedesktop/secrets" + dbusServiceInterface = "org.freedesktop.Secret.Service" + dbusServiceClose = dbusServiceInterface + ".Close" + dbusServiceUnlock = dbusServiceInterface + ".Unlock" + dbusCollectionBasePath = "/org/freedesktop/secrets/collection/" + dbusCollectionLoginPath = dbusCollectionBasePath + "login" + dbusCollectionLoginAliasPath = "/org/freedesktop/secrets/aliases/default" + dbusCollectionInterface = "org.freedesktop.Secret.Collection" + dbusCollectionGetSecret = dbusCollectionInterface + ".GetSecret" + dbusCollectionSearchItems = dbusCollectionInterface + ".SearchItems" + dbusPromptInterface = "org.freedesktop.Secret.Prompt" + dbusPrompt = dbusPromptInterface + ".Prompt" + dbusPromptCompleted = dbusPromptInterface + ".Completed" +) + +type keyring struct { + service string + session *dbus.Conn + object dbus.BusObject +} + +type keyringSecret struct { + Session dbus.ObjectPath + Parameters []byte + Value []byte + ContentType string `dbus:"content_type"` +} + +func keyringProvider(service string) (Provider, error) { + session, err := dbus.SessionBus() + if err != nil { + return nil, err + } + return &keyring{ + service: service, + session: session, + object: session.Object(dbusServiceName, dbusServicePath), + }, nil +} + +func (p *keyring) Close() error { + return p.object.Call(dbusServiceClose, 0).Err +} + +func (p *keyring) GetSecret(key string) (value []byte, err error) { + + collection := p.getLoginCollection() + if err = p.unlock(collection.Path()); err != nil { + return + } + + query := map[string]string{ + "service": p.service, + "username": key, + } + + var results []dbus.ObjectPath + if results, err = p.search(collection, query); err != nil { + return + } else if len(results) == 0 { + return nil, NotFound{Key: key} + } + + var secret *keyringSecret + if secret, err = p.getSecret(results[0]); err != nil { + return + } + return secret.Value, nil +} + +func (p keyring) checkCollectionPath(here dbus.ObjectPath) bool { + obj := p.session.Object(dbusServiceName, dbusServicePath) + val, err := obj.GetProperty(dbusCollectionInterface) + if err != nil { + return false + } + + paths := val.Value().([]dbus.ObjectPath) + for _, p := range paths { + if p == here { + return true + } + } + return false +} + +func (p keyring) getLoginCollection() dbus.BusObject { + here := dbus.ObjectPath(dbusCollectionLoginPath) + if !p.checkCollectionPath(here) { + here = dbus.ObjectPath(dbusCollectionLoginAliasPath) + } + return p.session.Object(dbusServiceName, here) +} + +func (p *keyring) search(collection dbus.BusObject, query interface{}) (results []dbus.ObjectPath, err error) { + err = collection.Call(dbusCollectionSearchItems, 0, query).Store(&results) + return +} + +func (p *keyring) getSecret(here dbus.ObjectPath) (secret *keyringSecret, err error) { + secret = new(keyringSecret) + err = p.session.Object(dbusServiceName, here).Call(dbusCollectionGetSecret, 0).Store(secret) + return +} + +func (p *keyring) unlock(here dbus.ObjectPath) (err error) { + var ( + unlocked []dbus.ObjectPath + prompt dbus.ObjectPath + ) + if err = p.object.Call(dbusServiceUnlock, 0, []dbus.ObjectPath{here}).Store(&unlocked, &prompt); err != nil { + return + } + + var variant dbus.Variant + if _, variant, err = p.handlePrompt(prompt); err != nil { + return + } + + collections := variant.Value() + switch t := collections.(type) { + case []dbus.ObjectPath: + unlocked = append(unlocked, t...) + } + + if len(unlocked) != 1 || (here != dbusCollectionLoginAliasPath && unlocked[0] != here) { + return fmt.Errorf("secret: failed to unlock keyring collection %q", here) + } + + return +} + +func (s *keyring) handlePrompt(prompt dbus.ObjectPath) (bool, dbus.Variant, error) { + if prompt != dbus.ObjectPath("/") { + err := s.session.AddMatchSignal(dbus.WithMatchObjectPath(prompt), + dbus.WithMatchInterface(dbusPromptInterface), + ) + if err != nil { + return false, dbus.MakeVariant(""), err + } + + defer func(s *keyring, options ...dbus.MatchOption) { + _ = s.session.RemoveMatchSignal(options...) + }(s, dbus.WithMatchObjectPath(prompt), dbus.WithMatchInterface(dbusPromptInterface)) + + promptSignal := make(chan *dbus.Signal, 1) + s.session.Signal(promptSignal) + + err = s.session.Object(dbusServiceName, prompt).Call(dbusPrompt, 0, "").Err + if err != nil { + return false, dbus.MakeVariant(""), err + } + + signal := <-promptSignal + switch signal.Name { + case dbusPromptCompleted: + dismissed := signal.Body[0].(bool) + result := signal.Body[1].(dbus.Variant) + return dismissed, result, nil + } + + } + + return false, dbus.MakeVariant(""), nil +} diff --git a/keyring_windows.go b/keyring_windows.go new file mode 100644 index 0000000..66f3c8e --- /dev/null +++ b/keyring_windows.go @@ -0,0 +1,104 @@ +package secret + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + modadvapi32 = windows.NewLazySystemDLL("advapi32.dll") + procCredRead = modadvapi32.NewProc("CredReadW") + procCredFree winproc = modadvapi32.NewProc("CredFree") +) + +// Interface for syscall.Proc: helps testing +type winproc interface { + Call(a ...uintptr) (r1, r2 uintptr, lastErr error) +} + +type keyring struct { + service string +} + +func keyringProvider(service string) (Provider, error) { + return keyring{service}, nil +} + +func (p keyring) GetSecret(key string) (value []byte, err error) { + return sysCredRead(p.service+":"+key, sysCRED_TYPE_GENERIC) +} + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/ns-wincred-_credentialw +type sysCREDENTIAL struct { + Flags uint32 + Type uint32 + TargetName *uint16 + Comment *uint16 + LastWritten windows.Filetime + CredentialBlobSize uint32 + CredentialBlob uintptr + Persist uint32 + AttributeCount uint32 + Attributes uintptr + TargetAlias *uint16 + UserName *uint16 +} + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/ns-wincred-_credentialw +type sysCRED_TYPE uint32 + +const ( + sysCRED_TYPE_GENERIC sysCRED_TYPE = 0x1 + sysCRED_TYPE_DOMAIN_PASSWORD sysCRED_TYPE = 0x2 + sysCRED_TYPE_DOMAIN_CERTIFICATE sysCRED_TYPE = 0x3 + sysCRED_TYPE_DOMAIN_VISIBLE_PASSWORD sysCRED_TYPE = 0x4 + sysCRED_TYPE_GENERIC_CERTIFICATE sysCRED_TYPE = 0x5 + sysCRED_TYPE_DOMAIN_EXTENDED sysCRED_TYPE = 0x6 +) + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/nf-wincred-credreadw +func sysCredRead(targetName string, typ sysCRED_TYPE) ([]byte, error) { + var pcred *sysCREDENTIAL + targetNamePtr, _ := windows.UTF16PtrFromString(targetName) + ret, _, err := syscall.SyscallN( + procCredRead.Addr(), + uintptr(unsafe.Pointer(targetNamePtr)), + uintptr(typ), + 0, + uintptr(unsafe.Pointer(&pcred)), + ) + if ret == 0 { + return nil, err + } + defer procCredFree.Call(uintptr(unsafe.Pointer(pcred))) + + return goBytes(pcred.CredentialBlob, pcred.CredentialBlobSize), nil +} + +// goBytes copies the given C byte array to a Go byte array (see `C.GoBytes`). +// This function avoids having cgo as dependency. +func goBytes(src uintptr, len uint32) []byte { + if src == uintptr(0) { + return []byte{} + } + rv := make([]byte, len) + copy(rv, *(*[]byte)(unsafe.Pointer(&struct { + Data unsafe.Pointer + Len int + Cap int + }{ + Data: unsafe.Pointer(src), + Len: int(len), + Cap: int(len), + }))) + /* + copy(rv, *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{ + Data: src, + Len: int(len), + Cap: int(len), + }))) + */ + return rv +} diff --git a/provider.go b/provider.go new file mode 100644 index 0000000..79e4f55 --- /dev/null +++ b/provider.go @@ -0,0 +1,57 @@ +package secret + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "strings" +) + +// Provider for secrets. +type Provider interface { + // GetSecret loads a secret by named key. + GetSecret(key string) (value []byte, err error) +} + +// AmbiguousKey is an error incdicating that the secret doesn't resolve to exactly one item. +type AmbiguousKey struct { + Key string +} + +func (err AmbiguousKey) Error() string { + return fmt.Sprintf("secret: ambigious secret key %q", err.Key) +} + +// NotFound is an error indicating the secret can not be found. +type NotFound struct { + Key string +} + +func (err NotFound) Error() string { + if err.Key == "" { + return "secret: not found" + } + return fmt.Sprintf("secret: %q not found", err.Key) +} + +// ToBinary converts a string to []bytes. +// +// There are special prefixes for binary encoded formats: +// - hex: for hexadecimal encoded strings +// - b64: for base-64 encoded strings (raw encoding) +// +// If a special prefix is found, the appropriate codec will be used to decode to string to []byte, +// when there is an error decoding, it may result in an empty value. +// +// All other strings will be converted to []byte as-is. +func ToBinary(s string) (bytes []byte) { + switch { + case strings.HasPrefix(s, "hex:"): + bytes, _ = hex.DecodeString(s[4:]) + case strings.HasPrefix(s, "b64:"): + bytes, _ = base64.RawStdEncoding.DecodeString(s[4:]) + default: + bytes = []byte(s) + } + return +} diff --git a/provider_test.go b/provider_test.go new file mode 100644 index 0000000..57893fc --- /dev/null +++ b/provider_test.go @@ -0,0 +1,65 @@ +package secret + +import ( + "bytes" + "errors" + "fmt" + "testing" +) + +type testProviderCase struct { + Name string + Key string + Err error + Test func([]byte) error +} + +func testProvider(t *testing.T, p Provider, tests ...testProviderCase) { + t.Helper() + + for _, test := range tests { + if test.Name == "" { + test.Name = test.Key + } + t.Run(test.Name, func(it *testing.T) { + v, err := p.GetSecret(test.Key) + if err != nil { + if test.Err == nil { + it.Fatalf("unexpected error %q in %T (%#+v)", err, p, p) + return + } else if !errors.Is(err, test.Err) { + it.Fatalf("unexpected error %q, expected error %q", err, test.Err) + return + } + return + } else if test.Err != nil { + it.Fatalf("expected error %q", test.Err) + return + } + + if err := test.Test(v); err != nil { + it.Error(err) + } + }) + } +} + +func testNotEmpty(v []byte) error { + if len(v) > 0 { + return nil + } + return fmt.Errorf("expected empty, got %q", v) +} + +func testEqual(a []byte) func([]byte) error { + return func(b []byte) error { + if bytes.Equal(a, b) { + return nil + } + return fmt.Errorf("expected %q, got %q", a, b) + } +} + +func testEqualString(a string) func([]byte) error { + return testEqual([]byte(a)) +} diff --git a/testdata/env b/testdata/env new file mode 100644 index 0000000..ecced38 --- /dev/null +++ b/testdata/env @@ -0,0 +1,4 @@ +# This is a test case for the Environment provider +#ignored=true +test=case + spaces = yeah \ No newline at end of file diff --git a/vault.go b/vault.go new file mode 100644 index 0000000..9654171 --- /dev/null +++ b/vault.go @@ -0,0 +1,89 @@ +package secret + +import ( + "fmt" + "strings" + + vault "github.com/hashicorp/vault/api" +) + +type vaultProvider struct{} + +// Vault provider uses Hashicorp Vault for secrets storage. +// +// The secretKey is used to +func Vault() Provider { + return vaultProvider{} +} + +func (p vaultProvider) GetSecret(key string) (value []byte, err error) { + var ( + config = vault.DefaultConfig() + client *vault.Client + ) + if client, err = vault.NewClient(config); err != nil { + return + } + + var mounts map[string]*vault.MountOutput + if mounts, err = client.Sys().ListMounts(); err != nil { + return + } + + var ( + mount *vault.MountOutput + location string + ) + if mount, location, err = p.split(key, mounts); err != nil { + return + } + + switch mount.Type { + case "generic", "kv": + // Supported + default: + return nil, fmt.Errorf("secret: mount type %q is not supported", mount.Type) + } + + var secret *vault.Secret + if secret, err = client.Logical().Read(location); err != nil { + return + } else if secret == nil || len(secret.Data) == 0 { + return nil, NotFound{Key: key} + } + + data, ok := secret.Data["data"].(map[string]interface{}) + if !ok { + return nil, NotFound{Key: key} + } + + if len(data) == 1 { + for _, item := range data { + switch data := item.(type) { + case []byte: + return data, nil + case string: + return []byte(data), nil + default: + return nil, fmt.Errorf("secret: unexpected return type %T", data) + } + } + } + + return nil, AmbiguousKey{Key: key} +} + +func (p vaultProvider) split(key string, mounts map[string]*vault.MountOutput) (mount *vault.MountOutput, location string, err error) { + location = strings.TrimPrefix(key, "/") + + var tree string + for tree, mount = range mounts { + if strings.HasPrefix(location, tree) { + if mount.Type == "kv" && mount.Options["version"] == "2" { + location = tree + "data/" + location[len(tree):] + } + return + } + } + return nil, "", fmt.Errorf("secret: no Vault mount found for secret path %q", key) +} diff --git a/vault_test.go b/vault_test.go new file mode 100644 index 0000000..ef68c28 --- /dev/null +++ b/vault_test.go @@ -0,0 +1,25 @@ +package secret + +import ( + "os" + "testing" +) + +func TestVault(t *testing.T) { + testKey := os.Getenv("TEST_VAULT_KEY") + if testKey == "" { + t.Skip("TEST_VAULT_KEY not set, which should contain the path to a secret for testing") + return + } + testValue := os.Getenv("TEST_VAULT_VALUE") + if testValue == "" { + t.Skip("TEST_VAULT_VALUE not set, which should contain the secret value for testing") + return + } + + p := Vault() + testProvider(t, p, testProviderCase{ + Key: testKey, + Test: testEqualString(testValue), + }) +}