Skip to content

Commit

Permalink
add support for AWS KMS credentials using .spec.decryption
Browse files Browse the repository at this point in the history
Signed-off-by: Sanskar Jaiswal <[email protected]>
  • Loading branch information
Sanskar Jaiswal committed Apr 28, 2022
1 parent bcfd424 commit 92851a2
Show file tree
Hide file tree
Showing 9 changed files with 494 additions and 1 deletion.
13 changes: 13 additions & 0 deletions controllers/kustomization_decryptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import (

kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2"
"github.com/fluxcd/kustomize-controller/internal/sops/age"
"github.com/fluxcd/kustomize-controller/internal/sops/awskms"
"github.com/fluxcd/kustomize-controller/internal/sops/azkv"
intkeyservice "github.com/fluxcd/kustomize-controller/internal/sops/keyservice"
"github.com/fluxcd/kustomize-controller/internal/sops/pgp"
Expand All @@ -64,6 +65,9 @@ const (
// DecryptionVaultTokenFileName is the name of the file containing the
// Hashicorp Vault token.
DecryptionVaultTokenFileName = "sops.vault-token"
// DecryptionVaultTokenFileName is the name of the file containing the
// AWS KMS credentials
DecryptionAWSKmsFile = "sops.aws-kms"
// DecryptionAzureAuthFile is the name of the file containing the Azure
// credentials.
DecryptionAzureAuthFile = "sops.azure-kv"
Expand Down Expand Up @@ -125,6 +129,9 @@ type KustomizeDecryptor struct {
// vaultToken is the Hashicorp Vault token used to authenticate towards
// any Vault server.
vaultToken string
// awsCreds is the AWS credentials object used to authenticate towards
// any AWS KMS.
awsCreds *awskms.Creds
// azureToken is the Azure credential token used to authenticate towards
// any Azure Key Vault.
azureToken *azkv.Token
Expand Down Expand Up @@ -216,6 +223,12 @@ func (d *KustomizeDecryptor) ImportKeys(ctx context.Context) error {
token = strings.Trim(strings.TrimSpace(token), "\n")
d.vaultToken = token
}
case filepath.Ext(DecryptionAWSKmsFile):
if name == DecryptionAWSKmsFile {
if d.awsCreds, err = awskms.LoadAwsKmsCredsFromYaml(value); err != nil {
return fmt.Errorf("failed to import '%s' data from %s decryption Secret '%s': %w", name, provider, secretName, err)
}
}
case filepath.Ext(DecryptionAzureAuthFile):
// Make sure we have the absolute name
if name == DecryptionAzureAuthFile {
Expand Down
23 changes: 23 additions & 0 deletions controllers/kustomization_decryptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,29 @@ func TestKustomizeDecryptor_ImportKeys(t *testing.T) {
g.Expect(decryptor.vaultToken).To(Equal("some-hcvault-token"))
},
},
{
name: "AWS KMS credentials",
decryption: &kustomizev1.Decryption{
Provider: provider,
SecretRef: &meta.LocalObjectReference{
Name: "awskms-secret",
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "awskms-secret",
Namespace: provider,
},
Data: map[string][]byte{
DecryptionAWSKmsFile: []byte(`aws_access_key_id: test-id
aws_secret_access_key: test-secret
aws_session_token: test-token`),
},
},
inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) {
g.Expect(decryptor.awsCreds).ToNot(BeNil())
},
},
{
name: "Azure Key Vault token",
decryption: &kustomizev1.Decryption{
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v0.22.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.2
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.4.0
github.com/aws/aws-sdk-go v1.37.18
github.com/cyphar/filepath-securejoin v0.2.3
github.com/dimchansky/utfbom v1.1.1
github.com/drone/envsubst v1.0.3
Expand Down Expand Up @@ -82,7 +83,6 @@ require (
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/armon/go-metrics v0.3.10 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/aws/aws-sdk-go v1.37.18 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
Expand Down
284 changes: 284 additions & 0 deletions internal/sops/awskms/keysource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
package awskms

import (
"encoding/base64"
"fmt"
"os"
"regexp"
"strings"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/kms"
"github.com/aws/aws-sdk-go/service/sts"
"sigs.k8s.io/yaml"
)

const (
arnRegex = `^arn:aws[\w-]*:kms:(.+):[0-9]+:(key|alias)/.+$`
stsSessionRegex = "[^a-zA-Z0-9=,.@-]+"
)

// MasterKey is a AWS KMS key used to encrypt and decrypt sops' data key.
type MasterKey struct {
Arn string
Role string
EncryptedKey string
CreationDate time.Time
EncryptionContext map[string]*string
credentials *credentials.Credentials
}

type Creds struct {
credentials *credentials.Credentials
}

func NewCreds(credentials *credentials.Credentials) *Creds {
return &Creds{
credentials: credentials,
}
}

func LoadAwsKmsCredsFromYaml(b []byte) (*Creds, error) {
credInfo := struct {
AccessKeyID string `json:"aws_access_key_id"`
SecretAccessKey string `json:"aws_secret_access_key"`
SessionToken string `json:"aws_session_token"`
}{}
if err := yaml.Unmarshal(b, &credInfo); err != nil {
return nil, fmt.Errorf("failed to unmarshal AWS credentials file: %w", err)
}
creds := credentials.NewStaticCredentials(credInfo.AccessKeyID, credInfo.SecretAccessKey, credInfo.SessionToken)
return &Creds{
credentials: creds,
}, nil
}

func (c Creds) ApplyToMasterKey(key *MasterKey) {
key.credentials = c.credentials
}

// EncryptedDataKey returns the encrypted data key this master key holds
func (key *MasterKey) EncryptedDataKey() []byte {
return []byte(key.EncryptedKey)
}

// SetEncryptedDataKey sets the encrypted data key for this master key
func (key *MasterKey) SetEncryptedDataKey(enc []byte) {
key.EncryptedKey = string(enc)
}

// Encrypt takes a sops data key, encrypts it with KMS and stores the result in the EncryptedKey field
func (key *MasterKey) Encrypt(dataKey []byte) error {
sess, err := key.createSession()
if err != nil {
return fmt.Errorf("failed to create session: %w", err)
}
kmsSvc := kms.New(sess)
out, err := kmsSvc.Encrypt(&kms.EncryptInput{Plaintext: dataKey, KeyId: &key.Arn, EncryptionContext: key.EncryptionContext})
if err != nil {
return fmt.Errorf("failed to encrypt sops data key with AWS KMS: %w", err)
}
key.EncryptedKey = base64.StdEncoding.EncodeToString(out.CiphertextBlob)
return nil
}

// EncryptIfNeeded encrypts the provided sops' data key and encrypts it if it hasn't 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 AWS KMS and returns the result.
func (key *MasterKey) Decrypt() ([]byte, error) {
k, err := base64.StdEncoding.DecodeString(key.EncryptedKey)
if err != nil {
return nil, fmt.Errorf("error base64-decoding encrypted data key: %s", err)
}
sess, err := key.createSession()
if err != nil {
return nil, fmt.Errorf("error creating AWS session: %w", err)
}
kmsSvc := kms.New(sess)
decrypted, err := kmsSvc.Decrypt(&kms.DecryptInput{CiphertextBlob: k, EncryptionContext: key.EncryptionContext})
if err != nil {
return nil, fmt.Errorf("failed to decrypt sops data key with AWS KMS: %w", err)
}
return decrypted.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)
}

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

// NewMasterKey creates a new MasterKey from an ARN, role and context, setting the creation date to the current date
func NewMasterKey(arn string, role string, context map[string]*string) *MasterKey {
return &MasterKey{
Arn: arn,
Role: role,
EncryptionContext: context,
CreationDate: time.Now().UTC(),
}
}

// NewMasterKeyFromArn takes an ARN string and returns a new MasterKey for that ARN
func NewMasterKeyFromArn(arn string, context map[string]*string, awsProfile string) *MasterKey {
k := &MasterKey{}
arn = strings.Replace(arn, " ", "", -1)
roleIndex := strings.Index(arn, "+arn:aws:iam::")
if roleIndex > 0 {
k.Arn = arn[:roleIndex]
k.Role = arn[roleIndex+1:]
} else {
k.Arn = arn
}
k.EncryptionContext = context
k.CreationDate = time.Now().UTC()
return k
}

// MasterKeysFromArnString takes a comma separated list of AWS KMS ARNs and returns a slice of new MasterKeys for those ARNs
func MasterKeysFromArnString(arn string, context map[string]*string, awsProfile string) []*MasterKey {
var keys []*MasterKey
if arn == "" {
return keys
}
for _, s := range strings.Split(arn, ",") {
keys = append(keys, NewMasterKeyFromArn(s, context, awsProfile))
}
return keys
}

func (key MasterKey) createSession() (*session.Session, error) {
re := regexp.MustCompile(arnRegex)
matches := re.FindStringSubmatch(key.Arn)
if matches == nil {
return nil, fmt.Errorf("No valid ARN found in %q", key.Arn)
}

config := aws.Config{
Region: aws.String(matches[1]),
Credentials: key.credentials,
}

opts := session.Options{
Config: config,
AssumeRoleTokenProvider: stscreds.StdinTokenProvider,
SharedConfigState: session.SharedConfigEnable,
}
sess, err := session.NewSessionWithOptions(opts)
if err != nil {
return nil, err
}
if key.Role != "" {
return key.createStsSession(config, sess)
}
return sess, nil
}

func (key MasterKey) createStsSession(config aws.Config, sess *session.Session) (*session.Session, error) {
hostname, err := os.Hostname()
if err != nil {
return nil, err
}
stsRoleSessionNameRe, err := regexp.Compile(stsSessionRegex)
if err != nil {
return nil, fmt.Errorf("Failed to compile STS role session name regex: %w", err)
}
sanitizedHostname := stsRoleSessionNameRe.ReplaceAllString(hostname, "")
stsService := sts.New(sess)
name := "sops@" + sanitizedHostname
out, err := stsService.AssumeRole(&sts.AssumeRoleInput{
RoleArn: &key.Role, RoleSessionName: &name})
if err != nil {
return nil, fmt.Errorf("Failed to assume role %q: %w", key.Role, err)
}
config.Credentials = credentials.NewStaticCredentials(*out.Credentials.AccessKeyId,
*out.Credentials.SecretAccessKey, *out.Credentials.SessionToken)
sess, err = session.NewSession(&config)
if err != nil {
return nil, fmt.Errorf("Failed to create new aws session: %w", err)
}
return sess, nil
}

// ToMap converts the MasterKey to a map for serialization purposes
func (key MasterKey) ToMap() map[string]interface{} {
out := make(map[string]interface{})
out["arn"] = key.Arn
if key.Role != "" {
out["role"] = key.Role
}
out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339)
out["enc"] = key.EncryptedKey
if key.EncryptionContext != nil {
outcontext := make(map[string]string)
for k, v := range key.EncryptionContext {
outcontext[k] = *v
}
out["context"] = outcontext
}
return out
}

// ParseKMSContext takes either a KMS context map or a comma-separated list of KMS context key:value pairs and returns a map
func ParseKMSContext(in interface{}) map[string]*string {
// nonStringValueWarning := "Encryption context contains a non-string value, context will not be used"
out := make(map[string]*string)

switch in := in.(type) {
case map[string]interface{}:
if len(in) == 0 {
return nil
}
for k, v := range in {
value, ok := v.(string)
if !ok {
// log.Warn(nonStringValueWarning)
return nil
}
out[k] = &value
}
case map[interface{}]interface{}:
if len(in) == 0 {
return nil
}
for k, v := range in {
key, ok := k.(string)
if !ok {
// log.Warn(nonStringValueWarning)
return nil
}
value, ok := v.(string)
if !ok {
// log.Warn(nonStringValueWarning)
return nil
}
out[key] = &value
}
case string:
if in == "" {
return nil
}
for _, kv := range strings.Split(in, ",") {
kv := strings.Split(kv, ":")
if len(kv) != 2 {
// log.Warn(nonStringValueWarning)
return nil
}
out[kv[0]] = &kv[1]
}
}
return out
}
Loading

0 comments on commit 92851a2

Please sign in to comment.