Add unencrypted Method for migrations (#1458)

Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
This commit is contained in:
Christian Mesh 2024-04-12 09:38:21 -04:00 committed by GitHub
parent b868012192
commit d7e96665f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 131 additions and 74 deletions

View File

@ -143,7 +143,7 @@ func TestEncryptionFlow(t *testing.T) {
with("required.tf", func() {
// Can't switch directly to encryption, need to migrate
apply().Failure().StderrContains("decrypted payload provided without fallback specified")
apply().Failure().StderrContains("encountered unencrypted payload without unencrypted method")
requireUnencryptedState()
})
@ -177,7 +177,7 @@ func TestEncryptionFlow(t *testing.T) {
requireEncryptedState()
// Can't apply unencrypted plan
applyPlan(unencryptedPlan).Failure().StderrContains("decrypted payload provided without fallback specified")
applyPlan(unencryptedPlan).Failure().StderrContains("encountered unencrypted payload without unencrypted method")
requireEncryptedState()
// Apply encrypted plan

View File

@ -10,12 +10,15 @@ terraform {
method "aes_gcm" "example" {
keys = key_provider.pbkdf2.basic
}
method "unencrypted" "fallback" {}
state {
method = method.unencrypted.fallback
fallback {
method = method.aes_gcm.example
}
}
plan {
method = method.unencrypted.fallback
fallback {
method = method.aes_gcm.example
}

View File

@ -10,13 +10,18 @@ terraform {
method "aes_gcm" "example" {
keys = key_provider.pbkdf2.basic
}
method "unencrypted" "fallback" {}
state {
method = method.aes_gcm.example
fallback {}
fallback {
method = method.unencrypted.fallback
}
}
plan {
method = method.aes_gcm.example
fallback {}
fallback {
method = method.unencrypted.fallback
}
}
}
}

View File

@ -12,6 +12,7 @@ import (
"github.com/opentofu/opentofu/internal/encryption/config"
"github.com/opentofu/opentofu/internal/encryption/keyprovider"
"github.com/opentofu/opentofu/internal/encryption/method"
"github.com/opentofu/opentofu/internal/encryption/method/unencrypted"
"github.com/hashicorp/hcl/v2"
)
@ -67,6 +68,7 @@ func newBaseEncryption(enc *encryption, target *config.TargetConfig, enforced bo
//
methods, diags := base.buildTargetMethods(base.encMeta)
base.encMethods = methods
return base, diags
}
@ -88,21 +90,14 @@ func IsEncryptionPayload(data []byte) (bool, error) {
}
func (s *baseEncryption) encrypt(data []byte, enhance func(basedata) interface{}) ([]byte, error) {
// No configuration provided, don't do anything
if s.target == nil {
return data, nil
}
// buildTargetMethods above guarantees that there will be at least one encryption method. They are not optional in the common target
// block, which is required to get to this code.
encryptor := s.encMethods[0]
var encryptor method.Method = nil
if len(s.encMethods) != 0 {
// Use the pre-configured encryption method
encryptor = s.encMethods[0]
}
if encryptor == nil {
if unencrypted.Is(encryptor) {
// ensure that the method is defined when Enforced is true
if s.enforced {
return nil, fmt.Errorf("encryption of %q is enforced, and therefore requires a method to be provided", s.name)
return nil, fmt.Errorf("unable to use unencrypted method for %q when enforced = true", s.name)
}
return data, nil
}
@ -127,10 +122,6 @@ func (s *baseEncryption) encrypt(data []byte, enhance func(basedata) interface{}
// TODO Find a way to make these errors actionable / clear
func (s *baseEncryption) decrypt(data []byte, validator func([]byte) error) ([]byte, error) {
if s.target == nil {
return data, nil
}
es := basedata{}
err := json.Unmarshal(data, &es)
@ -149,20 +140,16 @@ func (s *baseEncryption) decrypt(data []byte, validator func([]byte) error) ([]b
return nil, fmt.Errorf("unable to determine data structure during decryption: %w", verr)
}
methods, diags := s.buildTargetMethods(make(map[keyprovider.Addr][]byte))
if diags.HasErrors() {
// This cast to error here is safe as we know that at least one error exists
// This is also quite unlikely to happen as the constructor already has checked this code path
return nil, diags
}
// Yep, it's already decrypted
for _, method := range methods {
if method == nil {
// fallback allowed
for _, method := range s.encMethods {
if unencrypted.Is(method) {
if s.enforced {
return nil, fmt.Errorf("unable to use unencrypted method when enforced = true")
}
return data, nil
}
}
return data, fmt.Errorf("decrypted payload provided without fallback specified")
return nil, fmt.Errorf("encountered unencrypted payload without unencrypted method configured")
}
if es.Version != encryptionVersion {
@ -177,26 +164,10 @@ func (s *baseEncryption) decrypt(data []byte, validator func([]byte) error) ([]b
return nil, diags
}
if len(methods) == 0 {
err = validator(data)
if err != nil {
// TODO improve this error message
return nil, err
}
// No methods/fallbacks specified and data is valid payload
return data, nil
}
errs := make([]error, 0)
for _, method := range methods {
if method == nil {
// No method specified for this target
err = validator(data)
if err == nil {
return data, nil
}
// TODO improve this error message
errs = append(errs, fmt.Errorf("payload is not already decrypted: %w", err))
if unencrypted.Is(method) {
// Not applicable
continue
}
uncd, err := method.Decrypt(es.Data)

View File

@ -11,6 +11,7 @@ import (
"github.com/opentofu/opentofu/internal/encryption/keyprovider/openbao"
"github.com/opentofu/opentofu/internal/encryption/keyprovider/pbkdf2"
"github.com/opentofu/opentofu/internal/encryption/method/aesgcm"
"github.com/opentofu/opentofu/internal/encryption/method/unencrypted"
"github.com/opentofu/opentofu/internal/encryption/registry/lockingencryptionregistry"
)
@ -20,9 +21,6 @@ func init() {
if err := DefaultRegistry.RegisterKeyProvider(pbkdf2.New()); err != nil {
panic(err)
}
if err := DefaultRegistry.RegisterMethod(aesgcm.New()); err != nil {
panic(err)
}
if err := DefaultRegistry.RegisterKeyProvider(aws_kms.New()); err != nil {
panic(err)
}
@ -32,4 +30,10 @@ func init() {
if err := DefaultRegistry.RegisterKeyProvider(openbao.New()); err != nil {
panic(err)
}
if err := DefaultRegistry.RegisterMethod(aesgcm.New()); err != nil {
panic(err)
}
if err := DefaultRegistry.RegisterMethod(unencrypted.New()); err != nil {
panic(err)
}
}

View File

@ -13,6 +13,7 @@ import (
"github.com/opentofu/opentofu/internal/encryption/config"
"github.com/opentofu/opentofu/internal/encryption/keyprovider/static"
"github.com/opentofu/opentofu/internal/encryption/method/aesgcm"
"github.com/opentofu/opentofu/internal/encryption/method/unencrypted"
"github.com/opentofu/opentofu/internal/encryption/registry/lockingencryptionregistry"
)
@ -26,6 +27,9 @@ func EncryptionDirect(configData string) encryption.Encryption {
if err := reg.RegisterMethod(aesgcm.New()); err != nil {
panic(err)
}
if err := reg.RegisterMethod(unencrypted.New()); err != nil {
panic(err)
}
cfg, diags := config.LoadConfigFromString("Test Config Source", configData)
@ -67,18 +71,25 @@ func EncryptionWithFallback() encryption.Encryption {
method "aes_gcm" "example" {
keys = key_provider.static.basic
}
method "unencrypted" "migration" {}
state {
method = method.aes_gcm.example
fallback {}
fallback {
method = method.unencrypted.migration
}
}
plan {
method = method.aes_gcm.example
fallback {}
fallback {
method = method.unencrypted.migration
}
}
remote_state_data_sources {
default {
method = method.aes_gcm.example
fallback {}
fallback {
method = method.unencrypted.migration
}
}
}
`)

View File

@ -0,0 +1,38 @@
package unencrypted
import (
"github.com/opentofu/opentofu/internal/encryption/method"
)
func New() method.Descriptor {
return &descriptor{}
}
type descriptor struct{}
func (f *descriptor) ID() method.ID {
return "unencrypted"
}
func (f *descriptor) ConfigStruct() method.Config {
return new(config)
}
type config struct{}
func (c *config) Build() (method.Method, error) {
return new(unenc), nil
}
type unenc struct{}
func (a *unenc) Encrypt(data []byte) ([]byte, error) {
panic("Placeholder for type check! Should never be called!")
}
func (a *unenc) Decrypt(data []byte) ([]byte, error) {
panic("Placeholder for type check! Should never be called!")
}
func Is(m method.Method) bool {
_, ok := m.(*unenc)
return ok
}

View File

@ -13,6 +13,7 @@ import (
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/opentofu/opentofu/internal/encryption/config"
"github.com/opentofu/opentofu/internal/encryption/method"
"github.com/opentofu/opentofu/internal/encryption/method/unencrypted"
"github.com/opentofu/opentofu/internal/encryption/registry"
"github.com/zclconf/go-cty/cty"
)
@ -56,24 +57,25 @@ func (e *targetBuilder) setupMethod(cfg config.MethodConfig) hcl.Diagnostics {
// Handle if the method was not found
var notFoundError *registry.MethodNotFoundError
if errors.Is(err, notFoundError) {
return hcl.Diagnostics{&hcl.Diagnostic{
return append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unknown encryption method type",
Detail: fmt.Sprintf("Can not find %q", cfg.Type),
}}
})
}
// Or, we don't know the error type, so we'll just return it as a generic error
return hcl.Diagnostics{&hcl.Diagnostic{
return append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Error fetching encryption method %q", cfg.Type),
Detail: err.Error(),
}}
})
}
// TODO: we could use varhcl here to provider better error messages
methodConfig := encryptionMethod.ConfigStruct()
diags = gohcl.DecodeBody(cfg.Body, e.ctx, methodConfig)
methodDiags := gohcl.DecodeBody(cfg.Body, e.ctx, methodConfig)
diags = append(diags, methodDiags...)
if diags.HasErrors() {
return diags
}
@ -82,12 +84,21 @@ func (e *targetBuilder) setupMethod(cfg config.MethodConfig) hcl.Diagnostics {
m, err := methodConfig.Build()
if err != nil {
// TODO this error handling could use some work
return hcl.Diagnostics{&hcl.Diagnostic{
return append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Encryption method configuration failed",
Detail: err.Error(),
}}
})
}
e.methods[addr] = m
return nil
if unencrypted.Is(m) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Unencrypted method configured",
Detail: "Method unencrypted is present in configuration. This is a security risk and should only be enabled during migrations.",
})
}
return diags
}

View File

@ -46,16 +46,19 @@ func (base *baseEncryption) buildTargetMethods(meta map[keyprovider.Addr][]byte)
keyProviderMetadata: meta,
}
diags = append(diags, builder.setupKeyProviders()...)
keyDiags := append(diags, builder.setupKeyProviders()...)
diags = append(diags, keyDiags...)
if diags.HasErrors() {
return nil, diags
}
diags = append(diags, builder.setupMethods()...)
methodDiags := append(diags, builder.setupMethods()...)
diags = append(diags, methodDiags...)
if diags.HasErrors() {
return nil, diags
}
return builder.build(base.target, base.name)
methods, targetDiags := builder.build(base.target, base.name)
return methods, append(diags, targetDiags...)
}
// build sets up a single target for encryption. It returns the primary and fallback methods for the target, as well
@ -74,7 +77,6 @@ func (e *targetBuilder) build(target *config.TargetConfig, targetName string) (m
// Only attempt to fetch the method if the decoding was successful
if !decodeDiags.HasErrors() {
if methodIdent != nil {
if method, ok := e.methods[method.Addr(*methodIdent)]; ok {
methods = append(methods, method)
@ -88,8 +90,12 @@ func (e *targetBuilder) build(target *config.TargetConfig, targetName string) (m
})
}
} else {
// nil is a nop method
methods = append(methods, nil)
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing encryption method",
Detail: fmt.Sprintf("undefined or null method used for %q", targetName),
Subject: target.Method.Range().Ptr(),
})
}
}

View File

@ -163,6 +163,10 @@ AES-GCM is a secure, industry-standard encryption algorithm, but suffers from "k
:::
### Unencrypted
The `unencrypted` method is used to provide an explicit migration path to and from encryption. It takes no configuration and can be see in use below in the [Initial Setup](#initial-setup) block.
## 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:
@ -173,13 +177,13 @@ If OpenTofu fails to **read** your state or plan file with the new method, it wi
## Initial setup
When you first configure encryption, your state and plan files are unencrypted. OpenTofu, by default, refuses to read them because they could have been manipulated. To enable reading unencrypted data, you will have to specify an empty fallback block:
When you first configure encryption, your state and plan files are unencrypted. OpenTofu, by default, refuses to read them because they could have been manipulated. To enable reading unencrypted data, you will have to specify an `unencrypted` method:
<CodeBlock language="hcl">{FallbackFromUnencrypted}</CodeBlock>
## Rolling back encryption
Similar to the initial setup above, migrating to unencrypted state and plan files is also possible in a similar manner. You simply have to specify no method in the target block as follows:
Similar to the initial setup above, migrating to unencrypted state and plan files is also possible in a similar manner. You simply have to specify an `unencrypted` method in the target block as follows:
<CodeBlock language="hcl">{FallbackToUnencrypted}</CodeBlock>

View File

@ -1,12 +1,14 @@
terraform {
encryption {
# Methods and key providers here.
method "unencrypted" "migrate" {}
state {
method = method.some_method.new_method
fallback {
# The empty fallback block allows reading unencrypted state files.
# The unencrypted method in a fallback block allows reading unencrypted state files.
method = method.unencrypted.migrate
}
}
}
}
}

View File

@ -1,13 +1,15 @@
terraform {
encryption {
# Methods and key providers here.
method "unencrypted" "migrate" {}
state {
# The empty block allows writing unencrypted state files
# The unencrypted method allows writing unencrypted state files.
# unless the enforced setting is set to true.
method = method.unencrypted.migrate
fallback {
method = method.some_method.old_method
}
}
}
}
}