Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gcpkms: update SDK to latest, add tests, tidy #1072

Merged
merged 1 commit into from
Jul 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 163 additions & 86 deletions gcpkms/keysource.go
Original file line number Diff line number Diff line change
@@ -1,167 +1,244 @@
package gcpkms //import "go.mozilla.org/sops/v3/gcpkms"
package gcpkms // import "go.mozilla.org/sops/v3/gcpkms"

import (
"context"
"encoding/base64"
"fmt"
"google.golang.org/api/option"
"os"
"regexp"
"strings"
"time"

kms "cloud.google.com/go/kms/apiv1"
"github.com/sirupsen/logrus"
"google.golang.org/api/option"
kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1"
"google.golang.org/grpc"

"go.mozilla.org/sops/v3/logging"
)

"github.com/sirupsen/logrus"
"golang.org/x/net/context"
cloudkms "google.golang.org/api/cloudkms/v1"
const (
// SopsGoogleCredentialsEnv can be set as an environment variable as either
// a path to a credentials file, or directly as the variable's value in JSON
// format.
SopsGoogleCredentialsEnv = "GOOGLE_CREDENTIALS"
)

var log *logrus.Logger
var (
// gcpkmsTTL is the duration after which a MasterKey requires rotation.
gcpkmsTTL = time.Hour * 24 * 30 * 6
// log is the global logger for any GCP KMS MasterKey.
log *logrus.Logger
)

func init() {
log = logging.NewLogger("GCPKMS")
}

// MasterKey is a GCP KMS key used to encrypt and decrypt sops' data key.
// MasterKey is a GCP KMS key used to encrypt and decrypt the SOPS
// data key.
type MasterKey struct {
ResourceID string
// 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 is the creation timestamp of the MasterKey. Used
// for NeedsRotation.
CreationDate time.Time

// credentialJSON is the Service Account credentials JSON used for
// authenticating towards the GCP KMS service.
credentialJSON []byte
// grpcConn can be used to inject a custom GCP client connection.
// Mostly useful for testing at present, to wire the client to a mock
// server.
grpcConn *grpc.ClientConn
}

// EncryptedDataKey returns the encrypted data key this master key holds
func (key *MasterKey) EncryptedDataKey() []byte {
return []byte(key.EncryptedKey)
// NewMasterKeyFromResourceID creates a new MasterKey with the provided resource
// ID.
func NewMasterKeyFromResourceID(resourceID string) *MasterKey {
k := &MasterKey{}
resourceID = strings.Replace(resourceID, " ", "", -1)
k.ResourceID = resourceID
k.CreationDate = time.Now().UTC()
return k
}

// SetEncryptedDataKey sets the encrypted data key for this master key
func (key *MasterKey) SetEncryptedDataKey(enc []byte) {
key.EncryptedKey = string(enc)
// MasterKeysFromResourceIDString takes a comma separated list of GCP KMS
// resource IDs and returns a slice of new MasterKeys for them.
func MasterKeysFromResourceIDString(resourceID string) []*MasterKey {
var keys []*MasterKey
if resourceID == "" {
return keys
}
for _, s := range strings.Split(resourceID, ",") {
keys = append(keys, NewMasterKeyFromResourceID(s))
}
return keys
}

// Encrypt takes a sops data key, encrypts it with GCP KMS and stores the result in the EncryptedKey field
// CredentialJSON is the Service Account credentials JSON used for authenticating
// towards the GCP KMS service.
type CredentialJSON []byte

// ApplyToMasterKey configures the CredentialJSON on the provided key.
func (c CredentialJSON) ApplyToMasterKey(key *MasterKey) {
key.credentialJSON = c
}

// 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.createCloudKMSService()
service, err := key.newKMSClient()
if err != nil {
log.WithField("resourceID", key.ResourceID).Info("Encryption failed")
return fmt.Errorf("Cannot create GCP KMS service: %w", err)
log.WithError(err).WithField("resourceID", key.ResourceID).Error("Encryption failed")
return fmt.Errorf("cannot create GCP KMS service: %w", err)
}
req := &cloudkms.EncryptRequest{
Plaintext: base64.StdEncoding.EncodeToString(dataKey),
defer func() {
if err := service.Close(); err != nil {
log.WithError(err).Error("failed to close GCP KMS client connection")
}
}()

req := &kmspb.EncryptRequest{
Name: key.ResourceID,
Plaintext: dataKey,
}
resp, err := cloudkmsService.Projects.Locations.KeyRings.CryptoKeys.Encrypt(key.ResourceID, req).Do()
ctx := context.Background()
resp, err := service.Encrypt(ctx, req)
if err != nil {
log.WithField("resourceID", key.ResourceID).Info("Encryption failed")
return fmt.Errorf("Failed to call GCP KMS encryption service: %w", err)
log.WithError(err).WithField("resourceID", key.ResourceID).Error("Encryption failed")
return fmt.Errorf("failed to encrypt sops data key with GCP KMS key: %w", err)
}
// NB: base64 encoding is for compatibility with SOPS <=3.8.x.
// The previous GCP KMS client used to work with base64 encoded
// strings.
key.EncryptedKey = base64.StdEncoding.EncodeToString(resp.Ciphertext)
log.WithField("resourceID", key.ResourceID).Info("Encryption succeeded")
key.EncryptedKey = resp.Ciphertext
return nil
}

// EncryptIfNeeded encrypts the provided sops' data key and encrypts it if it hasn't been encrypted yet
// 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 CGP KMS and returns the result.
// Decrypt decrypts the EncryptedKey field with GCP KMS and returns
// the result.
func (key *MasterKey) Decrypt() ([]byte, error) {
cloudkmsService, err := key.createCloudKMSService()
service, err := key.newKMSClient()
if err != nil {
log.WithField("resourceID", key.ResourceID).Info("Decryption failed")
return nil, fmt.Errorf("Cannot create GCP KMS service: %w", err)
}

req := &cloudkms.DecryptRequest{
Ciphertext: key.EncryptedKey,
log.WithError(err).WithField("resourceID", key.ResourceID).Error("Decryption failed")
return nil, fmt.Errorf("cannot create GCP KMS service: %w", err)
}
resp, err := cloudkmsService.Projects.Locations.KeyRings.CryptoKeys.Decrypt(key.ResourceID, req).Do()
defer func() {
if err := service.Close(); err != nil {
log.WithError(err).Error("failed to close GCP KMS client connection")
}
}()

// NB: this is for compatibility with SOPS <=3.8.x. The previous GCP KMS
// client used to work with base64 encoded strings.
decodedCipher, err := base64.StdEncoding.DecodeString(string(key.EncryptedDataKey()))
if err != nil {
log.WithField("resourceID", key.ResourceID).Info("Decryption failed")
return nil, fmt.Errorf("Error decrypting key: %w", err)
log.WithError(err).WithField("resourceID", key.ResourceID).Error("Decryption failed")
return nil, err
}

req := &kmspb.DecryptRequest{
Name: key.ResourceID,
Ciphertext: decodedCipher,
}
encryptedKey, err := base64.StdEncoding.DecodeString(resp.Plaintext)
ctx := context.Background()
resp, err := service.Decrypt(ctx, req)
if err != nil {
log.WithField("resourceID", key.ResourceID).Info("Decryption failed")
return nil, err
log.WithError(err).WithField("resourceID", key.ResourceID).Error("Decryption failed")
return nil, fmt.Errorf("failed to decrypt sops data key with GCP KMS key: %w", err)
}

log.WithField("resourceID", key.ResourceID).Info("Decryption succeeded")
return encryptedKey, nil
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) > (time.Hour * 24 * 30 * 6)
return time.Since(key.CreationDate) > (gcpkmsTTL)
}

// ToString converts the key to a string representation
// ToString converts the key to a string representation.
func (key *MasterKey) ToString() string {
return key.ResourceID
}

// NewMasterKeyFromResourceID takes a GCP KMS resource ID string and returns a new MasterKey for that
func NewMasterKeyFromResourceID(resourceID string) *MasterKey {
k := &MasterKey{}
resourceID = strings.Replace(resourceID, " ", "", -1)
k.ResourceID = resourceID
k.CreationDate = time.Now().UTC()
return k
}

// MasterKeysFromResourceIDString takes a comma separated list of GCP KMS resource IDs and returns a slice of new MasterKeys for them
func MasterKeysFromResourceIDString(resourceID string) []*MasterKey {
var keys []*MasterKey
if resourceID == "" {
return keys
}
for _, s := range strings.Split(resourceID, ",") {
keys = append(keys, NewMasterKeyFromResourceID(s))
}
return keys
// 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
}

func (key MasterKey) createCloudKMSService() (*cloudkms.Service, error) {
// newKMSClient returns a GCP KMS client configured with the credentialJSON
// and/or grpcConn, falling back to environmental defaults.
// It returns an error if the ResourceID is invalid, or if the setup of the
// client fails.
func (key *MasterKey) newKMSClient() (*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)
return nil, fmt.Errorf("no valid resource ID found in %q", key.ResourceID)
}

ctx := context.Background()
var options []option.ClientOption

if credentials, err := getGoogleCredentials(); err != nil {
return nil, err
} else if len(credentials) > 0 {
options = append(options, option.WithCredentialsJSON(credentials))
var opts []option.ClientOption
switch {
case key.credentialJSON != nil:
opts = append(opts, option.WithCredentialsJSON(key.credentialJSON))
default:
credentials, err := getGoogleCredentials()
if err != nil {
return nil, err
}
if credentials != nil {
opts = append(opts, option.WithCredentialsJSON(key.credentialJSON))
}
}
if key.grpcConn != nil {
opts = append(opts, option.WithGRPCConn(key.grpcConn))
}

cloudkmsService, err := cloudkms.NewService(ctx, options...)
ctx := context.Background()
client, err := kms.NewKeyManagementClient(ctx, opts...)
if err != nil {
return nil, err
}
return cloudkmsService, nil
}

// 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["enc"] = key.EncryptedKey
out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339)
return out
return client, nil
}

// getGoogleCredentials looks for a GCP Service Account in the environment
// variable: GOOGLE_CREDENTIALS, set as either a path to a credentials file or directly as the
// variable's value in JSON format.
//
// If not set, will default to use GOOGLE_APPLICATION_CREDENTIALS
// getGoogleCredentials returns the SopsGoogleCredentialsEnv variable, as
// either the file contents of the path of a credentials file, or as value in
// JSON format. It returns an error if the file cannot be read, and may return
// a nil byte slice if no value is set.
func getGoogleCredentials() ([]byte, error) {
defaultCredentials := os.Getenv("GOOGLE_CREDENTIALS")
defaultCredentials := os.Getenv(SopsGoogleCredentialsEnv)
if _, err := os.Stat(defaultCredentials); err == nil {
return os.ReadFile(defaultCredentials)
}
Expand Down
Loading