mirror of
https://github.com/opentofu/opentofu.git
synced 2025-02-25 18:45:20 -06:00
Signed-off-by: Janos <86970079+janosdebugs@users.noreply.github.com> Signed-off-by: Mikel Olasagasti Uranga <mikel@olasagasti.info> Signed-off-by: Christian Mesh <christianmesh1@gmail.com> Signed-off-by: James Humphries <James@james-humphries.co.uk> Co-authored-by: James Humphries <jamesh@spacelift.io> Co-authored-by: Serdar Dalgıç <serdardalgic@users.noreply.github.com> Co-authored-by: Mikel Olasagasti Uranga <mikel@olasagasti.info> Co-authored-by: Christian Mesh <christianmesh1@gmail.com>
This commit is contained in:
parent
4482ce9226
commit
fa638907f1
@ -36,6 +36,8 @@ In this section we describe the features that are out of scope for state and pla
|
||||
|
||||
The primary goal of this feature is to protect state and plan files **at rest**. It is not the goal of this feature to protect other channels secrets may be accessed through, such as the JSON output. As such, it is not a goal of this feature to encrypt any output on the standard output, or file output that is not a state or plan file.
|
||||
|
||||
Furthermore, it is not a goal of this feature to *authenticate* that the user is running an up-to-date plan file. It does not protect against, among others, replay attacks where a malicious actor replaces a current plan or state file with an old one.
|
||||
|
||||
It is also not a goal of this feature to protect the state file against the operator of the device running `tofu`. The operator already has access to the encryption key and can decrypt the data without the `tofu` binary being present if they so chose.
|
||||
|
||||
## User-facing effects
|
||||
|
59
internal/collections/set.go
Normal file
59
internal/collections/set.go
Normal file
@ -0,0 +1,59 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// Set is a container that can hold each item only once and has a fast lookup time.
|
||||
//
|
||||
// You can define a new set like this:
|
||||
//
|
||||
// var validKeyLengths = collections.Set[int]{
|
||||
// 16: {},
|
||||
// 24: {},
|
||||
// 32: {},
|
||||
// }
|
||||
//
|
||||
// You can also use the constructor to create a new set
|
||||
//
|
||||
// var validKeyLengths = collections.NewSet[int](16,24,32)
|
||||
type Set[T comparable] map[T]struct{}
|
||||
|
||||
// Constructs a new set given the members of type T
|
||||
func NewSet[T comparable](members ...T) Set[T] {
|
||||
set := Set[T]{}
|
||||
for _, member := range members {
|
||||
set[member] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// Has returns true if the item exists in the Set
|
||||
func (s Set[T]) Has(value T) bool {
|
||||
_, ok := s[value]
|
||||
return ok
|
||||
}
|
||||
|
||||
// String creates a comma-separated list of all values in the set.
|
||||
func (s Set[T]) String() string {
|
||||
parts := make([]string, len(s))
|
||||
i := 0
|
||||
for v := range s {
|
||||
parts[i] = fmt.Sprintf("%v", v)
|
||||
i++
|
||||
}
|
||||
|
||||
slices.SortStableFunc(parts, func(a, b string) int {
|
||||
if a < b {
|
||||
return -1
|
||||
} else if b > a {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
95
internal/collections/set_test.go
Normal file
95
internal/collections/set_test.go
Normal file
@ -0,0 +1,95 @@
|
||||
package collections_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/collections"
|
||||
)
|
||||
|
||||
type hasTestCase struct {
|
||||
name string
|
||||
set collections.Set[string]
|
||||
testValueResults map[string]bool
|
||||
}
|
||||
|
||||
func TestSet_NewSet(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
constructed collections.Set[int]
|
||||
expected collections.Set[int]
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
constructed: collections.NewSet[int](),
|
||||
expected: collections.Set[int]{},
|
||||
}, {
|
||||
name: "items",
|
||||
constructed: collections.NewSet[int](1, 54, 284),
|
||||
expected: collections.Set[int]{1: {}, 54: {}, 284: {}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if len(tc.constructed) != len(tc.expected) {
|
||||
t.Fatal("Set length mismatch")
|
||||
}
|
||||
|
||||
for k := range tc.expected {
|
||||
if _, ok := tc.constructed[k]; !ok {
|
||||
t.Fatalf("Expected to find key %v in constructed set", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet_has(t *testing.T) {
|
||||
testCases := []hasTestCase{
|
||||
{
|
||||
name: "string",
|
||||
set: collections.Set[string]{
|
||||
"a": {},
|
||||
"b": {},
|
||||
"c": {},
|
||||
},
|
||||
testValueResults: map[string]bool{
|
||||
"a": true,
|
||||
"b": true,
|
||||
"c": true,
|
||||
"d": false,
|
||||
"e": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for value, has := range testCase.testValueResults {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
if has {
|
||||
if !testCase.set.Has(value) {
|
||||
t.Fatalf("Set does not have expected value of %s", value)
|
||||
}
|
||||
} else {
|
||||
if testCase.set.Has(value) {
|
||||
t.Fatalf("Set has unexpected value of %s", value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet_string(t *testing.T) {
|
||||
testSet := collections.Set[string]{
|
||||
"a": {},
|
||||
"b": {},
|
||||
"c": {},
|
||||
}
|
||||
|
||||
if str := testSet.String(); str != "a, b, c" {
|
||||
t.Fatalf("Incorrect string concatenation: %s", str)
|
||||
}
|
||||
}
|
@ -36,14 +36,14 @@ func newBaseEncryption(enc *encryption, target *config.TargetConfig, enforced bo
|
||||
}
|
||||
// This performs a e2e validation run of the config -> methods flow. It serves as a validation step and allows us to
|
||||
// return detailed diagnostics here and simple errors below
|
||||
_, diags := base.buildTargetMethods(make(map[keyprovider.Addr][]byte))
|
||||
_, diags := base.buildTargetMethods(make(map[keyprovider.Addr]any))
|
||||
return base, diags
|
||||
}
|
||||
|
||||
type basedata struct {
|
||||
Meta map[keyprovider.Addr][]byte `json:"meta"`
|
||||
Data []byte `json:"encrypted_data"`
|
||||
Version string `json:"encryption_version"` // This is both a sigil for a valid encrypted payload and a future compatability field
|
||||
Meta map[keyprovider.Addr]any `json:"meta"`
|
||||
Data []byte `json:"encrypted_data"`
|
||||
Version string `json:"encryption_version"` // This is both a sigil for a valid encrypted payload and a future compatability field
|
||||
}
|
||||
|
||||
func (s *baseEncryption) encrypt(data []byte) ([]byte, error) {
|
||||
@ -53,7 +53,7 @@ func (s *baseEncryption) encrypt(data []byte) ([]byte, error) {
|
||||
}
|
||||
|
||||
es := basedata{
|
||||
Meta: make(map[keyprovider.Addr][]byte),
|
||||
Meta: make(map[keyprovider.Addr]any),
|
||||
Version: encryptionVersion,
|
||||
}
|
||||
|
||||
|
@ -27,7 +27,7 @@ key_provider "static" "basic" {
|
||||
key = "6f6f706830656f67686f6834616872756f3751756165686565796f6f72653169"
|
||||
}
|
||||
method "aes_gcm" "example" {
|
||||
cipher = key_provider.static.basic
|
||||
keys = key_provider.static.basic
|
||||
}
|
||||
statefile {
|
||||
method = method.aes_gcm.example
|
||||
|
@ -9,6 +9,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/config"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
@ -162,6 +164,33 @@ func (e *targetBuilder) setupKeyProvider(cfg config.KeyProviderConfig, stack []c
|
||||
if diags.HasErrors() {
|
||||
return diags
|
||||
}
|
||||
// Add the metadata
|
||||
if meta, ok := e.keyProviderMetadata[metakey]; ok {
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
// We want all metadata fields to be consumed:
|
||||
ErrorUnused: true,
|
||||
// Fill the results in this struct:
|
||||
Result: &keyProviderConfig,
|
||||
// Use the "meta" tag:
|
||||
TagName: "meta",
|
||||
// Ignore fields not tagged with "meta":
|
||||
IgnoreUntaggedFields: true,
|
||||
})
|
||||
if err != nil {
|
||||
return append(diags, &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Unable to decode encrypted metadata (did you change your encryption config?)",
|
||||
Detail: fmt.Sprintf("initializing metadata decoder for %s failed with error: %s", metakey, err.Error()),
|
||||
})
|
||||
}
|
||||
if err := decoder.Decode(meta); err != nil {
|
||||
return append(diags, &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Unable to decode encrypted metadata (did you change your encryption config?)",
|
||||
Detail: fmt.Sprintf("decoding %s failed with error: %s", metakey, err.Error()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build the Key Provider from the configuration
|
||||
keyProvider, err := keyProviderConfig.Build()
|
||||
@ -173,9 +202,7 @@ func (e *targetBuilder) setupKeyProvider(cfg config.KeyProviderConfig, stack []c
|
||||
})
|
||||
}
|
||||
|
||||
meta := e.keyProviderMetadata[metakey]
|
||||
|
||||
data, newmeta, err := keyProvider.Provide(meta)
|
||||
output, err := keyProvider.Provide()
|
||||
if err != nil {
|
||||
return append(diags, &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
@ -184,14 +211,8 @@ func (e *targetBuilder) setupKeyProvider(cfg config.KeyProviderConfig, stack []c
|
||||
})
|
||||
}
|
||||
|
||||
e.keyProviderMetadata[metakey] = newmeta
|
||||
|
||||
// Convert the data into it's cty equivalent
|
||||
ctyData := make([]cty.Value, len(data))
|
||||
for i, d := range data {
|
||||
ctyData[i] = cty.NumberIntVal(int64(d))
|
||||
}
|
||||
e.keyValues[cfg.Type][cfg.Name] = cty.ListVal(ctyData)
|
||||
e.keyProviderMetadata[metakey] = output.Metadata
|
||||
e.keyValues[cfg.Type][cfg.Name] = output.Cty()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -23,6 +23,6 @@ type Descriptor interface {
|
||||
}
|
||||
|
||||
type KeyProvider interface {
|
||||
// Provide provides an encryption key. If the process fails, it returns an error.
|
||||
Provide(metadata []byte) ([]byte, []byte, error)
|
||||
// Provide provides an encryption and decryption keys. If the process fails, it returns an error.
|
||||
Provide() (Output, error)
|
||||
}
|
||||
|
30
internal/encryption/keyprovider/output.go
Normal file
30
internal/encryption/keyprovider/output.go
Normal file
@ -0,0 +1,30 @@
|
||||
package keyprovider
|
||||
|
||||
import "github.com/zclconf/go-cty/cty"
|
||||
|
||||
// Output is the standardized structure a key provider must return when providing a key.
|
||||
// It contains two keys because some key providers may prefer include random data (e.g. salt)
|
||||
// in the generated keys.
|
||||
// Additionally, the Metadata should contain a struct with `meta` tags that OpenTofu can serialize
|
||||
// into the encrypted form. OpenTofu will inject the `meta` tagged items back into the
|
||||
// key provider configuration when an item needs to be decrypted.
|
||||
type Output struct {
|
||||
EncryptionKey []byte `hcl:"encryption_key" cty:"encryption_key" json:"encryption_key" yaml:"encryption_key"`
|
||||
DecryptionKey []byte `hcl:"decryption_key" cty:"decryption_key" json:"decryption_key" yaml:"decryption_key"`
|
||||
Metadata any
|
||||
}
|
||||
|
||||
func (o *Output) Cty() cty.Value {
|
||||
return cty.ObjectVal(map[string]cty.Value{
|
||||
"encryption_key": o.byteToCty(o.EncryptionKey),
|
||||
"decryption_key": o.byteToCty(o.DecryptionKey),
|
||||
})
|
||||
}
|
||||
|
||||
func (o *Output) byteToCty(data []byte) cty.Value {
|
||||
ctyData := make([]cty.Value, len(data))
|
||||
for i, d := range data {
|
||||
ctyData[i] = cty.NumberIntVal(int64(d))
|
||||
}
|
||||
return cty.ListVal(ctyData)
|
||||
}
|
@ -6,14 +6,15 @@
|
||||
// Package static contains a key provider that emits a static key.
|
||||
package static
|
||||
|
||||
import "github.com/opentofu/opentofu/internal/encryption/keyprovider"
|
||||
|
||||
type staticKeyProvider struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func (p staticKeyProvider) Provide(metadata []byte) ([]byte, []byte, error) {
|
||||
if metadata == nil {
|
||||
metadata = []byte("magic")
|
||||
}
|
||||
|
||||
return p.key, metadata, nil
|
||||
func (p staticKeyProvider) Provide() (keyprovider.Output, error) {
|
||||
return keyprovider.Output{
|
||||
EncryptionKey: p.key,
|
||||
DecryptionKey: p.key,
|
||||
}, nil
|
||||
}
|
||||
|
@ -6,8 +6,11 @@
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/keyprovider"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/keyprovider/static"
|
||||
)
|
||||
|
||||
@ -17,16 +20,14 @@ func TestKeyProvider(t *testing.T) {
|
||||
name string
|
||||
key string
|
||||
expectSuccess bool
|
||||
expectedData string // The key as a string taken from the hex value of the key
|
||||
expectedMeta string
|
||||
expectedData keyprovider.Output
|
||||
}
|
||||
|
||||
testCases := []testCase{
|
||||
{
|
||||
name: "Empty",
|
||||
expectSuccess: true,
|
||||
expectedData: "",
|
||||
expectedMeta: "magic", // We currently always output the metadata "magic"
|
||||
expectedData: keyprovider.Output{},
|
||||
},
|
||||
{
|
||||
name: "InvalidInput",
|
||||
@ -37,8 +38,7 @@ func TestKeyProvider(t *testing.T) {
|
||||
name: "Success",
|
||||
key: "48656c6c6f20776f726c6421",
|
||||
expectSuccess: true,
|
||||
expectedData: "Hello world!", // "48656c6c6f20776f726c6421" in hex is "Hello world!"
|
||||
expectedMeta: "magic", // We currently always output the metadata "magic"
|
||||
expectedData: keyprovider.Output{EncryptionKey: []byte("Hello world!"), DecryptionKey: []byte("Hello world!")}, // "48656c6c6f20776f726c6421" in hex is "Hello world!"
|
||||
},
|
||||
}
|
||||
|
||||
@ -58,15 +58,15 @@ func TestKeyProvider(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", buildErr)
|
||||
}
|
||||
|
||||
data, newMetadata, err := keyProvider.Provide(nil)
|
||||
output, err := keyProvider.Provide()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(data) != tc.expectedData {
|
||||
t.Fatalf("unexpected key output: got %v, want %v", data, tc.expectedData)
|
||||
if !bytes.Equal(output.EncryptionKey, tc.expectedData.EncryptionKey) {
|
||||
t.Fatalf("unexpected encryption key in output: got %v, want %v", output.EncryptionKey, tc.expectedData.EncryptionKey)
|
||||
}
|
||||
if string(newMetadata) != tc.expectedMeta {
|
||||
t.Fatalf("unexpected metadata: got %v, want %v", newMetadata, tc.expectedMeta)
|
||||
if !bytes.Equal(output.DecryptionKey, tc.expectedData.DecryptionKey) {
|
||||
t.Fatalf("unexpected decryption key in output: got %v, want %v", output.DecryptionKey, tc.expectedData.EncryptionKey)
|
||||
}
|
||||
} else {
|
||||
if buildErr == nil {
|
||||
|
47
internal/encryption/method/aesgcm/README.md
Normal file
47
internal/encryption/method/aesgcm/README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# AES-GCM encryption method
|
||||
|
||||
> [!WARNING]
|
||||
> This file is not an end-user documentation, it is intended for developers. Please follow the user documentation on the OpenTofu website unless you want to work on the encryption code.
|
||||
|
||||
This folder contains the state encryption implementation of the AES-GCM encryption method. This is implemented following the guidance of the following document: ([NIST SP 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final)).
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure the encryption by specifying the following method block:
|
||||
|
||||
```hcl2
|
||||
terraform {
|
||||
encryption {
|
||||
method "aes_gcm" "mymethod" {
|
||||
# Pass the key provider with a 16, 24, or 32 byte encryption key here:
|
||||
keys = key_provider.someprovider.somename
|
||||
|
||||
# Leave the AAD empty unless needed. Pass as a list of bytes if needed:
|
||||
aad = [1,2,3,4,...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `keys` (*required*) | Encryption and decryption key in the standard output structure of the key providers (`{"encryption_key":[]byte, "decryption_key":[]byte}`). |
|
||||
| `aad` | Additional Authenticated Data. This data is stored along the encrypted form and authenticated. The AAD value of the encrypted form must match the configuration, otherwise the decryption fails. |
|
||||
|
||||
## Key exhaustion
|
||||
|
||||
AES-GCM keys have a limited lifetime of `2^32` blocks, equaling roughly 64 GB of data that can be encrypted before the keys should be considered compromised. The end-user documentation of this method should guide users to use either a key-derivation function, such as PBKDF2 or Argon2 with a sufficiently long passphrase, or a key management system that can automatically rotate the keys.
|
||||
|
||||
## Encryption vs. Authentication
|
||||
|
||||
The AES-GCM implementation protects data at rest from being accessed. It does not, however, protect against malicious actors reusing old data (replay attacks) to compromise the integrity of the system. Users with the need for payload authentication should rotate their key and/or AAD frequently to ensure that old data cannot be used in this manner.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
### Additional Authenticated Data (AAD)
|
||||
|
||||
The AAD in AES-GCM is a general-purpose authenticated, but not encrypted field in the encrypted payload. The Go implementation only supports using this field as a canary value, rejecting decryption if the value mismatches. AES-GCM would support using this field as a means to store data. Since Go does not support it, neither do we.
|
||||
|
||||
### Panics
|
||||
|
||||
The current Go implementation of AES-GCM uses `panic()` to handle some input errors.
|
@ -9,53 +9,114 @@ import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/method"
|
||||
)
|
||||
|
||||
// aesgcm contains the encryption/decryption methods according to AES-GCM (NIST SP 800-38D).
|
||||
type aesgcm struct {
|
||||
key []byte
|
||||
encryptionKey []byte
|
||||
decryptionKey []byte
|
||||
aad []byte
|
||||
}
|
||||
|
||||
// Inspired by https://bruinsslot.jp/post/golang-crypto/
|
||||
|
||||
// Encrypt encrypts the passed data with AES-GCM. If the data the encryption fails, it returns an error.
|
||||
func (a aesgcm) Encrypt(data []byte) ([]byte, error) {
|
||||
// TODO change this to the implementation in Stephan's PR.
|
||||
blockCipher, err := aes.NewCipher(a.key)
|
||||
result, err := handlePanic(
|
||||
func() ([]byte, error) {
|
||||
gcm, err := a.getGCM(a.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, &method.ErrEncryptionFailed{Cause: err}
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, &method.ErrEncryptionFailed{Cause: &method.ErrCryptoFailure{
|
||||
Message: "could not generate nonce",
|
||||
Cause: err,
|
||||
}}
|
||||
}
|
||||
|
||||
encrypted := gcm.Seal(nil, nonce, data, a.aad)
|
||||
|
||||
return append(nonce, encrypted...), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var encryptionFailed *method.ErrEncryptionFailed
|
||||
if errors.As(err, &encryptionFailed) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, &method.ErrEncryptionFailed{Cause: &method.ErrCryptoFailure{Message: "unexpected error", Cause: err}}
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(blockCipher)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, data, nil)
|
||||
|
||||
return ciphertext, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts an AES-GCM-encrypted data set. If the data set fails decryption, it returns an error.
|
||||
func (a aesgcm) Decrypt(data []byte) ([]byte, error) {
|
||||
blockCipher, err := aes.NewCipher(a.key)
|
||||
result, err := handlePanic(
|
||||
func() ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, &method.ErrDecryptionFailed{
|
||||
Cause: method.ErrCryptoFailure{
|
||||
Message: "cannot decrypt empty data",
|
||||
Cause: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
gcm, err := a.getGCM(a.decryptionKey)
|
||||
if err != nil {
|
||||
return nil, &method.ErrDecryptionFailed{Cause: err}
|
||||
}
|
||||
|
||||
if len(data) < gcm.NonceSize() {
|
||||
return nil, &method.ErrDecryptionFailed{
|
||||
Cause: method.ErrCryptoFailure{
|
||||
Message: "cannot decrypt data because it is too small (likely data corruption)",
|
||||
Cause: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
nonce := data[:gcm.NonceSize()]
|
||||
data = data[gcm.NonceSize():]
|
||||
|
||||
decrypted, err := gcm.Open(nil, nonce, data, a.aad)
|
||||
if err != nil {
|
||||
return nil, &method.ErrDecryptionFailed{Cause: err}
|
||||
}
|
||||
return decrypted, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var decryptionFailed *method.ErrDecryptionFailed
|
||||
if errors.As(err, &decryptionFailed) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, &method.ErrDecryptionFailed{
|
||||
Cause: &method.ErrCryptoFailure{Message: "unexpected error", Cause: err},
|
||||
}
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(blockCipher)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a aesgcm) getGCM(key []byte) (cipher.AEAD, error) {
|
||||
cipherBlock, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, &method.ErrCryptoFailure{
|
||||
Message: "failed to create AES cypher block",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(cipherBlock)
|
||||
if err != nil {
|
||||
return nil, &method.ErrCryptoFailure{
|
||||
Message: "failed to create AES GCM",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
return gcm, nil
|
||||
}
|
||||
|
58
internal/encryption/method/aesgcm/aesgcm_internal_test.go
Normal file
58
internal/encryption/method/aesgcm/aesgcm_internal_test.go
Normal file
@ -0,0 +1,58 @@
|
||||
package aesgcm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testCase struct {
|
||||
aes *aesgcm
|
||||
error bool
|
||||
}
|
||||
|
||||
func TestInternalErrorHandling(t *testing.T) {
|
||||
testCases := map[string]testCase{
|
||||
"ok": {
|
||||
&aesgcm{
|
||||
encryptionKey: []byte("aeshi1quahb2Rua0ooquaiwahbonedoh"),
|
||||
decryptionKey: []byte("aeshi1quahb2Rua0ooquaiwahbonedoh"),
|
||||
},
|
||||
false,
|
||||
},
|
||||
"no-key": {
|
||||
&aesgcm{},
|
||||
true,
|
||||
},
|
||||
"bad-key-length": {
|
||||
&aesgcm{
|
||||
encryptionKey: []byte("Hello world!"),
|
||||
decryptionKey: []byte("Hello world!"),
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
encrypted, err := tc.aes.Encrypt([]byte("Hello world!"))
|
||||
if tc.error && err == nil {
|
||||
t.Fatalf("Expected error, none returned.")
|
||||
} else if !tc.error && err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if !tc.error {
|
||||
decrypted, err := tc.aes.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if string(decrypted) != "Hello world!" {
|
||||
t.Fatalf("Incorrect decrypted string: %s", decrypted)
|
||||
}
|
||||
} else {
|
||||
// Test error handling on the decrypt side as best as we can:
|
||||
_, err := tc.aes.Decrypt([]byte("Hello world!"))
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error, none returned.")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
93
internal/encryption/method/aesgcm/aesgcm_test.go
Normal file
93
internal/encryption/method/aesgcm/aesgcm_test.go
Normal file
@ -0,0 +1,93 @@
|
||||
package aesgcm_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/keyprovider"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/method"
|
||||
"github.com/opentofu/opentofu/internal/encryption/method/aesgcm"
|
||||
)
|
||||
|
||||
var config = &aesgcm.Config{
|
||||
Keys: keyprovider.Output{
|
||||
EncryptionKey: []byte("aeshi1quahb2Rua0ooquaiwahbonedoh"),
|
||||
DecryptionKey: []byte("aeshi1quahb2Rua0ooquaiwahbonedoh"),
|
||||
},
|
||||
}
|
||||
|
||||
func TestDecryptEmptyData(t *testing.T) {
|
||||
m, err := config.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (%v)", err)
|
||||
}
|
||||
|
||||
_, err = m.Decrypt(nil)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error, none returned.")
|
||||
}
|
||||
|
||||
var e *method.ErrDecryptionFailed
|
||||
if !errors.As(err, &e) {
|
||||
t.Fatalf("Incorrect error type returned: %T (%v)", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptShortData(t *testing.T) {
|
||||
m, err := config.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (%v)", err)
|
||||
}
|
||||
|
||||
// Passing a non-empty, but shorter-than-nonce data
|
||||
_, err = m.Decrypt([]byte("1"))
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error, none returned.")
|
||||
}
|
||||
|
||||
var e *method.ErrDecryptionFailed
|
||||
if !errors.As(err, &e) {
|
||||
t.Fatalf("Incorrect error type returned: %T (%v)", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidData(t *testing.T) {
|
||||
m, err := config.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (%v)", err)
|
||||
}
|
||||
|
||||
// Passing a non-empty, but shorter-than-nonce data
|
||||
_, err = m.Decrypt([]byte("abcdefghijklmnopqrstuvwxyz"))
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error, none returned.")
|
||||
}
|
||||
|
||||
var e *method.ErrDecryptionFailed
|
||||
if !errors.As(err, &e) {
|
||||
t.Fatalf("Incorrect error type returned: %T (%v)", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptCorruptData(t *testing.T) {
|
||||
m, err := config.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (%v)", err)
|
||||
}
|
||||
|
||||
encrypted, err := m.Encrypt([]byte("Hello world!"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (%v)", err)
|
||||
}
|
||||
|
||||
encrypted = encrypted[:len(encrypted)-1]
|
||||
decrypted, err := m.Decrypt(encrypted)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error, got: %v", decrypted)
|
||||
}
|
||||
var e *method.ErrDecryptionFailed
|
||||
if !errors.As(err, &e) {
|
||||
t.Fatalf("Incorrect error type returned: %T (%v)", err, err)
|
||||
}
|
||||
}
|
@ -8,18 +8,62 @@ package aesgcm
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/keyprovider"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/collections"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/method"
|
||||
)
|
||||
|
||||
// validKeyLengths holds the valid key lengths supported by this method.
|
||||
var validKeyLengths = collections.NewSet[int](16, 24, 32)
|
||||
|
||||
// Config is the configuration for the AES-GCM method.
|
||||
type Config struct {
|
||||
Key []byte `hcl:"cipher"`
|
||||
// Key is the encryption key for the AES-GCM encryption. It has to be 16, 24, or 32 bytes long for AES-128, 192, or
|
||||
// 256, respectively.
|
||||
Keys keyprovider.Output `hcl:"keys" json:"keys" yaml:"keys"`
|
||||
|
||||
// AAD is the Additional Authenticated Data that is authenticated, but not encrypted. In the Go implementation, this
|
||||
// data serves as a canary value against replay attacks. The AAD value on decryption must match this setting,
|
||||
// otherwise the decryption will fail. (Note: this is Go-specific and differs from the NIST SP 800-38D description
|
||||
// of the AAD.)
|
||||
AAD []byte `hcl:"aad,optional" json:"aad,omitempty" yaml:"aad,omitempty"`
|
||||
}
|
||||
|
||||
func (c Config) Build() (method.Method, error) {
|
||||
if len(c.Key) != 32 {
|
||||
return nil, fmt.Errorf("AES-GCM requires a 32-byte key")
|
||||
// Build checks the validity of the configuration and returns a ready-to-use AES-GCM implementation.
|
||||
func (c *Config) Build() (method.Method, error) {
|
||||
encryptionKey := c.Keys.EncryptionKey
|
||||
decryptionKey := c.Keys.DecryptionKey
|
||||
|
||||
if len(decryptionKey) == 0 {
|
||||
// Use encryption key as decryption key if missing
|
||||
decryptionKey = encryptionKey
|
||||
}
|
||||
|
||||
if !validKeyLengths.Has(len(encryptionKey)) {
|
||||
return nil, &method.ErrInvalidConfiguration{
|
||||
Cause: fmt.Errorf(
|
||||
"AES-GCM requires the key length to be one of: %s, received %d bytes in the encryption key",
|
||||
validKeyLengths.String(),
|
||||
len(encryptionKey),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if !validKeyLengths.Has(len(decryptionKey)) {
|
||||
return nil, &method.ErrInvalidConfiguration{
|
||||
Cause: fmt.Errorf(
|
||||
"AES-GCM requires the key length to be one of: %s, received %d bytes in the decryption key",
|
||||
validKeyLengths.String(),
|
||||
len(decryptionKey),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return &aesgcm{
|
||||
c.Key,
|
||||
encryptionKey,
|
||||
decryptionKey,
|
||||
c.AAD,
|
||||
}, nil
|
||||
}
|
||||
|
149
internal/encryption/method/aesgcm/config_test.go
Normal file
149
internal/encryption/method/aesgcm/config_test.go
Normal file
@ -0,0 +1,149 @@
|
||||
package aesgcm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/keyprovider"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/method"
|
||||
)
|
||||
|
||||
func TestConfig_Build(t *testing.T) {
|
||||
var testCases = []struct {
|
||||
name string
|
||||
config *Config
|
||||
errorType any
|
||||
expected aesgcm
|
||||
}{
|
||||
{
|
||||
name: "key-32-bytes",
|
||||
config: &Config{
|
||||
Keys: keyprovider.Output{
|
||||
EncryptionKey: []byte("bohwu9zoo7Zool5olaileef1eibeathe"),
|
||||
DecryptionKey: []byte("bohwu9zoo7Zool5olaileef1eibeathd"),
|
||||
},
|
||||
},
|
||||
errorType: nil,
|
||||
expected: aesgcm{
|
||||
encryptionKey: []byte("bohwu9zoo7Zool5olaileef1eibeathe"),
|
||||
decryptionKey: []byte("bohwu9zoo7Zool5olaileef1eibeathd"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key-24-bytes",
|
||||
config: &Config{
|
||||
Keys: keyprovider.Output{
|
||||
EncryptionKey: []byte("bohwu9zoo7Zool5olaileefe"),
|
||||
DecryptionKey: []byte("bohwu9zoo7Zool5olaileefd"),
|
||||
},
|
||||
},
|
||||
errorType: nil,
|
||||
expected: aesgcm{
|
||||
encryptionKey: []byte("bohwu9zoo7Zool5olaileefe"),
|
||||
decryptionKey: []byte("bohwu9zoo7Zool5olaileefd"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key-16-bytes",
|
||||
config: &Config{
|
||||
Keys: keyprovider.Output{
|
||||
EncryptionKey: []byte("bohwu9zoo7Zool5e"),
|
||||
DecryptionKey: []byte("bohwu9zoo7Zool5d"),
|
||||
},
|
||||
},
|
||||
errorType: nil,
|
||||
expected: aesgcm{
|
||||
encryptionKey: []byte("bohwu9zoo7Zool5e"),
|
||||
decryptionKey: []byte("bohwu9zoo7Zool5d"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-key",
|
||||
config: &Config{},
|
||||
errorType: &method.ErrInvalidConfiguration{},
|
||||
},
|
||||
{
|
||||
name: "encryption-key-15-bytes",
|
||||
config: &Config{
|
||||
Keys: keyprovider.Output{
|
||||
EncryptionKey: []byte("bohwu9zoo7Ze15"),
|
||||
DecryptionKey: []byte("bohwu9zoo7Zod16"),
|
||||
},
|
||||
},
|
||||
errorType: &method.ErrInvalidConfiguration{},
|
||||
},
|
||||
{
|
||||
name: "decryption-key-15-bytes",
|
||||
config: &Config{
|
||||
Keys: keyprovider.Output{
|
||||
EncryptionKey: []byte("bohwu9zoo7Zooe16"),
|
||||
DecryptionKey: []byte("bohwu9zoo7Zod15"),
|
||||
},
|
||||
},
|
||||
errorType: &method.ErrInvalidConfiguration{},
|
||||
},
|
||||
{
|
||||
name: "decryption-key-fallback",
|
||||
config: &Config{
|
||||
Keys: keyprovider.Output{
|
||||
EncryptionKey: []byte("bohwu9zoo7Zooe16"),
|
||||
},
|
||||
},
|
||||
errorType: nil,
|
||||
expected: aesgcm{
|
||||
encryptionKey: []byte("bohwu9zoo7Zooe16"),
|
||||
decryptionKey: []byte("bohwu9zoo7Zooe16"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "aad",
|
||||
config: &Config{
|
||||
Keys: keyprovider.Output{
|
||||
EncryptionKey: []byte("bohwu9zoo7Zool5olaileef1eibeathe"),
|
||||
DecryptionKey: []byte("bohwu9zoo7Zool5olaileef1eibeathd"),
|
||||
},
|
||||
AAD: []byte("foobar"),
|
||||
},
|
||||
expected: aesgcm{
|
||||
encryptionKey: []byte("bohwu9zoo7Zool5olaileef1eibeathe"),
|
||||
decryptionKey: []byte("bohwu9zoo7Zool5olaileef1eibeathd"),
|
||||
aad: []byte("foobar"),
|
||||
},
|
||||
errorType: nil,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
built, err := tc.config.Build()
|
||||
if tc.errorType == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error returned: %v", err)
|
||||
}
|
||||
|
||||
built := built.(*aesgcm)
|
||||
|
||||
if !bytes.Equal(tc.expected.encryptionKey, built.encryptionKey) {
|
||||
t.Fatalf("Incorrect encryption key built: %v != %v", tc.expected.encryptionKey, built.encryptionKey)
|
||||
}
|
||||
if !bytes.Equal(tc.expected.decryptionKey, built.decryptionKey) {
|
||||
t.Fatalf("Incorrect decryption key built: %v != %v", tc.expected.decryptionKey, built.decryptionKey)
|
||||
}
|
||||
if !bytes.Equal(tc.expected.aad, built.aad) {
|
||||
t.Fatalf("Incorrect aad built: %v != %v", tc.expected.aad, built.aad)
|
||||
}
|
||||
|
||||
} else if tc.errorType != nil {
|
||||
if err == nil {
|
||||
t.Fatal("Expected error, none received")
|
||||
}
|
||||
if !errors.As(err, &tc.errorType) {
|
||||
t.Fatalf("Incorrect error type received: %T", err)
|
||||
}
|
||||
t.Logf("Correct error of type %T received: %v", err, err)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
@ -5,20 +5,38 @@
|
||||
|
||||
package aesgcm
|
||||
|
||||
import "github.com/opentofu/opentofu/internal/encryption/method"
|
||||
import (
|
||||
"github.com/opentofu/opentofu/internal/encryption/keyprovider"
|
||||
"github.com/opentofu/opentofu/internal/encryption/method"
|
||||
)
|
||||
|
||||
// Descriptor integrates the method.Descriptor and provides a TypedConfig for easier configuration.
|
||||
type Descriptor interface {
|
||||
method.Descriptor
|
||||
|
||||
// TypedConfig returns a config typed for this method.
|
||||
TypedConfig() *Config
|
||||
}
|
||||
|
||||
// New creates a new descriptor for the AES-GCM encryption method, which requires a 32-byte key.
|
||||
func New() method.Descriptor {
|
||||
func New() Descriptor {
|
||||
return &descriptor{}
|
||||
}
|
||||
|
||||
type descriptor struct {
|
||||
}
|
||||
|
||||
func (f *descriptor) TypedConfig() *Config {
|
||||
return &Config{
|
||||
Keys: keyprovider.Output{},
|
||||
AAD: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *descriptor) ID() method.ID {
|
||||
return "aes_gcm"
|
||||
}
|
||||
|
||||
func (f *descriptor) ConfigStruct() method.Config {
|
||||
return &Config{}
|
||||
return f.TypedConfig()
|
||||
}
|
||||
|
13
internal/encryption/method/aesgcm/descriptor_test.go
Normal file
13
internal/encryption/method/aesgcm/descriptor_test.go
Normal file
@ -0,0 +1,13 @@
|
||||
package aesgcm_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/method/aesgcm"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
if id := aesgcm.New().ID(); id != "aes_gcm" {
|
||||
t.Fatalf("Incorrect descriptor ID returned: %s", id)
|
||||
}
|
||||
}
|
175
internal/encryption/method/aesgcm/example_test.go
Normal file
175
internal/encryption/method/aesgcm/example_test.go
Normal file
@ -0,0 +1,175 @@
|
||||
package aesgcm_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/gohcl"
|
||||
"github.com/hashicorp/hcl/v2/hclsyntax"
|
||||
"github.com/opentofu/opentofu/internal/encryption/keyprovider"
|
||||
"github.com/opentofu/opentofu/internal/encryption/method/aesgcm"
|
||||
)
|
||||
|
||||
func Example() {
|
||||
descriptor := aesgcm.New()
|
||||
|
||||
// Get the config struct. You can fill it manually by type-asserting it to aesgcm.Config, but you could also use
|
||||
// it as JSON.
|
||||
config := descriptor.ConfigStruct()
|
||||
|
||||
if err := json.Unmarshal(
|
||||
// Set up a randomly generated 32-byte key. In JSON, you can base64-encode the value.
|
||||
[]byte(`{
|
||||
"keys": {
|
||||
"encryption_key": "Y29veTRhaXZ1NWFpeW9vMWlhMG9vR29vVGFlM1BhaTQ=",
|
||||
"decryption_key": "Y29veTRhaXZ1NWFpeW9vMWlhMG9vR29vVGFlM1BhaTQ="
|
||||
}
|
||||
}`), &config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
method, err := config.Build()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Encrypt some data:
|
||||
encrypted, err := method.Encrypt([]byte("Hello world!"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Now decrypt it:
|
||||
decrypted, err := method.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s", decrypted)
|
||||
// Output: Hello world!
|
||||
}
|
||||
|
||||
func Example_config() {
|
||||
// First, get the descriptor to make sure we always have the default values.
|
||||
descriptor := aesgcm.New()
|
||||
|
||||
// Obtain a modifiable, buildable config. Alternatively, you can also use ConfigStruct() method to obtain a
|
||||
// struct you can fill with HCL or JSON tags.
|
||||
config := descriptor.TypedConfig()
|
||||
|
||||
// Set up an encryption key:
|
||||
config.Keys = keyprovider.Output{
|
||||
EncryptionKey: []byte("AiphoogheuwohShal8Aefohy7ooLeeyu"),
|
||||
DecryptionKey: []byte("AiphoogheuwohShal8Aefohy7ooLeeyu"),
|
||||
}
|
||||
|
||||
// Now you can build a method:
|
||||
method, err := config.Build()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Encrypt something:
|
||||
encrypted, err := method.Encrypt([]byte("Hello world!"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Decrypt it:
|
||||
decrypted, err := method.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s", decrypted)
|
||||
// Output: Hello world!
|
||||
}
|
||||
|
||||
func Example_config_json() {
|
||||
// First, get the descriptor to make sure we always have the default values.
|
||||
descriptor := aesgcm.New()
|
||||
|
||||
// Get an untyped config struct you can use for JSON unmarshalling:
|
||||
config := descriptor.ConfigStruct()
|
||||
|
||||
// Unmarshal JSON into the config struct:
|
||||
if err := json.Unmarshal(
|
||||
// Set up a randomly generated 32-byte key. In JSON, you can base64-encode the value.
|
||||
[]byte(`{
|
||||
"keys": {
|
||||
"encryption_key": "Y29veTRhaXZ1NWFpeW9vMWlhMG9vR29vVGFlM1BhaTQ=",
|
||||
"decryption_key": "Y29veTRhaXZ1NWFpeW9vMWlhMG9vR29vVGFlM1BhaTQ="
|
||||
}
|
||||
}`), &config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Now you can build a method:
|
||||
method, err := config.Build()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Encrypt something:
|
||||
encrypted, err := method.Encrypt([]byte("Hello world!"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Decrypt it:
|
||||
decrypted, err := method.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s", decrypted)
|
||||
// Output: Hello world!
|
||||
}
|
||||
|
||||
func Example_config_hcl() {
|
||||
// First, get the descriptor to make sure we always have the default values.
|
||||
descriptor := aesgcm.New()
|
||||
|
||||
// Get an untyped config struct you can use for HCL unmarshalling:
|
||||
config := descriptor.ConfigStruct()
|
||||
|
||||
// Unmarshal HCL code into the config struct. The input must be a list of bytes, so in a real world scenario
|
||||
// you may want to put in a hex-decoding function:
|
||||
rawHCLInput := `keys = {
|
||||
encryption_key = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],
|
||||
decryption_key = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32]
|
||||
}`
|
||||
file, diags := hclsyntax.ParseConfig(
|
||||
[]byte(rawHCLInput),
|
||||
"example.hcl",
|
||||
hcl.Pos{Byte: 0, Line: 1, Column: 1},
|
||||
)
|
||||
if diags.HasErrors() {
|
||||
panic(diags)
|
||||
}
|
||||
if diags := gohcl.DecodeBody(file.Body, nil, config); diags.HasErrors() {
|
||||
panic(diags)
|
||||
}
|
||||
|
||||
// Now you can build a method:
|
||||
method, err := config.Build()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Encrypt something:
|
||||
encrypted, err := method.Encrypt([]byte("Hello world!"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Decrypt it:
|
||||
decrypted, err := method.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s", decrypted)
|
||||
// Output: Hello world!
|
||||
}
|
26
internal/encryption/method/aesgcm/panic.go
Normal file
26
internal/encryption/method/aesgcm/panic.go
Normal file
@ -0,0 +1,26 @@
|
||||
package aesgcm
|
||||
|
||||
import "fmt"
|
||||
|
||||
// handlePanic runs the specified function and returns its result value or returned error. If a panic occurs, it returns the
|
||||
// panic as an error.
|
||||
func handlePanic(f func() ([]byte, error)) (result []byte, err error) {
|
||||
result, e := func() ([]byte, error) {
|
||||
defer func() {
|
||||
var ok bool
|
||||
e := recover()
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if err, ok = e.(error); !ok {
|
||||
// In case the panic is not an error
|
||||
err = fmt.Errorf("%v", e)
|
||||
}
|
||||
}()
|
||||
return f()
|
||||
}()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, e
|
||||
}
|
13
internal/encryption/method/aesgcm/panic_test.go
Normal file
13
internal/encryption/method/aesgcm/panic_test.go
Normal file
@ -0,0 +1,13 @@
|
||||
package aesgcm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func Example_handlePanic() {
|
||||
_, err := handlePanic(func() ([]byte, error) {
|
||||
panic("Hello world!")
|
||||
})
|
||||
fmt.Printf("%v", err)
|
||||
// Output: Hello world!
|
||||
}
|
69
internal/encryption/method/errors.go
Normal file
69
internal/encryption/method/errors.go
Normal file
@ -0,0 +1,69 @@
|
||||
package method
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ErrCryptoFailure indicates a generic cryptographic failure. This error should be embedded into
|
||||
// ErrEncryptionFailed, ErrDecryptionFailed, or ErrInvalidConfiguration.
|
||||
type ErrCryptoFailure struct {
|
||||
Message string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e ErrCryptoFailure) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s (%v)", e.Message, e.Cause)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e ErrCryptoFailure) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// ErrEncryptionFailed indicates that encrypting a set of data failed.
|
||||
type ErrEncryptionFailed struct {
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e ErrEncryptionFailed) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("encryption failed (%v)", e.Cause)
|
||||
}
|
||||
return "encryption failed"
|
||||
}
|
||||
|
||||
func (e ErrEncryptionFailed) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// ErrDecryptionFailed indicates that decrypting a set of data failed.
|
||||
type ErrDecryptionFailed struct {
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e ErrDecryptionFailed) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("decryption failed (%v)", e.Cause)
|
||||
}
|
||||
return "decryption failed"
|
||||
}
|
||||
|
||||
func (e ErrDecryptionFailed) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// ErrInvalidConfiguration indicates that the method configuration is incorrect.
|
||||
type ErrInvalidConfiguration struct {
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e ErrInvalidConfiguration) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("invalid method configuration (%v)", e.Cause)
|
||||
}
|
||||
return "invalid method configuration"
|
||||
}
|
||||
|
||||
func (e ErrInvalidConfiguration) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
@ -5,8 +5,18 @@
|
||||
|
||||
package method
|
||||
|
||||
// Config describes a configuration struct for setting up an encryption Method. You should always implement this
|
||||
// interface with a struct, and you should tag the fields with HCL tags so the encryption implementation can read
|
||||
// the .tf code into it. For example:
|
||||
//
|
||||
// type MyConfig struct {
|
||||
// Key string `hcl:"key"`
|
||||
// }
|
||||
//
|
||||
// func (m MyConfig) Build() (Method, error) { ... }
|
||||
type Config interface {
|
||||
// Build takes the configuration and builds an encryption method.
|
||||
// TODO this may be better changed to return hcl.Diagnostics so warnings can be issued?
|
||||
Build() (Method, error)
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,7 @@ type targetBuilder struct {
|
||||
// Used to evaluate hcl expressions
|
||||
ctx *hcl.EvalContext
|
||||
|
||||
keyProviderMetadata map[keyprovider.Addr][]byte
|
||||
keyProviderMetadata map[keyprovider.Addr]any
|
||||
|
||||
// Used to build EvalContext (and related mappings)
|
||||
keyValues map[string]map[string]cty.Value
|
||||
@ -32,7 +32,7 @@ type targetBuilder struct {
|
||||
methods map[method.Addr]method.Method
|
||||
}
|
||||
|
||||
func (base *baseEncryption) buildTargetMethods(meta map[keyprovider.Addr][]byte) ([]method.Method, hcl.Diagnostics) {
|
||||
func (base *baseEncryption) buildTargetMethods(meta map[keyprovider.Addr]any) ([]method.Method, hcl.Diagnostics) {
|
||||
var diags hcl.Diagnostics
|
||||
|
||||
builder := &targetBuilder{
|
||||
|
116
website/docs/language/state/encryption.mdx
Normal file
116
website/docs/language/state/encryption.mdx
Normal file
@ -0,0 +1,116 @@
|
||||
---
|
||||
description: >-
|
||||
Encrypt your state-related data at rest.
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import ConfigurationTF from '!!raw-loader!./examples/encryption/configuration.tf'
|
||||
import ConfigurationSH from '!!raw-loader!./examples/encryption/configuration.sh'
|
||||
import ConfigurationPS1 from '!!raw-loader!./examples/encryption/configuration.ps1'
|
||||
import Enforce from '!!raw-loader!./examples/encryption/enforce.tf'
|
||||
import AESGCM from '!!raw-loader!./examples/encryption/aes_gcm.tf'
|
||||
import Fallback from '!!raw-loader!./examples/encryption/fallback.tf'
|
||||
import RemoteState from '!!raw-loader!./examples/encryption/terraform_remote_state.tf'
|
||||
|
||||
# State and Plan Encryption
|
||||
|
||||
OpenTofu supports encrypting state and plan files at rest, both for local storage and when using a backend. In addition, you can also use encryption with the `terraform_remote_state` data source. This page explains how to set up encryption and what encryption method is suitable for which use case.
|
||||
|
||||
## General guidance and pitfalls (please read)
|
||||
|
||||
When you enable encryption, your state and plan files become unrecoverable without the appropriate encryption key. Please make sure you read this section carefully before enabling encryption.
|
||||
|
||||
### What does encryption protect against?
|
||||
|
||||
When you enable encryption, OpenTofu will encrypt state data *at rest*. If an attacker were to gain access to your state file, they should not be able to read it and use the sensitive values (e.g. access keys) contained in the state file.
|
||||
|
||||
However, encryption does not protect against data loss (your state file getting damaged) and it also does not protect against replay attack (an attacker using an older state or plan file and tricking you into running it). Additionally, OpenTofu does not and cannot protect the sensitive values in the state file from the person running the `tofu` command.
|
||||
|
||||
### What precautions do I need to take?
|
||||
|
||||
When you enable encryption, consider who needs access to your state file directly. If you have more than a very small number of people with access needs, you may want to consider running your production `plan` and `apply` runs from a continuous integration system to protect both the encryption key and the sensitive values in your state.
|
||||
|
||||
You will also need to decide what kind of key you would like to use based on your security requirements. You can either opt for a static passphrase or you can choose a key management system. If you opt for a key management system, it is imperative to configure automatic key rotation for some encryption methods. This is particularly crucial if the encryption algorithm you choose has the potential to reach a point of 'key saturation', where the maximum safe usage limit of the key is approached, such as AES-GCM. You can find more information about this in the [encryption methods](#encryption-methods) section below.
|
||||
|
||||
Finally, before enabling encryption, please exercise your disaster recovery plan and make a temporary backup of your unencrypted state file. You should also have backups of your keys. Once you enable encryption, OpenTofu cannot read your state file without the correct key.
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure encryption in OpenTofu either by specifying the configuration in the OpenTofu code, or using the `TF_ENCRYPTION` environment variable. Both solutions are equivalent and if you use both, OpenTofu will merge the two configurations, overriding any code-based settings with the environment ones.
|
||||
|
||||
The basic configuration structure looks as follows:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="code" label="Code" default>
|
||||
<CodeBlock language={"hcl"}>{ConfigurationTF}</CodeBlock>
|
||||
</TabItem>
|
||||
<TabItem value="env-sh" label="Environment (Linux/UNIX shell)">
|
||||
<CodeBlock language={"shell"}>{ConfigurationSH}</CodeBlock>
|
||||
</TabItem>
|
||||
<TabItem value="env-ps1" label="Environment (Powershell)">
|
||||
<CodeBlock language={"powershell"}>{ConfigurationPS1}</CodeBlock>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::warning
|
||||
|
||||
Once your data is encrypted, you should not rename key providers and methods in your configuration! The encrypted data stored in the backend contains metadata related to their specific names. Instead you should use a [fallback block](#key-and-method-rollover) to handled changes to key providers.
|
||||
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
You can use the [JSON configuration syntax](/docs/language/syntax/json/) instead of HCL for encryption configuration.
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
If you use environment configuration, you can include the following code configuration to prevent unencrypted data from being written in the absence of an environment variable:
|
||||
|
||||
<CodeBlock language="hcl">{Enforce}</CodeBlock>
|
||||
|
||||
:::
|
||||
|
||||
## Key providers
|
||||
|
||||
There are currently no supported key providers.
|
||||
|
||||
## Methods
|
||||
|
||||
### AES-GCM
|
||||
|
||||
The only currently supported encryption method is AES-GCM. You can configure it in the following way:
|
||||
|
||||
<CodeBlock language="hcl">{AESGCM}</CodeBlock>
|
||||
|
||||
:::warning
|
||||
|
||||
AES-GCM is a secure, industry-standard encryption algorithm, but suffers from "key saturation". In order to configure a secure setup, you should either use a key-derivation key provider (such as PBKDF2) with a long and complex passphrase, or use a key management system that automatically rotates keys regularly. Using short, static keys will degrade your encryption.
|
||||
|
||||
:::
|
||||
|
||||
## Key and method rollover
|
||||
|
||||
In some cases, you may want to change your encryption configuration. This can include renaming a key provider or method, changing a passphrase for a key provider, or switching key-management systems. OpenTofu supports an automatic rollover of your encryption configuration if you provide your old configuration in a `fallback` block:
|
||||
|
||||
<CodeBlock language="hcl">{Fallback}</CodeBlock>
|
||||
|
||||
If OpenTofu fails to **read** your state or plan file with the new method, it will automatically try the fallback method. When OpenTofu **saves** your state or plan file, it will always use the new method and not the fallback.
|
||||
|
||||
|
||||
## Remote state data sources
|
||||
|
||||
You can also configure an encryption setup for projects using the `terraform_remote_state` data source. This can be the same encryption setup as your main configuration, but you can also define a separate set of keys and methods. The configuration syntax looks as follows:
|
||||
|
||||
|
||||
<CodeBlock language="hcl">{RemoteState}</CodeBlock>
|
||||
|
||||
For specific remote states, you can use the following syntax:
|
||||
|
||||
- `myname` to target a data source in the main project with the given name.
|
||||
- `mymodule.myname` to target a data source in the specified module with the given name.
|
||||
- `mymodule.myname[0]` to target the first data source in the specified module with the given name.
|
@ -0,0 +1,9 @@
|
||||
terraform {
|
||||
encryption {
|
||||
# Key provider configuration here
|
||||
|
||||
method "aes_gcm" "yourname" {
|
||||
keys = key_provider.yourkeyprovider.yourname
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
$Env:TF_ENCRYPTION = @"
|
||||
terraform {
|
||||
encryption {
|
||||
key_provider "some_key_provider" "some_name" {
|
||||
# Key provider options here
|
||||
}
|
||||
|
||||
method "some_method" "some_method_name" {
|
||||
# Method options here
|
||||
keys = key_provider.some_key_provider.some_name
|
||||
}
|
||||
|
||||
statefile {
|
||||
# Encryption/decryption for local state files
|
||||
method = method.some_method.some_method_name
|
||||
}
|
||||
|
||||
planfile {
|
||||
# Encryption/decryption for local plan files
|
||||
method = method.some_method.some_method_name
|
||||
}
|
||||
|
||||
backend {
|
||||
# Encryption/decryption method for backends
|
||||
method = method.some_method.some_method_name
|
||||
}
|
||||
|
||||
remote {
|
||||
# See below
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
@ -0,0 +1,33 @@
|
||||
read -d '' TF_ENCRYPTION << EOF
|
||||
terraform {
|
||||
encryption {
|
||||
key_provider "some_key_provider" "some_name" {
|
||||
# Key provider options here
|
||||
}
|
||||
|
||||
method "some_method" "some_method_name" {
|
||||
# Method options here
|
||||
keys = key_provider.some_key_provider.some_name
|
||||
}
|
||||
|
||||
statefile {
|
||||
# Encryption/decryption for local state files
|
||||
method = method.some_method.some_method_name
|
||||
}
|
||||
|
||||
planfile {
|
||||
# Encryption/decryption for local plan files
|
||||
method = method.some_method.some_method_name
|
||||
}
|
||||
|
||||
backend {
|
||||
# Encryption/decryption method for backends
|
||||
method = method.some_method.some_method_name
|
||||
}
|
||||
|
||||
remote {
|
||||
# See below
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
@ -0,0 +1,31 @@
|
||||
terraform {
|
||||
encryption {
|
||||
key_provider "some_key_provider" "some_name" {
|
||||
# Key provider options here
|
||||
}
|
||||
|
||||
method "some_method" "some_method_name" {
|
||||
# Method options here
|
||||
keys = key_provider.some_key_provider.some_name
|
||||
}
|
||||
|
||||
statefile {
|
||||
# Encryption/decryption for local state files
|
||||
method = method.some_method.some_method_name
|
||||
}
|
||||
|
||||
planfile {
|
||||
# Encryption/decryption for local plan files
|
||||
method = method.some_method.some_method_name
|
||||
}
|
||||
|
||||
backend {
|
||||
# Encryption/decryption method for backends
|
||||
method = method.some_method.some_method_name
|
||||
}
|
||||
|
||||
remote {
|
||||
# See below
|
||||
}
|
||||
}
|
||||
}
|
13
website/docs/language/state/examples/encryption/enforce.tf
Normal file
13
website/docs/language/state/examples/encryption/enforce.tf
Normal file
@ -0,0 +1,13 @@
|
||||
terraform {
|
||||
encryption {
|
||||
statefile {
|
||||
enforce = true
|
||||
}
|
||||
planfile {
|
||||
enforce = true
|
||||
}
|
||||
backend {
|
||||
enforce = true
|
||||
}
|
||||
}
|
||||
}
|
26
website/docs/language/state/examples/encryption/fallback.tf
Normal file
26
website/docs/language/state/examples/encryption/fallback.tf
Normal file
@ -0,0 +1,26 @@
|
||||
terraform {
|
||||
encryption {
|
||||
# Methods and key providers here.
|
||||
|
||||
statefile {
|
||||
method = method.some_method.new_method
|
||||
fallback {
|
||||
method = method.some_method.old_method
|
||||
}
|
||||
}
|
||||
|
||||
planfile {
|
||||
method = method.some_method.new_method
|
||||
fallback {
|
||||
method = method.some_method.old_method
|
||||
}
|
||||
}
|
||||
|
||||
backend {
|
||||
method = method.some_method.new_method
|
||||
fallback {
|
||||
method = method.some_method.old_method
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
terraform {
|
||||
encryption {
|
||||
# Key provider and method configuration here
|
||||
|
||||
remote {
|
||||
default {
|
||||
method = method.my_method.my_name
|
||||
}
|
||||
terraform_remote_state "my_state" {
|
||||
method = method.my_method.my_other_name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "my_state" {
|
||||
# ...
|
||||
}
|
Loading…
Reference in New Issue
Block a user