diff --git a/controllers/kustomization_decryptor.go b/controllers/kustomization_decryptor.go index c7d2b5cc..d2451ad4 100644 --- a/controllers/kustomization_decryptor.go +++ b/controllers/kustomization_decryptor.go @@ -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" @@ -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" @@ -129,6 +133,9 @@ type KustomizeDecryptor struct { // vaultToken is the Hashicorp Vault token used to authenticate towards // any Vault server. vaultToken string + // awsCredsProvider is the AWS credentials provider object used to authenticate + // towards any AWS KMS. + awsCredsProvider *awskms.CredsProvider // azureToken is the Azure credential token used to authenticate towards // any Azure Key Vault. azureToken *azkv.Token @@ -220,6 +227,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.awsCredsProvider, err = awskms.LoadCredsProviderFromYaml(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 { @@ -534,6 +547,7 @@ func (d *KustomizeDecryptor) loadKeyServiceServers() { if d.azureToken != nil { serverOpts = append(serverOpts, intkeyservice.WithAzureToken{Token: d.azureToken}) } + serverOpts = append(serverOpts, intkeyservice.WithAWSKeys{CredsProvider: d.awsCredsProvider}) server := intkeyservice.NewServer(serverOpts...) d.keyServices = append(make([]keyservice.KeyServiceClient, 0), keyservice.NewCustomLocalClient(server)) } diff --git a/controllers/kustomization_decryptor_test.go b/controllers/kustomization_decryptor_test.go index 5ceff12d..02eb4140 100644 --- a/controllers/kustomization_decryptor_test.go +++ b/controllers/kustomization_decryptor_test.go @@ -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.awsCredsProvider).ToNot(BeNil()) + }, + }, { name: "Azure Key Vault token", decryption: &kustomizev1.Decryption{ diff --git a/docs/spec/v1beta2/kustomization.md b/docs/spec/v1beta2/kustomization.md index c5423564..71f55c38 100644 --- a/docs/spec/v1beta2/kustomization.md +++ b/docs/spec/v1beta2/kustomization.md @@ -1105,6 +1105,25 @@ data: identity.asc: ``` +#### AWS KMS Secret Entry + +To specify credentials for an AWS user account linked to the IAM role with access +to KMS, append a `.data` entry with a fixed `sops.aws-kms` key. + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: sops-keys + namespace: default +data: + sops.aws-kms: | + aws_access_key_id: some-access-key-id + aws_secret_access_key: some-aws-secret-access-key + aws_session_token: some-aws-session-token # this field is optional +``` + #### Azure Key Vault Secret entry To specify credentials for Azure Key Vault in a Secret, append a `.data` entry @@ -1227,13 +1246,14 @@ it is possible to specify global decryption settings on the kustomize-controller Pod. When the controller fails to find credentials on the Kustomization object itself, it will fall back to these defaults. -#### AWS +#### AWS KMS While making use of the [IAM OIDC provider](https://eksctl.io/usage/iamserviceaccounts/) on your EKS cluster, you can create an IAM Role and Service Account with access to AWS KMS (using at least `kms:Decrypt` and `kms:DescribeKey`). Once these are created, you can annotate the kustomize-controller Service Account with the -Role ARN, granting the controller permissions to decrypt the Secrets. +Role ARN, granting the controller permissions to decrypt the Secrets. Please refer +to the [SOPS guide](https://fluxcd.io/docs/guides/mozilla-sops/#aws) for detailed steps. ```sh kubectl -n flux-system annotate serviceaccount kustomize-controller \ @@ -1241,10 +1261,54 @@ kubectl -n flux-system annotate serviceaccount kustomize-controller \ eks.amazonaws.com/role-arn='arn:aws:iam:::role/' ``` +Furthermore, you can also use the usual [environment variables used for specifying AWS +credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html#envvars-list) +, by patching the kustomize-controller deployment: + +```yaml +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kustomize-controller + namespace: flux-system +spec: + template: + spec: + containers: + - name: manager + env: + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: aws-creds + key: awsAccessKeyID + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: aws-creds + key: awsSecretAccessKey + - name: AWS_SESSION_TOKEN + valueFrom: + secretKeyRef: + name: aws-creds + key: awsSessionToken +``` + In addition to this, the [general SOPS documentation around KMS AWS applies](https://github.com/mozilla/sops#27kms-aws-profiles), allowing you to specify e.g. a `SOPS_KMS_ARN` environment variable. +> **Note:**: If you're mounting a secret containing the AWS credentials as a file in the `kustomize-controller` pod, +> you'd need to specify an environment variable `$HOME`, since the AWS credentials file is expected to be present +> at `~/.aws`, like so: +```yaml +env: + - name: HOME + value: /home/{$USER} +``` + + #### Azure Key Vault While making use of [AAD Pod Identity](https://github.com/Azure/aad-pod-identity), diff --git a/go.mod b/go.mod index bbf2d6e6..f46427fd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,11 @@ 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-v2 v1.16.4 + github.com/aws/aws-sdk-go-v2/config v1.15.4 + github.com/aws/aws-sdk-go-v2/credentials v1.12.0 + github.com/aws/aws-sdk-go-v2/service/kms v1.17.1 + github.com/aws/aws-sdk-go-v2/service/sts v1.16.4 github.com/cyphar/filepath-securejoin v0.2.3 github.com/dimchansky/utfbom v1.1.1 github.com/drone/envsubst v1.0.3 @@ -83,6 +88,13 @@ require ( 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.43.43 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.11.4 // indirect + github.com/aws/smithy-go v1.11.2 // 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 diff --git a/go.sum b/go.sum index de013c12..3ff77727 100644 --- a/go.sum +++ b/go.sum @@ -141,6 +141,32 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/aws/aws-sdk-go v1.43.43 h1:1L06qzQvl4aC3Skfh5rV7xVhGHjIZoHcqy16NoyQ1o4= github.com/aws/aws-sdk-go v1.43.43/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/aws/aws-sdk-go-v2 v1.16.3/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= +github.com/aws/aws-sdk-go-v2 v1.16.4 h1:swQTEQUyJF/UkEA94/Ga55miiKFoXmm/Zd67XHgmjSg= +github.com/aws/aws-sdk-go-v2 v1.16.4/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= +github.com/aws/aws-sdk-go-v2/config v1.15.4 h1:P4mesY1hYUxru4f9SU0XxNKXmzfxsD0FtMIPRBjkH7Q= +github.com/aws/aws-sdk-go-v2/config v1.15.4/go.mod h1:ZijHHh0xd/A+ZY53az0qzC5tT46kt4JVCePf2NX9Lk4= +github.com/aws/aws-sdk-go-v2/credentials v1.12.0 h1:4R/NqlcRFSkR0wxOhgHi+agGpbEr5qMCjn7VqUIJY+E= +github.com/aws/aws-sdk-go-v2/credentials v1.12.0/go.mod h1:9YWk7VW+eyKsoIL6/CljkTrNVWBSK9pkqOPUuijid4A= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.4 h1:FP8gquGeGHHdfY6G5llaMQDF+HAf20VKc8opRwmjf04= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.4/go.mod h1:u/s5/Z+ohUQOPXl00m2yJVyioWDECsbpXTQlaqSlufc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.10 h1:uFWgo6mGJI1n17nbcvSc6fxVuR3xLNqvXt12JCnEcT8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.10/go.mod h1:F+EZtuIwjlv35kRJPyBGcsA4f7bnSoz15zOQ2lJq1Z4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.4 h1:cnsvEKSoHN4oAN7spMMr0zhEW2MHnhAVpmqQg8E6UcM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.4/go.mod h1:8glyUqVIM4AmeenIsPo0oVh3+NUwnsQml2OFupfQW+0= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.11/go.mod h1:0MR+sS1b/yxsfAPvAESrw8NfwUoxMinDyw6EYR9BS2U= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.12 h1:j0VqrjtgsY1Bx27tD0ysay36/K4kFMWRp9K3ieO9nLU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.12/go.mod h1:00c7+ALdPh4YeEUPXJzyU0Yy01nPGOq2+9rUaz05z9g= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.4 h1:b16QW0XWl0jWjLABFc1A+uh145Oqv+xDcObNk0iQgUk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.4/go.mod h1:uKkN7qmSIsNJVyMtxNQoCEYMvFEXbOg9fwCJPdfp2u8= +github.com/aws/aws-sdk-go-v2/service/kms v1.17.1 h1:8T0uFw+t/+uP0ukowdDQ2fxhh5jh07bM4WI8/KRGtv8= +github.com/aws/aws-sdk-go-v2/service/kms v1.17.1/go.mod h1:0B58/BshOoe7rhRRRtHWVGcXqlJn7gQZmNLyKucFhCU= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.4 h1:Uw5wBybFQ1UeA9ts0Y07gbv0ncZnIAyw858tDW0NP2o= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.4/go.mod h1:cPDwJwsP4Kff9mldCXAmddjJL6JGQqtA3Mzer2zyr88= +github.com/aws/aws-sdk-go-v2/service/sts v1.16.4 h1:+xtV90n3abQmgzk1pS++FdxZTrPEDgQng6e4/56WR2A= +github.com/aws/aws-sdk-go-v2/service/sts v1.16.4/go.mod h1:lfSYenAXtavyX2A1LsViglqlG9eEFYxNryTZS5rn3QE= +github.com/aws/smithy-go v1.11.2 h1:eG/N+CcUMAvsdffgMvjMKwfyDzIkjM6pfxMJ8Mzc6mE= +github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= diff --git a/internal/sops/awskms/keysource.go b/internal/sops/awskms/keysource.go new file mode 100644 index 00000000..866f89de --- /dev/null +++ b/internal/sops/awskms/keysource.go @@ -0,0 +1,273 @@ +/* +Copyright (C) 2022 The Flux authors + +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 awskms + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + "time" + + "encoding/base64" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/sts" + "sigs.k8s.io/yaml" +) + +const ( + // arnRegex matches an AWS ARN. + // valid ARN example: arn:aws:kms:us-west-2:107501996527:key/612d5f0p-p1l3-45e6-aca6-a5b005693a48 + arnRegex = `^arn:aws[\w-]*:kms:(.+):[0-9]+:(key|alias)/.+$` + // stsSessionRegex matches an AWS STS session name. + // valid STS session examples: john_s, sops@42WQm042 + stsSessionRegex = "[^a-zA-Z0-9=,.@-_]+" + // kmsTTL is the duration after which a MasterKey requires rotation. + kmsTTL = time.Hour * 24 * 30 * 6 +) + +// MasterKey is an AWS KMS key used to encrypt and decrypt sops' data key. +// Adapted from: https://github.com/mozilla/sops/blob/v3.7.2/kms/keysource.go#L39 +// Modified to accept custom static credentials as opposed to using env vars by default +// and use aws-sdk-go-v2 instead of aws-sdk-go being used in upstream. +type MasterKey struct { + // AWS Role ARN associated with the KMS key. + Arn string + // AWS Role ARN used to assume a role through AWS STS. + Role string + // EncryptedKey stores the data key in it's encrypted form. + EncryptedKey string + // CreationDate is when this MasterKey was created. + CreationDate time.Time + // EncryptionContext provides additional context about the data key. + // Ref: https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#encrypt_context + EncryptionContext map[string]string + + // credentialsProvider is used to configure the AWS config with the + // necessary credentials. + credentialsProvider aws.CredentialsProvider + + // epResolver can be used to override the endpoint the AWS client resolves + // to by default. This is mostly used for testing purposes as it can not be + // injected using e.g. an environment variable. The field is not publicly + // exposed, nor configurable. + epResolver aws.EndpointResolver +} + +// CredsProvider is a wrapper around aws.CredentialsProvider used for authenticating +// towards AWS KMS. +type CredsProvider struct { + credsProvider aws.CredentialsProvider +} + +// NewCredsProvider returns a CredsProvider object with the provided aws.CredentialsProvider. +func NewCredsProvider(cp aws.CredentialsProvider) *CredsProvider { + return &CredsProvider{ + credsProvider: cp, + } +} + +// ApplyToMasterKey configures the credentials the provided key. +func (c CredsProvider) ApplyToMasterKey(key *MasterKey) { + key.credentialsProvider = c.credsProvider +} + +// LoadCredsProviderFromYaml parses the given YAML returns a CredsProvider object +// which contains the credentials provider used for authenticating towards AWS KMS. +func LoadCredsProviderFromYaml(b []byte) (*CredsProvider, 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) + } + return &CredsProvider{ + credsProvider: credentials.NewStaticCredentialsProvider(credInfo.AccessKeyID, + credInfo.SecretAccessKey, credInfo.SessionToken), + }, nil +} + +// 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 { + cfg, err := key.createKMSConfig() + if err != nil { + return err + } + client := kms.NewFromConfig(*cfg) + input := &kms.EncryptInput{ + KeyId: &key.Arn, + Plaintext: dataKey, + } + out, err := client.Encrypt(context.TODO(), input) + 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 +// 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 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) + } + cfg, err := key.createKMSConfig() + if err != nil { + return nil, err + } + client := kms.NewFromConfig(*cfg) + input := &kms.DecryptInput{ + KeyId: &key.Arn, + CiphertextBlob: k, + EncryptionContext: key.EncryptionContext, + } + decrypted, err := client.Decrypt(context.TODO(), input) + 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) > kmsTTL +} + +// ToString converts the key to a string representation. +func (key *MasterKey) ToString() string { + return key.Arn +} + +// 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 +} + +// 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 +} + +// createKMSConfig returns a Config configured with the appropriate credentials. +func (key MasterKey) createKMSConfig() (*aws.Config, error) { + // Use the credentialsProvider if present, otherwise default to reading credentials + // from the environment. + cfg, err := config.LoadDefaultConfig(context.TODO(), func(lo *config.LoadOptions) error { + if key.credentialsProvider != nil { + lo.Credentials = key.credentialsProvider + } + // Set the epResolver, if present. Used ONLY for tests. + if key.epResolver != nil { + lo.EndpointResolver = key.epResolver + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("couldn't load AWS config: %w", err) + } + if key.Role != "" { + return key.createSTSConfig(&cfg) + } + + return &cfg, nil +} + +// createSTSConfig uses AWS STS to assume a role and returns a Config configured +// with that role's credentials. +func (key MasterKey) createSTSConfig(config *aws.Config) (*aws.Config, 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, "") + name := "sops@" + sanitizedHostname + + client := sts.NewFromConfig(*config) + input := &sts.AssumeRoleInput{ + RoleArn: &key.Role, + RoleSessionName: &name, + } + out, err := client.AssumeRole(context.TODO(), input) + if err != nil { + return nil, fmt.Errorf("failed to assume role '%s': %w", key.Role, err) + } + config.Credentials = credentials.NewStaticCredentialsProvider(*out.Credentials.AccessKeyId, + *out.Credentials.SecretAccessKey, *out.Credentials.SessionToken, + ) + return config, nil +} diff --git a/internal/sops/awskms/keysource_test.go b/internal/sops/awskms/keysource_test.go new file mode 100644 index 00000000..38691b0b --- /dev/null +++ b/internal/sops/awskms/keysource_test.go @@ -0,0 +1,340 @@ +/* +Copyright (C) 2022 The Flux authors + +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 awskms + +import ( + "context" + "encoding/base64" + "fmt" + logger "log" + "os" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/kms" + . "github.com/onsi/gomega" + "github.com/ory/dockertest" +) + +var ( + testKMSServerURL string + testKMSARN string +) + +const ( + dummyARN = "arn:aws:kms:us-west-2:107501996527:key/612d5f0p-p1l3-45e6-aca6-a5b005693a48" + testLocalKMSTag = "3.11.1" + testLocalKMSImage = "nsmithuk/local-kms" +) + +// TestMain initializes a AWS KMS server using Docker, writes the HTTP address to +// testAWSEndpoint, tries to generate a key for encryption-decryption using a +// backoff retry approach and then sets testKMSARN to the id of the generated key. +// It then runs all the tests, which can make use of the various `test*` variables. +func TestMain(m *testing.M) { + // Uses a sensible default on Windows (TCP/HTTP) and Linux/MacOS (socket) + pool, err := dockertest.NewPool("") + if err != nil { + logger.Fatalf("could not connect to docker: %s", err) + } + + // Pull the image, create a container based on it, and run it + // resource, err := pool.Run("nsmithuk/local-kms", testLocalKMSVersion, []string{}) + resource, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: testLocalKMSImage, + Tag: testLocalKMSTag, + ExposedPorts: []string{"8080"}, + }) + if err != nil { + logger.Fatalf("could not start resource: %s", err) + } + + purgeResource := func() { + if err := pool.Purge(resource); err != nil { + logger.Printf("could not purge resource: %s", err) + } + } + + testKMSServerURL = fmt.Sprintf("http://127.0.0.1:%v", resource.GetPort("8080/tcp")) + masterKey := createTestMasterKey(dummyARN) + + kmsClient, err := createTestKMSClient(masterKey) + if err != nil { + purgeResource() + logger.Fatalf("could not create session: %s", err) + } + + var key *kms.CreateKeyOutput + if err := pool.Retry(func() error { + key, err = kmsClient.CreateKey(context.TODO(), &kms.CreateKeyInput{}) + if err != nil { + return err + } + return nil + }); err != nil { + purgeResource() + logger.Fatalf("could not create key: %s", err) + } + + if key.KeyMetadata.Arn != nil { + testKMSARN = *key.KeyMetadata.Arn + } else { + purgeResource() + logger.Fatalf("could not set arn") + } + + // Run the tests, but only if we succeeded in setting up the AWS KMS server. + var code int + if err == nil { + code = m.Run() + } + + // This can't be deferred, as os.Exit simpy does not care + if err := pool.Purge(resource); err != nil { + logger.Fatalf("could not purge resource: %s", err) + } + + os.Exit(code) +} + +func TestMasterKey_Encrypt(t *testing.T) { + g := NewWithT(t) + key := createTestMasterKey(testKMSARN) + dataKey := []byte("thisistheway") + g.Expect(key.Encrypt(dataKey)).To(Succeed()) + g.Expect(key.EncryptedKey).ToNot(BeEmpty()) + + kmsClient, err := createTestKMSClient(key) + g.Expect(err).ToNot(HaveOccurred()) + + k, err := base64.StdEncoding.DecodeString(key.EncryptedKey) + g.Expect(err).ToNot(HaveOccurred()) + + input := &kms.DecryptInput{ + CiphertextBlob: k, + EncryptionContext: key.EncryptionContext, + } + decrypted, err := kmsClient.Decrypt(context.TODO(), input) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(decrypted.Plaintext).To(Equal(dataKey)) +} + +func TestMasterKey_Encrypt_SOPS_Compat(t *testing.T) { + g := NewWithT(t) + + encryptKey := createTestMasterKey(testKMSARN) + dataKey := []byte("encrypt-compat") + g.Expect(encryptKey.Encrypt(dataKey)).To(Succeed()) + + decryptKey := createTestMasterKey(testKMSARN) + decryptKey.credentialsProvider = nil + decryptKey.EncryptedKey = encryptKey.EncryptedKey + t.Setenv("AWS_ACCESS_KEY_ID", "id") + t.Setenv("AWS_SECRET_ACCESS_KEY", "secret") + dec, err := decryptKey.Decrypt() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(dec).To(Equal(dataKey)) +} + +func TestMasterKey_EncryptIfNeeded(t *testing.T) { + g := NewWithT(t) + + key := createTestMasterKey(testKMSARN) + g.Expect(key.EncryptIfNeeded([]byte("data"))).To(Succeed()) + + encryptedKey := key.EncryptedKey + g.Expect(encryptedKey).ToNot(BeEmpty()) + + g.Expect(key.EncryptIfNeeded([]byte("some other data"))).To(Succeed()) + g.Expect(key.EncryptedKey).To(Equal(encryptedKey)) +} + +func TestMasterKey_EncryptedDataKey(t *testing.T) { + g := NewWithT(t) + + key := &MasterKey{EncryptedKey: "some key"} + g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(key.EncryptedKey)) +} + +func TestMasterKey_Decrypt(t *testing.T) { + g := NewWithT(t) + + key := createTestMasterKey(testKMSARN) + kmsClient, err := createTestKMSClient(key) + g.Expect(err).ToNot(HaveOccurred()) + + dataKey := []byte("itsalwaysdns") + out, err := kmsClient.Encrypt(context.TODO(), &kms.EncryptInput{ + Plaintext: dataKey, KeyId: &key.Arn, EncryptionContext: key.EncryptionContext, + }) + g.Expect(err).ToNot(HaveOccurred()) + + key.EncryptedKey = base64.StdEncoding.EncodeToString(out.CiphertextBlob) + got, err := key.Decrypt() + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(dataKey)) +} + +func TestMasterKey_Decrypt_SOPS_Compat(t *testing.T) { + g := NewWithT(t) + + dataKey := []byte("decrypt-compat") + + encryptKey := createTestMasterKey(testKMSARN) + encryptKey.credentialsProvider = nil + t.Setenv("AWS_ACCESS_KEY_ID", "id") + t.Setenv("AWS_SECRET_ACCESS_KEY", "secret") + + g.Expect(encryptKey.Encrypt(dataKey)).To(Succeed()) + + decryptKey := createTestMasterKey(testKMSARN) + decryptKey.EncryptedKey = encryptKey.EncryptedKey + dec, err := decryptKey.Decrypt() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(dec).To(Equal(dataKey)) +} + +func TestMasterKey_EncryptDecrypt_RoundTrip(t *testing.T) { + g := NewWithT(t) + + dataKey := []byte("thisistheway") + + encryptKey := createTestMasterKey(testKMSARN) + g.Expect(encryptKey.Encrypt(dataKey)).To(Succeed()) + g.Expect(encryptKey.EncryptedKey).ToNot(BeEmpty()) + + decryptKey := createTestMasterKey(testKMSARN) + decryptKey.EncryptedKey = encryptKey.EncryptedKey + + decryptedData, err := decryptKey.Decrypt() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(decryptedData).To(Equal(dataKey)) +} + +func TestMasterKey_NeedsRotation(t *testing.T) { + g := NewWithT(t) + + key := NewMasterKeyFromArn(dummyARN, nil, "") + g.Expect(key.NeedsRotation()).To(BeFalse()) + + key.CreationDate = key.CreationDate.Add(-(kmsTTL + time.Second)) + g.Expect(key.NeedsRotation()).To(BeTrue()) +} + +func TestMasterKey_ToMap(t *testing.T) { + g := NewWithT(t) + key := MasterKey{ + Arn: "test-arn", + Role: "test-role", + EncryptedKey: "enc-key", + EncryptionContext: map[string]string{ + "env": "test", + }, + } + g.Expect(key.ToMap()).To(Equal(map[string]interface{}{ + "arn": "test-arn", + "role": "test-role", + "created_at": "0001-01-01T00:00:00Z", + "enc": "enc-key", + "context": map[string]string{ + "env": "test", + }, + })) +} + +func TestCreds_ApplyToMasterKey(t *testing.T) { + g := NewWithT(t) + + creds := CredsProvider{ + credsProvider: credentials.NewStaticCredentialsProvider("", "", ""), + } + key := &MasterKey{} + creds.ApplyToMasterKey(key) + g.Expect(key.credentialsProvider).To(Equal(creds.credsProvider)) +} + +func TestLoadAwsKmsCredsFromYaml(t *testing.T) { + g := NewWithT(t) + credsYaml := []byte(` +aws_access_key_id: test-id +aws_secret_access_key: test-secret +aws_session_token: test-token +`) + credsProvider, err := LoadCredsProviderFromYaml(credsYaml) + g.Expect(err).ToNot(HaveOccurred()) + + creds, err := credsProvider.credsProvider.Retrieve(context.TODO()) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(creds.AccessKeyID).To(Equal("test-id")) + g.Expect(creds.SecretAccessKey).To(Equal("test-secret")) + g.Expect(creds.SessionToken).To(Equal("test-token")) +} + +func Test_createKMSConfig(t *testing.T) { + g := NewWithT(t) + + key := MasterKey{ + credentialsProvider: credentials.NewStaticCredentialsProvider("test-id", "test-secret", "test-token"), + } + cfg, err := key.createKMSConfig() + g.Expect(err).ToNot(HaveOccurred()) + + creds, err := cfg.Credentials.Retrieve(context.TODO()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(creds.AccessKeyID).To(Equal("test-id")) + g.Expect(creds.SecretAccessKey).To(Equal("test-secret")) + g.Expect(creds.SessionToken).To(Equal("test-token")) + + // test if we fallback to the default way of fetching credentials + // if no static credentials are provided. + key.credentialsProvider = nil + t.Setenv("AWS_ACCESS_KEY_ID", "id") + t.Setenv("AWS_SECRET_ACCESS_KEY", "secret") + t.Setenv("AWS_SESSION_TOKEN", "token") + + cfg, err = key.createKMSConfig() + g.Expect(err).ToNot(HaveOccurred()) + + creds, err = cfg.Credentials.Retrieve(context.TODO()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(creds.AccessKeyID).To(Equal("id")) + g.Expect(creds.SecretAccessKey).To(Equal("secret")) + g.Expect(creds.SessionToken).To(Equal("token")) +} + +func createTestMasterKey(arn string) MasterKey { + return MasterKey{ + Arn: arn, + credentialsProvider: credentials.NewStaticCredentialsProvider("id", "secret", ""), + epResolver: epResolver{}, + } +} + +// epResolver is a dummy resolver that points to the local test KMS server +type epResolver struct{} + +func (e epResolver) ResolveEndpoint(service, region string) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: testKMSServerURL, + }, nil +} + +func createTestKMSClient(key MasterKey) (*kms.Client, error) { + cfg, err := key.createKMSConfig() + if err != nil { + return nil, err + } + + cfg.EndpointResolver = epResolver{} + + return kms.NewFromConfig(*cfg), nil +} diff --git a/internal/sops/hcvault/keysource_test.go b/internal/sops/hcvault/keysource_test.go index 75ad8b50..413e8f40 100644 --- a/internal/sops/hcvault/keysource_test.go +++ b/internal/sops/hcvault/keysource_test.go @@ -60,6 +60,12 @@ func TestMain(m *testing.M) { logger.Fatalf("could not start resource: %s", err) } + purgeResource := func() { + if err := pool.Purge(resource); err != nil { + logger.Printf("could not purge resource: %s", err) + } + } + testVaultAddress = fmt.Sprintf("http://127.0.0.1:%v", resource.GetPort("8200/tcp")) // Wait until Vault is ready to serve requests if err := pool.Retry(func() error { @@ -78,10 +84,12 @@ func TestMain(m *testing.M) { } return nil }); err != nil { + purgeResource() logger.Fatalf("could not connect to docker: %s", err) } if err = enableVaultTransit(testVaultAddress, testVaultToken, testEnginePath); err != nil { + purgeResource() logger.Fatalf("could not enable Vault transit: %s", err) } diff --git a/internal/sops/keyservice/options.go b/internal/sops/keyservice/options.go index 6e2cb8e3..f8a3868c 100644 --- a/internal/sops/keyservice/options.go +++ b/internal/sops/keyservice/options.go @@ -21,6 +21,7 @@ import ( "go.mozilla.org/sops/v3/keyservice" "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/hcvault" "github.com/fluxcd/kustomize-controller/internal/sops/pgp" @@ -56,6 +57,16 @@ func (o WithAgeIdentities) ApplyToServer(s *Server) { s.ageIdentities = age.ParsedIdentities(o) } +// WithAWSKeys configures the AWS credentials on the Server +type WithAWSKeys struct { + CredsProvider *awskms.CredsProvider +} + +// ApplyToServer applies this configuration to the given Server. +func (o WithAWSKeys) ApplyToServer(s *Server) { + s.awsCredsProvider = o.CredsProvider +} + // 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 545608e4..28cca0ad 100644 --- a/internal/sops/keyservice/server.go +++ b/internal/sops/keyservice/server.go @@ -11,6 +11,7 @@ import ( "golang.org/x/net/context" "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/hcvault" "github.com/fluxcd/kustomize-controller/internal/sops/pgp" @@ -43,6 +44,11 @@ type Server struct { // When nil, the request will be handled by defaultServer. azureToken *azkv.Token + // awsCredsProvider is the Credentials object used for Encrypt and Decrypt + // operations of AWS KMS requests. + // When nil, the request will be handled by defaultServer. + awsCredsProvider *awskms.CredsProvider + // defaultServer is the fallback server, used to handle any request that // is not eligible to be handled by this Server. defaultServer keyservice.KeyServiceServer @@ -96,6 +102,14 @@ func (ks Server) Encrypt(ctx context.Context, req *keyservice.EncryptRequest) (* Ciphertext: ciphertext, }, nil } + case *keyservice.Key_KmsKey: + cipherText, err := ks.encryptWithAWSKMS(k.KmsKey, req.Plaintext) + if err != nil { + return nil, err + } + return &keyservice.EncryptResponse{ + Ciphertext: cipherText, + }, nil case *keyservice.Key_AzureKeyvaultKey: if ks.azureToken != nil { ciphertext, err := ks.encryptWithAzureKeyVault(k.AzureKeyvaultKey, req.Plaintext) @@ -144,6 +158,14 @@ func (ks Server) Decrypt(ctx context.Context, req *keyservice.DecryptRequest) (* Plaintext: plaintext, }, nil } + case *keyservice.Key_KmsKey: + plaintext, err := ks.decryptWithAWSKMS(k.KmsKey, req.Ciphertext) + if err != nil { + return nil, err + } + return &keyservice.DecryptResponse{ + Plaintext: plaintext, + }, nil case *keyservice.Key_AzureKeyvaultKey: if ks.azureToken != nil { plaintext, err := ks.decryptWithAzureKeyVault(k.AzureKeyvaultKey, req.Ciphertext) @@ -232,6 +254,43 @@ func (ks *Server) decryptWithHCVault(key *keyservice.VaultKey, ciphertext []byte return plaintext, err } +func (ks *Server) encryptWithAWSKMS(key *keyservice.KmsKey, plaintext []byte) ([]byte, error) { + context := make(map[string]string) + for key, val := range key.Context { + context[key] = val + } + awsKey := awskms.MasterKey{ + Arn: key.Arn, + Role: key.Role, + EncryptionContext: context, + } + if ks.awsCredsProvider != nil { + ks.awsCredsProvider.ApplyToMasterKey(&awsKey) + } + if err := awsKey.Encrypt(plaintext); err != nil { + return nil, err + } + return []byte(awsKey.EncryptedKey), nil +} + +func (ks *Server) decryptWithAWSKMS(key *keyservice.KmsKey, cipherText []byte) ([]byte, error) { + context := make(map[string]string) + for key, val := range key.Context { + context[key] = val + } + awsKey := awskms.MasterKey{ + Arn: key.Arn, + Role: key.Role, + EncryptionContext: context, + } + awsKey.EncryptedKey = string(cipherText) + + if ks.awsCredsProvider != nil { + ks.awsCredsProvider.ApplyToMasterKey(&awsKey) + } + return awsKey.Decrypt() +} + func (ks *Server) encryptWithAzureKeyVault(key *keyservice.AzureKeyVaultKey, plaintext []byte) ([]byte, error) { azureKey := azkv.MasterKey{ VaultURL: key.VaultUrl, diff --git a/internal/sops/keyservice/server_test.go b/internal/sops/keyservice/server_test.go index 2231a930..b82e0de5 100644 --- a/internal/sops/keyservice/server_test.go +++ b/internal/sops/keyservice/server_test.go @@ -22,11 +22,13 @@ import ( "testing" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/aws/aws-sdk-go-v2/credentials" . "github.com/onsi/gomega" "go.mozilla.org/sops/v3/keyservice" "golang.org/x/net/context" "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/hcvault" "github.com/fluxcd/kustomize-controller/internal/sops/pgp" @@ -147,6 +149,26 @@ func TestServer_EncryptDecrypt_HCVault_Fallback(t *testing.T) { g.Expect(fallback.encryptReqs).To(HaveLen(0)) } +func TestServer_EncryptDecrypt_awskms(t *testing.T) { + g := NewWithT(t) + s := NewServer(WithAWSKeys{ + CredsProvider: awskms.NewCredsProvider(credentials.StaticCredentialsProvider{}), + }) + + key := KeyFromMasterKey(awskms.NewMasterKeyFromArn("arn:aws:kms:us-west-2:107501996527:key/612d5f0p-p1l3-45e6-aca6-a5b005693a48", nil, "")) + _, 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 AWS 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 AWS KMS")) +} + func TestServer_EncryptDecrypt_azkv(t *testing.T) { g := NewWithT(t) diff --git a/internal/sops/keyservice/utils_test.go b/internal/sops/keyservice/utils_test.go index 945ef1d0..1cdaf5f8 100644 --- a/internal/sops/keyservice/utils_test.go +++ b/internal/sops/keyservice/utils_test.go @@ -24,6 +24,7 @@ import ( "go.mozilla.org/sops/v3/keyservice" "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/hcvault" "github.com/fluxcd/kustomize-controller/internal/sops/pgp" @@ -51,6 +52,14 @@ func KeyFromMasterKey(k keys.MasterKey) keyservice.Key { }, }, } + case *awskms.MasterKey: + return keyservice.Key{ + KeyType: &keyservice.Key_KmsKey{ + KmsKey: &keyservice.KmsKey{ + Arn: mk.Arn, + }, + }, + } case *azkv.MasterKey: return keyservice.Key{ KeyType: &keyservice.Key_AzureKeyvaultKey{