From abb7bc4c52eaa316951e5683a81be3ad9449d47f Mon Sep 17 00:00:00 2001 From: Christian Mesh Date: Wed, 13 Mar 2024 13:06:03 -0400 Subject: [PATCH] Encryption e2e tests (#1389) Signed-off-by: Christian Mesh --- internal/command/e2etest/encryption_test.go | 226 ++++++++++++++++++ .../encryption-flow/broken.tf.disabled | 20 ++ .../e2etest/testdata/encryption-flow/main.tf | 7 + .../encryption-flow/migratefrom.tf.disabled | 24 ++ .../encryption-flow/migrateto.tf.disabled | 22 ++ .../encryption-flow/required.tf.disabled | 20 ++ internal/plans/planfile/reader.go | 5 + 7 files changed, 324 insertions(+) create mode 100644 internal/command/e2etest/encryption_test.go create mode 100644 internal/command/e2etest/testdata/encryption-flow/broken.tf.disabled create mode 100644 internal/command/e2etest/testdata/encryption-flow/main.tf create mode 100644 internal/command/e2etest/testdata/encryption-flow/migratefrom.tf.disabled create mode 100644 internal/command/e2etest/testdata/encryption-flow/migrateto.tf.disabled create mode 100644 internal/command/e2etest/testdata/encryption-flow/required.tf.disabled diff --git a/internal/command/e2etest/encryption_test.go b/internal/command/e2etest/encryption_test.go new file mode 100644 index 0000000000..d5b7faba97 --- /dev/null +++ b/internal/command/e2etest/encryption_test.go @@ -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() + } +} diff --git a/internal/command/e2etest/testdata/encryption-flow/broken.tf.disabled b/internal/command/e2etest/testdata/encryption-flow/broken.tf.disabled new file mode 100644 index 0000000000..54c3746de9 --- /dev/null +++ b/internal/command/e2etest/testdata/encryption-flow/broken.tf.disabled @@ -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 + } + } +} diff --git a/internal/command/e2etest/testdata/encryption-flow/main.tf b/internal/command/e2etest/testdata/encryption-flow/main.tf new file mode 100644 index 0000000000..af9341cc1b --- /dev/null +++ b/internal/command/e2etest/testdata/encryption-flow/main.tf @@ -0,0 +1,7 @@ +variable "iter" { + type = string +} + +resource "tfcoremock_simple_resource" "simple" { + string = "helloworld ${var.iter}" +} diff --git a/internal/command/e2etest/testdata/encryption-flow/migratefrom.tf.disabled b/internal/command/e2etest/testdata/encryption-flow/migratefrom.tf.disabled new file mode 100644 index 0000000000..0601d79695 --- /dev/null +++ b/internal/command/e2etest/testdata/encryption-flow/migratefrom.tf.disabled @@ -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 + } + } + } +} diff --git a/internal/command/e2etest/testdata/encryption-flow/migrateto.tf.disabled b/internal/command/e2etest/testdata/encryption-flow/migrateto.tf.disabled new file mode 100644 index 0000000000..fb8d02d306 --- /dev/null +++ b/internal/command/e2etest/testdata/encryption-flow/migrateto.tf.disabled @@ -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 {} + } + } +} diff --git a/internal/command/e2etest/testdata/encryption-flow/required.tf.disabled b/internal/command/e2etest/testdata/encryption-flow/required.tf.disabled new file mode 100644 index 0000000000..d036c553f2 --- /dev/null +++ b/internal/command/e2etest/testdata/encryption-flow/required.tf.disabled @@ -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 + } + } +} diff --git a/internal/plans/planfile/reader.go b/internal/plans/planfile/reader.go index 33d3da5087..f06bca7c64 100644 --- a/internal/plans/planfile/reader.go +++ b/internal/plans/planfile/reader.go @@ -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 {