diff --git a/README.md b/README.md index 72522a1..43b7a16 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ It supports various backends including: - AWS Secrets Manager - AWS S3 - GCP Secrets Manager +- GCP KMS - [Google Sheets](#google-sheets) - [SOPS](https://github.com/getsops/sops)-encrypted files - Terraform State @@ -204,6 +205,7 @@ Please see the [relevant unit test cases](https://github.com/helmfile/vals/blob/ - [AWS Secrets Manager](#aws-secrets-manager) - [AWS S3](#aws-s3) - [GCP Secrets Manager](#gcp-secrets-manager) +- [GCP KMS](#gcp-kms) - [Google Sheets](#google-sheets) - [Google GCS](#google-gcs) - [SOPS](#sops) powered by [sops](https://github.com/getsops/sops) @@ -393,6 +395,24 @@ Examples: > In some cases like you need to use an alternative credentials or project, > you'll likely need to set `GOOGLE_APPLICATION_CREDENTIALS` and/or `GCP_PROJECT` envvars. +### GCP KMS + +- `ref+gkms://BASE64CIPHERTEXT?project=myproject&location=global&keyring=mykeyring&crypto_key=mykey` +- `ref+gkms://BASE64CIPHERTEXT?project=myproject&location=global&keyring=mykeyring&crypto_key=mykey#/yaml_or_json_key/in/secret` + +Decrypts the URL-safe base64-encoded ciphertext using GCP KMS. Note that URL-safe base64 encoding is the same as "traditional" base64 encoding, except it uses _ and - in place of / and +, respectively. For example, to get a URL-safe base64-encoded ciphertext using the GCP CLI, you might run +``` +echo test | gcloud kms encrypt \ + --project myproject \ + --location global \ + --keyring mykeyring \ + --key mykey \ + --plaintext-file - \ + --ciphertext-file - \ + | base64 -w0 \ + | tr '/+' '_-' +``` + ### Google Sheets - `ref+googlesheets://SPREADSHEET_ID?credentials_file=credentials.json#/KEY` diff --git a/go.mod b/go.mod index 4de8add..4406bbc 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/helmfile/vals go 1.21 require ( + cloud.google.com/go/kms v1.15.2 cloud.google.com/go/secretmanager v1.11.1 cloud.google.com/go/storage v1.33.0 github.com/1Password/connect-sdk-go v1.5.3 @@ -28,7 +29,6 @@ require ( cloud.google.com/go/compute v1.23.0 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/iam v1.1.1 // indirect - cloud.google.com/go/kms v1.15.2 // indirect filippo.io/age v1.1.1 // indirect github.com/AlecAivazis/survey/v2 v2.3.6 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.0 // indirect diff --git a/pkg/providers/gkms/gkms.go b/pkg/providers/gkms/gkms.go new file mode 100644 index 0000000..db47056 --- /dev/null +++ b/pkg/providers/gkms/gkms.go @@ -0,0 +1,83 @@ +package gkms + +import ( + "context" + "encoding/base64" + "fmt" + "os" + + kms "cloud.google.com/go/kms/apiv1" + kmspb "cloud.google.com/go/kms/apiv1/kmspb" + "gopkg.in/yaml.v3" + + "github.com/helmfile/vals/pkg/api" + "github.com/helmfile/vals/pkg/log" +) + +type provider struct { + log *log.Logger + Project string + Location string + Keyring string + CryptoKey string +} + +func New(l *log.Logger, cfg api.StaticConfig) *provider { + p := &provider{ + log: l, + } + p.Project = cfg.String("project") + p.Location = cfg.String("location") + p.Keyring = cfg.String("keyring") + p.CryptoKey = cfg.String("crypto_key") + return p +} + +func (p *provider) GetString(key string) (string, error) { + ctx := context.Background() + value, err := p.getValue(ctx, key) + if err != nil { + return "", err + } + return string(value), nil +} + +func (p *provider) GetStringMap(key string) (map[string]interface{}, error) { + ctx := context.Background() + value, err := p.getValue(ctx, key) + if err != nil { + return nil, err + } + var valueMap map[string]interface{} + if err := yaml.Unmarshal(value, &valueMap); err != nil { + return nil, fmt.Errorf("failed to unmarshal value: %w", err) + } + return valueMap, nil +} + +func (p *provider) getValue(ctx context.Context, key string) ([]byte, error) { + c, err := kms.NewKeyManagementClient(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to connect: %s", err) + return nil, err + } + defer func() { + if err := c.Close(); err != nil { + p.log.Debugf("gkms: %v", err) + } + }() + blob, err := base64.URLEncoding.DecodeString(key) + if err != nil { + return nil, err + } + req := &kmspb.DecryptRequest{ + Name: fmt.Sprintf("projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s", p.Project, p.Location, p.Keyring, p.CryptoKey), + Ciphertext: blob, + } + + resp, err := c.Decrypt(ctx, req) + if err != nil { + return nil, err + } + return resp.Plaintext, nil +} diff --git a/pkg/stringmapprovider/stringmapprovider.go b/pkg/stringmapprovider/stringmapprovider.go index 0da3593..04d725d 100644 --- a/pkg/stringmapprovider/stringmapprovider.go +++ b/pkg/stringmapprovider/stringmapprovider.go @@ -10,6 +10,7 @@ import ( "github.com/helmfile/vals/pkg/providers/azurekeyvault" "github.com/helmfile/vals/pkg/providers/doppler" "github.com/helmfile/vals/pkg/providers/gcpsecrets" + "github.com/helmfile/vals/pkg/providers/gkms" "github.com/helmfile/vals/pkg/providers/onepasswordconnect" "github.com/helmfile/vals/pkg/providers/sops" "github.com/helmfile/vals/pkg/providers/ssm" @@ -40,6 +41,8 @@ func New(l *log.Logger, provider api.StaticConfig) (api.LazyLoadedStringMapProvi return onepasswordconnect.New(provider), nil case "doppler": return doppler.New(l, provider), nil + case "gkms": + return gkms.New(l, provider), nil } return nil, fmt.Errorf("failed initializing string-map provider from config: %v", provider) diff --git a/pkg/stringprovider/stringprovider.go b/pkg/stringprovider/stringprovider.go index 03b0c03..456111f 100644 --- a/pkg/stringprovider/stringprovider.go +++ b/pkg/stringprovider/stringprovider.go @@ -12,6 +12,7 @@ import ( "github.com/helmfile/vals/pkg/providers/gcpsecrets" "github.com/helmfile/vals/pkg/providers/gcs" "github.com/helmfile/vals/pkg/providers/gitlab" + "github.com/helmfile/vals/pkg/providers/gkms" "github.com/helmfile/vals/pkg/providers/onepasswordconnect" "github.com/helmfile/vals/pkg/providers/pulumi" "github.com/helmfile/vals/pkg/providers/s3" @@ -61,6 +62,8 @@ func New(l *log.Logger, provider api.StaticConfig) (api.LazyLoadedStringProvider return doppler.New(l, provider), nil case "pulumistateapi": return pulumi.New(l, provider, "pulumistateapi"), nil + case "gkms": + return gkms.New(l, provider), nil } return nil, fmt.Errorf("failed initializing string provider from config: %v", provider) diff --git a/vals.go b/vals.go index 8027b13..1e09712 100644 --- a/vals.go +++ b/vals.go @@ -30,6 +30,7 @@ import ( "github.com/helmfile/vals/pkg/providers/gcpsecrets" "github.com/helmfile/vals/pkg/providers/gcs" "github.com/helmfile/vals/pkg/providers/gitlab" + "github.com/helmfile/vals/pkg/providers/gkms" "github.com/helmfile/vals/pkg/providers/googlesheets" "github.com/helmfile/vals/pkg/providers/onepasswordconnect" "github.com/helmfile/vals/pkg/providers/pulumi" @@ -87,6 +88,7 @@ const ( ProviderOnePasswordConnect = "onepasswordconnect" ProviderDoppler = "doppler" ProviderPulumiStateAPI = "pulumistateapi" + ProviderGKMS = "gkms" ) var ( @@ -243,6 +245,9 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) { case ProviderPulumiStateAPI: p := pulumi.New(r.logger, conf, "pulumistateapi") return p, nil + case ProviderGKMS: + p := gkms.New(r.logger, conf) + return p, nil } return nil, fmt.Errorf("no provider registered for scheme %q", scheme) } diff --git a/vals_gkms_test.go b/vals_gkms_test.go new file mode 100644 index 0000000..06e91fc --- /dev/null +++ b/vals_gkms_test.go @@ -0,0 +1,66 @@ +package vals + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestValues_GKMS(t *testing.T) { + // TODO + // create gkms and encrypt test value + // gcloud kms keyrings create "test" --location "global" + // gcloud kms keys create "default" --location "global" --keyring "test" --purpose "encryption" + // echo -n "test_value" \ + // | gcloud kms encrypt \ + // --location "global" \ + // --keyring "test" \ + // --key "default" \ + // --plaintext-file - \ + // --ciphertext-file - \ + // | base64 -w0 \ + // | tr '/+' '_-' + // + // run with: + // + // go test -run '^(TestValues_GKMS)$' + + type testcase struct { + template map[string]interface{} + expected map[string]interface{} + } + + plain_value := "test_value" + encrypted_value := "CiQAmPqoGAKT97oUK0DdiI_cLDm3j6iPDK4-TJ3yQII-snFHCckSMwAkTpnEoD5wOeRaZrt3eC1ewFMuw617fqqjTStrsar9ciGERzk5t6uMgA0HKzSxGMdjHQ==" + + project := "test-project" + location := "global" + keyring := "test" + crypto_key := "default" + + testcases := []testcase{ + { + template: map[string]interface{}{ + "test_key": fmt.Sprintf("ref+gkms://%s?project=%s&location=%s&keyring=%s&crypto_key=%s", encrypted_value, project, location, keyring, crypto_key), + }, + expected: map[string]interface{}{ + "test_key": plain_value, + }, + }, + } + + for i := range testcases { + tc := testcases[i] + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + vals, err := Eval(tc.template) + if err != nil { + t.Fatalf("%v", err) + } + diff := cmp.Diff(tc.expected, vals) + if diff != "" { + t.Errorf("unexpected diff: %s", diff) + } + }) + } +}