mirror of
https://github.com/opentofu/opentofu.git
synced 2025-02-25 18:45:20 -06:00
Encryption e2e tests (#1389)
Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
This commit is contained in:
parent
fb41232d30
commit
abb7bc4c52
226
internal/command/e2etest/encryption_test.go
Normal file
226
internal/command/e2etest/encryption_test.go
Normal file
@ -0,0 +1,226 @@
|
||||
package e2etest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/e2e"
|
||||
)
|
||||
|
||||
type tofuResult struct {
|
||||
t *testing.T
|
||||
|
||||
stdout string
|
||||
stderr string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r tofuResult) Success() tofuResult {
|
||||
if r.stderr != "" {
|
||||
debug.PrintStack()
|
||||
r.t.Fatalf("unexpected stderr output:\n%s", r.stderr)
|
||||
}
|
||||
if r.err != nil {
|
||||
debug.PrintStack()
|
||||
r.t.Fatalf("unexpected error: %s", r.err)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (r tofuResult) Failure() tofuResult {
|
||||
if r.err == nil {
|
||||
debug.PrintStack()
|
||||
r.t.Fatal("expected error")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r tofuResult) StderrContains(msg string) tofuResult {
|
||||
if !strings.Contains(r.stderr, msg) {
|
||||
debug.PrintStack()
|
||||
r.t.Fatalf("expected stderr output %q:\n%s", msg, r.stderr)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// This test covers the scenario where a user migrates an existing project
|
||||
// to having encryption enabled, uses it, then migrates back to encryption
|
||||
// disabled
|
||||
func TestEncryptionFlow(t *testing.T) {
|
||||
|
||||
// This test reaches out to registry.opentofu.org to download the
|
||||
// mock provider, so it can only run if network access is allowed
|
||||
skipIfCannotAccessNetwork(t)
|
||||
|
||||
// There is a lot of setup / helpers defined. Actual test logic is below.
|
||||
|
||||
fixturePath := filepath.Join("testdata", "encryption-flow")
|
||||
tf := e2e.NewBinary(t, tofuBin, fixturePath)
|
||||
|
||||
// tofu init
|
||||
_, stderr, err := tf.Run("init")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Errorf("unexpected stderr output:\n%s", stderr)
|
||||
}
|
||||
|
||||
iter := 0
|
||||
|
||||
run := func(args ...string) tofuResult {
|
||||
stdout, stderr, err := tf.Run(args...)
|
||||
return tofuResult{t, stdout, stderr, err}
|
||||
}
|
||||
apply := func() tofuResult {
|
||||
iter += 1
|
||||
return run("apply", fmt.Sprintf("-var=iter=%v", iter), "-auto-approve")
|
||||
}
|
||||
|
||||
createPlan := func(planfile string) tofuResult {
|
||||
iter += 1
|
||||
return run("plan", fmt.Sprintf("-var=iter=%v", iter), "-out="+planfile)
|
||||
}
|
||||
applyPlan := func(planfile string) tofuResult {
|
||||
return run("apply", "-auto-approve", planfile)
|
||||
}
|
||||
|
||||
requireUnencryptedState := func() {
|
||||
_, err = tf.LocalState()
|
||||
if err != nil {
|
||||
t.Fatalf("expected unencrypted state file: %q", err)
|
||||
}
|
||||
}
|
||||
requireEncryptedState := func() {
|
||||
_, err = tf.LocalState()
|
||||
if err == nil || err.Error() != "Error reading statefile: Unsupported state file format: This state file is encrypted and can not be read without an encryption configuration" {
|
||||
t.Fatalf("expected encrypted state file: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
with := func(path string, fn func()) {
|
||||
src := tf.Path(path + ".disabled")
|
||||
dst := tf.Path(path)
|
||||
|
||||
err := os.Rename(src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf(err.Error())
|
||||
}
|
||||
|
||||
fn()
|
||||
|
||||
err = os.Rename(dst, src)
|
||||
if err != nil {
|
||||
t.Fatalf(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Actual test begins HERE
|
||||
// NOTE: state plans are still readable and tests the encryption state
|
||||
|
||||
unencryptedPlan := "unencrypted.tfplan"
|
||||
encryptedPlan := "encrypted.tfplan"
|
||||
|
||||
{
|
||||
// Everything works before adding encryption configuration
|
||||
apply().Success()
|
||||
requireUnencryptedState()
|
||||
// Check read/write of state file
|
||||
apply().Success()
|
||||
requireUnencryptedState()
|
||||
|
||||
// Save an unencrypted plan
|
||||
createPlan(unencryptedPlan).Success()
|
||||
// Validate unencrypted plan
|
||||
applyPlan(unencryptedPlan).Success()
|
||||
requireUnencryptedState()
|
||||
}
|
||||
|
||||
with("required.tf", func() {
|
||||
// Can't switch directly to encryption, need to migrate
|
||||
apply().Failure().StderrContains("decrypted payload provided without fallback specified")
|
||||
requireUnencryptedState()
|
||||
})
|
||||
|
||||
with("migrateto.tf", func() {
|
||||
// Migrate to using encryption
|
||||
apply().Success()
|
||||
requireEncryptedState()
|
||||
// Make changes and confirm it's still encrypted (even with migration enabled)
|
||||
apply().Success()
|
||||
requireEncryptedState()
|
||||
|
||||
// Save an encrypted plan
|
||||
createPlan(encryptedPlan).Success()
|
||||
|
||||
// Apply encrypted plan (with migration active)
|
||||
applyPlan(encryptedPlan).Success()
|
||||
requireEncryptedState()
|
||||
// Apply unencrypted plan (with migration active)
|
||||
applyPlan(unencryptedPlan).StderrContains("Saved plan is stale")
|
||||
requireEncryptedState()
|
||||
})
|
||||
|
||||
{
|
||||
// Unconfigured encryption clearly fails on encrypted state
|
||||
apply().Failure().StderrContains("can not be read without an encryption configuration")
|
||||
}
|
||||
|
||||
with("required.tf", func() {
|
||||
// Encryption works with fallback removed
|
||||
apply().Success()
|
||||
requireEncryptedState()
|
||||
|
||||
// Can't apply unencrypted plan
|
||||
applyPlan(unencryptedPlan).Failure().StderrContains("decrypted payload provided without fallback specified")
|
||||
requireEncryptedState()
|
||||
|
||||
// Apply encrypted plan
|
||||
applyPlan(encryptedPlan).StderrContains("Saved plan is stale")
|
||||
requireEncryptedState()
|
||||
})
|
||||
|
||||
with("broken.tf", func() {
|
||||
// Make sure changes to encryption keys notify the user correctly
|
||||
apply().Failure().StderrContains("decryption failed for state")
|
||||
requireEncryptedState()
|
||||
|
||||
applyPlan(encryptedPlan).Failure().StderrContains("decryption failed: cipher: message authentication failed")
|
||||
requireEncryptedState()
|
||||
})
|
||||
|
||||
with("migratefrom.tf", func() {
|
||||
// Apply migration from encrypted state
|
||||
apply().Success()
|
||||
requireUnencryptedState()
|
||||
// Make changes and confirm it's still encrypted (even with migration enabled)
|
||||
apply().Success()
|
||||
requireUnencryptedState()
|
||||
|
||||
// Apply unencrypted plan (with migration active)
|
||||
applyPlan(unencryptedPlan).StderrContains("Saved plan is stale")
|
||||
requireUnencryptedState()
|
||||
|
||||
// Apply encrypted plan (with migration active)
|
||||
applyPlan(encryptedPlan).StderrContains("Saved plan is stale")
|
||||
requireUnencryptedState()
|
||||
})
|
||||
|
||||
{
|
||||
// Back to no encryption configuration with unencrypted state
|
||||
apply().Success()
|
||||
requireUnencryptedState()
|
||||
|
||||
// Apply unencrypted plan
|
||||
applyPlan(unencryptedPlan).StderrContains("Saved plan is stale")
|
||||
requireUnencryptedState()
|
||||
// Can't apply encrypted plan
|
||||
applyPlan(encryptedPlan).Failure().StderrContains("the given plan file is encrypted and requires a valid encryption")
|
||||
requireUnencryptedState()
|
||||
}
|
||||
}
|
20
internal/command/e2etest/testdata/encryption-flow/broken.tf.disabled
vendored
Normal file
20
internal/command/e2etest/testdata/encryption-flow/broken.tf.disabled
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
terraform {
|
||||
encryption {
|
||||
key_provider "pbkdf2" "basic" {
|
||||
passphrase = "aaaaaaaa-83f1-47ec-9b2d-2aebf6417167"
|
||||
key_length = 32
|
||||
iterations = 200000
|
||||
hash_function = "sha512"
|
||||
salt_length = 12
|
||||
}
|
||||
method "aes_gcm" "example" {
|
||||
keys = key_provider.pbkdf2.basic
|
||||
}
|
||||
state {
|
||||
method = method.aes_gcm.example
|
||||
}
|
||||
plan {
|
||||
method = method.aes_gcm.example
|
||||
}
|
||||
}
|
||||
}
|
7
internal/command/e2etest/testdata/encryption-flow/main.tf
vendored
Normal file
7
internal/command/e2etest/testdata/encryption-flow/main.tf
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
variable "iter" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "tfcoremock_simple_resource" "simple" {
|
||||
string = "helloworld ${var.iter}"
|
||||
}
|
24
internal/command/e2etest/testdata/encryption-flow/migratefrom.tf.disabled
vendored
Normal file
24
internal/command/e2etest/testdata/encryption-flow/migratefrom.tf.disabled
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
terraform {
|
||||
encryption {
|
||||
key_provider "pbkdf2" "basic" {
|
||||
passphrase = "26281afb-83f1-47ec-9b2d-2aebf6417167"
|
||||
key_length = 32
|
||||
iterations = 200000
|
||||
hash_function = "sha512"
|
||||
salt_length = 12
|
||||
}
|
||||
method "aes_gcm" "example" {
|
||||
keys = key_provider.pbkdf2.basic
|
||||
}
|
||||
state {
|
||||
fallback {
|
||||
method = method.aes_gcm.example
|
||||
}
|
||||
}
|
||||
plan {
|
||||
fallback {
|
||||
method = method.aes_gcm.example
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
22
internal/command/e2etest/testdata/encryption-flow/migrateto.tf.disabled
vendored
Normal file
22
internal/command/e2etest/testdata/encryption-flow/migrateto.tf.disabled
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
terraform {
|
||||
encryption {
|
||||
key_provider "pbkdf2" "basic" {
|
||||
passphrase = "26281afb-83f1-47ec-9b2d-2aebf6417167"
|
||||
key_length = 32
|
||||
iterations = 200000
|
||||
hash_function = "sha512"
|
||||
salt_length = 12
|
||||
}
|
||||
method "aes_gcm" "example" {
|
||||
keys = key_provider.pbkdf2.basic
|
||||
}
|
||||
state {
|
||||
method = method.aes_gcm.example
|
||||
fallback {}
|
||||
}
|
||||
plan {
|
||||
method = method.aes_gcm.example
|
||||
fallback {}
|
||||
}
|
||||
}
|
||||
}
|
20
internal/command/e2etest/testdata/encryption-flow/required.tf.disabled
vendored
Normal file
20
internal/command/e2etest/testdata/encryption-flow/required.tf.disabled
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
terraform {
|
||||
encryption {
|
||||
key_provider "pbkdf2" "basic" {
|
||||
passphrase = "26281afb-83f1-47ec-9b2d-2aebf6417167"
|
||||
key_length = 32
|
||||
iterations = 200000
|
||||
hash_function = "sha512"
|
||||
salt_length = 12
|
||||
}
|
||||
method "aes_gcm" "example" {
|
||||
keys = key_provider.pbkdf2.basic
|
||||
}
|
||||
state {
|
||||
method = method.aes_gcm.example
|
||||
}
|
||||
plan {
|
||||
method = method.aes_gcm.example
|
||||
}
|
||||
}
|
||||
}
|
@ -71,6 +71,11 @@ func Open(filename string, enc encryption.PlanEncryption) (*Reader, error) {
|
||||
|
||||
r, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted)))
|
||||
if err != nil {
|
||||
// Check to see if it's encrypted
|
||||
if encrypted, _ := encryption.IsEncryptionPayload(decrypted); encrypted {
|
||||
return nil, errUnusable(fmt.Errorf("the given plan file is encrypted and requires a valid encryption configuration to decrypt"))
|
||||
}
|
||||
|
||||
// To give a better error message, we'll sniff to see if this looks
|
||||
// like our old plan format from versions prior to 0.12.
|
||||
if b, sErr := os.ReadFile(filename); sErr == nil {
|
||||
|
Loading…
Reference in New Issue
Block a user