diff --git a/integrations/gcp/GCPKeyVaultStorage.go b/integrations/gcp/GCPKeyVaultStorage.go new file mode 100644 index 0000000..eedbed9 --- /dev/null +++ b/integrations/gcp/GCPKeyVaultStorage.go @@ -0,0 +1,461 @@ +// -*- coding: utf-8 -*- +// _ __ +// | |/ /___ ___ _ __ ___ _ _ (R) +// | ' 0 { + updatedConfigJson, err := json.Marshal(updatedConfig) + if err != nil { + return fmt.Errorf("failed to marshal updated config: %w", err) + } + + updatedConfigHash := g.createHash(updatedConfigJson) + if updatedConfigHash != configHash { + configHash = updatedConfigHash + g.config = make(map[core.ConfigKey]interface{}) + for k, v := range updatedConfig { + g.config[k] = fmt.Sprintf("%v", v) + } + } + } + + if !force && configHash == g.lastSavedConfigHash { + glog.Info("Skipped config JSON save. No changes detected.") + return nil + } + + if err := g.createConfigFileIfMissing(); err != nil { + return err + } + + if err := g.encryptConfig(ctx, configJson); err != nil { + return err + } + + g.lastSavedConfigHash = configHash + return nil +} + +// Creates a hash of the given configuration data. +func (g *googleCloudKeyVaultStorage) createHash(config []byte) string { + glog.Debug("Creating hash of config") + hash := md5.Sum(config) + return hex.EncodeToString(hash[:]) +} + +// Creates the config file if it does not already exist. +func (g *googleCloudKeyVaultStorage) createConfigFileIfMissing() error { + if _, err := os.Stat(g.configFileLocation); !os.IsNotExist(err) { + glog.Info("Config file already exists at: %s", g.configFileLocation) + return nil + } + + dir := filepath.Dir(g.configFileLocation) + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + + if err := g.encryptConfig(context.Background(), []byte("{}")); err != nil { + return err + } + + glog.Info("Config file created at: %s", g.configFileLocation) + return nil +} + +// Retrieves the details of the specified key from Google Cloud KMS. +func getKeyDetails(ctx context.Context, credentialFileWithPath string, keyResourceName string) (*kmspb.CryptoKey, error) { + glog.Info("Getting key details from GCP") + client, err := getGCPKMSClient(credentialFileWithPath) + if err != nil { + return nil, err + } + + defer client.Close() + + // Remove the cryptoKeyVersions/ from the keyResourceName + index := strings.Index(keyResourceName, "/cryptoKeyVersions/") + if index != -1 { + keyResourceName = keyResourceName[:index] + } + + req := &kmspb.GetCryptoKeyRequest{ + Name: keyResourceName, + } + + // Fetch the key details from GCP + resp, err := client.GetCryptoKey(ctx, req) + if err != nil { + glog.Error(fmt.Sprintf("Failed to fetch the key details from GCP: %v", err.Error())) + return nil, fmt.Errorf("failed to get key details: %w", err) + } + + return resp, nil +} + +// Encrypts the configuration data and writes it to the config file. +func (g *googleCloudKeyVaultStorage) encryptConfig(ctx context.Context, config []byte) error { + client, err := getGCPKMSClient(g.credentialFileWithPath) + if err != nil { + return err + } + + defer client.Close() + if g.keyDetails.Purpose == kmspb.CryptoKey_ENCRYPT_DECRYPT { + glog.Debug("Encrypting config using symmetric key") + ciphertext, err := encryptionSymmetric(ctx, client, g.keyResourceName, config) + if err != nil { + return err + } + + if err := os.WriteFile(g.configFileLocation, ciphertext, 0644); err != nil { + return fmt.Errorf("failed to write encrypted config file: %w", err) + } + } else if g.keyDetails.Purpose == kmspb.CryptoKey_RAW_ENCRYPT_DECRYPT { + glog.Debug("Encrypting config using raw symmetric key") + token, err := getOAuthToken(ctx, g.credentialFileWithPath) + if err != nil { + glog.Error("Failed to get OAuth token") + return fmt.Errorf("failed to get OAuth token") + } + + ciphertext, err := encryptRawSymmteric(g.keyResourceName, config, *token) + if err != nil { + return err + } + + if err := os.WriteFile(g.configFileLocation, ciphertext, 0644); err != nil { + return fmt.Errorf("failed to write encrypted config file: %w", err) + } + } else { + glog.Debug("Encrypting config using asymmetric key") + ciphertext, err := encryptAsymmetric(ctx, client, g.keyResourceName, config) + if err != nil { + return err + } + + if err := os.WriteFile(g.configFileLocation, ciphertext, 0644); err != nil { + return fmt.Errorf("failed to write encrypted config file: %w", err) + } + } + + return nil +} + +func getGCPKMSClient(credentialFileWithPath string) (*kms.KeyManagementClient, error) { + ctx := context.Background() + client, err := kms.NewKeyManagementClient(ctx, option.WithCredentialsFile(credentialFileWithPath)) + if err != nil { + glog.Error(fmt.Sprintf("Failed to create GCP Key Management client: %v", err.Error())) + return nil, fmt.Errorf("failed to create GCP Key Management client: %w", err) + } + + return client, nil +} + +func getOAuthToken(ctx context.Context, credentialFileWithPath string) (*string, error) { + creds, err := os.ReadFile(credentialFileWithPath) + if err != nil { + glog.Error(fmt.Sprintf("Failed to read credentials file: %v", err.Error())) + return nil, fmt.Errorf("failed to read credentials file: %w", err) + } + config, err := google.JWTConfigFromJSON(creds, cloud_api_url) + if err != nil { + return nil, err + } + token, err := config.TokenSource(ctx).Token() + if err != nil { + return nil, err + } + + return &token.AccessToken, nil +} + +func (g *googleCloudKeyVaultStorage) ChangeKey(updatedKeyResourceName string, updatedCredentialFileWithPath string) (bool, error) { + oldKeyResourceName := g.keyResourceName + oldCredentialFileWithPath := g.credentialFileWithPath + oldKeyDetails := g.keyDetails + + if updatedCredentialFileWithPath == "" { + updatedCredentialFileWithPath = g.credentialFileWithPath + } + + keyDetails, err := getKeyDetails(context.Background(), updatedCredentialFileWithPath, updatedKeyResourceName) + if err != nil { + glog.Error(fmt.Sprintf("Failed to get key details for key '%s': %v", updatedKeyResourceName, err)) + return false, err + } + + g.keyDetails = keyDetails + g.credentialFileWithPath = updatedCredentialFileWithPath + g.keyResourceName = updatedKeyResourceName + if err := g.saveConfig(make(map[core.ConfigKey]interface{}), true); err != nil { + g.keyResourceName = oldKeyResourceName + g.credentialFileWithPath = oldCredentialFileWithPath + g.keyDetails = oldKeyDetails + glog.Error(fmt.Sprintf("Failed to change the key to '%s' for config '%s': %v", updatedKeyResourceName, g.configFileLocation, err)) + return false, fmt.Errorf("failed to change the key for %s: %w", g.configFileLocation, err) + } + + return true, nil +} + +func (g *googleCloudKeyVaultStorage) DecryptConfig(autosave bool) (string, error) { + var ciphertext []byte + var plaintext []byte + ctx := context.Background() + + ciphertext, err := os.ReadFile(g.configFileLocation) + if err != nil { + return "", fmt.Errorf("failed to read config file: %w", err) + } + + if len(ciphertext) == 0 { + glog.Warning(fmt.Sprintf("empty config file %s", g.configFileLocation)) + return "", nil + } + + gcpKeyManagementClient, err := getGCPKMSClient(g.credentialFileWithPath) + if err != nil { + return "", err + } + + defer gcpKeyManagementClient.Close() + + if g.keyDetails.Purpose == kmspb.CryptoKey_ENCRYPT_DECRYPT { + plaintext, err = decryptionSymmetric(ctx, gcpKeyManagementClient, g.keyResourceName, ciphertext) + if err != nil { + glog.Error(fmt.Sprintf("Failed to decrypt config file: %s", err.Error())) + return "", fmt.Errorf("failed to decrypt config file %s", g.configFileLocation) + } + } else if g.keyDetails.Purpose == kmspb.CryptoKey_RAW_ENCRYPT_DECRYPT { + token, err := getOAuthToken(ctx, g.credentialFileWithPath) + if err != nil { + glog.Error("Failed to get OAuth token") + return "", fmt.Errorf("failed to get OAuth token") + } + plaintext, err = decryptRawSymmteric(g.keyResourceName, ciphertext, *token) + if err != nil { + glog.Error(fmt.Sprintf("Failed to decrypt config file: %s", err.Error())) + return "", fmt.Errorf("failed to decrypt config file %s", g.configFileLocation) + } + } else { + plaintext, err = decryptAsymmetric(ctx, gcpKeyManagementClient, g.keyResourceName, ciphertext) + if err != nil { + glog.Error(fmt.Sprintf("Failed to decrypt config file: %s", err.Error())) + return "", fmt.Errorf("failed to decrypt config file %s", g.configFileLocation) + } + } + + if len(plaintext) == 0 { + glog.Error("empty config file") + return "", fmt.Errorf("empty config file") + } else if autosave { + if err := os.WriteFile(g.configFileLocation, plaintext, 0644); err != nil { + glog.Error(fmt.Sprintf("failed to write decrypted config file %s: %v", g.configFileLocation, err)) + return "", fmt.Errorf("failed to write decrypted config file %s", g.configFileLocation) + } + } + + return string(plaintext), nil +} diff --git a/integrations/gcp/README.md b/integrations/gcp/README.md new file mode 100644 index 0000000..a9dd013 --- /dev/null +++ b/integrations/gcp/README.md @@ -0,0 +1,129 @@ +# GCP Cloud Key Management + +Protect Secrets Manager connection details with GCP Cloud Key Management + +Keeper Secrets Manager integrates with GCP Cloud Key Management in order to provide protection for Keeper Secrets Manager configuration files. With this integration, you can protect connection details on your machine while taking advantage of Keeper's zero-knowledge encryption of all your secret credentials. + +# Features + +* Encrypt and Decrypt your Keeper Secrets Manager configuration files with GCP Cloud Key Management +* Protect against unauthorized access to your Secrets Manager connections +* Requires only minor changes to code for immediate protection. Works with all Keeper Secrets Manager Go-Lang SDK functionality + +# Prerequisites +* Supports the Go-Lang Secrets Manager SDK. +* Requires GCP Cloud packages: kms/apiv1, kmspb, core, kms +* Works with just AES/RSA key types with `Encrypt` and `Decrypt` permissions. + +# Setup +1. Install Secret-Manager-Go Package + +The Secrets Manager GCP package are located in the Keeper Secrets Manager storage package which can be installed using + +> `go get github.com/keeper-security/secrets-manager-go/integrations/gcp` + +Configure GCP Connection + +``` + +package main + +import ( + "encoding/json" + "fmt" + + "github.com/keeper-security/secrets-manager-go/core" + gcpkv "github.com/keeper-security/secrets-manager-go/integrations/gcp" +) + +func main() { + decryptConfig := false + changeKey := false + + credentialFileWithPath := "" + keyResourceName := "" + ksmConfigFileName := "" + oneTimeToken := "" + + cfg := gcpkv.NewGCPKeyVaultStorage(ksmConfigFileName, keyResourceName, credentialFileWithPath) + + client_options := &core.ClientOptions{ + Token: oneTimeToken, + Config: cfg, + } + + fmt.Printf("Client ID Value: %s", cfg.Get(core.KEY_CLIENT_ID)) + + secrets_manager := core.NewSecretsManager(client_options) + secrets, err := secrets_manager.GetSecrets([]string{}) + if err != nil { + // do something + fmt.Printf("Error while fetching secrets: %v\n", err) + } + + for _, record := range secrets { + fmt.Printf("Records: %v\n", record) + } + + if changeKey { + // isChanged gives boolean value to check the key is changed or not. + // Pass (updatedResourceName, "") as a parameter to change the key. Its just change the key for encryption and decryption. + updatedResourceName := "" + isChanged, err := cfg.ChangeKey(updatedResourceName, "") + if err != nil { + // do something + } + + fmt.Printf("Key changed: %v\n", isChanged) + + // Pass updated service account credentials along with the updated key if you need to change the credentails. + // Pass (updatedResourceName, updatedCredentialFileWithPath) as a parameter. + // updatedCredentialFileWithPath := "" + // isChanged, err = cfg.ChangeKey(updatedResourceName, updatedCredentialFileWithPath) + // if err != nil { + // // do something + // fmt.Printf("Error while changing key: %v\n", err) + // } else { + // fmt.Printf("Key changed: %v\n", isChanged) + // } + + // fmt.Printf("Client ID Value after changing Key: %s", cfg.Get(core.KEY_CLIENT_ID)) + } + + if decryptConfig { + configs := make(map[core.ConfigKey]interface{}) + // Decrypt the config + // Pass true as a parameter to save the decrypted config in the given file, else pass false + plainText, err := cfg.DecryptConfig(false) + if err != nil { + // do something + fmt.Printf("Error while decrypting config: %v", err) + } else { + if err := json.Unmarshal([]byte(plainText), &configs); err != nil { + fmt.Printf("Error while unmarshalling: %v", err) + } + fmt.Printf("Decrypted data: %v\n", configs["clientId"]) + } + } +} + + + +``` +# Configuration +The NewGCPKeyVaultStorage requires the following parameters to encrypt the KSM configuration using GCP Cloud Key Management: + +* `ksmConfigFileName` : The file name of KSM configuration. +* `GCP CredentialFile` : The file name along with its path for the GCP credential file. +* `KeyResourceName` : The name of the key resource to be used for encryption/decryption. + +KeyResourceName format must be `projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/KEY_NAME/cryptoKeyVersions/KEY_VERSION` + +For more information about KeyResourceName see the GCP Cloud Key Management Documentation +https://cloud.google.com/kms/docs/getting-resource-ids + +You're ready to use the KSM integration 👍 + +Using the GCP Cloud Key Management Integration + +Review the SDK usage. Refer to the SDK (documentation) [https://docs.keeper.io/en/privileged-access-manager/secrets-manager/developer-sdk-library/golang-sdk#retrieve-secrets]. diff --git a/integrations/gcp/go.mod b/integrations/gcp/go.mod new file mode 100644 index 0000000..d9bdef9 --- /dev/null +++ b/integrations/gcp/go.mod @@ -0,0 +1,42 @@ +module github.com/keeper-security/secrets-manager-go/integrations/gcp + +go 1.23.5 + +require ( + cloud.google.com/go/kms v1.21.0 + github.com/keeper-security/secrets-manager-go/core v1.6.4 + google.golang.org/api v0.223.0 + google.golang.org/protobuf v1.36.5 +) + +require ( + cloud.google.com/go v0.118.2 // indirect + cloud.google.com/go/auth v0.15.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/iam v1.4.0 // indirect + cloud.google.com/go/longrunning v0.6.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/oauth2 v0.26.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + golang.org/x/time v0.10.0 // indirect + google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250219182151-9fdb1cabc7b2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 // indirect + google.golang.org/grpc v1.70.0 // indirect +) diff --git a/integrations/gcp/go.sum b/integrations/gcp/go.sum new file mode 100644 index 0000000..58911d3 --- /dev/null +++ b/integrations/gcp/go.sum @@ -0,0 +1,85 @@ +cloud.google.com/go v0.118.2 h1:bKXO7RXMFDkniAAvvuMrAPtQ/VHrs9e7J5UT3yrGdTY= +cloud.google.com/go v0.118.2/go.mod h1:CFO4UPEPi8oV21xoezZCrd3d81K4fFkDTEJu4R8K+9M= +cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= +cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= +cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/iam v1.4.0 h1:ZNfy/TYfn2uh/ukvhp783WhnbVluqf/tzOaqVUPlIPA= +cloud.google.com/go/iam v1.4.0/go.mod h1:gMBgqPaERlriaOV0CUl//XUzDhSfXevn4OEUbg6VRs4= +cloud.google.com/go/kms v1.21.0 h1:x3EeWKuYwdlo2HLse/876ZrKjk2L5r7Uexfm8+p6mSI= +cloud.google.com/go/kms v1.21.0/go.mod h1:zoFXMhVVK7lQ3JC9xmhHMoQhnjEDZFoLAr5YMwzBLtk= +cloud.google.com/go/longrunning v0.6.4 h1:3tyw9rO3E2XVXzSApn1gyEEnH2K9SynNQjMlBi3uHLg= +cloud.google.com/go/longrunning v0.6.4/go.mod h1:ttZpLCe6e7EXvn9OxpBRx7kZEB0efv8yBO6YnVMfhJs= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/keeper-security/secrets-manager-go/core v1.6.4 h1:ly2XvAgDxHoHVvFXOIYlxzxBF0yoQir1KfNHUNG4eRA= +github.com/keeper-security/secrets-manager-go/core v1.6.4/go.mod h1:dtlaeeds9+SZsbDAZnQRsDSqEAK9a62SYtqhNql+VgQ= +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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= +golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/api v0.223.0 h1:JUTaWEriXmEy5AhvdMgksGGPEFsYfUKaPEYXd4c3Wvc= +google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M= +google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s= +google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4/go.mod h1:qbZzneIOXSq+KFAFut9krLfRLZiFLzZL5u2t8SV83EE= +google.golang.org/genproto/googleapis/api v0.0.0-20250219182151-9fdb1cabc7b2 h1:35ZFtrCgaAjF7AFAK0+lRSf+4AyYnWRbH7og13p7rZ4= +google.golang.org/genproto/googleapis/api v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:W9ynFDP/shebLB1Hl/ESTOap2jHd6pmLXPNZC7SVDbA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 h1:DMTIbak9GhdaSxEjvVzAeNZvyc03I61duqNbnm3SU0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= +google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +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/integrations/gcp/storage.go b/integrations/gcp/storage.go new file mode 100644 index 0000000..2e25b3d --- /dev/null +++ b/integrations/gcp/storage.go @@ -0,0 +1,84 @@ +package gcpkv + +import ( + "fmt" + + "github.com/keeper-security/secrets-manager-go/core" + glog "github.com/keeper-security/secrets-manager-go/core/logger" +) + +func (g *googleCloudKeyVaultStorage) ReadStorage() map[string]interface{} { + if err := g.loadConfig(); err != nil { + glog.Error(fmt.Sprintf("Failed to load config: %v", err)) + return nil + } + + convertedConfig := make(map[string]interface{}) + for k, v := range g.config { + convertedConfig[string(k)] = v + } + + return convertedConfig +} + +func (g *googleCloudKeyVaultStorage) SaveStorage(updatedConfig map[string]interface{}) { + convertedConfig := make(map[core.ConfigKey]interface{}) + for k, v := range updatedConfig { + if strVal, ok := v.(string); ok { + convertedConfig[core.ConfigKey(k)] = strVal + } + } + + if err := g.saveConfig(convertedConfig, false); err != nil { + glog.Error(fmt.Sprintf("Failed to save config: %v", err)) + } +} + +func (g *googleCloudKeyVaultStorage) Get(key core.ConfigKey) string { + if val, ok := g.config[key]; ok { + if strVal, ok := val.(string); ok { + return strVal + } + } + + return "" + +} + +func (g *googleCloudKeyVaultStorage) Set(key core.ConfigKey, value interface{}) map[string]interface{} { + g.config[key] = value + convertedConfig := make(map[string]interface{}) + for k, v := range g.config { + convertedConfig[string(k)] = v + } + + g.SaveStorage(convertedConfig) + return g.ReadStorage() +} + +func (g *googleCloudKeyVaultStorage) Delete(key core.ConfigKey) map[string]interface{} { + if _, found := g.config[key]; found { + delete(g.config, key) + glog.Debug(fmt.Sprintf("Deleted key '%s' from config", string(key))) + g.saveConfig(g.config, false) + } else { + glog.Warning("No key '%s' was found in config", string(key)) + } + + return g.ReadStorage() +} + +func (g *googleCloudKeyVaultStorage) DeleteAll() map[string]interface{} { + g.config = map[core.ConfigKey]interface{}{} + g.saveConfig(g.config, false) + return g.ReadStorage() +} + +func (g *googleCloudKeyVaultStorage) IsEmpty() bool { + return len(g.config) == 0 +} + +func (g *googleCloudKeyVaultStorage) Contains(key core.ConfigKey) bool { + _, found := g.config[key] + return found +} diff --git a/integrations/gcp/utils.go b/integrations/gcp/utils.go new file mode 100644 index 0000000..e82a6bf --- /dev/null +++ b/integrations/gcp/utils.go @@ -0,0 +1,414 @@ +package gcpkv + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + + "hash/crc32" + "io" + "strings" + + kms "cloud.google.com/go/kms/apiv1" + "cloud.google.com/go/kms/apiv1/kmspb" + glog "github.com/keeper-security/secrets-manager-go/core/logger" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +const ( + BLOB_HEADER = "\xff\xff" + cloud_api_url = "https://www.googleapis.com/auth/cloud-platform" + raw_gcp_url = "https://cloudkms.googleapis.com/v1/%s" + additionalAuthenticatedData = "keeper_auth" +) + +type EncryptionResponse struct { + Ciphertext string `json:"ciphertext"` + InitializationVector string `json:"initializationVector"` +} + +// Encrypts the message using a symmetric key stored in Google Cloud KMS. +func encryptionSymmetric(ctx context.Context, gcpKMClient *kms.KeyManagementClient, keyResourceName string, message []byte) ([]byte, error) { + glog.Debug("Encryption Symmetric") + if keyResourceName == "" { + glog.Error("keyResourceName is empty") + return nil, fmt.Errorf("keyResourceName is empty") + } + + crc32c := func(data []byte) uint32 { + t := crc32.MakeTable(crc32.Castagnoli) + return crc32.Checksum(data, t) + } + + text, err := gcpKMClient.Encrypt(ctx, &kmspb.EncryptRequest{ + Name: keyResourceName, + Plaintext: message, + PlaintextCrc32C: wrapperspb.Int64(int64(crc32c(message))), + }) + + if err != nil { + glog.Error(fmt.Sprintf("Symmetric Encryption failed: %v", err.Error())) + return nil, fmt.Errorf("failed to encrypt message: %w", err) + } + + return text.Ciphertext, nil +} + +// Decrypts the ciphertext using a symmetric key stored in Google Cloud KMS. +func decryptionSymmetric(ctx context.Context, gcpKMClient *kms.KeyManagementClient, keyResourceName string, cipherText []byte) ([]byte, error) { + glog.Debug("Decryption Symmetric") + if keyResourceName == "" { + glog.Error("Empty keyResourceName") + return nil, fmt.Errorf("keyResourceName is empty") + } + + index := strings.Index(keyResourceName, "/cryptoKeyVersions/") + if index != -1 { + keyResourceName = keyResourceName[:index] + } + + crc32c := func(data []byte) uint32 { + t := crc32.MakeTable(crc32.Castagnoli) + return crc32.Checksum(data, t) + } + + plainText, err := gcpKMClient.Decrypt(ctx, &kmspb.DecryptRequest{ + Name: keyResourceName, + Ciphertext: cipherText, + CiphertextCrc32C: wrapperspb.Int64(int64(crc32c(cipherText))), + }) + if err != nil { + glog.Error(fmt.Sprintf("Symmetric Decryption failed: %v", err.Error())) + return nil, fmt.Errorf("failed to decrypt message: %w", err) + } + + return plainText.Plaintext, nil +} + +// Encrypts the key using an asymmetric key stored in Google Cloud KMS. +func encryptionAsymmetricKey(ctx context.Context, gcpKMClient *kms.KeyManagementClient, keyResourceName string, key []byte) ([]byte, error) { + glog.Debug("Encryption Asymmetric Key") + response, err := gcpKMClient.GetPublicKey(ctx, &kmspb.GetPublicKeyRequest{ + Name: keyResourceName, + }) + if err != nil { + glog.Error(fmt.Sprintf("Error while fetching public key: %v", err.Error())) + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + block, _ := pem.Decode([]byte(response.Pem)) + if block == nil { + return nil, fmt.Errorf("failed to decode public key: no PEM data found") + } + + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %w", err) + } + + rsaKey, ok := publicKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("public key is not rsa") + } + + keyVersion, err := gcpKMClient.GetCryptoKeyVersion(ctx, &kmspb.GetCryptoKeyVersionRequest{ + Name: keyResourceName, + }) + if err != nil { + glog.Error(fmt.Sprintf("Error while fetching key version: %v", err.Error())) + return nil, fmt.Errorf("failed to get key version: %w", err) + } + + hashAlg, ok := keyDetails[keyVersion.Algorithm] + if !ok { + return nil, fmt.Errorf("unsupported key algorithm: %v", keyVersion.Algorithm) + } + + ciphertext, err := rsa.EncryptOAEP(hashAlg, rand.Reader, rsaKey, key, nil) + if err != nil { + return nil, fmt.Errorf("rsa.EncryptOAEP: %w", err) + } + + return ciphertext, nil +} + +// Decrypts the ciphertext key using an asymmetric key stored in Google Cloud KMS. +func decryptAsymmetricKey(ctx context.Context, gcpKMClient *kms.KeyManagementClient, keyResourceName string, key []byte) ([]byte, error) { + glog.Debug("Decryption Asymmetric Key") + crc32c := func(data []byte) uint32 { + t := crc32.MakeTable(crc32.Castagnoli) + return crc32.Checksum(data, t) + } + ciphertextCRC32C := crc32c(key) + + req := &kmspb.AsymmetricDecryptRequest{ + Name: keyResourceName, + Ciphertext: key, + CiphertextCrc32C: wrapperspb.Int64(int64(ciphertextCRC32C)), + } + + result, err := gcpKMClient.AsymmetricDecrypt(ctx, req) + if err != nil { + glog.Error(fmt.Sprintf("Asymmetric Decryption failed: %v", err.Error())) + return nil, fmt.Errorf("failed to decrypt ciphertext: %w", err) + } + + if !result.VerifiedCiphertextCrc32C { + return nil, fmt.Errorf("AsymmetricDecrypt: request corrupted in-transit") + } + + if int64(crc32c(result.Plaintext)) != result.PlaintextCrc32C.Value { + return nil, fmt.Errorf("AsymmetricDecrypt: response corrupted in-transit") + } + + return result.Plaintext, nil +} + +// Encrypts the message using an asymmetric key stored in Google Cloud KMS. +func encryptAsymmetric(ctx context.Context, gcpKMClient *kms.KeyManagementClient, keyResourceName string, message []byte) ([]byte, error) { + glog.Debug("Encryption Asymmetric") + var blob []byte + key := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + ciphertext := aesGCM.Seal(nil, nonce, message, nil) + tag := ciphertext[len(ciphertext)-aesGCM.Overhead():] + ciphertext = ciphertext[:len(ciphertext)-aesGCM.Overhead()] + + encryptedKey, err := encryptionAsymmetricKey(ctx, gcpKMClient, keyResourceName, key) + if err != nil { + glog.Error(fmt.Sprintf("Encryption Asymmetric failed: %v", err.Error())) + return nil, fmt.Errorf("failed to encrypt key: %w", err) + } + + blob = append([]byte{}, []byte(BLOB_HEADER)...) + + components := [][]byte{ + encryptedKey, + nonce, + tag, + ciphertext, + } + + // Iterate over the components and append the length and data + for _, comp := range components { + blob = append(blob, uint32ToBytes(uint32(len(comp)))...) + blob = append(blob, comp...) + } + return blob, nil +} + +// Decrypts the given ciphertext using an asymmetric key stored in Google Cloud KMS. +func decryptAsymmetric(ctx context.Context, gcpKMClient *kms.KeyManagementClient, keyResourceName string, cipherText []byte) ([]byte, error) { + glog.Debug("Decryption Asymmetric") + if !bytes.HasPrefix(cipherText, []byte(BLOB_HEADER)) { + return nil, fmt.Errorf("invalid BLOB_HEADER") + } + + cipherText = cipherText[len(BLOB_HEADER):] + // Extract components + components := make([][]byte, 4) + for i := range components { + compLen := binary.BigEndian.Uint32(cipherText[:4]) + cipherText = cipherText[4:] + components[i] = cipherText[:compLen] + cipherText = cipherText[compLen:] + } + + decryptedKey, err := decryptAsymmetricKey(ctx, gcpKMClient, keyResourceName, components[0]) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(decryptedKey) + if err != nil { + return nil, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + plaintext, err := aesGCM.Open(nil, components[1], append(components[3], components[2]...), nil) + if err != nil { + glog.Error(fmt.Sprintf("Data tampering detected or decryption failed: %v", err.Error())) + return nil, fmt.Errorf("failed to decrypt message: %w", err) + } + + return plaintext, nil +} + +func uint32ToBytes(n uint32) []byte { + buf := make([]byte, 4) + binary.BigEndian.PutUint32(buf, n) + return buf +} + +func encryptRawSymmteric(keyResourceName string, message []byte, token string) ([]byte, error) { + var blob []byte + key := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + ciphertext := aesGCM.Seal(nil, nonce, message, nil) + tag := ciphertext[len(ciphertext)-aesGCM.Overhead():] + ciphertext = ciphertext[:len(ciphertext)-aesGCM.Overhead()] + + apiURL := fmt.Sprintf(raw_gcp_url, keyResourceName+":rawEncrypt") + payload := fmt.Sprintf(`{ + "plaintext": "%s", + "additionalAuthenticatedData": "%s" + }`, base64.StdEncoding.EncodeToString(key), base64.StdEncoding.EncodeToString([]byte(additionalAuthenticatedData))) + + client := &http.Client{} + req, err := http.NewRequest("POST", apiURL, strings.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to perform HTTP request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("rawEncrypt API call failed with status: %s, response: %s", resp.Status, string(body)) + } + + var response EncryptionResponse + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + blob = append([]byte{}, []byte(BLOB_HEADER)...) + components := [][]byte{ + []byte(response.Ciphertext), + []byte(response.InitializationVector), + nonce, + tag, + ciphertext, + } + // Iterate over the components and append the length and data + for _, comp := range components { + blob = append(blob, uint32ToBytes(uint32(len(comp)))...) + blob = append(blob, comp...) + } + return blob, nil +} + +func decryptRawSymmteric(keyResourceName string, cipherText []byte, token string) ([]byte, error) { + if !bytes.HasPrefix(cipherText, []byte(BLOB_HEADER)) { + return nil, fmt.Errorf("invalid BLOB_HEADER") + } + + cipherText = cipherText[len(BLOB_HEADER):] + components := make([][]byte, 5) + for i := range components { + compLen := binary.BigEndian.Uint32(cipherText[:4]) + cipherText = cipherText[4:] + components[i] = cipherText[:compLen] + cipherText = cipherText[compLen:] + } + + apiURL := fmt.Sprintf(raw_gcp_url, keyResourceName+":rawDecrypt") + payload := fmt.Sprintf(`{ + "ciphertext": "%s", + "additionalAuthenticatedData": "%s", + "initializationVector": "%s" + }`, string(components[0]), base64.StdEncoding.EncodeToString([]byte(additionalAuthenticatedData)), string(components[1])) + + client := &http.Client{} + req, err := http.NewRequest("POST", apiURL, strings.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to perform HTTP request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("rawDecrypt API call failed with status: %s, response: %s", resp.Status, string(body)) + } + + var response kmspb.RawDecryptResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + block, err := aes.NewCipher(response.Plaintext) + if err != nil { + return nil, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + plaintext, err := aesGCM.Open(nil, components[2], append(components[4], components[3]...), nil) + if err != nil { + glog.Error(fmt.Sprintf("Data tampering detected or decryption failed: %v", err.Error())) + return nil, fmt.Errorf("failed to decrypt message: %w", err) + } + + return plaintext, nil +}