From cabfae09c969f1dcd29cfffc62596b5dd23a0bec Mon Sep 17 00:00:00 2001 From: Somtochi Onyekwere Date: Fri, 22 Apr 2022 16:00:39 +0100 Subject: [PATCH] Load credentials from secrets for GCP KMS Signed-off-by: Somtochi Onyekwere --- controllers/kustomization_decryptor.go | 15 +- controllers/kustomization_decryptor_test.go | 23 ++ controllers/kustomization_generator.go | 1 - docs/spec/v1beta2/kustomization.md | 23 ++ go.mod | 13 +- go.sum | 11 +- internal/sops/gcpkms/keysource.go | 174 +++++++++ .../sops/gcpkms/keysource_integration_test.go | 158 ++++++++ internal/sops/gcpkms/keysource_test.go | 175 +++++++++ internal/sops/gcpkms/mock_kms_server.go | 343 ++++++++++++++++++ internal/sops/keyservice/options.go | 9 + internal/sops/keyservice/server.go | 41 +++ internal/sops/keyservice/server_test.go | 26 +- internal/sops/keyservice/utils_test.go | 9 + 14 files changed, 1011 insertions(+), 10 deletions(-) create mode 100644 internal/sops/gcpkms/keysource.go create mode 100644 internal/sops/gcpkms/keysource_integration_test.go create mode 100644 internal/sops/gcpkms/keysource_test.go create mode 100644 internal/sops/gcpkms/mock_kms_server.go diff --git a/controllers/kustomization_decryptor.go b/controllers/kustomization_decryptor.go index d2451ad42..5785cf451 100644 --- a/controllers/kustomization_decryptor.go +++ b/controllers/kustomization_decryptor.go @@ -71,9 +71,12 @@ const ( // DecryptionAzureAuthFile is the name of the file containing the Azure // credentials. DecryptionAzureAuthFile = "sops.azure-kv" - + // DecryptionGCPCredsFile is the name of the file containing the GCP + // credentials. + DecryptionGCPCredsFile = "sops.gcp-kms" // maxEncryptedFileSize is the max allowed file size in bytes of an encrypted // file. + maxEncryptedFileSize int64 = 5 << 20 // unsupportedFormat is used to signal no sopsFormatToMarkerBytes format was // detected by detectFormatFromMarkerBytes. @@ -140,6 +143,10 @@ type KustomizeDecryptor struct { // any Azure Key Vault. azureToken *azkv.Token + // gcpCredsJSON is the credential json that is used to authenticate towards + // any GCP KMS. + gcpCredsJSON []byte + // keyServices are the SOPS keyservice.KeyServiceClient's available to the // decryptor. keyServices []keyservice.KeyServiceClient @@ -244,6 +251,11 @@ func (d *KustomizeDecryptor) ImportKeys(ctx context.Context) error { return fmt.Errorf("failed to import '%s' data from %s decryption Secret '%s': %w", name, provider, secretName, err) } } + case filepath.Ext(DecryptionGCPCredsFile): + if name == DecryptionGCPCredsFile { + b := bytes.Trim(value, "\n") + d.gcpCredsJSON = b + } } } } @@ -543,6 +555,7 @@ func (d *KustomizeDecryptor) loadKeyServiceServers() { intkeyservice.WithGnuPGHome(d.gnuPGHome), intkeyservice.WithVaultToken(d.vaultToken), intkeyservice.WithAgeIdentities(d.ageIdentities), + intkeyservice.WithGCPCredsJSON(d.gcpCredsJSON), } if d.azureToken != nil { serverOpts = append(serverOpts, intkeyservice.WithAzureToken{Token: d.azureToken}) diff --git a/controllers/kustomization_decryptor_test.go b/controllers/kustomization_decryptor_test.go index 02eb4140b..ba29e8e09 100644 --- a/controllers/kustomization_decryptor_test.go +++ b/controllers/kustomization_decryptor_test.go @@ -410,6 +410,29 @@ aws_session_token: test-token`), g.Expect(decryptor.awsCredsProvider).ToNot(BeNil()) }, }, + { + name: "GCP Service Account key", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "gcpkms-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gcpkms-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + DecryptionGCPCredsFile: []byte(`{ "client_id": ".apps.googleusercontent.com", +"client_secret": "", +"type": "authorized_user"}`), + }, + }, + inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) { + g.Expect(decryptor.gcpCredsJSON).ToNot(BeNil()) + }, + }, { name: "Azure Key Vault token", decryption: &kustomizev1.Decryption{ diff --git a/controllers/kustomization_generator.go b/controllers/kustomization_generator.go index 9657cd08a..3b46168bb 100644 --- a/controllers/kustomization_generator.go +++ b/controllers/kustomization_generator.go @@ -117,7 +117,6 @@ func (kg *KustomizeGenerator) WriteFile(dirPath string) error { if err != nil { return err } - return os.WriteFile(kfile, kd, os.ModePerm) } diff --git a/docs/spec/v1beta2/kustomization.md b/docs/spec/v1beta2/kustomization.md index 71f55c38a..f073de094 100644 --- a/docs/spec/v1beta2/kustomization.md +++ b/docs/spec/v1beta2/kustomization.md @@ -1222,6 +1222,29 @@ stringData: clientId: some-client-id ``` +#### GCP KMS Secret entry + +To specify credentials for GCP KMS in a Kubernetes Secret, append a `.data` +entry with a fixed `sops.gcp-kms` key and the service account keys as its value. + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: sops-keys + namespace: default +stringData: + # Exemplary Azure Managed Identity with Client ID + sops.gcp-kms: | + { + "type": "service_account", + "project_id": "", + "private_key_id": "", + "private_key": "" + } +``` + #### Hashicorp Vault Secret entry To specify credentials for Hashicorp Vault in a Kubernetes Secret, append a diff --git a/go.mod b/go.mod index f46427fd8..cff8fcd06 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.17 replace github.com/fluxcd/kustomize-controller/api => ./api require ( + cloud.google.com/go/kms v1.4.0 filippo.io/age v1.0.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v0.22.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.2 @@ -27,6 +28,7 @@ require ( github.com/fluxcd/pkg/testserver v0.2.0 github.com/fluxcd/pkg/untar v0.1.0 github.com/fluxcd/source-controller/api v0.24.4 + github.com/golang/protobuf v1.5.2 github.com/hashicorp/go-retryablehttp v0.7.1 github.com/hashicorp/vault/api v1.5.0 github.com/onsi/gomega v1.19.0 @@ -34,6 +36,9 @@ require ( github.com/spf13/pflag v1.0.5 go.mozilla.org/sops/v3 v3.7.3 golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 + google.golang.org/api v0.74.0 + google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf + google.golang.org/grpc v1.45.0 k8s.io/api v0.23.5 k8s.io/apiextensions-apiserver v0.23.5 k8s.io/apimachinery v0.23.6 @@ -63,7 +68,9 @@ replace github.com/opencontainers/runc => github.com/opencontainers/runc v1.0.3 replace github.com/opencontainers/image-spec => github.com/opencontainers/image-spec v1.0.2 require ( + cloud.google.com/go v0.100.2 // indirect cloud.google.com/go/compute v1.5.0 // indirect + cloud.google.com/go/iam v0.3.0 // indirect github.com/Azure/azure-sdk-for-go v63.3.0+incompatible // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.2.1 // indirect @@ -119,7 +126,6 @@ require ( github.com/golang-jwt/jwt v3.2.1+incompatible // indirect github.com/golang-jwt/jwt/v4 v4.3.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/btree v1.0.1 // indirect github.com/google/go-cmp v0.5.7 // indirect @@ -198,15 +204,12 @@ require ( go.uber.org/zap v1.21.0 // indirect golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect - golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect + golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect - google.golang.org/api v0.74.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf // indirect - google.golang.org/grpc v1.45.0 // indirect google.golang.org/protobuf v1.28.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.66.4 // indirect diff --git a/go.sum b/go.sum index 3ff777279..5e5b5faad 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U= +cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y= cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= @@ -43,6 +45,11 @@ cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= +cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c= +cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc= +cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= +cloud.google.com/go/kms v1.4.0 h1:iElbfoE61VeLhnZcGOltqL8HIly8Nhbe5t6JlH9GXjo= +cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -1138,8 +1145,8 @@ golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a h1:N2T1jUrTQE9Re6TFF5PhvEHXHCguynGhKjWVsIUt5cY= +golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= diff --git a/internal/sops/gcpkms/keysource.go b/internal/sops/gcpkms/keysource.go new file mode 100644 index 000000000..b5b41078e --- /dev/null +++ b/internal/sops/gcpkms/keysource.go @@ -0,0 +1,174 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package gcpkms + +import ( + "context" + "encoding/base64" + "fmt" + "regexp" + "time" + + kms "cloud.google.com/go/kms/apiv1" + "google.golang.org/api/option" + kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1" + "google.golang.org/grpc" +) + +var ( + // gcpkmsTTL is the duration after which a MasterKey requiers rotation. + gcpkmsTTL = time.Hour * 24 * 30 * 6 +) + +// CredentialJSON is the service account keys used for aythentication towards a GCP KMS service. +type CredentialJSON []byte + +// ApplyToMasterKey configures the credentialJSON on the provided key. +func (c CredentialJSON) ApplyToMasterKey(key *MasterKey) { + key.credentialJSON = []byte(c) +} + +// MasterKey is a GCP KMS key used to encrypt and decrypt the SOPS +// data key +// Adapted from https://github.com/mozilla/sops/blob/v3.7.2/gcpkms/keysource.go +// to be able to have fine-grain control over the credentials used to authenticate +// to GCP KMS Service. +type MasterKey struct { + // ResourceID is the resource id used to refer to the gcp kms key. + // It can be retrieved using the `gcloud` command. + ResourceID string + // EncryptedKey is the string returned after encrypting with GCP KMS + EncryptedKey string + CreationDate time.Time + + // credentialJSON are the service account keys used to authenticate + // to Google Cloud + credentialJSON []byte + // custom grpc server that will be used for cloudkms service + // for cloudkms service. This allows for mocking test. + grpcClient *grpc.ClientConn +} + +// MasterKeyFromResourceID creates a new MasterKey with the ResourceID set, +func MasterKeyFromResourceID(resourceID string) *MasterKey { + return &MasterKey{ + ResourceID: resourceID, + CreationDate: time.Now().UTC(), + } +} + +// Encrypt takes a sops data key, encrypts it with GCP KMS +// and stores the result in the EncryptedKey field +func (key *MasterKey) Encrypt(datakey []byte) error { + cloudkmsService, err := key.createKMSService() + if err != nil { + return err + } + defer cloudkmsService.Close() + + req := &kmspb.EncryptRequest{ + Name: key.ResourceID, + Plaintext: datakey, + } + ctx := context.Background() + resp, err := cloudkmsService.Encrypt(ctx, req) + if err != nil { + return fmt.Errorf("failed to encrypt sops data key with GCP KMS: %w", err) + } + key.EncryptedKey = base64.StdEncoding.EncodeToString(resp.Ciphertext) + return nil +} + +// SetEncryptedDataKey sets the encrypted data key for this master key. +func (key *MasterKey) SetEncryptedDataKey(enc []byte) { + key.EncryptedKey = string(enc) +} + +// EncryptedDataKey returns the encrypted data key this master key holds. +func (key *MasterKey) EncryptedDataKey() []byte { + return []byte(key.EncryptedKey) +} + +// EncryptIfNeeded encrypts the provided SOPS data key, if it has not been +// encrypted yet. +func (key *MasterKey) EncryptIfNeeded(dataKey []byte) error { + if key.EncryptedKey == "" { + return key.Encrypt(dataKey) + } + return nil +} + +// Decrypt decrypts the EncryptedKey field with GCP KMS and returns +// the result. +func (key *MasterKey) Decrypt() ([]byte, error) { + cloudkmsService, err := key.createKMSService() + if err != nil { + return nil, err + } + defer cloudkmsService.Close() + + decodedCipher, err := base64.StdEncoding.DecodeString(string(key.EncryptedDataKey())) + if err != nil { + return nil, err + } + req := &kmspb.DecryptRequest{ + Name: key.ResourceID, + Ciphertext: decodedCipher, + } + ctx := context.Background() + resp, err := cloudkmsService.Decrypt(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to decrypt sops data key with GCP KMS Key: %w", err) + } + + return resp.Plaintext, nil +} + +// NeedsRotation returns whether the data key needs to be rotated or not. +func (key *MasterKey) NeedsRotation() bool { + return time.Since(key.CreationDate) > (gcpkmsTTL) +} + +// ToString converts the key to a string representation. +func (key *MasterKey) ToString() string { + return key.ResourceID +} + +// ToMap converts the MasterKey to a map for serialization purposes. +func (key MasterKey) ToMap() map[string]interface{} { + out := make(map[string]interface{}) + out["resource_id"] = key.ResourceID + out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339) + out["enc"] = key.EncryptedKey + return out +} + +// createCloudKMSService create a GCP KMS client configured with the JSON credentials +// stored in the credentialJSON field. +func (key *MasterKey) createKMSService() (*kms.KeyManagementClient, error) { + re := regexp.MustCompile(`^projects/[^/]+/locations/[^/]+/keyRings/[^/]+/cryptoKeys/[^/]+$`) + matches := re.FindStringSubmatch(key.ResourceID) + if matches == nil { + return nil, fmt.Errorf("no valid resourceId found in %q", key.ResourceID) + } + + ctx := context.Background() + opts := []option.ClientOption{} + + if key.credentialJSON != nil { + opts = append(opts, option.WithCredentialsJSON(key.credentialJSON)) + } + + if key.grpcClient != nil { + opts = append(opts, option.WithGRPCConn(key.grpcClient)) + } + + client, err := kms.NewKeyManagementClient(ctx, opts...) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/sops/gcpkms/keysource_integration_test.go b/internal/sops/gcpkms/keysource_integration_test.go new file mode 100644 index 000000000..3ebeadb13 --- /dev/null +++ b/internal/sops/gcpkms/keysource_integration_test.go @@ -0,0 +1,158 @@ +//go:build integration +// +build integration + +/* +Copyright 2022 The Flux authors + +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 gcpkms + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "testing" + + . "github.com/onsi/gomega" + "go.mozilla.org/sops/v3/gcpkms" + + "google.golang.org/api/option" + kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + kms "cloud.google.com/go/kms/apiv1" +) + +var ( + project = os.Getenv("TEST_PROJECT") + testKeyring = os.Getenv("TEST_KEYRING") + testKey = os.Getenv("TEST_CRYPTO_KEY") + testCredsJSON = os.Getenv("TEST_CRED_JSON") + resourceID = fmt.Sprintf("projects/%s/locations/global/keyRings/%s/cryptoKeys/%s", + project, testKeyring, testKey) +) + +func TestMasterKey_Decrypt_SOPS_Compat(t *testing.T) { + g := NewWithT(t) + os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", testCredsJSON) + + g.Expect(createKMSKeyIfNotExists(resourceID)).To(Succeed()) + + dataKey := []byte("blue golden light") + encryptedKey := gcpkms.NewMasterKeyFromResourceID(resourceID) + g.Expect(encryptedKey.Encrypt(dataKey)).To(Succeed()) + + decryptionKey := MasterKeyFromResourceID(resourceID) + creds, err := ioutil.ReadFile(testCredsJSON) + g.Expect(err).ToNot(HaveOccurred()) + decryptionKey.EncryptedKey = encryptedKey.EncryptedKey + decryptionKey.credentialJSON = creds + dec, err := decryptionKey.Decrypt() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(dec).To(Equal(dataKey)) +} + +func TestMasterKey_Encrypt_SOPS_Compat(t *testing.T) { + os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", testCredsJSON) + g := NewWithT(t) + + g.Expect(createKMSKeyIfNotExists(resourceID)).To(Succeed()) + + dataKey := []byte("silver golden lights") + + encryptionKey := MasterKeyFromResourceID(resourceID) + creds, err := ioutil.ReadFile(testCredsJSON) + g.Expect(err).ToNot(HaveOccurred()) + encryptionKey.credentialJSON = creds + err = encryptionKey.Encrypt(dataKey) + g.Expect(err).ToNot(HaveOccurred()) + + decryptionKey := gcpkms.NewMasterKeyFromResourceID(resourceID) + decryptionKey.EncryptedKey = encryptionKey.EncryptedKey + dec, err := decryptionKey.Decrypt() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(dec).To(Equal(dataKey)) +} + +func TestMasterKey_EncryptDecrypt_RoundTrip(t *testing.T) { + g := NewWithT(t) + + g.Expect(createKMSKeyIfNotExists(resourceID)).To(Succeed()) + + key := MasterKeyFromResourceID(resourceID) + creds, err := ioutil.ReadFile(testCredsJSON) + g.Expect(err).ToNot(HaveOccurred()) + key.credentialJSON = creds + + datakey := []byte("a thousand splendid sons") + g.Expect(key.Encrypt(datakey)).To(Succeed()) + g.Expect(key.EncryptedKey).ToNot(BeEmpty()) + + dec, err := key.Decrypt() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(dec).To(Equal(datakey)) +} + +func createKMSKeyIfNotExists(resourceID string) error { + ctx := context.Background() + // check if crypto key exists if not create it + c, err := kms.NewKeyManagementClient(ctx, option.WithCredentialsFile(testCredsJSON)) + if err != nil { + return fmt.Errorf("err creating client: %q", err) + } + + getCryptoKeyReq := &kmspb.GetCryptoKeyRequest{ + Name: resourceID, + } + _, err = c.GetCryptoKey(ctx, getCryptoKeyReq) + if err == nil { + return nil + } + + e, ok := status.FromError(err) + if !ok || (ok && e.Code() != codes.NotFound) { + return fmt.Errorf("err getting crypto key: %q", err) + } + + projectID := fmt.Sprintf("projects/%s/locations/global", project) + createKeyRingReq := &kmspb.CreateKeyRingRequest{ + Parent: projectID, + KeyRingId: testKeyring, + } + + _, err = c.CreateKeyRing(ctx, createKeyRingReq) + e, ok = status.FromError(err) + if err != nil && !(ok && e.Code() == codes.AlreadyExists) { + return fmt.Errorf("err creating key ring: %q", err) + } + + keyRingName := fmt.Sprintf("%s/keyRings/%s", projectID, testKeyring) + keyReq := &kmspb.CreateCryptoKeyRequest{ + Parent: keyRingName, + CryptoKeyId: testKey, + CryptoKey: &kmspb.CryptoKey{ + Purpose: kmspb.CryptoKey_ENCRYPT_DECRYPT, + }, + } + _, err = c.CreateCryptoKey(ctx, keyReq) + e, ok = status.FromError(err) + if err != nil && !(ok && e.Code() == codes.AlreadyExists) { + return fmt.Errorf("err creating crypto key: %q", err) + } + + return nil +} diff --git a/internal/sops/gcpkms/keysource_test.go b/internal/sops/gcpkms/keysource_test.go new file mode 100644 index 000000000..07b95dbb1 --- /dev/null +++ b/internal/sops/gcpkms/keysource_test.go @@ -0,0 +1,175 @@ +/* +Copyright 2022 The Flux authors + +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 gcpkms + +import ( + "encoding/base64" + "fmt" + "log" + "net" + "testing" + "time" + + . "github.com/onsi/gomega" + kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1" + "google.golang.org/grpc" +) + +var ( + testResourceID = "projects/test-flux/locations/global/keyRings/test-flux/cryptoKeys/sops" + decryptedData = "decrypted data" + encryptedData = "encrypted data" + testCreds = `{"type": "service_account"}` +) + +func TestMasterKey_EncryptedDataKey(t *testing.T) { + g := NewWithT(t) + key := MasterKey{EncryptedKey: encryptedData} + g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(encryptedData)) +} + +func TestMasterKey_SetEncryptedDataKey(t *testing.T) { + g := NewWithT(t) + enc := "encrypted key" + key := &MasterKey{} + key.SetEncryptedDataKey([]byte(enc)) + g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(enc)) +} + +func TestMasterKey_EncryptIfNeeded(t *testing.T) { + g := NewWithT(t) + key := MasterKey{EncryptedKey: "encrypted key"} + g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(string(key.EncryptedKey))) + + key.EncryptIfNeeded([]byte("sops data key")) + g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(string(key.EncryptedKey))) +} + +func TestMasterKey_ToString(t *testing.T) { + rsrcId := testResourceID + g := NewWithT(t) + key := MasterKeyFromResourceID(rsrcId) + g.Expect(key.ToString()).To(Equal(rsrcId)) +} + +func TestMasterKey_ToMap(t *testing.T) { + g := NewWithT(t) + key := MasterKey{ + credentialJSON: []byte("sensitive creds"), + CreationDate: time.Date(2016, time.October, 31, 10, 0, 0, 0, time.UTC), + ResourceID: testResourceID, + EncryptedKey: "this is encrypted", + } + g.Expect(key.ToMap()).To(Equal(map[string]interface{}{ + "resource_id": testResourceID, + "enc": "this is encrypted", + "created_at": "2016-10-31T10:00:00Z", + })) +} + +func TestMasterKey_createCloudKMSService(t *testing.T) { + g := NewWithT(t) + + tests := []struct { + key MasterKey + errString string + }{ + { + key: MasterKey{ + ResourceID: "/projects", + credentialJSON: []byte("some secret"), + }, + errString: "no valid resourceId", + }, + { + key: MasterKey{ + ResourceID: testResourceID, + credentialJSON: []byte(`{ "client_id": ".apps.googleusercontent.com", + "client_secret": "", + "type": "authorized_user"}`), + }, + }, + } + + for _, tt := range tests { + _, err := tt.key.createKMSService() + if tt.errString != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.errString)) + } else { + g.Expect(err).To(BeNil()) + } + } +} + +func TestMasterKey_Decrypt(t *testing.T) { + g := NewWithT(t) + + mockKeyManagement.err = nil + mockKeyManagement.reqs = nil + mockKeyManagement.resps = append(mockKeyManagement.resps[:0], &kmspb.DecryptResponse{ + Plaintext: []byte(decryptedData), + }) + key := MasterKey{ + grpcClient: newGRPCServer("0"), + ResourceID: testResourceID, + EncryptedKey: "encryptedKey", + } + data, err := key.Decrypt() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(data).To(BeEquivalentTo(decryptedData)) +} + +func TestMasterKey_Encrypt(t *testing.T) { + g := NewWithT(t) + + mockKeyManagement.err = nil + mockKeyManagement.reqs = nil + mockKeyManagement.resps = append(mockKeyManagement.resps[:0], &kmspb.EncryptResponse{ + Ciphertext: []byte(encryptedData), + }) + + key := MasterKey{ + grpcClient: newGRPCServer("0"), + ResourceID: testResourceID, + } + err := key.Encrypt([]byte("encrypt")) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(base64.StdEncoding.EncodeToString([]byte(encryptedData)))) +} + +var ( + mockKeyManagement mockKeyManagementServer +) + +func newGRPCServer(port string) *grpc.ClientConn { + serv := grpc.NewServer() + kmspb.RegisterKeyManagementServiceServer(serv, &mockKeyManagement) + + lis, err := net.Listen("tcp", fmt.Sprintf("localhost:%s", port)) + if err != nil { + log.Fatal(err) + } + go serv.Serve(lis) + + conn, err := grpc.Dial(lis.Addr().String(), grpc.WithInsecure()) + if err != nil { + log.Fatal(err) + } + + return conn +} diff --git a/internal/sops/gcpkms/mock_kms_server.go b/internal/sops/gcpkms/mock_kms_server.go new file mode 100644 index 000000000..39c32928e --- /dev/null +++ b/internal/sops/gcpkms/mock_kms_server.go @@ -0,0 +1,343 @@ +// Copyright 2019 Google LLC +// +// 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 +// +// https://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. + +// Code generated by gapic-generator. DO NOT EDIT. + +/* +Copyright 2022 The Flux authors +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. +This package exchanges an ARM access token for an ACR access token on Azure +It has been derived from +https://github.com/Azure/msi-acrpull/blob/main/pkg/authorizer/token_exchanger.go +since the project isn't actively maintained. +*/ + +package gcpkms + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/golang/protobuf/proto" + "github.com/golang/protobuf/ptypes" + kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1" + + status "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/grpc/metadata" +) + +var _ = io.EOF +var _ = ptypes.MarshalAny +var _ status.Status + +type mockKeyManagementServer struct { + // Embed for forward compatibility. + // Tests will keep working if more methods are added + // in the future. + kmspb.KeyManagementServiceServer + + reqs []proto.Message + + // If set, all calls return this error. + err error + + // responses to return if err == nil + resps []proto.Message +} + +func (s *mockKeyManagementServer) ListKeyRings(ctx context.Context, req *kmspb.ListKeyRingsRequest) (*kmspb.ListKeyRingsResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.ListKeyRingsResponse), nil +} + +func (s *mockKeyManagementServer) ListCryptoKeys(ctx context.Context, req *kmspb.ListCryptoKeysRequest) (*kmspb.ListCryptoKeysResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.ListCryptoKeysResponse), nil +} + +func (s *mockKeyManagementServer) ListCryptoKeyVersions(ctx context.Context, req *kmspb.ListCryptoKeyVersionsRequest) (*kmspb.ListCryptoKeyVersionsResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.ListCryptoKeyVersionsResponse), nil +} + +func (s *mockKeyManagementServer) ListImportJobs(ctx context.Context, req *kmspb.ListImportJobsRequest) (*kmspb.ListImportJobsResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.ListImportJobsResponse), nil +} + +func (s *mockKeyManagementServer) GetKeyRing(ctx context.Context, req *kmspb.GetKeyRingRequest) (*kmspb.KeyRing, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.KeyRing), nil +} + +func (s *mockKeyManagementServer) GetCryptoKey(ctx context.Context, req *kmspb.GetCryptoKeyRequest) (*kmspb.CryptoKey, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKey), nil +} + +func (s *mockKeyManagementServer) GetCryptoKeyVersion(ctx context.Context, req *kmspb.GetCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKeyVersion), nil +} + +func (s *mockKeyManagementServer) GetPublicKey(ctx context.Context, req *kmspb.GetPublicKeyRequest) (*kmspb.PublicKey, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.PublicKey), nil +} + +func (s *mockKeyManagementServer) GetImportJob(ctx context.Context, req *kmspb.GetImportJobRequest) (*kmspb.ImportJob, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.ImportJob), nil +} + +func (s *mockKeyManagementServer) CreateKeyRing(ctx context.Context, req *kmspb.CreateKeyRingRequest) (*kmspb.KeyRing, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.KeyRing), nil +} + +func (s *mockKeyManagementServer) CreateCryptoKey(ctx context.Context, req *kmspb.CreateCryptoKeyRequest) (*kmspb.CryptoKey, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKey), nil +} + +func (s *mockKeyManagementServer) CreateCryptoKeyVersion(ctx context.Context, req *kmspb.CreateCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKeyVersion), nil +} + +func (s *mockKeyManagementServer) ImportCryptoKeyVersion(ctx context.Context, req *kmspb.ImportCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKeyVersion), nil +} + +func (s *mockKeyManagementServer) CreateImportJob(ctx context.Context, req *kmspb.CreateImportJobRequest) (*kmspb.ImportJob, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.ImportJob), nil +} + +func (s *mockKeyManagementServer) UpdateCryptoKey(ctx context.Context, req *kmspb.UpdateCryptoKeyRequest) (*kmspb.CryptoKey, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKey), nil +} + +func (s *mockKeyManagementServer) UpdateCryptoKeyVersion(ctx context.Context, req *kmspb.UpdateCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKeyVersion), nil +} + +func (s *mockKeyManagementServer) Encrypt(ctx context.Context, req *kmspb.EncryptRequest) (*kmspb.EncryptResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.EncryptResponse), nil +} + +func (s *mockKeyManagementServer) Decrypt(ctx context.Context, req *kmspb.DecryptRequest) (*kmspb.DecryptResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.DecryptResponse), nil +} + +func (s *mockKeyManagementServer) AsymmetricSign(ctx context.Context, req *kmspb.AsymmetricSignRequest) (*kmspb.AsymmetricSignResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.AsymmetricSignResponse), nil +} + +func (s *mockKeyManagementServer) AsymmetricDecrypt(ctx context.Context, req *kmspb.AsymmetricDecryptRequest) (*kmspb.AsymmetricDecryptResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.AsymmetricDecryptResponse), nil +} + +func (s *mockKeyManagementServer) UpdateCryptoKeyPrimaryVersion(ctx context.Context, req *kmspb.UpdateCryptoKeyPrimaryVersionRequest) (*kmspb.CryptoKey, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKey), nil +} + +func (s *mockKeyManagementServer) DestroyCryptoKeyVersion(ctx context.Context, req *kmspb.DestroyCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKeyVersion), nil +} + +func (s *mockKeyManagementServer) RestoreCryptoKeyVersion(ctx context.Context, req *kmspb.RestoreCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) { + md, _ := metadata.FromIncomingContext(ctx) + if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") { + return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg) + } + s.reqs = append(s.reqs, req) + if s.err != nil { + return nil, s.err + } + return s.resps[0].(*kmspb.CryptoKeyVersion), nil +} diff --git a/internal/sops/keyservice/options.go b/internal/sops/keyservice/options.go index 7508a2699..c17fb4e36 100644 --- a/internal/sops/keyservice/options.go +++ b/internal/sops/keyservice/options.go @@ -13,6 +13,7 @@ import ( "github.com/fluxcd/kustomize-controller/internal/sops/age" "github.com/fluxcd/kustomize-controller/internal/sops/awskms" "github.com/fluxcd/kustomize-controller/internal/sops/azkv" + "github.com/fluxcd/kustomize-controller/internal/sops/gcpkms" "github.com/fluxcd/kustomize-controller/internal/sops/hcvault" "github.com/fluxcd/kustomize-controller/internal/sops/pgp" ) @@ -57,6 +58,14 @@ func (o WithAWSKeys) ApplyToServer(s *Server) { s.awsCredsProvider = o.CredsProvider } +// WithGCPCredsJSON configures the Azure credential token on the Server. +type WithGCPCredsJSON []byte + +// ApplyToServer applies this configuration to the given Server. +func (o WithGCPCredsJSON) ApplyToServer(s *Server) { + s.gcpCredsJSON = gcpkms.CredentialJSON(o) +} + // WithAzureToken configures the Azure credential token on the Server. type WithAzureToken struct { Token *azkv.Token diff --git a/internal/sops/keyservice/server.go b/internal/sops/keyservice/server.go index 8959c7696..ec92ba06f 100644 --- a/internal/sops/keyservice/server.go +++ b/internal/sops/keyservice/server.go @@ -15,6 +15,7 @@ import ( "github.com/fluxcd/kustomize-controller/internal/sops/age" "github.com/fluxcd/kustomize-controller/internal/sops/awskms" "github.com/fluxcd/kustomize-controller/internal/sops/azkv" + "github.com/fluxcd/kustomize-controller/internal/sops/gcpkms" "github.com/fluxcd/kustomize-controller/internal/sops/hcvault" "github.com/fluxcd/kustomize-controller/internal/sops/pgp" ) @@ -50,6 +51,9 @@ type Server struct { // operations of AWS KMS requests. // When nil, the request will be handled by defaultServer. awsCredsProvider *awskms.CredsProvider + // gcpCredsJSON is the JSON credentials used for Decrypt and Encrypt + // operations of GCP KMS requests + gcpCredsJSON gcpkms.CredentialJSON // defaultServer is the fallback server, used to handle any request that // is not eligible to be handled by this Server. @@ -122,6 +126,14 @@ func (ks Server) Encrypt(ctx context.Context, req *keyservice.EncryptRequest) (* Ciphertext: ciphertext, }, nil } + case *keyservice.Key_GcpKmsKey: + ciphertext, err := ks.encryptWithGCPKMS(k.GcpKmsKey, req.Plaintext) + if err != nil { + return nil, err + } + return &keyservice.EncryptResponse{ + Ciphertext: ciphertext, + }, nil case nil: return nil, fmt.Errorf("must provide a key") } @@ -178,6 +190,14 @@ func (ks Server) Decrypt(ctx context.Context, req *keyservice.DecryptRequest) (* Plaintext: plaintext, }, nil } + case *keyservice.Key_GcpKmsKey: + plaintext, err := ks.decryptWithGCPKMS(k.GcpKmsKey, req.Ciphertext) + if err != nil { + return nil, err + } + return &keyservice.DecryptResponse{ + Plaintext: plaintext, + }, nil case nil: return nil, fmt.Errorf("must provide a key") } @@ -317,3 +337,24 @@ func (ks *Server) decryptWithAzureKeyVault(key *keyservice.AzureKeyVaultKey, cip plaintext, err := azureKey.Decrypt() return plaintext, err } + +func (ks *Server) encryptWithGCPKMS(key *keyservice.GcpKmsKey, plaintext []byte) ([]byte, error) { + gcpKey := gcpkms.MasterKey{ + ResourceID: key.ResourceId, + } + ks.gcpCredsJSON.ApplyToMasterKey(&gcpKey) + if err := gcpKey.Encrypt(plaintext); err != nil { + return nil, err + } + return gcpKey.EncryptedDataKey(), nil +} + +func (ks *Server) decryptWithGCPKMS(key *keyservice.GcpKmsKey, ciphertext []byte) ([]byte, error) { + gcpKey := gcpkms.MasterKey{ + ResourceID: key.ResourceId, + } + ks.gcpCredsJSON.ApplyToMasterKey(&gcpKey) + gcpKey.EncryptedKey = string(ciphertext) + plaintext, err := gcpKey.Decrypt() + return plaintext, err +} diff --git a/internal/sops/keyservice/server_test.go b/internal/sops/keyservice/server_test.go index a0fa1a32f..fb4da3115 100644 --- a/internal/sops/keyservice/server_test.go +++ b/internal/sops/keyservice/server_test.go @@ -20,6 +20,7 @@ import ( "github.com/fluxcd/kustomize-controller/internal/sops/age" "github.com/fluxcd/kustomize-controller/internal/sops/awskms" "github.com/fluxcd/kustomize-controller/internal/sops/azkv" + "github.com/fluxcd/kustomize-controller/internal/sops/gcpkms" "github.com/fluxcd/kustomize-controller/internal/sops/hcvault" "github.com/fluxcd/kustomize-controller/internal/sops/pgp" ) @@ -128,7 +129,6 @@ func TestServer_EncryptDecrypt_HCVault_Fallback(t *testing.T) { fallback = NewMockKeyServer() s = NewServer(WithDefaultServer{Server: fallback}) - decReq := &keyservice.DecryptRequest{ Key: &key, Ciphertext: []byte("some ciphertext"), @@ -211,6 +211,30 @@ func TestServer_EncryptDecrypt_azkv_Fallback(t *testing.T) { g.Expect(fallback.encryptReqs).To(HaveLen(0)) } +func TestServer_EncryptDecrypt_gcpkms(t *testing.T) { + g := NewWithT(t) + + creds := `{ "client_id": ".apps.googleusercontent.com", + "client_secret": "", + "type": "authorized_user"}` + s := NewServer(WithGCPCredsJSON([]byte(creds))) + + resourceID := "projects/test-flux/locations/global/keyRings/test-flux/cryptoKeys/sops" + key := KeyFromMasterKey(gcpkms.MasterKeyFromResourceID(resourceID)) + _, err := s.Encrypt(context.TODO(), &keyservice.EncryptRequest{ + Key: &key, + }) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to encrypt sops data key with GCP KMS")) + + _, err = s.Decrypt(context.TODO(), &keyservice.DecryptRequest{ + Key: &key, + }) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to decrypt sops data key with GCP KMS")) + +} + func TestServer_EncryptDecrypt_Nil_KeyType(t *testing.T) { g := NewWithT(t) diff --git a/internal/sops/keyservice/utils_test.go b/internal/sops/keyservice/utils_test.go index e0d3bada9..9bdfb5b43 100644 --- a/internal/sops/keyservice/utils_test.go +++ b/internal/sops/keyservice/utils_test.go @@ -16,6 +16,7 @@ import ( "github.com/fluxcd/kustomize-controller/internal/sops/age" "github.com/fluxcd/kustomize-controller/internal/sops/awskms" "github.com/fluxcd/kustomize-controller/internal/sops/azkv" + "github.com/fluxcd/kustomize-controller/internal/sops/gcpkms" "github.com/fluxcd/kustomize-controller/internal/sops/hcvault" "github.com/fluxcd/kustomize-controller/internal/sops/pgp" ) @@ -68,6 +69,14 @@ func KeyFromMasterKey(k keys.MasterKey) keyservice.Key { }, }, } + case *gcpkms.MasterKey: + return keyservice.Key{ + KeyType: &keyservice.Key_GcpKmsKey{ + GcpKmsKey: &keyservice.GcpKmsKey{ + ResourceId: mk.ResourceID, + }, + }, + } default: panic(fmt.Sprintf("tried to convert unknown MasterKey type %T to keyservice.Key", mk)) }