Remove usage of gohcl from method decoding

This allows us to build more powerful decode methods that give better
errors

Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
This commit is contained in:
Christian Mesh
2025-12-02 08:39:39 -05:00
committed by Andrei Ciobanu
parent 265b9003a5
commit 1be062b4bf
12 changed files with 207 additions and 220 deletions

View File

@@ -5,7 +5,14 @@
package keyprovider
import "github.com/zclconf/go-cty/cty"
import (
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclsyntax"
"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)
@@ -15,6 +22,38 @@ type Output struct {
DecryptionKey []byte `hcl:"decryption_key,optional" cty:"decryption_key" json:"decryption_key,omitempty" yaml:"decryption_key"`
}
func DecodeOutput(val cty.Value, subject hcl.Range) (Output, hcl.Diagnostics) {
var out Output
if !val.CanIterateElements() {
return out, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: "Expected key_provider value",
Detail: fmt.Sprintf("Expected a key_provider compatible value, found %s instead", val.Type().FriendlyName()),
Subject: &subject,
}}
}
var diags hcl.Diagnostics
mapVal := val.AsValueMap()
if attr, ok := mapVal["encryption_key"]; ok {
decodeDiags := gohcl.DecodeExpression(&hclsyntax.LiteralValueExpr{Val: attr, SrcRange: subject}, nil, &out.EncryptionKey)
diags = diags.Extend(decodeDiags)
} else {
return out, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: "Missing encryption_key value",
Detail: "A encryption_key value is required in the key_provider compatible object at this location",
Subject: &subject,
}}
}
if attr, ok := mapVal["decryption_key"]; ok {
decodeDiags := gohcl.DecodeExpression(&hclsyntax.LiteralValueExpr{Val: attr, SrcRange: subject}, nil, &out.DecryptionKey)
diags = diags.Extend(decodeDiags)
}
return out, diags
}
// Cty turns the Output struct into a CTY value.
func (o *Output) Cty() cty.Value {
return cty.ObjectVal(map[string]cty.Value{

View File

@@ -180,16 +180,6 @@ func TestCompliance(t *testing.T) {
Validate: nil,
},
},
ConfigStructTestCases: map[string]compliancetest.ConfigStructTestCase[*Config, *aesgcm]{
"empty": {
Config: &Config{
Keys: keyprovider.Output{},
AAD: nil,
},
ValidBuild: false,
Validate: nil,
},
},
EncryptDecryptTestCase: compliancetest.EncryptDecryptTestCase[*Config, *aesgcm]{
ValidEncryptOnlyConfig: &Config{
Keys: keyprovider.Output{

View File

@@ -22,13 +22,13 @@ var validKeyLengths = collections.NewSet[int](16, 24, 32)
type Config struct {
// 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"`
Keys keyprovider.Output
// 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"`
AAD []byte
}
// Build checks the validity of the configuration and returns a ready-to-use AES-GCM implementation.

View File

@@ -6,37 +6,58 @@
package aesgcm
import (
"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"
)
// 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() Descriptor {
func New() method.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 f.TypedConfig()
func (f *descriptor) DecodeConfig(methodCtx method.EvalContext, body hcl.Body) (method.Config, hcl.Diagnostics) {
var diags hcl.Diagnostics
methodCfg := &Config{}
content, contentDiags := body.Content(&hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "keys", Required: true},
{Name: "aad", Required: false},
},
})
diags = diags.Extend(contentDiags)
if diags.HasErrors() {
return nil, diags
}
keyExpr := content.Attributes["keys"].Expr
// keyExpr can either be raw data/references to raw data or a string reference to a key provider (JSON support)
keyVal, keyDiags := methodCtx.ValueForExpression(keyExpr)
diags = diags.Extend(keyDiags)
if diags.HasErrors() {
return nil, diags
}
methodCfg.Keys, keyDiags = keyprovider.DecodeOutput(keyVal, keyExpr.Range())
diags = diags.Extend(keyDiags)
if attr, ok := content.Attributes["aad"]; ok {
attrVal, attrDiags := methodCtx.ValueForExpression(attr.Expr)
diags = diags.Extend(attrDiags)
decodeDiags := gohcl.DecodeExpression(&hclsyntax.LiteralValueExpr{Val: attrVal, SrcRange: attr.Expr.Range()}, nil, &methodCfg.AAD)
diags = diags.Extend(decodeDiags)
}
return methodCfg, diags
}

View File

@@ -6,62 +6,19 @@
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"
"github.com/opentofu/opentofu/internal/encryption/method/aesgcm"
"github.com/zclconf/go-cty/cty"
)
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()
// Obtain a modifiable, buildable config.
config := aesgcm.Config{}
// Set up an encryption key:
config.Keys = keyprovider.Output{
@@ -91,54 +48,10 @@ func Example_config() {
// 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 = {
@@ -153,7 +66,12 @@ func Example_config_hcl() {
if diags.HasErrors() {
panic(diags)
}
if diags := gohcl.DecodeBody(file.Body, nil, config); diags.HasErrors() {
methodCtx := method.EvalContext{func(expr hcl.Expression) (cty.Value, hcl.Diagnostics) {
return expr.Value(nil)
}}
config, diags := descriptor.DecodeConfig(methodCtx, file.Body)
if diags.HasErrors() {
panic(diags)
}

View File

@@ -11,10 +11,11 @@ import (
"reflect"
"testing"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2"
"github.com/opentofu/opentofu/internal/encryption/compliancetest"
"github.com/opentofu/opentofu/internal/encryption/config"
"github.com/opentofu/opentofu/internal/encryption/method"
"github.com/zclconf/go-cty/cty"
)
// ComplianceTest tests the functionality of a method to make sure it conforms to the expectations of the method
@@ -32,9 +33,6 @@ type TestConfiguration[TDescriptor method.Descriptor, TConfig method.Config, TMe
// function.
HCLParseTestCases map[string]HCLParseTestCase[TDescriptor, TConfig, TMethod]
// ConfigStructT validates that a certain config results or does not result in a valid Build() call.
ConfigStructTestCases map[string]ConfigStructTestCase[TConfig, TMethod]
// ProvideTestCase exercises the entire chain and generates two keys.
EncryptDecryptTestCase EncryptDecryptTestCase[TConfig, TMethod]
}
@@ -46,9 +44,6 @@ func (cfg *TestConfiguration[TDescriptor, TConfig, TMethod]) execute(t *testing.
t.Run("hcl", func(t *testing.T) {
cfg.testHCL(t)
})
t.Run("config-struct", func(t *testing.T) {
cfg.testConfigStruct(t)
})
t.Run("encrypt-decrypt", func(t *testing.T) {
cfg.EncryptDecryptTestCase.execute(t)
})
@@ -100,20 +95,6 @@ func (cfg *TestConfiguration[TDescriptor, TConfig, TMethod]) testHCL(t *testing.
})
}
func (cfg *TestConfiguration[TDescriptor, TConfig, TMethod]) testConfigStruct(t *testing.T) {
compliancetest.ConfigStruct[TConfig](t, cfg.Descriptor.ConfigStruct())
if cfg.ConfigStructTestCases == nil {
compliancetest.Fail(t, "Please provide a map to ConfigStructTestCases.")
}
for name, tc := range cfg.ConfigStructTestCases {
t.Run(name, func(t *testing.T) {
tc.execute(t)
})
}
}
// HCLParseTestCase contains a test case that parses HCL into a configuration.
type HCLParseTestCase[TDescriptor method.Descriptor, TConfig method.Config, TMethod method.Method] struct {
// HCL contains the code that should be parsed into the configuration structure.
@@ -143,12 +124,11 @@ func (h *HCLParseTestCase[TDescriptor, TConfig, TMethod]) execute(t *testing.T,
}
}
configStruct := descriptor.ConfigStruct()
diags = gohcl.DecodeBody(
parsedConfig.MethodConfigs[0].Body,
nil,
configStruct,
)
methodCtx := method.EvalContext{func(expr hcl.Expression) (cty.Value, hcl.Diagnostics) {
return expr.Value(nil)
}}
configStruct, diags := descriptor.DecodeConfig(methodCtx, parsedConfig.MethodConfigs[0].Body)
var m TMethod
if h.ValidHCL {
if diags.HasErrors() {
@@ -177,22 +157,6 @@ func (h *HCLParseTestCase[TDescriptor, TConfig, TMethod]) execute(t *testing.T,
}
}
// ConfigStructTestCase validates that the config struct is behaving correctly when Build() is called.
type ConfigStructTestCase[TConfig method.Config, TMethod method.Method] struct {
Config TConfig
ValidBuild bool
Validate func(method TMethod) error
}
func (m ConfigStructTestCase[TConfig, TMethod]) execute(t *testing.T) {
newMethod := buildConfigAndValidate[TMethod, TConfig](t, m.Config, m.ValidBuild)
if m.Validate != nil {
if err := m.Validate(newMethod); err != nil {
compliancetest.Fail(t, "method validation failed (%v)", err)
}
}
}
// EncryptDecryptTestCase handles a full encryption-decryption cycle.
type EncryptDecryptTestCase[TConfig method.Config, TMethod method.Method] struct {
// ValidEncryptOnlyConfig is a configuration that has no decryption key and can only be used for encryption. The

View File

@@ -5,6 +5,11 @@
package method
import (
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
)
// Descriptor contains the details on an encryption method and produces a configuration structure with default values.
type Descriptor interface {
// ID returns the unique identifier used when parsing HCL or JSON configs.
@@ -16,5 +21,9 @@ type Descriptor interface {
// Common errors:
// - Returning a struct without a pointer
// - Returning a non-struct
ConfigStruct() Config
DecodeConfig(methodCtx EvalContext, body hcl.Body) (Config, hcl.Diagnostics)
}
type EvalContext struct {
ValueForExpression func(expr hcl.Expression) (cty.Value, hcl.Diagnostics)
}

View File

@@ -74,13 +74,6 @@ func runTest(t *testing.T, cmd []string) {
},
},
},
ConfigStructTestCases: map[string]compliancetest.ConfigStructTestCase[*Config, *command]{
"empty": {
Config: &Config{},
ValidBuild: false,
Validate: nil,
},
},
EncryptDecryptTestCase: compliancetest.EncryptDecryptTestCase[*Config, *command]{
ValidEncryptOnlyConfig: &Config{
Keys: &keyprovider.Output{

View File

@@ -6,33 +6,76 @@
package external
import (
"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"
)
// 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() Descriptor {
func New() method.Descriptor {
return &descriptor{}
}
type descriptor struct {
}
func (f *descriptor) TypedConfig() *Config {
return &Config{}
}
func (f *descriptor) ID() method.ID {
return "external"
}
func (f *descriptor) ConfigStruct() method.Config {
return f.TypedConfig()
func (d *descriptor) DecodeConfig(methodCtx method.EvalContext, body hcl.Body) (method.Config, hcl.Diagnostics) {
var diags hcl.Diagnostics
methodCfg := &Config{}
content, contentDiags := body.Content(&hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "keys", Required: false},
{Name: "encrypt_command", Required: true},
{Name: "decrypt_command", Required: true},
},
})
diags = diags.Extend(contentDiags)
if diags.HasErrors() {
return nil, diags
}
if keyAttr, ok := content.Attributes["keys"]; ok {
keyExpr := keyAttr.Expr
// keyExpr can either be raw data/references to raw data or a string reference to a key provider (JSON support)
keyVal, keyDiags := methodCtx.ValueForExpression(keyExpr)
diags = diags.Extend(keyDiags)
if diags.HasErrors() {
return nil, diags
}
keys, err := keyprovider.DecodeOutput(keyVal, keyExpr.Range())
if err != nil {
// TODO diags
panic(err)
}
methodCfg.Keys = &keys
}
encryptAttr := content.Attributes["encrypt_command"]
encryptVal, valueDiags := methodCtx.ValueForExpression(encryptAttr.Expr)
diags = diags.Extend(valueDiags)
if diags.HasErrors() {
return nil, diags
}
decodeDiags := gohcl.DecodeExpression(&hclsyntax.LiteralValueExpr{Val: encryptVal, SrcRange: encryptAttr.Expr.Range()}, nil, &methodCfg.EncryptCommand)
diags = diags.Extend(decodeDiags)
decryptAttr := content.Attributes["decrypt_command"]
decryptVal, valueDiags := methodCtx.ValueForExpression(decryptAttr.Expr)
diags = diags.Extend(valueDiags)
if diags.HasErrors() {
return nil, diags
}
decodeDiags = gohcl.DecodeExpression(&hclsyntax.LiteralValueExpr{Val: decryptVal, SrcRange: decryptAttr.Expr.Range()}, nil, &methodCfg.DecryptCommand)
diags = diags.Extend(decodeDiags)
return methodCfg, diags
}

View File

@@ -6,6 +6,7 @@
package unencrypted
import (
"github.com/hashicorp/hcl/v2"
"github.com/opentofu/opentofu/internal/encryption/config"
"github.com/opentofu/opentofu/internal/encryption/method"
)
@@ -19,8 +20,8 @@ type descriptor struct{}
func (f *descriptor) ID() method.ID {
return "unencrypted"
}
func (f *descriptor) ConfigStruct() method.Config {
return new(methodConfig)
func (d *descriptor) DecodeConfig(methodCtx method.EvalContext, body hcl.Body) (method.Config, hcl.Diagnostics) {
return new(methodConfig), nil
}
type methodConfig struct{}

View File

@@ -11,12 +11,12 @@ import (
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/encryption/config"
"github.com/opentofu/opentofu/internal/encryption/method"
"github.com/opentofu/opentofu/internal/encryption/registry"
"github.com/zclconf/go-cty/cty"
)
// setupMethod sets up a single method for encryption. It returns a list of diagnostics if the method is invalid.
@@ -43,37 +43,45 @@ func setupMethod(ctx context.Context, enc *config.EncryptionConfig, cfg config.M
}}
}
methodConfig := encryptionMethod.ConfigStruct()
methodCtx := method.EvalContext{func(expr hcl.Expression) (cty.Value, hcl.Diagnostics) {
var diags hcl.Diagnostics
deps, diags := gohcl.VariablesInBody(cfg.Body, methodConfig)
if diags.HasErrors() {
return nil, diags
}
deps := expr.Variables()
kpConfigs, refs, kpDiags := filterKeyProviderReferences(enc, deps)
diags = diags.Extend(kpDiags)
if diags.HasErrors() {
return nil, diags
}
kpConfigs, refs, kpDiags := filterKeyProviderReferences(enc, deps)
diags = diags.Extend(kpDiags)
if diags.HasErrors() {
return cty.NilVal, diags
}
hclCtx, kpDiags := setupKeyProviders(ctx, enc, kpConfigs, meta, reg, staticEval)
diags = diags.Extend(kpDiags)
if diags.HasErrors() {
return nil, diags
}
hclCtx, kpDiags := setupKeyProviders(ctx, enc, kpConfigs, meta, reg, staticEval)
diags = diags.Extend(kpDiags)
if diags.HasErrors() {
return cty.NilVal, diags
}
hclCtx, evalDiags := staticEval.EvalContextWithParent(ctx, hclCtx, configs.StaticIdentifier{
Module: addrs.RootModule,
Subject: fmt.Sprintf("encryption.method.%s.%s", cfg.Type, cfg.Name),
DeclRange: enc.DeclRange,
}, refs)
diags = diags.Extend(evalDiags)
if diags.HasErrors() {
return nil, diags
}
hclCtx, evalDiags := staticEval.EvalContextWithParent(ctx, hclCtx, configs.StaticIdentifier{
Module: addrs.RootModule,
Subject: fmt.Sprintf("encryption.method.%s.%s", cfg.Type, cfg.Name),
DeclRange: enc.DeclRange,
}, refs)
diags = diags.Extend(evalDiags)
if diags.HasErrors() {
return cty.NilVal, diags
}
methodDiags := gohcl.DecodeBody(cfg.Body, hclCtx, methodConfig)
diags = diags.Extend(methodDiags)
val, valDiags := expr.Value(hclCtx)
diags = diags.Extend(valDiags)
if diags.HasErrors() {
return cty.NilVal, diags
}
// TODO inspect to see if KP string!
return val, diags
}}
methodConfig, diags := encryptionMethod.DecodeConfig(methodCtx, cfg.Body)
if diags.HasErrors() {
return nil, diags
}

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"testing"
"github.com/hashicorp/hcl/v2"
"github.com/opentofu/opentofu/internal/encryption/method"
"github.com/opentofu/opentofu/internal/encryption/registry"
)
@@ -124,8 +125,8 @@ func (t testMethodDescriptor) ID() method.ID {
return t.id
}
func (t testMethodDescriptor) ConfigStruct() method.Config {
return &testMethodConfig{}
func (t testMethodDescriptor) DecodeConfig(method.EvalContext, hcl.Body) (method.Config, hcl.Diagnostics) {
return &testMethodConfig{}, nil
}
type testMethodConfig struct {