diff --git a/internal/states/statecrypto/cryptoconfig/config.go b/internal/states/statecrypto/cryptoconfig/config.go index 19e86ef124..c5f5610668 100644 --- a/internal/states/statecrypto/cryptoconfig/config.go +++ b/internal/states/statecrypto/cryptoconfig/config.go @@ -14,8 +14,9 @@ type Config struct { // Methods selects the methods to place in the encryption stack // // supported values are - // "client-side/AES256-CFB/SHA256" - // ... + // "encrypt/AES256-CFB/SHA256" + // "derive-key/PBKDF2/AES256" + // ... (see the constants at the top of each method implementation) // // supplying an unsupported value raises an error. // diff --git a/internal/states/statecrypto/cryptoconfig/method.go b/internal/states/statecrypto/cryptoconfig/method.go index 6d1b1aefb2..08a597ec86 100644 --- a/internal/states/statecrypto/cryptoconfig/method.go +++ b/internal/states/statecrypto/cryptoconfig/method.go @@ -45,5 +45,12 @@ type MethodMetadata struct { // // You can reject invalid configurations here, but if you want another method to be // able to provide a configuration value, then you should not check it during the construction phase. - Constructor func(Config) (Method, error) + // Example: it would be wrong to check that the configuration provides the encryption key, + // because that may be provided by a method further up in the stack. + // + // nextInStack is the next method in the stack, or nil if this method is the last + // in the stack. If your method expects to be the last in the stack, you should + // return an error if nextInStack is not nil. Similarly, if your method is for key derivation, + // you should return a meaningful error if nextInStack is nil. + Constructor func(configuration Config, nextInStack Method) (Method, error) } diff --git a/internal/states/statecrypto/flow/config.go b/internal/states/statecrypto/flow/config.go index d41e53dd0e..f267d41be9 100644 --- a/internal/states/statecrypto/flow/config.go +++ b/internal/states/statecrypto/flow/config.go @@ -34,16 +34,14 @@ func EnabledForPlanFile(config cryptoconfig.Config) bool { // constructor functions defined in the metadata of the listed methods. // // This will typically also validate static parameters, but not dynamic parameters such as keys. -func buildMethodStack(config cryptoconfig.Config) ([]cryptoconfig.Method, error) { - result := make([]cryptoconfig.Method, 0) - for _, name := range config.Methods { - method, err := methods.MethodByName(name, config) +func buildMethodStack(config cryptoconfig.Config) (current cryptoconfig.Method, err error) { + for i := len(config.Methods) - 1; i >= 0; i-- { + current, err = methods.MethodByName(config.Methods[i], config, current) if err != nil { - return result, err + return nil, err } - result = append(result, method) } - return result, nil + return current, nil } func configFromEnv(envName string) (cryptoconfig.Config, error) { diff --git a/internal/states/statecrypto/flow/decryption.go b/internal/states/statecrypto/flow/decryption.go index b5a3a5d4e6..747c03ae35 100644 --- a/internal/states/statecrypto/flow/decryption.go +++ b/internal/states/statecrypto/flow/decryption.go @@ -66,11 +66,7 @@ func attemptDecryption(stateJson []byte, config cryptoconfig.Config) ([]byte, er return stateJson, err } - for _, method := range stack { - stateJson, config, err = method.Decrypt(stateJson, config) - if err != nil { - return stateJson, err - } - } - return stateJson, nil + stateJson, _, err = stack.Decrypt(stateJson, config) + + return stateJson, err } diff --git a/internal/states/statecrypto/flow/encryption.go b/internal/states/statecrypto/flow/encryption.go index 739e368df2..a28703ebbd 100644 --- a/internal/states/statecrypto/flow/encryption.go +++ b/internal/states/statecrypto/flow/encryption.go @@ -38,11 +38,7 @@ func attemptEncryption(stateJson []byte, config cryptoconfig.Config) ([]byte, er return stateJson, err } - for _, method := range stack { - stateJson, config, err = method.Encrypt(stateJson, config) - if err != nil { - return stateJson, err - } - } - return stateJson, nil + stateJson, _, err = stack.Encrypt(stateJson, config) + + return stateJson, err } diff --git a/internal/states/statecrypto/flow/flow_test.go b/internal/states/statecrypto/flow/flow_test.go index 320e7f3956..54248625e4 100644 --- a/internal/states/statecrypto/flow/flow_test.go +++ b/internal/states/statecrypto/flow/flow_test.go @@ -3,6 +3,7 @@ package flow import ( "fmt" "github.com/opentofu/opentofu/internal/states/statecrypto/cryptoconfig" + "log" "testing" ) @@ -36,14 +37,22 @@ func TestCreation_invalidConfigUnknownMethod(t *testing.T) { // business scenarios -const validConfigWithKey1 = `{"encrypt_remote_state":true,"methods":["client-side/AES256-CFB/SHA256"],"parameters":{"key":"a0a1a2a3a4a5a6a7a8a9b0b1b2b3b4b5b6b7b8b9c0c1c2c3c4c5c6c7c8c9d0d1"}}` -const validConfigWithKey2 = `{"encrypt_remote_state":true,"methods":["client-side/AES256-CFB/SHA256"],"parameters":{"key":"89346775897897a35892735ffd34723489734ee238748293741abcdef0123456"}}` -const validConfigWithKey3 = `{"encrypt_remote_state":true,"methods":["client-side/AES256-CFB/SHA256"],"parameters":{"key":"33336775897897a35892735ffd34723489734ee238748293741abcdef0123456"}}` +const validConfigWithKey1 = `{"encrypt_remote_state":true,"methods":["encrypt/AES256-CFB/SHA256"],"parameters":{"key":"a0a1a2a3a4a5a6a7a8a9b0b1b2b3b4b5b6b7b8b9c0c1c2c3c4c5c6c7c8c9d0d1"}}` +const validConfigWithKey2 = `{"encrypt_remote_state":true,"methods":["encrypt/AES256-CFB/SHA256"],"parameters":{"key":"89346775897897a35892735ffd34723489734ee238748293741abcdef0123456"}}` +const validConfigWithKey3 = `{"encrypt_remote_state":true,"methods":["encrypt/AES256-CFB/SHA256"],"parameters":{"key":"33336775897897a35892735ffd34723489734ee238748293741abcdef0123456"}}` + +const validConfigWithPassphrase1 = `{"encrypt_remote_state":true,"methods":["derive-key/PBKDF2/AES256","encrypt/AES256-CFB/SHA256"],"parameters":{"passphrase":"this is demo passphrase 1"}}` +const validConfigWithPassphrase2 = `{"encrypt_remote_state":true,"methods":["derive-key/PBKDF2/AES256","encrypt/AES256-CFB/SHA256"],"parameters":{"passphrase":"this is demo passphrase 2"}}` const validPlaintext = `{"animals":[{"species":"cheetah","genus":"acinonyx"}]}` -const validEncryptedKey1 = `{"crypted":"e93e3e7ad3434055251f695865a13c11744b97e54cb7dee8f8fb40d1fb096b728f2a00606e7109f0720aacb15008b410cf2f92dd7989c2ff10b9712b6ef7d69ecdad1dccd2f1bddd127f0f0d87c79c3c062e03c2297614e2effa2fb1f4072d86df0dda4fc061"}` +const validEncryptedKey1 = `{"method":"encrypt/AES256-CFB/SHA256","payload":"e93e3e7ad3434055251f695865a13c11744b97e54cb7dee8f8fb40d1fb096b728f2a00606e7109f0720aacb15008b410cf2f92dd7989c2ff10b9712b6ef7d69ecdad1dccd2f1bddd127f0f0d87c79c3c062e03c2297614e2effa2fb1f4072d86df0dda4fc061"}` +const validEncryptedPassphrase1 = `{"method":"derive-key/PBKDF2/AES256","payload":{"method":"encrypt/AES256-CFB/SHA256","payload":"226ba6151c7751f2bd2ffcc2f666397f0cf8f6ea8ac2e336d7f3e07c8b8145f7e317f70a6bf4478b3c4469189ba1daccfe21ee76a88cbc66e460081d323b9344d5771b1a02ed7e477f69ad326c6c9d7d44d154b2e3d2c9b23fb34241be5cdcbdb4137321ddd2"},"salt":"a7da6b991820460619aaf1fb647ebde7"}` + +const invalidConfigNoKey = `{"encrypt_remote_state":true,"methods":["encrypt/AES256-CFB/SHA256"],"parameters":{}}` -const invalidConfigNoKey = `{"encrypt_remote_state":true,"methods":["client-side/AES256-CFB/SHA256"],"parameters":{}}` +// TODO write test for these +const invalidConfigEncryptionNotLast = `{"encrypt_remote_state":true,"methods":["encrypt/AES256-CFB/SHA256","derive-key/PBKDF2/AES256"],"parameters":{"passphrase","this is a demo passphrase"}}` +const invalidConfigNoEncryption = `{"encrypt_remote_state":true,"methods":["derive-key/PBKDF2/AES256"],"parameters":{"passphrase","this is a demo passphrase"}}` func compareSlices(got []byte, expected []byte) bool { eEmpty := len(expected) == 0 @@ -79,6 +88,7 @@ func compareErrors(got error, expected string) string { } type roundtripTestCase struct { + name string description string primaryConfiguration string fallbackConfiguration string @@ -91,34 +101,42 @@ type roundtripTestCase struct { func TestEncryptDecrypt(t *testing.T) { // each test case first encrypts, then decrypts again testCases := []roundtripTestCase{ - // happy path cases + // happy path cases - no encryption { + name: "no encryption", description: "unencrypted operation - no encryption configuration present, no fallback", input: validPlaintext, }, + + // happy path cases - AES256 { + name: "aes256 normal", description: "normal operation on encrypted data - main configuration for aes256, no fallback", primaryConfiguration: validConfigWithKey1, input: validPlaintext, }, { + name: "aes256 initial encrypt", description: "initial encryption - main configuration for aes256, no fallback - prints a warning but must work anyway", primaryConfiguration: validConfigWithKey1, input: validPlaintext, injectOutput: validPlaintext, }, { + name: "aes256 decrypt", description: "decryption - no main configuration, fallback aes256", fallbackConfiguration: validConfigWithKey1, input: validPlaintext, // exact value irrelevant for this test case injectOutput: validEncryptedKey1, }, { + name: "aes256 already decrypted", description: "unencrypted operation with fallback still present (decryption edge case) - no encryption configuration present, fallback aes256 - prints a warning but must still work anyway", input: validPlaintext, fallbackConfiguration: validConfigWithKey1, }, { + name: "aes256 rotation", description: "key rotation - main configuration for aes256 key 2, fallback aes256 key 1, read state with key 1 encryption - prints a warning but must work anyway", primaryConfiguration: validConfigWithKey2, fallbackConfiguration: validConfigWithKey1, @@ -126,12 +144,14 @@ func TestEncryptDecrypt(t *testing.T) { injectOutput: validEncryptedKey1, }, { + name: "aes256 already rotated", description: "key rotation - main configuration for aes256 key 2, fallback aes256 key 1, read state with key 2 encryption", primaryConfiguration: validConfigWithKey2, fallbackConfiguration: validConfigWithKey1, input: validPlaintext, }, { + name: "aes256 initial encrypt during rotation", description: "initial encryption happens during key rotation (key rotation edge case) - main configuration for aes256 key 1, fallback for aes256 key 2 - prints a warning but must still work anyway", primaryConfiguration: validConfigWithKey1, fallbackConfiguration: validConfigWithKey2, @@ -139,8 +159,68 @@ func TestEncryptDecrypt(t *testing.T) { injectOutput: validPlaintext, }, - // error cases + // happy path cases - PBKDF2 + AES256 + { + name: "pbkdf2 aes256 normal", + description: "normal operation on encrypted data - main configuration for pbkdf2_aes256, no fallback", + primaryConfiguration: validConfigWithPassphrase1, + input: validPlaintext, + }, + { + name: "pbkdf2 aes256 initial encrypt", + description: "initial encryption - main configuration for pbkdf2_aes256, no fallback - prints a warning but must work anyway", + primaryConfiguration: validConfigWithPassphrase1, + input: validPlaintext, + injectOutput: validPlaintext, + }, + { + name: "pbkdf2 aes256 decrypt", + description: "decryption - no main configuration, fallback pbkdf2_aes256", + fallbackConfiguration: validConfigWithPassphrase1, + input: validPlaintext, // exact value irrelevant for this test case + injectOutput: validEncryptedPassphrase1, + }, + { + name: "pbkdf2 aes256 already decrypted", + description: "unencrypted operation with fallback still present (decryption edge case) - no encryption configuration present, fallback pbkdf2_aes256 - prints a warning but must still work anyway", + input: validPlaintext, + fallbackConfiguration: validConfigWithPassphrase1, + }, + { + name: "pbkdf2 aes256 rotation", + description: "key rotation for pbkdf2_aes256 - prints a warning but must work anyway", + primaryConfiguration: validConfigWithPassphrase2, + fallbackConfiguration: validConfigWithPassphrase1, + input: validPlaintext, // exact value irrelevant for this test case + injectOutput: validEncryptedPassphrase1, + }, + { + name: "pbkdf2 aes256 already rotated", + description: "key rotation for pbkdf2_aes256 - already rotated state - read state with phrase 2 encryption", + primaryConfiguration: validConfigWithPassphrase2, + fallbackConfiguration: validConfigWithPassphrase1, + input: validPlaintext, + }, + { + name: "pbkdf2 aes256 initial encrypt during rotation", + description: "initial encryption happens during pbkdf2_aes256 key rotation (key rotation edge case) - prints a warning but must still work anyway", + primaryConfiguration: validConfigWithPassphrase1, + fallbackConfiguration: validConfigWithPassphrase2, + input: validPlaintext, // exact value irrelevant for this test case + injectOutput: validPlaintext, + }, + { + name: "aes256 to pbkdf2 aes256 switch", + description: "transparent switch from aes256 encryption to pbkdf2_aes256 - decryption using the fallback configuration - prints a warning but must still work anyway", + primaryConfiguration: validConfigWithPassphrase2, + fallbackConfiguration: validConfigWithKey1, + input: validPlaintext, // exact value irrelevant for this test case + injectOutput: validEncryptedKey1, + }, + + // error cases - AES256 { + name: "aes256 wrong key", description: "decryption fails due to wrong key - main configuration for aes256 key 3 - but state was encrypted with key 1", primaryConfiguration: validConfigWithKey3, input: validPlaintext, // exact value irrelevant for this test case @@ -148,6 +228,7 @@ func TestEncryptDecrypt(t *testing.T) { expectedDecError: "hash of decrypted payload did not match at position 0", }, { + name: "aes256 wrong fallback key", description: "decryption fails due to wrong fallback key during decrypt lifecycle - no main configuration, fallback configuration for aes256 key 3 - but state was encrypted with key 1 - must fail and not use passthrough", fallbackConfiguration: validConfigWithKey3, input: validPlaintext, // exact value irrelevant for this test case @@ -155,6 +236,7 @@ func TestEncryptDecrypt(t *testing.T) { expectedDecError: "hash of decrypted payload did not match at position 0", }, { + name: "aes256 two wrong keys", description: "decryption fails due to two wrong keys - main configuration for aes256 key 3, fallback for aes256 key 2 - but state was encrypted with key 1", primaryConfiguration: validConfigWithKey3, fallbackConfiguration: validConfigWithKey2, @@ -163,8 +245,9 @@ func TestEncryptDecrypt(t *testing.T) { expectedDecError: "hash of decrypted payload did not match at position 0", }, - // key error cases + // key error cases - AES256 { + name: "aes256 missing key", description: "encryption fails due to missing key", primaryConfiguration: invalidConfigNoKey, input: validPlaintext, // exact value irrelevant for this test case @@ -175,7 +258,8 @@ func TestEncryptDecrypt(t *testing.T) { } for _, tc := range testCases { - t.Run(fmt.Sprintf("test case: %s", tc.description), func(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { + log.Printf("test case: %s: %s", tc.name, tc.description) primaryConfig, err := configFromString(tc.primaryConfiguration) if err != nil { t.Fatalf("error parsing main configuration: %s", err) diff --git a/internal/states/statecrypto/methods/aes256-cfb-sha256/aes256state.go b/internal/states/statecrypto/methods/aes256-cfb-sha256/aes256state.go index bcd1655c83..384bfc7f49 100644 --- a/internal/states/statecrypto/methods/aes256-cfb-sha256/aes256state.go +++ b/internal/states/statecrypto/methods/aes256-cfb-sha256/aes256state.go @@ -1,11 +1,14 @@ package aes256state import ( + "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/hex" + "encoding/json" + "errors" "fmt" "github.com/opentofu/opentofu/internal/states/statecrypto/cryptoconfig" "io" @@ -13,7 +16,7 @@ import ( "regexp" ) -const ClientSide_Aes256cfb_Sha256 = "client-side/AES256-CFB/SHA256" +const ClientSide_Aes256cfb_Sha256 = "encrypt/AES256-CFB/SHA256" func Metadata() cryptoconfig.MethodMetadata { return cryptoconfig.MethodMetadata{ @@ -22,7 +25,10 @@ func Metadata() cryptoconfig.MethodMetadata { } } -func constructor(configuration cryptoconfig.Config) (cryptoconfig.Method, error) { +func constructor(configuration cryptoconfig.Config, next cryptoconfig.Method) (cryptoconfig.Method, error) { + if next != nil { + return nil, fmt.Errorf("invalid configuration, %s must be used last in the list of methods", ClientSide_Aes256cfb_Sha256) + } return &AES256CFBMethod{}, nil } @@ -54,45 +60,65 @@ func parseKeyFromConfiguration(config cryptoconfig.Config) ([]byte, error) { return key, nil } -// determine if data (which is a []byte containing a json structure) is encrypted, that is, of the following form: -// -// {"crypted":""} func (a *AES256CFBMethod) isEncrypted(data []byte) bool { - validator := regexp.MustCompile(`^{"crypted":".*$`) + validator := regexp.MustCompile(`^{"method":"[^"]*","payload":.*$`) return validator.Match(data) } -func (a *AES256CFBMethod) isSyntacticallyValidEncrypted(data []byte) bool { - validator := regexp.MustCompile(`^{"crypted":"[0-9a-f]+"}$`) - return validator.Match(data) +type Aes256CfbWrapper struct { + Method string `json:"method"` + Payload string `json:"payload"` +} + +func jsonToWrapper(raw []byte) *Aes256CfbWrapper { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + + result := &Aes256CfbWrapper{} + err := decoder.Decode(result) + if err != nil { + log.Print("[TRACE] failed to decode json input into Aes256CfbWrapper, probably not encrypted - continuing") + return nil + } + return result } func (a *AES256CFBMethod) decodeFromEncryptedJsonWithChecks(jsonCryptedData []byte) ([]byte, error) { - if !a.isSyntacticallyValidEncrypted(jsonCryptedData) { - return []byte{}, fmt.Errorf("ciphertext contains invalid characters, possibly cut off or garbled") + wrapper := jsonToWrapper(jsonCryptedData) + if wrapper == nil { + log.Printf("[WARN] found state that was not encoded with this method, transparently reading it anyway") + return jsonCryptedData, nil + } + if wrapper.Method != ClientSide_Aes256cfb_Sha256 { + return []byte{}, fmt.Errorf("found state that was encoded with method %s, not %s", wrapper.Method, ClientSide_Aes256cfb_Sha256) } - // extract the hex part only, cutting off {"crypted":" (12 characters) and "} (2 characters) - src := jsonCryptedData[12 : len(jsonCryptedData)-2] + if len(wrapper.Payload)%2 != 0 { + return []byte{}, errors.New("ciphertext contains odd number of characters, possibly cut off or garbled") + } - ciphertext := make([]byte, hex.DecodedLen(len(src))) - n, err := hex.Decode(ciphertext, src) + ciphertext := make([]byte, hex.DecodedLen(len(wrapper.Payload))) + n, err := hex.Decode(ciphertext, []byte(wrapper.Payload)) if err != nil { - return []byte{}, err + log.Printf("[TRACE] ciphertext contains invalid characters: %s", err.Error()) + return []byte{}, errors.New("ciphertext contains invalid characters, possibly cut off or garbled") } - if n != hex.DecodedLen(len(src)) { + if n != hex.DecodedLen(len(wrapper.Payload)) { return []byte{}, fmt.Errorf("did not fully decode, only read %d characters before encountering an error", n) } return ciphertext, nil } func (a *AES256CFBMethod) encodeToEncryptedJson(ciphertext []byte) []byte { - prefix := []byte(`{"crypted":"`) - postfix := []byte(`"}`) encryptedHex := make([]byte, hex.EncodedLen(len(ciphertext))) _ = hex.Encode(encryptedHex, ciphertext) - return append(append(prefix, encryptedHex...), postfix...) + wrapper := &Aes256CfbWrapper{ + Method: ClientSide_Aes256cfb_Sha256, + Payload: string(encryptedHex), + } + result, _ := json.Marshal(wrapper) + return result } func (a *AES256CFBMethod) attemptDecryption(jsonCryptedData []byte, key []byte) ([]byte, error) { @@ -155,8 +181,6 @@ func (a *AES256CFBMethod) attemptEncryption(plaintextPayload []byte, key []byte) // Encrypt data (which is a []byte containing a json structure) into a json structure // -// {"crypted":""} -// // fail if encryption is not possible to prevent writing unencrypted state func (a *AES256CFBMethod) Encrypt(plaintextPayload []byte, config cryptoconfig.Config) ([]byte, cryptoconfig.Config, error) { key, err := parseKeyFromConfiguration(config) @@ -173,8 +197,6 @@ func (a *AES256CFBMethod) Encrypt(plaintextPayload []byte, config cryptoconfig.C // Decrypt the hex-encoded contents of data, which is expected to be of the form // -// {"crypted":""} -// // supports reading unencrypted state as well but logs a warning func (a *AES256CFBMethod) Decrypt(data []byte, config cryptoconfig.Config) ([]byte, cryptoconfig.Config, error) { if a.isEncrypted(data) { diff --git a/internal/states/statecrypto/methods/aes256-cfb-sha256/aes256state_test.go b/internal/states/statecrypto/methods/aes256-cfb-sha256/aes256state_test.go index c9a953d9cf..5b949d4b4b 100644 --- a/internal/states/statecrypto/methods/aes256-cfb-sha256/aes256state_test.go +++ b/internal/states/statecrypto/methods/aes256-cfb-sha256/aes256state_test.go @@ -14,12 +14,12 @@ const tooLongKey = "a0a1a2a3a4a5a6a7a8a9b0b1b2b3b4b5b6b7b8b9c0c1c2c3c4c5c6c7c8c9 const invalidChars = "somethingsomethinga9b0b1b2b3b4b5b6b7b8b9c0c1c2c3c4c5c6c7c8c9d0d1" const validPlaintext = `{"animals":[{"species":"cheetah","genus":"acinonyx"}]}` -const validEncryptedKey1 = `{"crypted":"e93e3e7ad3434055251f695865a13c11744b97e54cb7dee8f8fb40d1fb096b728f2a00606e7109f0720aacb15008b410cf2f92dd7989c2ff10b9712b6ef7d69ecdad1dccd2f1bddd127f0f0d87c79c3c062e03c2297614e2effa2fb1f4072d86df0dda4fc061"}` -const invalidEncryptedHash = `{"crypted":"a6625332f6e3061e1202cea86d2ddf7cf6d5f296a9856fe989cd20b18c8522f670d368f523481876bb2b98eea1e8cf845b4e003de11153bc47b884ce907b1e6a075f515ddd2aa4fbdbc7bbab1b411e153d164f84990e9c6fa82d7cacde7401546b47b2f30000"}` -const invalidEncryptedCutoff = `{"crypted":"447c2fc8982ed203681298be9f1b03ed30dbfe794a68e4ad873fb68c34f10394ffddd9c76b2d3fdb006d75068453854af63766fc059a569d243eb7d8c92ec3a00535ccaab769bdafb534d5471ed01ca36f640d1f` -const invalidEncryptedChars = `{"crypted":"447c2fc8982ed203681298be9f1b03ed30dbfe794a68e4ad873fb68c34 SOMETHING WEIRD d3fdb006d75068453854af63766fc059a569d243eb7d8c92ec3a00535ccaab769bdafb534d5471ed01ca36f640d1f720c9a2bf0aa4e0a40496dacee92325a9f86"}` -const invalidEncryptedTooShort = `{"crypted":"a6625332"}` -const invalidEncryptedOddNumberCharacters = `{"crypted":"e93e3e7ad3434055251f695865a13c11744b97e54cb7dee8f8fb40d1fb096b728f2a00606e7109f0720aacb15008b410cf2f92dd7989c2ff10b9712b6ef7d69ecdad1dccd2f1bddd127f0f0d87c79c3c062e03c2297614e2effa2fb1f4072d86df0dda4fc06"}` +const validEncryptedKey1 = `{"method":"encrypt/AES256-CFB/SHA256","payload":"e93e3e7ad3434055251f695865a13c11744b97e54cb7dee8f8fb40d1fb096b728f2a00606e7109f0720aacb15008b410cf2f92dd7989c2ff10b9712b6ef7d69ecdad1dccd2f1bddd127f0f0d87c79c3c062e03c2297614e2effa2fb1f4072d86df0dda4fc061"}` +const invalidEncryptedHash = `{"method":"encrypt/AES256-CFB/SHA256","payload":"a6625332f6e3061e1202cea86d2ddf7cf6d5f296a9856fe989cd20b18c8522f670d368f523481876bb2b98eea1e8cf845b4e003de11153bc47b884ce907b1e6a075f515ddd2aa4fbdbc7bbab1b411e153d164f84990e9c6fa82d7cacde7401546b47b2f30000"}` +const invalidEncryptedCutoff = `{"method":"encrypt/AES256-CFB/SHA256","payload":"447c2fc8982ed203681298be9f1b03ed30dbfe794a68e4ad873fb68c34f10394ffddd9c76b2d3fdb006d75068453854af63766fc059a569d243eb7d8c92ec3a00535ccaab769bdafb534d5471ed01ca36f640d1f` +const invalidEncryptedChars = `{"method":"encrypt/AES256-CFB/SHA256","payload":"447c2fc8982ed203681298be9f1b03ed30dbfe794a68e4ad873fb68c34 SOMETHING WEIRD d3fdb006d75068453854af63766fc059a569d243eb7d8c92ec3a00535ccaab769bdafb534d5471ed01ca36f640d1f720c9a2bf0aa4e0a40496dacee92325a9f86"}` +const invalidEncryptedTooShort = `{"method":"encrypt/AES256-CFB/SHA256","payload":"a6625332"}` +const invalidEncryptedOddNumberCharacters = `{"method":"encrypt/AES256-CFB/SHA256","payload":"e93e3e7ad3434055251f695865a13c11744b97e54cb7dee8f8fb40d1fb096b728f2a00606e7109f0720aacb15008b410cf2f92dd7989c2ff10b9712b6ef7d69ecdad1dccd2f1bddd127f0f0d87c79c3c062e03c2297614e2effa2fb1f4072d86df0dda4fc06"}` type parseKeyTestCase struct { description string @@ -64,7 +64,7 @@ func compareErrors(got error, expected string) string { func conf(key string) cryptoconfig.Config { return cryptoconfig.Config{ Methods: []string{ - "client-side/AES256-CFB/SHA256", + "encrypt/AES256-CFB/SHA256", }, Parameters: map[string]string{ "key": key, @@ -161,21 +161,21 @@ func TestEncryptDecrypt(t *testing.T) { expectedDecError: "hash of decrypted payload did not match at position 30", }, { - description: "decrypt received incomplete crypted json", + description: "decrypt received incomplete encrypted json", configuration: conf(validKey1), input: validPlaintext, injectOutput: invalidEncryptedCutoff, - expectedDecError: "ciphertext contains invalid characters, possibly cut off or garbled", + expectedDecError: "hash of decrypted payload did not match at position 0", }, { - description: "decrypt received invalid crypted json", + description: "decrypt received invalid encrypted json", configuration: conf(validKey1), input: validPlaintext, injectOutput: invalidEncryptedChars, expectedDecError: "ciphertext contains invalid characters, possibly cut off or garbled", }, { - description: "decrypt received crypted json too short even for iv", + description: "decrypt received encrypted json too short even for iv", configuration: conf(validKey1), input: validPlaintext, injectOutput: invalidEncryptedTooShort, @@ -183,41 +183,43 @@ func TestEncryptDecrypt(t *testing.T) { }, } for _, tc := range testCases { - cut, err := constructor(tc.configuration) - if comp := compareErrors(err, tc.expectedNewError); comp != "" { - t.Error(comp) - } - if err == nil { - if cut == nil { - t.Error("got unexpected nil implementation") - } else { - encOutput, _, err := cut.Encrypt([]byte(tc.input), tc.configuration) - if comp := compareErrors(err, tc.expectedEncError); comp != "" { - t.Error(comp) + t.Run(tc.description, func(t *testing.T) { + cut, err := constructor(tc.configuration, nil) + if comp := compareErrors(err, tc.expectedNewError); comp != "" { + t.Error(comp) + } + if err == nil { + if cut == nil { + t.Error("got unexpected nil implementation") } else { - // log.Printf("crypted json is %s", string(encOutput)) - - if tc.injectOutput != "" { - encOutput = []byte(tc.injectOutput) - } - - decOutput, _, err := cut.Decrypt(encOutput, tc.configuration) - if comp := compareErrors(err, tc.expectedDecError); comp != "" { + encOutput, _, err := cut.Encrypt([]byte(tc.input), tc.configuration) + if comp := compareErrors(err, tc.expectedEncError); comp != "" { t.Error(comp) } else { - if err == nil && !compareSlices(decOutput, []byte(tc.input)) { - t.Errorf("round trip error, got %#v; want %#v", decOutput, []byte(tc.input)) + // log.Printf("crypted json is %s", string(encOutput)) + + if tc.injectOutput != "" { + encOutput = []byte(tc.injectOutput) + } + + decOutput, _, err := cut.Decrypt(encOutput, tc.configuration) + if comp := compareErrors(err, tc.expectedDecError); comp != "" { + t.Error(comp) + } else { + if err == nil && !compareSlices(decOutput, []byte(tc.input)) { + t.Errorf("round trip error, got %#v; want %#v", decOutput, []byte(tc.input)) + } } } } } - } + }) } } func TestEncryptDoesNotUseSameIV(t *testing.T) { config := conf(validKey1) - cut, _ := constructor(config) + cut, _ := constructor(config, nil) encOutput1, _, _ := cut.Encrypt([]byte(validPlaintext), config) if len(encOutput1) != len([]byte(validEncryptedKey1)) { t.Error("encryption output 1 did not have the expected length") @@ -257,7 +259,7 @@ func TestAttemptDecryption_InvalidHexadecimal(t *testing.T) { t.Error("unexpectedly failed to parse valid key") } _, err = cut.attemptDecryption([]byte(invalidEncryptedOddNumberCharacters), key) - if comp := compareErrors(err, "encoding/hex: odd length hex string"); comp != "" { + if comp := compareErrors(err, "ciphertext contains odd number of characters, possibly cut off or garbled"); comp != "" { t.Error(comp) } } diff --git a/internal/states/statecrypto/methods/pbkdf2-passphrase-to-aes256key/pbkdf2key.go b/internal/states/statecrypto/methods/pbkdf2-passphrase-to-aes256key/pbkdf2key.go new file mode 100644 index 0000000000..e9ecbbfa4f --- /dev/null +++ b/internal/states/statecrypto/methods/pbkdf2-passphrase-to-aes256key/pbkdf2key.go @@ -0,0 +1,147 @@ +package pbkdf2aes256key + +import ( + "bytes" + "crypto/rand" + "crypto/sha512" + "encoding/hex" + "encoding/json" + "fmt" + "github.com/opentofu/opentofu/internal/states/statecrypto/cryptoconfig" + "golang.org/x/crypto/pbkdf2" + "log" + "regexp" +) + +const Pbkdf2_Aes256key = "derive-key/PBKDF2/AES256" + +func Metadata() cryptoconfig.MethodMetadata { + return cryptoconfig.MethodMetadata{ + Name: Pbkdf2_Aes256key, + Constructor: constructor, + } +} + +func constructor(configuration cryptoconfig.Config, next cryptoconfig.Method) (cryptoconfig.Method, error) { + if next == nil { + return nil, fmt.Errorf("invalid configuration, %s is a key derivation method and cannot be last in the list of methods. It must be followed by an encryption method", Pbkdf2_Aes256key) + } + return &Pbkdf2Method{ + Next: next, + }, nil +} + +type Pbkdf2Method struct { + Next cryptoconfig.Method +} + +type Pbkdf2Wrapper struct { + Method string `json:"method"` + Payload json.RawMessage `json:"payload"` + Salt string `json:"salt"` +} + +func passphraseFromConfiguration(config cryptoconfig.Config) (string, error) { + passphrase, ok := config.Parameters["passphrase"] + if !ok { + return "", fmt.Errorf("configuration for PBKDF2 needs the parameter 'passphrase' set to your passphrase") + } + + if len(passphrase) == 0 { + return "", fmt.Errorf("configuration invalid, parameter 'passphrase' must not be empty") + } + + return passphrase, nil +} + +func jsonToWrapper(raw []byte) *Pbkdf2Wrapper { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + + result := &Pbkdf2Wrapper{} + err := decoder.Decode(result) + if err != nil { + log.Print("[TRACE] failed to decode json input into Pbkdf2Wrapper, probably not encrypted - continuing") + return nil + } + return result +} + +func wrapperToJson(wrapper *Pbkdf2Wrapper) ([]byte, error) { + return json.Marshal(wrapper) +} + +func parseSalt(hexValue string) ([]byte, error) { + validator := regexp.MustCompile("^[0-9a-f]{32}$") + if !validator.MatchString(hexValue) { + return []byte{}, fmt.Errorf("salt was not a hex string representing 16 bytes, must match [0-9a-f]{32}") + } + + salt, err := hex.DecodeString(hexValue) + return salt, err +} + +func encodeSalt(raw []byte) string { + return hex.EncodeToString(raw) +} + +func encodeKey(raw []byte) string { + return hex.EncodeToString(raw) +} + +func (m *Pbkdf2Method) Decrypt(data []byte, config cryptoconfig.Config) ([]byte, cryptoconfig.Config, error) { + wrapper := jsonToWrapper(data) + if wrapper == nil { + log.Printf("[WARN] found state that was not encoded with this method, transparently reading it anyway") + return data, config, nil + } + if wrapper.Method != Pbkdf2_Aes256key { + // could be the primary configuration when fallback configuration is needed + return data, config, fmt.Errorf("found state that was encoded with method %s, not %s", wrapper.Method, Pbkdf2_Aes256key) + } + + passphrase, err := passphraseFromConfiguration(config) + if err != nil { + return []byte{}, config, err + } + + salt, err := parseSalt(wrapper.Salt) + if err != nil { + return []byte{}, config, err + } + + key := pbkdf2.Key([]byte(passphrase), salt, 4096, 32, sha512.New) + + config.Parameters["key"] = encodeKey(key) + + return m.Next.Decrypt(wrapper.Payload, config) +} + +func (m *Pbkdf2Method) Encrypt(data []byte, config cryptoconfig.Config) ([]byte, cryptoconfig.Config, error) { + passphrase, err := passphraseFromConfiguration(config) + if err != nil { + return []byte{}, config, err + } + + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return []byte{}, config, fmt.Errorf("could not generate salt: %w", err) + } + + key := pbkdf2.Key([]byte(passphrase), salt, 4096, 32, sha512.New) + + config.Parameters["key"] = encodeKey(key) + + encryptedPayload, _, err := m.Next.Encrypt(data, config) + if err != nil { + return []byte{}, config, err + } + + resultJson, err := wrapperToJson(&Pbkdf2Wrapper{ + Method: Pbkdf2_Aes256key, + Payload: encryptedPayload, + Salt: encodeSalt(salt), + }) + + return resultJson, config, err +} diff --git a/internal/states/statecrypto/methods/register_methods.go b/internal/states/statecrypto/methods/register_methods.go index 3be6a36c4c..917e278873 100644 --- a/internal/states/statecrypto/methods/register_methods.go +++ b/internal/states/statecrypto/methods/register_methods.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/opentofu/opentofu/internal/states/statecrypto/cryptoconfig" aes256state "github.com/opentofu/opentofu/internal/states/statecrypto/methods/aes256-cfb-sha256" + pbkdf2aes256key "github.com/opentofu/opentofu/internal/states/statecrypto/methods/pbkdf2-passphrase-to-aes256key" "log" ) @@ -22,7 +23,8 @@ func EnsureMethodsRegistered() { err := registerMethods( aes256state.Metadata, - // register other state encryption methods here + pbkdf2aes256key.Metadata, + // register other encryption/key derivation methods here ) if err != nil { @@ -46,7 +48,7 @@ func registerMethods(methodsToRegister ...func() cryptoconfig.MethodMetadata) er return nil } -func MethodByName(name string, config cryptoconfig.Config) (cryptoconfig.Method, error) { +func MethodByName(name string, config cryptoconfig.Config, next cryptoconfig.Method) (cryptoconfig.Method, error) { metadata, ok := methodMetadata[name] if !ok { return nil, fmt.Errorf("invalid configuration, encryption method '%s' is unknown", name) @@ -54,5 +56,5 @@ func MethodByName(name string, config cryptoconfig.Config) (cryptoconfig.Method, if metadata.Constructor == nil { return nil, fmt.Errorf("encryption method '%s' does not define a constructor - this is an implementation bug", name) } - return metadata.Constructor(config) + return metadata.Constructor(config, next) } diff --git a/internal/states/statecrypto/methods/register_methods_test.go b/internal/states/statecrypto/methods/register_methods_test.go index d285ab2234..d2b74a24a8 100644 --- a/internal/states/statecrypto/methods/register_methods_test.go +++ b/internal/states/statecrypto/methods/register_methods_test.go @@ -17,7 +17,7 @@ func TestMethodByName_Invalid(t *testing.T) { if err != nil { t.Error("unexpected error") } - _, err = MethodByName("funny", cryptoconfig.Config{}) + _, err = MethodByName("funny", cryptoconfig.Config{}, nil) if err == nil || err.Error() != "encryption method 'funny' does not define a constructor - this is an implementation bug" { t.Error("missing or wrong error") } @@ -26,7 +26,7 @@ func TestMethodByName_Invalid(t *testing.T) { func metadataValid() cryptoconfig.MethodMetadata { return cryptoconfig.MethodMetadata{ Name: "duplicate", - Constructor: func(cryptoconfig.Config) (cryptoconfig.Method, error) { + Constructor: func(cryptoconfig.Config, cryptoconfig.Method) (cryptoconfig.Method, error) { return nil, nil }, }