Fixes #1169: AES-GCM implementation (#1291)

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:
Janos 2024-03-07 11:24:37 +01:00 committed by GitHub
parent 4482ce9226
commit fa638907f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1345 additions and 82 deletions

View File

@ -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

View 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, ", ")
}

View 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)
}
}

View File

@ -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,
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View 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)
}

View File

@ -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
}

View File

@ -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 {

View 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.

View File

@ -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
}

View 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.")
}
}
})
}
}

View 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)
}
}

View File

@ -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
}

View 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)
}
})
}
}

View File

@ -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()
}

View 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)
}
}

View 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!
}

View 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
}

View 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!
}

View 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
}

View File

@ -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)
}

View File

@ -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{

View 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.

View File

@ -0,0 +1,9 @@
terraform {
encryption {
# Key provider configuration here
method "aes_gcm" "yourname" {
keys = key_provider.yourkeyprovider.yourname
}
}
}

View File

@ -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
}
}
}
"@

View File

@ -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

View File

@ -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
}
}
}

View File

@ -0,0 +1,13 @@
terraform {
encryption {
statefile {
enforce = true
}
planfile {
enforce = true
}
backend {
enforce = true
}
}
}

View 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
}
}
}
}

View File

@ -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" {
# ...
}