Skip to content

Commit

Permalink
Merge pull request #1 from syndbg/aws-kms
Browse files Browse the repository at this point in the history
Add AWS KMS asymmetric keypair support
  • Loading branch information
syndbg authored Aug 17, 2021
2 parents 8257d28 + fd9022f commit f50eb1e
Show file tree
Hide file tree
Showing 10 changed files with 462 additions and 55 deletions.
9 changes: 9 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ linters:
- nlreturn
# NOTE: False-positives
- nestif
# NOTE: Doesn't play nice with `stacktrace` pkg
- wrapcheck
# NOTE: More opinionated than useful
- revive
# NOTE: Very bad practice in terms of readability and code consistency.
# Questionable benefit of saving 1 line of code.
- ifshort
# NOTE: Not that useful in the context of a terraform provider
- goerr113
issues:
exclude-rules:
- text: "don't use an underscore in package name"
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
# Terraform Provider Vaulted Null
# terraform-provider-vaulted-null

Terraform provider utilizing [sumup-oss/vaulted](https://github.com/sumup-oss/vaulted) to provide a data source
able to decrypt a vaulted encrypted payload.

Are you using HashiCorp Vault? Perhaps [terraform-provider-vaulted](https://github.com/sumup-oss/terraform-provider-vaulted)
is going to be useful to you.

Which one to use?

* terraform-provider-vaulted-null is meant to be used with remote/non-local encryption-at-transit Terraform state providers like Terraform Cloud.
Perfect for Terraform Cloud workspace agents/executors and trusted CI environments.
The encrypted payload is decrypted via the data source, therefore it is stored in **plaintext in the Terraform State**.
* terraform-provider-vaulted is meant for less secure CI environments. E.g "public cloud" CI agents/executors.
It provides Terraform resources provisioning HashiCorp Vault with a vaulted encrypted payload.
The encrypted payload **is never stored in plaintext in the Terraform State**.

## Usage

Check out the [examples' main.tf](./examples/main.tf).

## Contributing

### Build

Run the following command to build the provider

Expand Down
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ module github.com/syndbg/terraform-provider-vaulted-null
go 1.16

require (
github.com/aws/aws-sdk-go-v2 v1.8.0
github.com/aws/aws-sdk-go-v2/config v1.6.0
github.com/aws/aws-sdk-go-v2/credentials v1.3.2
github.com/aws/aws-sdk-go-v2/service/sts v1.6.1
github.com/hashicorp/terraform-plugin-sdk/v2 v2.7.0
github.com/magefile/mage v1.11.0
github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177
github.com/sumup-oss/go-pkgs v0.0.0-20210609105328-3fa305f4952d
github.com/sumup-oss/vaulted v0.2.1
github.com/sumup-oss/vaulted v0.3.1-0.20210817101118-552387c06938
)
114 changes: 107 additions & 7 deletions go.sum

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions internal/provider/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package provider

import "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"

// nolint:gochecknoinits
func init() {
// NOTE: Part of TF registry docs generation
schema.DescriptionKind = schema.StringMarkdown
}
24 changes: 24 additions & 0 deletions internal/provider/meta_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package provider

import "github.com/palantir/stacktrace"

type MetaClient struct {
payloadDeserializer PayloadDeserializer
payloadDecrypter PayloadDecrypter
}

func (m *MetaClient) DecryptValue(encryptedValue string) (string, error) {
deserializedValue, err := m.payloadDeserializer.Deserialize([]byte(encryptedValue))
if err != nil {
return "", stacktrace.Propagate(err, "unable to serialize `value`")
}

decryptedValue, err := m.payloadDecrypter.Decrypt(deserializedValue)
if err != nil {
return "", stacktrace.Propagate(err, "unable to decrypt `value`")
}

plaintext := decryptedValue.Content.Plaintext

return string(plaintext), nil
}
7 changes: 7 additions & 0 deletions internal/provider/payload_decrypter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package provider

import "github.com/sumup-oss/vaulted/pkg/vaulted/payload"

type PayloadDecrypter interface {
Decrypt(encryptedPayload *payload.EncryptedPayload) (*payload.Payload, error)
}
7 changes: 7 additions & 0 deletions internal/provider/payload_deserializer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package provider

import "github.com/sumup-oss/vaulted/pkg/vaulted/payload"

type PayloadDeserializer interface {
Deserialize(encodedContent []byte) (*payload.EncryptedPayload, error)
}
231 changes: 185 additions & 46 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,76 +3,116 @@ package provider
import (
"context"
stdRsa "crypto/rsa"
"errors"
"fmt"
"time"

extaws "github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
"github.com/aws/aws-sdk-go-v2/service/sts/types"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
"github.com/palantir/stacktrace"
"github.com/sumup-oss/go-pkgs/os"
"github.com/sumup-oss/vaulted/pkg/aes"
"github.com/sumup-oss/vaulted/pkg/aws"
"github.com/sumup-oss/vaulted/pkg/base64"
"github.com/sumup-oss/vaulted/pkg/pkcs7"
"github.com/sumup-oss/vaulted/pkg/rsa"
"github.com/sumup-oss/vaulted/pkg/vaulted/content"
"github.com/sumup-oss/vaulted/pkg/vaulted/header"
"github.com/sumup-oss/vaulted/pkg/vaulted/passphrase"
"github.com/sumup-oss/vaulted/pkg/vaulted/payload"
)

type MetaClient struct {
VaultedPrivateKey *stdRsa.PrivateKey
}

func (m *MetaClient) DecryptValue(encryptedValue string) (string, error) {
osExecutor := &os.RealOsExecutor{}
b64Svc := base64.NewBase64Service()
rsaSvc := rsa.NewRsaService(osExecutor)
aesSvc := aes.NewAesService(pkcs7.NewPkcs7Service())

encPayloadSvc := payload.NewEncryptedPayloadService(
header.NewHeaderService(),
passphrase.NewEncryptedPassphraseService(b64Svc, rsaSvc),
content.NewV1EncryptedContentService(b64Svc, aesSvc),
)

deserializedValue, err := encPayloadSvc.Deserialize([]byte(encryptedValue))
if err != nil {
return "", stacktrace.Propagate(err, "unable to serialize `value`")
}

decryptedValue, err := encPayloadSvc.Decrypt(m.VaultedPrivateKey, deserializedValue)
if err != nil {
return "", stacktrace.Propagate(err, "unable to decrypt `value`")
}

plaintext := decryptedValue.Content.Plaintext

return string(plaintext), nil
}

// nolint:gochecknoinits
func init() {
// NOTE: Part of TF registry docs generation
schema.DescriptionKind = schema.StringMarkdown
}

func New() func() *schema.Provider {
return func() *schema.Provider {
p := &schema.Provider{
Schema: map[string]*schema.Schema{
"aws_kms_key_id": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("VAULTED_AWS_KMS_KEY_ID", ""),
Description: "Either AWS KMS key ARN or AWS KMS key alias, used to decrypt. " +
"Make sure AWS_REGION and/or AWS_PROFILE environment variables are pointing to an AWS account that has the given KMS key." +
"This setting has higher priority than `private_key_content`.",
},
"aws_profile": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("AWS_PROFILE", ""),
Description: "AWS profile to use when authenticating against AWS. Equivalent of `AWS_PROFILE` env var that also works. " +
"In practice only useful when `aws_kms_key_id` is provided",
},
// NOTE: Intentionally mimic the official `terraform-provider-aws` as much as possible
// to make the use for anyone already familiar with it, smooth.
"aws_assume_role": {
Type: schema.TypeList,
Optional: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"duration_seconds": {
Type: schema.TypeInt,
Optional: true,
Description: "Seconds to restrict the assume role session duration.",
},
"external_id": {
Type: schema.TypeString,
Optional: true,
Description: "Unique identifier that might be required for assuming a role in another account.",
},
"policy": {
Type: schema.TypeString,
Optional: true,
Description: "IAM Policy JSON describing further restricting permissions for the IAM Role being assumed.",
ValidateFunc: validation.StringIsJSON,
},
"policy_arns": {
Type: schema.TypeSet,
Optional: true,
Description: "Amazon Resource Names (ARNs) of IAM Policies describing further restricting " +
"permissions for the IAM Role being assumed.",
Elem: &schema.Schema{
Type: schema.TypeString,
ValidateFunc: validateArn,
},
},
"role_arn": {
Type: schema.TypeString,
Optional: true,
Description: "Amazon Resource Name of an IAM Role to assume prior to making API calls.",
ValidateFunc: validateArn,
},
"session_name": {
Type: schema.TypeString,
Optional: true,
Description: "Identifier for the assumed role session.",
},
},
},
},
"aws_region": {
Type: schema.TypeString,
Required: true,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"AWS_REGION",
"AWS_DEFAULT_REGION",
}, nil),
Description: "AWS Region to use where `aws_kms_key_id` is present.",
},
"private_key_content": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("VAULTED_PRIVATE_KEY_CONTENT", ""),
Description: "Content of private key used to decrypt `vaulted-tfe_variable` resources. " +
"This setting has higher priority than `private_key_path`.",
Description: "Content of private key used to decrypt. This setting has higher priority than `private_key_path`.",
},
"private_key_path": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("VAULTED_PRIVATE_KEY_PATH", ""),
Description: "Path to private key used to decrypt `vaulted-tfe_variable` resources. " +
"This setting has lower priority than `private_key_content`.",
Description: "Path to private key used to decrypt. This setting has lower priority than `private_key_content`.",
},
},
DataSourcesMap: map[string]*schema.Resource{
Expand All @@ -90,14 +130,113 @@ func configure() func(context.Context, *schema.ResourceData) (interface{}, diag.
return func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) {
osExecutor := &os.RealOsExecutor{}
rsaSvc := rsa.NewRsaService(osExecutor)
aesSvc := aes.NewAesService(pkcs7.NewPkcs7Service())
b64Svc := base64.NewBase64Service()

contentSvc := content.NewV1Service(b64Svc, aesSvc)

var payloadDecrypter PayloadDecrypter

awsKMSkeyID, ok := d.Get("aws_kms_key_id").(string)
if !ok {
return nil, diag.FromErr(errors.New("unexpected non-string `aws_kms_key_id`"))
}

if awsKMSkeyID == "" {
privateKey, err := readPrivateKey(d, osExecutor, rsaSvc)
if err != nil {
return nil, diag.FromErr(err)
}

passphraseDecrypter := passphrase.NewDecryptionRsaPKCS1v15Service(privateKey, rsaSvc)
payloadDecrypter = payload.NewDecryptionService(passphraseDecrypter, contentSvc)
} else {
awsCfg, err := readAWScfg(ctx, d)
if err != nil {
return nil, diag.FromErr(err)
}

privateKey, err := readPrivateKey(d, osExecutor, rsaSvc)
if err != nil {
return nil, diag.FromErr(err)
awsSvc, _ := aws.NewService(awsCfg)
passphraseDecrypter := passphrase.NewDecryptionAwsKmsService(awsSvc, awsKMSkeyID)
payloadDecrypter = payload.NewDecryptionService(passphraseDecrypter, contentSvc)
}

return &MetaClient{VaultedPrivateKey: privateKey}, nil
payloadDeserializer := payload.NewSerdeService(b64Svc)

return &MetaClient{payloadDecrypter: payloadDecrypter, payloadDeserializer: payloadDeserializer}, nil
}
}

func readAWScfg(ctx context.Context, d *schema.ResourceData) (*extaws.Config, error) {
awsRegion, ok := d.Get("aws_region").(string)
if !ok {
return nil, errors.New("unexpected non-string `aws_region`")
}

awsCfgResolvers := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(awsRegion)}

awsProfile, ok := d.Get("aws_profile").(string)
if !ok {
return nil, errors.New("unexpected non-string `aws_profile`")
}

if awsProfile != "" {
awsCfgResolvers = append(awsCfgResolvers, awsconfig.WithSharedConfigProfile(awsProfile))
}

awsAssumeRole, ok := d.Get("aws_assume_role").([]interface{})
if ok && len(awsAssumeRole) > 0 && awsAssumeRole[0] != nil {
m, ok := awsAssumeRole[0].(map[string]interface{})
if !ok {
return nil, errors.New("unexpected non-map with key string, value interface{} - `aws_assume_role[0]`")
}

awsconfig.WithAssumeRoleCredentialOptions(func(opts *stscreds.AssumeRoleOptions) {
if v, ok := m["duration_seconds"].(int); ok && v != 0 {
opts.Duration = time.Second * time.Duration(v)
}

if v, ok := m["external_id"].(string); ok && v != "" {
opts.ExternalID = extaws.String(v)
}

if v, ok := m["policy"].(string); ok && v != "" {
opts.Policy = extaws.String(v)
}

if policyARNSet, ok := m["policy_arns"].(*schema.Set); ok && policyARNSet.Len() > 0 {
for _, policyARNRaw := range policyARNSet.List() {
policyARN, ok := policyARNRaw.(string)

if !ok {
continue
}

opts.PolicyARNs = append(
opts.PolicyARNs,
types.PolicyDescriptorType{
Arn: extaws.String(policyARN),
},
)
}
}

if v, ok := m["role_arn"].(string); ok && v != "" {
opts.RoleARN = v
}

if v, ok := m["session_name"].(string); ok && v != "" {
opts.RoleSessionName = v
}
})
}

awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsCfgResolvers...)
if err != nil {
return nil, err
}

return &awsCfg, nil
}

func readPrivateKey(
Expand Down
Loading

0 comments on commit f50eb1e

Please sign in to comment.