From 28344fdf478460a137aacbd1057030d4ef32534a Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Thu, 4 Jun 2026 20:28:54 -0300 Subject: [PATCH 01/18] PoC(keystore::plugin): Proof of Concept start Signed-off-by: Caio Rocha de Oliveira --- config/config.go | 12 +++ plugin/plugin-interface.go | 151 +++++++++++++++++++++++++++++++++++++ plugin/plugin_test.go | 52 +++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 plugin/plugin-interface.go create mode 100644 plugin/plugin_test.go diff --git a/config/config.go b/config/config.go index 511df1bc15..5ea22ed837 100644 --- a/config/config.go +++ b/config/config.go @@ -19,6 +19,7 @@ import ( "github.com/getsops/sops/v3/hcvault" "github.com/getsops/sops/v3/kms" "github.com/getsops/sops/v3/pgp" + "github.com/getsops/sops/v3/plugin" "github.com/getsops/sops/v3/publish" "go.yaml.in/yaml/v3" ) @@ -129,6 +130,11 @@ type configFile struct { Stores StoresConfig `yaml:"stores"` } +type pluginKey struct { + BinaryName string `yaml:"binary_name"` + Config map[string]any `yaml:"config"` +} + type keyGroup struct { Merge []keyGroup `yaml:"merge"` KMS []kmsKey `yaml:"kms"` @@ -138,6 +144,7 @@ type keyGroup struct { Vault []string `yaml:"hc_vault"` Age []string `yaml:"age"` PGP []string `yaml:"pgp"` + Plugin []pluginKey `yaml:"plugins"` } type gcpKmsKey struct { @@ -357,6 +364,11 @@ func extractMasterKeys(group keyGroup) (sops.KeyGroup, error) { return nil, err } } + + for _, k := range group.Plugin { + keyGroup = append(keyGroup, plugin.NewMasterKey(k.BinaryName, k.Config)) + } + return deduplicateKeygroup(keyGroup), nil } diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go new file mode 100644 index 0000000000..4bdfbf18c4 --- /dev/null +++ b/plugin/plugin-interface.go @@ -0,0 +1,151 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "time" +) + +const ( + // KeyTypeIdentifier is the string used to identify a Plugin MasterKey + // in the metadata of an encrypted file. + KeyTypeIdentifier = "plugin" +) + +// MasterKey is a generic plugin wrapper that satisfies the SOPS MasterKey interface. +// It bridges the SOPS Go Core with external external executables via stdin/stdout. +type MasterKey struct { + BinaryName string + PluginConfig map[string]any + EncryptedKey string + CreationDate time.Time +} + +func NewMasterKey(binaryName string, config map[string]any) *MasterKey { + return &MasterKey{ + BinaryName: binaryName, + PluginConfig: config, + CreationDate: time.Now().UTC(), + } +} + +func (key *MasterKey) TypeToIdentifier() string { + return KeyTypeIdentifier +} + +func (key *MasterKey) ToString() string { + return fmt.Sprintf("plugin:%s", key.BinaryName) +} + +func (key MasterKey) ToMap() map[string]any { + out := make(map[string]any) + out["binary_name"] = key.BinaryName + + out["config"] = key.PluginConfig + + out["enc"] = key.EncryptedKey + out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339) + return out +} + +func (key *MasterKey) SetEncryptedDataKey(enc []byte) { + key.EncryptedKey = string(enc) +} + +func (key *MasterKey) EncryptedDataKey() []byte { + return []byte(key.EncryptedKey) +} + +func (key *MasterKey) EncryptIfNeeded(dataKey []byte) error { + if key.EncryptedKey == "" { + return key.Encrypt(dataKey) + } + return nil +} + +func (key *MasterKey) NeedsRotation() bool { + return time.Since(key.CreationDate) > (time.Hour * 24 * 30 * 6) +} + +// Encrypt takes a SOPS data key, encrypts it via the external plugin, and stores +// the result in the EncryptedKey field. +func (key *MasterKey) Encrypt(dataKey []byte) error { + return key.EncryptContext(context.Background(), dataKey) +} + +func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error { + req := map[string]any{ + "action": "encrypt", + "config": key.PluginConfig, + "plaintext": dataKey, + } + + resp, err := executePlugin(ctx, key.BinaryName, req) + if err != nil { + return err + } + + key.EncryptedKey = resp.Ciphertext + return nil +} + +// Decrypt decrypts the EncryptedKey field via the external plugin and returns the result. +func (key *MasterKey) Decrypt() ([]byte, error) { + return key.DecryptContext(context.Background()) +} + +func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { + req := map[string]any{ + "action": "decrypt", + "config": key.PluginConfig, + "ciphertext": key.EncryptedKey, + } + + resp, err := executePlugin(ctx, key.BinaryName, req) + if err != nil { + return nil, err + } + + return resp.Plaintext, nil +} + +// PluginResponse is the contract we expect from the plugin's stdout. +type PluginResponse struct { + Plaintext []byte `json:"plaintext,omitempty"` + Ciphertext string `json:"ciphertext,omitempty"` + Error string `json:"error,omitempty"` +} + +// executePlugin is the IPC Sandbox +func executePlugin(ctx context.Context, binaryName string, req map[string]any) (*PluginResponse, error) { + // Binary naming convention: sops-plugin- + executableName := fmt.Sprintf("sops-plugin-%s", binaryName) + cmd := exec.CommandContext(ctx, executableName) + + reqBytes, _ := json.Marshal(req) + cmd.Stdin = bytes.NewReader(reqBytes) + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("plugin execution failed (%s): %v. Stderr: %s", executableName, err, stderr.String()) + } + + var resp PluginResponse + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("plugin %s violated IPC contract (invalid JSON): %v", executableName, err) + } + + if resp.Error != "" { + return nil, fmt.Errorf("plugin %s error: %s", executableName, resp.Error) + } + + return &resp, nil +} diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go new file mode 100644 index 0000000000..72a710cb5e --- /dev/null +++ b/plugin/plugin_test.go @@ -0,0 +1,52 @@ +package plugin + +import ( + "os/exec" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +const dummyPluginCode = `package main +import ( + "encoding/json" + "fmt" + "os" +) +func main() { + var req map[string]any + json.NewDecoder(os.Stdin).Decode(&req) + + action := req["action"].(string) + if action == "encrypt" { + fmt.Println("{\"ciphertext\": \"cifra-secreta\"}") + } else if action == "decrypt" { + fmt.Println("{\"plaintext\": \"dGV4dG8tY2xhcm8=\"}") // base64 de "texto-claro" + } +} +` + +func TestPluginIPC(t *testing.T) { + tmpDir := t.TempDir() + pluginPath := filepath.Join(tmpDir, "sops-plugin-dummy") + + srcFile := filepath.Join(tmpDir, "main.go") + os.WriteFile(srcFile, []byte(dummyPluginCode), 0644) + + err := exec.Command("go", "build", "-o", pluginPath, srcFile).Run() + assert.NoError(t, err, "failed to compile dummy plugin") + + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + key := NewMasterKey("dummy", map[string]any{"minha_config": "valor"}) + + err = key.Encrypt([]byte("texto-claro")) + assert.NoError(t, err) + assert.Equal(t, "cifra-secreta", string(key.EncryptedDataKey())) + + plaintext, err := key.Decrypt() + assert.NoError(t, err) + assert.Equal(t, "texto-claro", string(plaintext)) +} From 15cb869ff7ea3c4d0223742a669376dc834bae3f Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Thu, 4 Jun 2026 20:44:13 -0300 Subject: [PATCH 02/18] lint Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin-interface.go | 181 ++++++++++++++++++++----------------- 1 file changed, 97 insertions(+), 84 deletions(-) diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index 4bdfbf18c4..f26c886ff7 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -1,151 +1,164 @@ package plugin import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os/exec" - "time" + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "time" ) const ( - // KeyTypeIdentifier is the string used to identify a Plugin MasterKey - // in the metadata of an encrypted file. - KeyTypeIdentifier = "plugin" + // KeyTypeIdentifier is the string used to identify a Plugin MasterKey + // in the metadata of an encrypted file. + KeyTypeIdentifier = "plugin" ) // MasterKey is a generic plugin wrapper that satisfies the SOPS MasterKey interface. // It bridges the SOPS Go Core with external external executables via stdin/stdout. type MasterKey struct { - BinaryName string - PluginConfig map[string]any - EncryptedKey string - CreationDate time.Time + BinaryName string + PluginConfig map[string]any + EncryptedKey string + CreationDate time.Time } func NewMasterKey(binaryName string, config map[string]any) *MasterKey { - return &MasterKey{ - BinaryName: binaryName, - PluginConfig: config, - CreationDate: time.Now().UTC(), - } + return &MasterKey{ + BinaryName: binaryName, + PluginConfig: config, + CreationDate: time.Now().UTC(), + } } func (key *MasterKey) TypeToIdentifier() string { - return KeyTypeIdentifier + return KeyTypeIdentifier } func (key *MasterKey) ToString() string { - return fmt.Sprintf("plugin:%s", key.BinaryName) + return fmt.Sprintf("plugin:%s", key.BinaryName) } func (key MasterKey) ToMap() map[string]any { - out := make(map[string]any) - out["binary_name"] = key.BinaryName + out := make(map[string]any) + out["binary_name"] = key.BinaryName - out["config"] = key.PluginConfig + out["config"] = key.PluginConfig - out["enc"] = key.EncryptedKey - out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339) - return out + out["enc"] = key.EncryptedKey + out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339) + return out } func (key *MasterKey) SetEncryptedDataKey(enc []byte) { - key.EncryptedKey = string(enc) + key.EncryptedKey = string(enc) } func (key *MasterKey) EncryptedDataKey() []byte { - return []byte(key.EncryptedKey) + return []byte(key.EncryptedKey) } func (key *MasterKey) EncryptIfNeeded(dataKey []byte) error { - if key.EncryptedKey == "" { - return key.Encrypt(dataKey) - } - return nil + if key.EncryptedKey == "" { + return key.Encrypt(dataKey) + } + return nil } func (key *MasterKey) NeedsRotation() bool { - return time.Since(key.CreationDate) > (time.Hour * 24 * 30 * 6) + return time.Since(key.CreationDate) > (time.Hour * 24 * 30 * 6) } // Encrypt takes a SOPS data key, encrypts it via the external plugin, and stores // the result in the EncryptedKey field. func (key *MasterKey) Encrypt(dataKey []byte) error { - return key.EncryptContext(context.Background(), dataKey) + return key.EncryptContext(context.Background(), dataKey) } func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error { - req := map[string]any{ - "action": "encrypt", - "config": key.PluginConfig, - "plaintext": dataKey, - } - - resp, err := executePlugin(ctx, key.BinaryName, req) - if err != nil { - return err - } - - key.EncryptedKey = resp.Ciphertext - return nil + req := map[string]any{ + "action": "encrypt", + "config": key.PluginConfig, + "plaintext": dataKey, + } + + resp, err := executePlugin(ctx, key.BinaryName, req) + if err != nil { + return err + } + + key.EncryptedKey = resp.Ciphertext + return nil } // Decrypt decrypts the EncryptedKey field via the external plugin and returns the result. func (key *MasterKey) Decrypt() ([]byte, error) { - return key.DecryptContext(context.Background()) + return key.DecryptContext(context.Background()) } func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { - req := map[string]any{ - "action": "decrypt", - "config": key.PluginConfig, - "ciphertext": key.EncryptedKey, - } - - resp, err := executePlugin(ctx, key.BinaryName, req) - if err != nil { - return nil, err - } - - return resp.Plaintext, nil + req := map[string]any{ + "action": "decrypt", + "config": key.PluginConfig, + "ciphertext": key.EncryptedKey, + } + + resp, err := executePlugin(ctx, key.BinaryName, req) + if err != nil { + return nil, err + } + + return resp.Plaintext, nil } // PluginResponse is the contract we expect from the plugin's stdout. type PluginResponse struct { - Plaintext []byte `json:"plaintext,omitempty"` - Ciphertext string `json:"ciphertext,omitempty"` - Error string `json:"error,omitempty"` + Plaintext []byte `json:"plaintext,omitempty"` + Ciphertext string `json:"ciphertext,omitempty"` + Error string `json:"error,omitempty"` } // executePlugin is the IPC Sandbox -func executePlugin(ctx context.Context, binaryName string, req map[string]any) (*PluginResponse, error) { +func executePlugin( + ctx context.Context, + binaryName string, + req map[string]any, +) (*PluginResponse, error) { // Binary naming convention: sops-plugin- - executableName := fmt.Sprintf("sops-plugin-%s", binaryName) - cmd := exec.CommandContext(ctx, executableName) + executableName := fmt.Sprintf("sops-plugin-%s", binaryName) + cmd := exec.CommandContext(ctx, executableName) - reqBytes, _ := json.Marshal(req) - cmd.Stdin = bytes.NewReader(reqBytes) + reqBytes, _ := json.Marshal(req) + cmd.Stdin = bytes.NewReader(reqBytes) - var stdout bytes.Buffer - cmd.Stdout = &stdout + var stdout bytes.Buffer + cmd.Stdout = &stdout var stderr bytes.Buffer cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("plugin execution failed (%s): %v. Stderr: %s", executableName, err, stderr.String()) - } - - var resp PluginResponse - if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { - return nil, fmt.Errorf("plugin %s violated IPC contract (invalid JSON): %v", executableName, err) - } - - if resp.Error != "" { - return nil, fmt.Errorf("plugin %s error: %s", executableName, resp.Error) - } - - return &resp, nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf( + "plugin execution failed (%s): %v. Stderr: %s", + executableName, + err, + stderr.String(), + ) + } + + var resp PluginResponse + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + return nil, fmt.Errorf( + "plugin %s violated IPC contract (invalid JSON): %v", + executableName, + err, + ) + } + + if resp.Error != "" { + return nil, fmt.Errorf("plugin %s error: %s", executableName, resp.Error) + } + + return &resp, nil } From b3a4d5b1693f754875bc29859a6d70b1d01bfbec Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Thu, 4 Jun 2026 21:13:30 -0300 Subject: [PATCH 03/18] better handling Signed-off-by: Caio Rocha de Oliveira --- config/config.go | 4 ++++ plugin/plugin-interface.go | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index 5ea22ed837..dcbb8263f7 100644 --- a/config/config.go +++ b/config/config.go @@ -194,6 +194,7 @@ type creationRule struct { VaultURI interface{} `yaml:"hc_vault_transit_uri"` // string or []string KeyGroups []keyGroup `yaml:"key_groups"` ShamirThreshold int `yaml:"shamir_threshold"` + Plugin []pluginKey `yaml:"plugins"` UnencryptedSuffix string `yaml:"unencrypted_suffix"` EncryptedSuffix string `yaml:"encrypted_suffix"` UnencryptedRegex string `yaml:"unencrypted_regex"` @@ -458,6 +459,9 @@ func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[ keyGroup = append(keyGroup, k) } groups = append(groups, keyGroup) + for _, p := range cRule.Plugin { + keyGroup = append(keyGroup, plugin.NewMasterKey(p.BinaryName, p.Config)) + } } return groups, nil } diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index f26c886ff7..315a5593f9 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os/exec" + "regexp" "time" ) @@ -67,7 +69,9 @@ func (key *MasterKey) EncryptIfNeeded(dataKey []byte) error { } func (key *MasterKey) NeedsRotation() bool { - return time.Since(key.CreationDate) > (time.Hour * 24 * 30 * 6) + // for Now, we have no criteria for rotation of plugin-based keys, so we'll just return false. + // Maybe use a config inside of key?? + return false } // Encrypt takes a SOPS data key, encrypts it via the external plugin, and stores @@ -84,10 +88,16 @@ func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error } resp, err := executePlugin(ctx, key.BinaryName, req) + validBinaryName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + if err != nil { return err } + if !validBinaryName.MatchString(key.BinaryName) { + return fmt.Errorf("invalid binary name: only alphanumeric, dashes, and underscores allowed") + } + key.EncryptedKey = resp.Ciphertext return nil } @@ -104,6 +114,12 @@ func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { "ciphertext": key.EncryptedKey, } + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 10*time.Second) + defer cancel() + } + resp, err := executePlugin(ctx, key.BinaryName, req) if err != nil { return nil, err @@ -139,6 +155,16 @@ func executePlugin( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return nil, fmt.Errorf("plugin execution timed out (%s)", executableName) + } + if errors.Is(err, exec.ErrNotFound) { + return nil, fmt.Errorf( + "plugin executable not found: %s. Please ensure that %s is installed and in your PATH", + executableName, + executableName, + ) + } return nil, fmt.Errorf( "plugin execution failed (%s): %v. Stderr: %s", executableName, From 35c8b5110aa20680c193b39e547ee1f9c7712388 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Thu, 4 Jun 2026 21:41:14 -0300 Subject: [PATCH 04/18] fix order Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin-interface.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index 315a5593f9..ce19ad69d0 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -87,17 +87,16 @@ func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error "plaintext": dataKey, } - resp, err := executePlugin(ctx, key.BinaryName, req) validBinaryName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + if !validBinaryName.MatchString(key.BinaryName) { + return fmt.Errorf("invalid binary name: only alphanumeric, dashes, and underscores allowed") + } + resp, err := executePlugin(ctx, key.BinaryName, req) if err != nil { return err } - if !validBinaryName.MatchString(key.BinaryName) { - return fmt.Errorf("invalid binary name: only alphanumeric, dashes, and underscores allowed") - } - key.EncryptedKey = resp.Ciphertext return nil } From 60ba87e8e6696f84c117f3bcb18357d82adc75ba Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Thu, 4 Jun 2026 21:42:24 -0300 Subject: [PATCH 05/18] fix order Signed-off-by: Caio Rocha de Oliveira --- config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index dcbb8263f7..5a2b5536b0 100644 --- a/config/config.go +++ b/config/config.go @@ -458,10 +458,10 @@ func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[ for _, k := range vaultKeys { keyGroup = append(keyGroup, k) } - groups = append(groups, keyGroup) for _, p := range cRule.Plugin { keyGroup = append(keyGroup, plugin.NewMasterKey(p.BinaryName, p.Config)) } + groups = append(groups, keyGroup) } return groups, nil } From 063c8552d04c9f9e40fbe622482fd30af8061e59 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Thu, 4 Jun 2026 23:31:25 -0300 Subject: [PATCH 06/18] feat(keystore): add granular timeouts Signed-off-by: Caio Rocha de Oliveira --- config/config.go | 18 ++++++++++++++---- plugin/plugin-interface.go | 33 +++++++++++++++++++++++++++++++-- plugin/plugin_test.go | 2 +- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/config/config.go b/config/config.go index 5a2b5536b0..0c38a01492 100644 --- a/config/config.go +++ b/config/config.go @@ -131,6 +131,7 @@ type configFile struct { } type pluginKey struct { + Timeout string `yaml:"timeout,omitempty"` BinaryName string `yaml:"binary_name"` Config map[string]any `yaml:"config"` } @@ -184,6 +185,7 @@ type destinationRule struct { type creationRule struct { PathRegex string `yaml:"path_regex"` + Timeout string `yaml:"timeout,omitempty"` KMS interface{} `yaml:"kms"` // string or []string AwsProfile string `yaml:"aws_profile"` Age interface{} `yaml:"age"` // string or []string @@ -366,9 +368,12 @@ func extractMasterKeys(group keyGroup) (sops.KeyGroup, error) { } } - for _, k := range group.Plugin { - keyGroup = append(keyGroup, plugin.NewMasterKey(k.BinaryName, k.Config)) - } + + for _, p := range group.Plugin { + resolvedTimeout := p.Timeout + mKey := plugin.NewMasterKey(p.BinaryName, p.Config, resolvedTimeout) + keyGroup = append(keyGroup, mKey) + } return deduplicateKeygroup(keyGroup), nil } @@ -459,7 +464,12 @@ func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[ keyGroup = append(keyGroup, k) } for _, p := range cRule.Plugin { - keyGroup = append(keyGroup, plugin.NewMasterKey(p.BinaryName, p.Config)) + resolvedTimeout := p.Timeout + if resolvedTimeout == "" { + resolvedTimeout = cRule.Timeout + } + mKey := plugin.NewMasterKey(p.BinaryName, p.Config, resolvedTimeout) + keyGroup = append(keyGroup, mKey) } groups = append(groups, keyGroup) } diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index ce19ad69d0..0290724bd4 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -23,10 +23,11 @@ type MasterKey struct { BinaryName string PluginConfig map[string]any EncryptedKey string + Timeout string CreationDate time.Time } -func NewMasterKey(binaryName string, config map[string]any) *MasterKey { +func NewMasterKey(binaryName string, config map[string]any, timeout string) *MasterKey { return &MasterKey{ BinaryName: binaryName, PluginConfig: config, @@ -87,6 +88,12 @@ func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error "plaintext": dataKey, } + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, key.getTimeout()) + defer cancel() + } + validBinaryName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) if !validBinaryName.MatchString(key.BinaryName) { return fmt.Errorf("invalid binary name: only alphanumeric, dashes, and underscores allowed") @@ -97,6 +104,10 @@ func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error return err } + if resp.Ciphertext == "" { + return fmt.Errorf("plugin did not return ciphertext") + } + key.EncryptedKey = resp.Ciphertext return nil } @@ -124,6 +135,10 @@ func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { return nil, err } + if resp.Plaintext == nil { + return nil, fmt.Errorf("plugin did not return plaintext") + } + return resp.Plaintext, nil } @@ -144,7 +159,10 @@ func executePlugin( executableName := fmt.Sprintf("sops-plugin-%s", binaryName) cmd := exec.CommandContext(ctx, executableName) - reqBytes, _ := json.Marshal(req) + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal plugin request: %v", err) + } cmd.Stdin = bytes.NewReader(reqBytes) var stdout bytes.Buffer @@ -187,3 +205,14 @@ func executePlugin( return &resp, nil } + +// getTimeout is a helper function to parse the timeout string from the MasterKey config. +// falls back to 10s. +func (key *MasterKey) getTimeout() time.Duration { + if key.Timeout != "" { + if timeout, err := time.ParseDuration(key.Timeout); err == nil { + return timeout + } + } + return 10 * time.Second +} diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index 72a710cb5e..cfdbdd56d1 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -40,7 +40,7 @@ func TestPluginIPC(t *testing.T) { t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) - key := NewMasterKey("dummy", map[string]any{"minha_config": "valor"}) + key := NewMasterKey("dummy", map[string]any{"minha_config": "valor"}, "10s") err = key.Encrypt([]byte("texto-claro")) assert.NoError(t, err) From 007e7087f943a4624d8fe4aa32308d2cdec5f9df Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Thu, 4 Jun 2026 23:59:10 -0300 Subject: [PATCH 07/18] feat?(keystore): instanceId Signed-off-by: Caio Rocha de Oliveira --- config/config.go | 5 +++-- plugin/plugin-interface.go | 22 +++++++++++++++++++++- plugin/plugin_test.go | 8 ++++---- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/config/config.go b/config/config.go index 0c38a01492..9cb1912512 100644 --- a/config/config.go +++ b/config/config.go @@ -133,6 +133,7 @@ type configFile struct { type pluginKey struct { Timeout string `yaml:"timeout,omitempty"` BinaryName string `yaml:"binary_name"` + InstanceID string `yaml:"instance_id,omitempty"` Config map[string]any `yaml:"config"` } @@ -371,7 +372,7 @@ func extractMasterKeys(group keyGroup) (sops.KeyGroup, error) { for _, p := range group.Plugin { resolvedTimeout := p.Timeout - mKey := plugin.NewMasterKey(p.BinaryName, p.Config, resolvedTimeout) + mKey := plugin.NewMasterKey(p.BinaryName, p.Config, resolvedTimeout, p.InstanceID) keyGroup = append(keyGroup, mKey) } @@ -468,7 +469,7 @@ func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[ if resolvedTimeout == "" { resolvedTimeout = cRule.Timeout } - mKey := plugin.NewMasterKey(p.BinaryName, p.Config, resolvedTimeout) + mKey := plugin.NewMasterKey(p.BinaryName, p.Config, resolvedTimeout, p.InstanceID) keyGroup = append(keyGroup, mKey) } groups = append(groups, keyGroup) diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index 0290724bd4..5419f46ace 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -22,14 +22,19 @@ const ( type MasterKey struct { BinaryName string PluginConfig map[string]any + InstanceID string EncryptedKey string Timeout string CreationDate time.Time } -func NewMasterKey(binaryName string, config map[string]any, timeout string) *MasterKey { +func NewMasterKey(binaryName string, config map[string]any, timeout string, instanceID string) *MasterKey { + if instanceID == "" { + instanceID = "default" + } return &MasterKey{ BinaryName: binaryName, + InstanceID: instanceID, PluginConfig: config, CreationDate: time.Now().UTC(), } @@ -43,6 +48,17 @@ func (key *MasterKey) ToString() string { return fmt.Sprintf("plugin:%s", key.BinaryName) } +func (key *MasterKey) GetEnvPrefix() string { + reg := regexp.MustCompile(`[^a-zA-Z0-9_]`) + normalized := reg.ReplaceAllString(key.InstanceID, "_") + + // we put a trailing underscore to make it easier for users to append their own suffixes in the plugin. + // e.g, if instanceID is "my-vault", env prefix will be "SOPS_PLUGIN_MY_VAULT_", + // and users can then use env vars like "SOPS_PLUGIN_MY_VAULT_TOKEN + // or "SOPS_PLUGIN_MY_VAULT_KEY" in their plugin implementation. + return SOPS_PLUGIN + strings.ToUpper(normalized) + "_" +} + func (key MasterKey) ToMap() map[string]any { out := make(map[string]any) out["binary_name"] = key.BinaryName @@ -84,6 +100,8 @@ func (key *MasterKey) Encrypt(dataKey []byte) error { func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error { req := map[string]any{ "action": "encrypt", + "instance_id": key.InstanceID, + "env_prefix": key.GetEnvPrefix(), "config": key.PluginConfig, "plaintext": dataKey, } @@ -120,6 +138,8 @@ func (key *MasterKey) Decrypt() ([]byte, error) { func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { req := map[string]any{ "action": "decrypt", + "instance_id": key.InstanceID, + "env_prefix": key.GetEnvPrefix(), "config": key.PluginConfig, "ciphertext": key.EncryptedKey, } diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index cfdbdd56d1..6c9746eae2 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -23,7 +23,7 @@ func main() { if action == "encrypt" { fmt.Println("{\"ciphertext\": \"cifra-secreta\"}") } else if action == "decrypt" { - fmt.Println("{\"plaintext\": \"dGV4dG8tY2xhcm8=\"}") // base64 de "texto-claro" + fmt.Println("{\"plaintext\": \"dGV4dG8tY2xhcm8=\"}") } } ` @@ -40,13 +40,13 @@ func TestPluginIPC(t *testing.T) { t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) - key := NewMasterKey("dummy", map[string]any{"minha_config": "valor"}, "10s") + key := NewMasterKey("dummy", map[string]any{"my_config": "value"}, "10s", "dummy") err = key.Encrypt([]byte("texto-claro")) assert.NoError(t, err) - assert.Equal(t, "cifra-secreta", string(key.EncryptedDataKey())) + assert.Equal(t, "secret_cypher", string(key.EncryptedDataKey())) plaintext, err := key.Decrypt() assert.NoError(t, err) - assert.Equal(t, "texto-claro", string(plaintext)) + assert.Equal(t, "plain-as-day", string(plaintext)) } From 5e7c0b63526e2cf26e7e4f2d5234c364775cf97d Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Fri, 5 Jun 2026 00:01:00 -0300 Subject: [PATCH 08/18] fix(keystore::plugin): not having a lsp cause of formating is weird Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin-interface.go | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index 5419f46ace..e8b344f6c3 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -8,6 +8,7 @@ import ( "fmt" "os/exec" "regexp" + "strings" "time" ) @@ -28,7 +29,12 @@ type MasterKey struct { CreationDate time.Time } -func NewMasterKey(binaryName string, config map[string]any, timeout string, instanceID string) *MasterKey { +func NewMasterKey( + binaryName string, + config map[string]any, + timeout string, + instanceID string, +) *MasterKey { if instanceID == "" { instanceID = "default" } @@ -56,7 +62,7 @@ func (key *MasterKey) GetEnvPrefix() string { // e.g, if instanceID is "my-vault", env prefix will be "SOPS_PLUGIN_MY_VAULT_", // and users can then use env vars like "SOPS_PLUGIN_MY_VAULT_TOKEN // or "SOPS_PLUGIN_MY_VAULT_KEY" in their plugin implementation. - return SOPS_PLUGIN + strings.ToUpper(normalized) + "_" + return "SOPS_PLUGIN" + strings.ToUpper(normalized) + "_" } func (key MasterKey) ToMap() map[string]any { @@ -99,11 +105,11 @@ func (key *MasterKey) Encrypt(dataKey []byte) error { func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error { req := map[string]any{ - "action": "encrypt", + "action": "encrypt", "instance_id": key.InstanceID, - "env_prefix": key.GetEnvPrefix(), - "config": key.PluginConfig, - "plaintext": dataKey, + "env_prefix": key.GetEnvPrefix(), + "config": key.PluginConfig, + "plaintext": dataKey, } if _, ok := ctx.Deadline(); !ok { @@ -137,11 +143,11 @@ func (key *MasterKey) Decrypt() ([]byte, error) { func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { req := map[string]any{ - "action": "decrypt", - "instance_id": key.InstanceID, + "action": "decrypt", + "instance_id": key.InstanceID, "env_prefix": key.GetEnvPrefix(), - "config": key.PluginConfig, - "ciphertext": key.EncryptedKey, + "config": key.PluginConfig, + "ciphertext": key.EncryptedKey, } if _, ok := ctx.Deadline(); !ok { From 0318a6fe09b4485ff14e7ea9c9e4dd0e94288884 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Fri, 5 Jun 2026 00:05:27 -0300 Subject: [PATCH 09/18] fix(plugin): Now uses binaryName instead of default Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin-interface.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index e8b344f6c3..b203a88d0a 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -36,7 +36,7 @@ func NewMasterKey( instanceID string, ) *MasterKey { if instanceID == "" { - instanceID = "default" + instanceID = binaryName } return &MasterKey{ BinaryName: binaryName, From 2e6a6c6259d8b14b720ddd963ad4b09b97ef9f34 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Fri, 5 Jun 2026 00:39:11 -0300 Subject: [PATCH 10/18] fix(test::plugin): localization Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin_test.go | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index 6c9746eae2..6a0b6d2438 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -1,12 +1,12 @@ package plugin import ( + "os" "os/exec" - "os" - "path/filepath" - "testing" + "path/filepath" + "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/assert" ) const dummyPluginCode = `package main @@ -21,32 +21,32 @@ func main() { action := req["action"].(string) if action == "encrypt" { - fmt.Println("{\"ciphertext\": \"cifra-secreta\"}") + fmt.Println("{\"ciphertext\": \"secret_cypher\"}") } else if action == "decrypt" { - fmt.Println("{\"plaintext\": \"dGV4dG8tY2xhcm8=\"}") + fmt.Println("{\"plaintext\": \"cGxhaW4tYXMtZGF5Cg==\"}") } } ` func TestPluginIPC(t *testing.T) { - tmpDir := t.TempDir() - pluginPath := filepath.Join(tmpDir, "sops-plugin-dummy") + tmpDir := t.TempDir() + pluginPath := filepath.Join(tmpDir, "sops-plugin-dummy") - srcFile := filepath.Join(tmpDir, "main.go") - os.WriteFile(srcFile, []byte(dummyPluginCode), 0644) + srcFile := filepath.Join(tmpDir, "main.go") + os.WriteFile(srcFile, []byte(dummyPluginCode), 0o644) - err := exec.Command("go", "build", "-o", pluginPath, srcFile).Run() - assert.NoError(t, err, "failed to compile dummy plugin") + err := exec.Command("go", "build", "-o", pluginPath, srcFile).Run() + assert.NoError(t, err, "failed to compile dummy plugin") - t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) - key := NewMasterKey("dummy", map[string]any{"my_config": "value"}, "10s", "dummy") + key := NewMasterKey("dummy", map[string]any{"my_config": "value"}, "10s", "dummy") - err = key.Encrypt([]byte("texto-claro")) - assert.NoError(t, err) - assert.Equal(t, "secret_cypher", string(key.EncryptedDataKey())) + err = key.Encrypt([]byte("plain-as-day\n")) + assert.NoError(t, err) + assert.Equal(t, "secret_cypher", string(key.EncryptedDataKey())) - plaintext, err := key.Decrypt() - assert.NoError(t, err) - assert.Equal(t, "plain-as-day", string(plaintext)) + plaintext, err := key.Decrypt() + assert.NoError(t, err) + assert.Equal(t, "plain-as-day\n", string(plaintext)) } From 762e42e674e7640b5229787bd08a8d17d5fc8650 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Fri, 5 Jun 2026 09:55:32 -0300 Subject: [PATCH 11/18] add(metadata): plugins in the metadata Signed-off-by: Caio Rocha de Oliveira --- stores/stores.go | 61 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/stores/stores.go b/stores/stores.go index b1b496dc76..d1d37c07aa 100644 --- a/stores/stores.go +++ b/stores/stores.go @@ -23,6 +23,7 @@ import ( "github.com/getsops/sops/v3/hcvault" "github.com/getsops/sops/v3/kms" "github.com/getsops/sops/v3/pgp" + "github.com/getsops/sops/v3/plugin" ) const ( @@ -37,6 +38,7 @@ const ( type metadata struct { ShamirThreshold int `mapstructure:"shamir_threshold,omitempty"` KeyGroups []keygroup `mapstructure:"key_groups,omitempty,deep"` + PluginKeys []pluginkey `mapstructure:"plugins,omitempty,deep"` KMSKeys []kmskey `mapstructure:"kms,omitempty,deep"` GCPKMSKeys []gcpkmskey `mapstructure:"gcp_kms,omitempty,deep"` HCKmsKeys []hckmskey `mapstructure:"hckms,omitempty,deep"` @@ -58,6 +60,7 @@ type metadata struct { type keygroup struct { PGPKeys []pgpkey `mapstructure:"pgp,omitempty,deep"` + PluginKeys []pluginkey `mapstructure:"plugins,omitempty,deep"` KMSKeys []kmskey `mapstructure:"kms,omitempty,deep"` GCPKMSKeys []gcpkmskey `mapstructure:"gcp_kms,omitempty,deep"` HCKmsKeys []hckmskey `mapstructure:"hckms,omitempty,deep"` @@ -72,6 +75,15 @@ type pgpkey struct { Fingerprint string `mapstructure:"fp"` } +type pluginkey struct { + Timeout string `mapstructure:"timeout,omitempty"` + BinaryName string `mapstructure:"binary_name"` + InstanceID string `mapstructure:"instance_id,omitempty"` + CreatedAt string `mapstructure:"created_at"` + EncryptedDataKey string `mapstructure:"enc"` + Config map[string]any `mapstructure:"config"` +} + type kmskey struct { Arn string `mapstructure:"arn"` Role string `mapstructure:"role,omitempty"` @@ -138,6 +150,7 @@ func metadataFromInternal(sopsMetadata sops.Metadata) metadata { m.VaultKeys = vaultKeysFromGroup(group) m.AzureKeyVaultKeys = azkvKeysFromGroup(group) m.AgeKeys = ageKeysFromGroup(group) + m.PluginKeys = pluginKeysFromGroup(group) } else { for _, group := range sopsMetadata.KeyGroups { m.KeyGroups = append(m.KeyGroups, keygroup{ @@ -148,6 +161,7 @@ func metadataFromInternal(sopsMetadata sops.Metadata) metadata { VaultKeys: vaultKeysFromGroup(group), AzureKeyVaultKeys: azkvKeysFromGroup(group), AgeKeys: ageKeysFromGroup(group), + PluginKeys: pluginKeysFromGroup(group), }) } } @@ -244,6 +258,23 @@ func ageKeysFromGroup(group sops.KeyGroup) (keys []agekey) { return } +func pluginKeysFromGroup(group sops.KeyGroup) (keys []pluginkey) { + for _, key := range group { + switch key := key.(type) { + case *plugin.MasterKey: + keys = append(keys, pluginkey{ + BinaryName: key.BinaryName, + InstanceID: key.InstanceID, + Config: key.PluginConfig, + EncryptedDataKey: key.EncryptedKey, + CreatedAt: key.CreationDate.Format(time.RFC3339), + Timeout: key.Timeout, // Salva o timeout resolvido no arquivo + }) + } + } + return keys +} + func hckmsKeysFromGroup(group sops.KeyGroup) (keys []hckmskey) { for _, key := range group { switch key := key.(type) { @@ -312,7 +343,7 @@ func (m *metadata) ToInternal() (sops.Metadata, error) { }, nil } -func internalGroupFrom(kmsKeys []kmskey, pgpKeys []pgpkey, gcpKmsKeys []gcpkmskey, hckmsKeys []hckmskey, azkvKeys []azkvkey, vaultKeys []vaultkey, ageKeys []agekey) (sops.KeyGroup, error) { +func internalGroupFrom(kmsKeys []kmskey, pgpKeys []pgpkey, gcpKmsKeys []gcpkmskey, hckmsKeys []hckmskey, azkvKeys []azkvkey, vaultKeys []vaultkey, ageKeys []agekey, pluginKeys []pluginkey) (sops.KeyGroup, error) { var internalGroup sops.KeyGroup for _, kmsKey := range kmsKeys { k, err := kmsKey.toInternal() @@ -363,13 +394,20 @@ func internalGroupFrom(kmsKeys []kmskey, pgpKeys []pgpkey, gcpKmsKeys []gcpkmske } internalGroup = append(internalGroup, k) } + for _, pluginKey := range pluginKeys { + k, err := pluginKey.toInternal() + if err != nil { + return nil, err + } + internalGroup = append(internalGroup, k) + } return internalGroup, nil } func (m *metadata) internalKeygroups() ([]sops.KeyGroup, error) { var internalGroups []sops.KeyGroup - if len(m.PGPKeys) > 0 || len(m.KMSKeys) > 0 || len(m.GCPKMSKeys) > 0 || len(m.HCKmsKeys) > 0 || len(m.AzureKeyVaultKeys) > 0 || len(m.VaultKeys) > 0 || len(m.AgeKeys) > 0 { - internalGroup, err := internalGroupFrom(m.KMSKeys, m.PGPKeys, m.GCPKMSKeys, m.HCKmsKeys, m.AzureKeyVaultKeys, m.VaultKeys, m.AgeKeys) + if len(m.PGPKeys) > 0 || len(m.KMSKeys) > 0 || len(m.GCPKMSKeys) > 0 || len(m.HCKmsKeys) > 0 || len(m.AzureKeyVaultKeys) > 0 || len(m.VaultKeys) > 0 || len(m.AgeKeys) > 0 || len(m.PluginKeys) > 0 { + internalGroup, err := internalGroupFrom(m.KMSKeys, m.PGPKeys, m.GCPKMSKeys, m.HCKmsKeys, m.AzureKeyVaultKeys, m.VaultKeys, m.AgeKeys, m.PluginKeys) if err != nil { return nil, err } @@ -377,7 +415,7 @@ func (m *metadata) internalKeygroups() ([]sops.KeyGroup, error) { return internalGroups, nil } else if len(m.KeyGroups) > 0 { for _, group := range m.KeyGroups { - internalGroup, err := internalGroupFrom(group.KMSKeys, group.PGPKeys, group.GCPKMSKeys, group.HCKmsKeys, group.AzureKeyVaultKeys, group.VaultKeys, group.AgeKeys) + internalGroup, err := internalGroupFrom(group.KMSKeys, group.PGPKeys, group.GCPKMSKeys, group.HCKmsKeys, group.AzureKeyVaultKeys, group.VaultKeys, group.AgeKeys, group.PluginKeys) if err != nil { return nil, err } @@ -404,6 +442,21 @@ func (kmsKey *kmskey) toInternal() (*kms.MasterKey, error) { }, nil } +func (pluginKey *pluginkey) toInternal() (*plugin.MasterKey, error) { + creationDate, err := time.Parse(time.RFC3339, pluginKey.CreatedAt) + if err != nil { + return nil, err + } + return &plugin.MasterKey{ + BinaryName: pluginKey.BinaryName, + InstanceID: pluginKey.InstanceID, + PluginConfig: pluginKey.Config, + EncryptedKey: pluginKey.EncryptedDataKey, + CreationDate: creationDate, + Timeout: pluginKey.Timeout, + }, nil +} + func (gcpKmsKey *gcpkmskey) toInternal() (*gcpkms.MasterKey, error) { creationDate, err := time.Parse(time.RFC3339, gcpKmsKey.CreatedAt) if err != nil { From 08da8418e473c299f9ed538e573fe8e7725fd03d Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Fri, 5 Jun 2026 10:13:12 -0300 Subject: [PATCH 12/18] feat(plugin): add initial keyservice support Signed-off-by: Caio Rocha de Oliveira --- keyservice/keyservice.go | 14 ++ keyservice/keyservice.pb.go | 288 ++++++++++++++++++++----------- keyservice/keyservice.proto | 8 + keyservice/keyservice_grpc.pb.go | 2 +- keyservice/server.go | 42 +++++ plugin/plugin-interface.go | 1 + 6 files changed, 258 insertions(+), 97 deletions(-) diff --git a/keyservice/keyservice.go b/keyservice/keyservice.go index 04125f7510..9017684f5d 100644 --- a/keyservice/keyservice.go +++ b/keyservice/keyservice.go @@ -5,6 +5,7 @@ master keys. package keyservice import ( + "encoding/json" "fmt" "github.com/getsops/sops/v3/age" @@ -15,6 +16,7 @@ import ( "github.com/getsops/sops/v3/keys" "github.com/getsops/sops/v3/kms" "github.com/getsops/sops/v3/pgp" + "github.com/getsops/sops/v3/plugin" ) // KeyFromMasterKey converts a SOPS internal MasterKey to an RPC Key that can be serialized with Protocol Buffers @@ -46,6 +48,18 @@ func KeyFromMasterKey(mk keys.MasterKey) Key { }, }, } + case *plugin.MasterKey: + configBytes, _ := json.Marshal(mk.PluginConfig) + return Key{ + KeyType: &Key_PluginKey{ + PluginKey: &PluginKey{ + BinaryName: mk.BinaryName, + InstanceId: mk.InstanceID, + Config: string(configBytes), + Timeout: mk.Timeout, + }, + }, + } case *kms.MasterKey: ctx := make(map[string]string) for k, v := range mk.EncryptionContext { diff --git a/keyservice/keyservice.pb.go b/keyservice/keyservice.pb.go index 6314929c7d..30534bb211 100644 --- a/keyservice/keyservice.pb.go +++ b/keyservice/keyservice.pb.go @@ -1,17 +1,16 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.35.2 -// protoc v5.28.3 +// protoc v7.35.0 // source: keyservice/keyservice.proto package keyservice import ( - reflect "reflect" - sync "sync" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" ) const ( @@ -35,6 +34,7 @@ type Key struct { // *Key_VaultKey // *Key_AgeKey // *Key_HckmsKey + // *Key_PluginKey KeyType isKey_KeyType `protobuf_oneof:"key_type"` } @@ -124,6 +124,13 @@ func (x *Key) GetHckmsKey() *HckmsKey { return nil } +func (x *Key) GetPluginKey() *PluginKey { + if x, ok := x.GetKeyType().(*Key_PluginKey); ok { + return x.PluginKey + } + return nil +} + type isKey_KeyType interface { isKey_KeyType() } @@ -156,6 +163,10 @@ type Key_HckmsKey struct { HckmsKey *HckmsKey `protobuf:"bytes,7,opt,name=hckms_key,json=hckmsKey,proto3,oneof"` } +type Key_PluginKey struct { + PluginKey *PluginKey `protobuf:"bytes,8,opt,name=plugin_key,json=pluginKey,proto3,oneof"` +} + func (*Key_KmsKey) isKey_KeyType() {} func (*Key_PgpKey) isKey_KeyType() {} @@ -170,6 +181,8 @@ func (*Key_AgeKey) isKey_KeyType() {} func (*Key_HckmsKey) isKey_KeyType() {} +func (*Key_PluginKey) isKey_KeyType() {} + type PgpKey struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -541,6 +554,75 @@ func (x *HckmsKey) GetKeyId() string { return "" } +type PluginKey struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + BinaryName string `protobuf:"bytes,1,opt,name=binary_name,json=binaryName,proto3" json:"binary_name,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Config string `protobuf:"bytes,3,opt,name=config,proto3" json:"config,omitempty"` + Timeout string `protobuf:"bytes,4,opt,name=timeout,proto3" json:"timeout,omitempty"` +} + +func (x *PluginKey) Reset() { + *x = PluginKey{} + mi := &file_keyservice_keyservice_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PluginKey) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PluginKey) ProtoMessage() {} + +func (x *PluginKey) ProtoReflect() protoreflect.Message { + mi := &file_keyservice_keyservice_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PluginKey.ProtoReflect.Descriptor instead. +func (*PluginKey) Descriptor() ([]byte, []int) { + return file_keyservice_keyservice_proto_rawDescGZIP(), []int{8} +} + +func (x *PluginKey) GetBinaryName() string { + if x != nil { + return x.BinaryName + } + return "" +} + +func (x *PluginKey) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *PluginKey) GetConfig() string { + if x != nil { + return x.Config + } + return "" +} + +func (x *PluginKey) GetTimeout() string { + if x != nil { + return x.Timeout + } + return "" +} + type EncryptRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -552,7 +634,7 @@ type EncryptRequest struct { func (x *EncryptRequest) Reset() { *x = EncryptRequest{} - mi := &file_keyservice_keyservice_proto_msgTypes[8] + mi := &file_keyservice_keyservice_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -564,7 +646,7 @@ func (x *EncryptRequest) String() string { func (*EncryptRequest) ProtoMessage() {} func (x *EncryptRequest) ProtoReflect() protoreflect.Message { - mi := &file_keyservice_keyservice_proto_msgTypes[8] + mi := &file_keyservice_keyservice_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -577,7 +659,7 @@ func (x *EncryptRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EncryptRequest.ProtoReflect.Descriptor instead. func (*EncryptRequest) Descriptor() ([]byte, []int) { - return file_keyservice_keyservice_proto_rawDescGZIP(), []int{8} + return file_keyservice_keyservice_proto_rawDescGZIP(), []int{9} } func (x *EncryptRequest) GetKey() *Key { @@ -604,7 +686,7 @@ type EncryptResponse struct { func (x *EncryptResponse) Reset() { *x = EncryptResponse{} - mi := &file_keyservice_keyservice_proto_msgTypes[9] + mi := &file_keyservice_keyservice_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -616,7 +698,7 @@ func (x *EncryptResponse) String() string { func (*EncryptResponse) ProtoMessage() {} func (x *EncryptResponse) ProtoReflect() protoreflect.Message { - mi := &file_keyservice_keyservice_proto_msgTypes[9] + mi := &file_keyservice_keyservice_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -629,7 +711,7 @@ func (x *EncryptResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EncryptResponse.ProtoReflect.Descriptor instead. func (*EncryptResponse) Descriptor() ([]byte, []int) { - return file_keyservice_keyservice_proto_rawDescGZIP(), []int{9} + return file_keyservice_keyservice_proto_rawDescGZIP(), []int{10} } func (x *EncryptResponse) GetCiphertext() []byte { @@ -650,7 +732,7 @@ type DecryptRequest struct { func (x *DecryptRequest) Reset() { *x = DecryptRequest{} - mi := &file_keyservice_keyservice_proto_msgTypes[10] + mi := &file_keyservice_keyservice_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -662,7 +744,7 @@ func (x *DecryptRequest) String() string { func (*DecryptRequest) ProtoMessage() {} func (x *DecryptRequest) ProtoReflect() protoreflect.Message { - mi := &file_keyservice_keyservice_proto_msgTypes[10] + mi := &file_keyservice_keyservice_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -675,7 +757,7 @@ func (x *DecryptRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DecryptRequest.ProtoReflect.Descriptor instead. func (*DecryptRequest) Descriptor() ([]byte, []int) { - return file_keyservice_keyservice_proto_rawDescGZIP(), []int{10} + return file_keyservice_keyservice_proto_rawDescGZIP(), []int{11} } func (x *DecryptRequest) GetKey() *Key { @@ -702,7 +784,7 @@ type DecryptResponse struct { func (x *DecryptResponse) Reset() { *x = DecryptResponse{} - mi := &file_keyservice_keyservice_proto_msgTypes[11] + mi := &file_keyservice_keyservice_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -714,7 +796,7 @@ func (x *DecryptResponse) String() string { func (*DecryptResponse) ProtoMessage() {} func (x *DecryptResponse) ProtoReflect() protoreflect.Message { - mi := &file_keyservice_keyservice_proto_msgTypes[11] + mi := &file_keyservice_keyservice_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -727,7 +809,7 @@ func (x *DecryptResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DecryptResponse.ProtoReflect.Descriptor instead. func (*DecryptResponse) Descriptor() ([]byte, []int) { - return file_keyservice_keyservice_proto_rawDescGZIP(), []int{11} + return file_keyservice_keyservice_proto_rawDescGZIP(), []int{12} } func (x *DecryptResponse) GetPlaintext() []byte { @@ -741,7 +823,7 @@ var File_keyservice_keyservice_proto protoreflect.FileDescriptor var file_keyservice_keyservice_proto_rawDesc = []byte{ 0x0a, 0x1b, 0x6b, 0x65, 0x79, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2f, 0x6b, 0x65, 0x79, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc2, 0x02, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xef, 0x02, 0x0a, 0x03, 0x4b, 0x65, 0x79, 0x12, 0x22, 0x0a, 0x07, 0x6b, 0x6d, 0x73, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x4b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x48, 0x00, 0x52, 0x06, 0x6b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x22, 0x0a, 0x07, 0x70, 0x67, 0x70, @@ -761,66 +843,77 @@ var file_keyservice_keyservice_proto_rawDesc = []byte{ 0x65, 0x79, 0x48, 0x00, 0x52, 0x06, 0x61, 0x67, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x09, 0x68, 0x63, 0x6b, 0x6d, 0x73, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x09, 0x2e, 0x48, 0x63, 0x6b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x48, 0x00, 0x52, 0x08, 0x68, 0x63, - 0x6b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x42, 0x0a, 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f, 0x74, 0x79, - 0x70, 0x65, 0x22, 0x2a, 0x0a, 0x06, 0x50, 0x67, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x20, 0x0a, 0x0b, - 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x22, 0xbb, - 0x01, 0x0a, 0x06, 0x4b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x72, 0x6e, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x61, 0x72, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x72, - 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x12, - 0x2e, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x14, 0x2e, 0x4b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, - 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, - 0x1f, 0x0a, 0x0b, 0x61, 0x77, 0x73, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x77, 0x73, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, - 0x1a, 0x3a, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2c, 0x0a, 0x09, - 0x47, 0x63, 0x70, 0x4b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x22, 0x6b, 0x0a, 0x08, 0x56, 0x61, - 0x75, 0x6c, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x5f, - 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x76, - 0x61, 0x75, 0x6c, 0x74, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x65, - 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0a, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x19, 0x0a, 0x08, - 0x6b, 0x65, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x6b, 0x65, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x5d, 0x0a, 0x10, 0x41, 0x7a, 0x75, 0x72, 0x65, - 0x4b, 0x65, 0x79, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x76, - 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x76, 0x61, 0x75, 0x6c, 0x74, 0x55, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, - 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x26, 0x0a, 0x06, 0x41, 0x67, 0x65, 0x4b, 0x65, 0x79, - 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x22, 0x21, - 0x0a, 0x08, 0x48, 0x63, 0x6b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, - 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, - 0x64, 0x22, 0x46, 0x0a, 0x0e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x04, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x70, - 0x6c, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, - 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x31, 0x0a, 0x0f, 0x45, 0x6e, 0x63, - 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1e, 0x0a, 0x0a, - 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x22, 0x48, 0x0a, 0x0e, - 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x04, 0x2e, 0x4b, 0x65, - 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, - 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x63, 0x69, 0x70, 0x68, - 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x22, 0x2f, 0x0a, 0x0f, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x6c, 0x61, - 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x6c, - 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x32, 0x6c, 0x0a, 0x0a, 0x4b, 0x65, 0x79, 0x53, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x12, 0x0f, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x10, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x07, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x12, 0x0f, 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x10, 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x0e, 0x5a, 0x0c, 0x2e, 0x2f, 0x6b, 0x65, 0x79, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x0a, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x50, 0x6c, 0x75, + 0x67, 0x69, 0x6e, 0x4b, 0x65, 0x79, 0x48, 0x00, 0x52, 0x09, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x4b, 0x65, 0x79, 0x42, 0x0a, 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x22, + 0x2a, 0x0a, 0x06, 0x50, 0x67, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x20, 0x0a, 0x0b, 0x66, 0x69, 0x6e, + 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, + 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x22, 0xbb, 0x01, 0x0a, 0x06, + 0x4b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x72, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x61, 0x72, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x12, 0x2e, 0x0a, 0x07, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, + 0x4b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x1f, 0x0a, 0x0b, + 0x61, 0x77, 0x73, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x61, 0x77, 0x73, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x1a, 0x3a, 0x0a, + 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2c, 0x0a, 0x09, 0x47, 0x63, 0x70, + 0x4b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x22, 0x6b, 0x0a, 0x08, 0x56, 0x61, 0x75, 0x6c, 0x74, + 0x4b, 0x65, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x76, 0x61, 0x75, 0x6c, + 0x74, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x65, 0x6e, 0x67, 0x69, + 0x6e, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, + 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6b, 0x65, 0x79, + 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x5d, 0x0a, 0x10, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, + 0x56, 0x61, 0x75, 0x6c, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x76, 0x61, 0x75, 0x6c, + 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x76, 0x61, 0x75, + 0x6c, 0x74, 0x55, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x22, 0x26, 0x0a, 0x06, 0x41, 0x67, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x1c, 0x0a, + 0x09, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x22, 0x21, 0x0a, 0x08, 0x48, + 0x63, 0x6b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x7f, + 0x0a, 0x09, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x62, + 0x69, 0x6e, 0x61, 0x72, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0a, 0x62, 0x69, 0x6e, 0x61, 0x72, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, + 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x16, 0x0a, + 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x22, + 0x46, 0x0a, 0x0e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x16, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x04, + 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x6c, 0x61, + 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x6c, + 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x31, 0x0a, 0x0f, 0x45, 0x6e, 0x63, 0x72, 0x79, + 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x69, + 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, + 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x22, 0x48, 0x0a, 0x0e, 0x44, 0x65, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x03, + 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x04, 0x2e, 0x4b, 0x65, 0x79, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, + 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, + 0x74, 0x65, 0x78, 0x74, 0x22, 0x2f, 0x0a, 0x0f, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x6c, 0x61, 0x69, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x6c, 0x61, 0x69, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x32, 0x6c, 0x0a, 0x0a, 0x4b, 0x65, 0x79, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x12, 0x0f, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x10, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x07, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x12, 0x0f, + 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x10, 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x42, 0x0e, 0x5a, 0x0c, 0x2e, 0x2f, 0x6b, 0x65, 0x79, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -835,7 +928,7 @@ func file_keyservice_keyservice_proto_rawDescGZIP() []byte { return file_keyservice_keyservice_proto_rawDescData } -var file_keyservice_keyservice_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_keyservice_keyservice_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_keyservice_keyservice_proto_goTypes = []any{ (*Key)(nil), // 0: Key (*PgpKey)(nil), // 1: PgpKey @@ -845,11 +938,12 @@ var file_keyservice_keyservice_proto_goTypes = []any{ (*AzureKeyVaultKey)(nil), // 5: AzureKeyVaultKey (*AgeKey)(nil), // 6: AgeKey (*HckmsKey)(nil), // 7: HckmsKey - (*EncryptRequest)(nil), // 8: EncryptRequest - (*EncryptResponse)(nil), // 9: EncryptResponse - (*DecryptRequest)(nil), // 10: DecryptRequest - (*DecryptResponse)(nil), // 11: DecryptResponse - nil, // 12: KmsKey.ContextEntry + (*PluginKey)(nil), // 8: PluginKey + (*EncryptRequest)(nil), // 9: EncryptRequest + (*EncryptResponse)(nil), // 10: EncryptResponse + (*DecryptRequest)(nil), // 11: DecryptRequest + (*DecryptResponse)(nil), // 12: DecryptResponse + nil, // 13: KmsKey.ContextEntry } var file_keyservice_keyservice_proto_depIdxs = []int32{ 2, // 0: Key.kms_key:type_name -> KmsKey @@ -859,18 +953,19 @@ var file_keyservice_keyservice_proto_depIdxs = []int32{ 4, // 4: Key.vault_key:type_name -> VaultKey 6, // 5: Key.age_key:type_name -> AgeKey 7, // 6: Key.hckms_key:type_name -> HckmsKey - 12, // 7: KmsKey.context:type_name -> KmsKey.ContextEntry - 0, // 8: EncryptRequest.key:type_name -> Key - 0, // 9: DecryptRequest.key:type_name -> Key - 8, // 10: KeyService.Encrypt:input_type -> EncryptRequest - 10, // 11: KeyService.Decrypt:input_type -> DecryptRequest - 9, // 12: KeyService.Encrypt:output_type -> EncryptResponse - 11, // 13: KeyService.Decrypt:output_type -> DecryptResponse - 12, // [12:14] is the sub-list for method output_type - 10, // [10:12] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 8, // 7: Key.plugin_key:type_name -> PluginKey + 13, // 8: KmsKey.context:type_name -> KmsKey.ContextEntry + 0, // 9: EncryptRequest.key:type_name -> Key + 0, // 10: DecryptRequest.key:type_name -> Key + 9, // 11: KeyService.Encrypt:input_type -> EncryptRequest + 11, // 12: KeyService.Decrypt:input_type -> DecryptRequest + 10, // 13: KeyService.Encrypt:output_type -> EncryptResponse + 12, // 14: KeyService.Decrypt:output_type -> DecryptResponse + 13, // [13:15] is the sub-list for method output_type + 11, // [11:13] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name } func init() { file_keyservice_keyservice_proto_init() } @@ -886,6 +981,7 @@ func file_keyservice_keyservice_proto_init() { (*Key_VaultKey)(nil), (*Key_AgeKey)(nil), (*Key_HckmsKey)(nil), + (*Key_PluginKey)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -893,7 +989,7 @@ func file_keyservice_keyservice_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_keyservice_keyservice_proto_rawDesc, NumEnums: 0, - NumMessages: 13, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/keyservice/keyservice.proto b/keyservice/keyservice.proto index 3a471a34fd..d7644e000d 100644 --- a/keyservice/keyservice.proto +++ b/keyservice/keyservice.proto @@ -11,6 +11,7 @@ message Key { VaultKey vault_key = 5; AgeKey age_key = 6; HckmsKey hckms_key = 7; + PluginKey plugin_key = 8; } } @@ -49,6 +50,13 @@ message HckmsKey { string key_id = 1; } +message PluginKey { + string binary_name = 1; + string instance_id = 2; + string config = 3; + string timeout = 4; +} + message EncryptRequest { Key key = 1; bytes plaintext = 2; diff --git a/keyservice/keyservice_grpc.pb.go b/keyservice/keyservice_grpc.pb.go index d278b82d97..48944f21f6 100644 --- a/keyservice/keyservice_grpc.pb.go +++ b/keyservice/keyservice_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.28.3 +// - protoc v7.35.0 // source: keyservice/keyservice.proto package keyservice diff --git a/keyservice/server.go b/keyservice/server.go index c1f1e8ce86..a3fbccf5a6 100644 --- a/keyservice/server.go +++ b/keyservice/server.go @@ -2,6 +2,7 @@ package keyservice import ( "fmt" + "encoding/json" "github.com/getsops/sops/v3/age" "github.com/getsops/sops/v3/azkv" @@ -10,6 +11,7 @@ import ( "github.com/getsops/sops/v3/hcvault" "github.com/getsops/sops/v3/kms" "github.com/getsops/sops/v3/pgp" + "github.com/getsops/sops/v3/plugin" "golang.org/x/net/context" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -182,6 +184,12 @@ func (ks Server) Encrypt(ctx context.Context, response = &EncryptResponse{ Ciphertext: ciphertext, } + case *Key_PluginKey: + ciphertext, err := ks.encryptWithPlugin(k.PluginKey, req.Plaintext) + if err != nil { + return nil, err + } + response = &EncryptResponse{Ciphertext: ciphertext} case *Key_KmsKey: ciphertext, err := ks.encryptWithKms(k.KmsKey, req.Plaintext) if err != nil { @@ -310,6 +318,12 @@ func (ks Server) Decrypt(ctx context.Context, response = &DecryptResponse{ Plaintext: plaintext, } + case *Key_PluginKey: + plaintext, err := ks.decryptWithPlugin(k.PluginKey, req.Ciphertext) + if err != nil { + return nil, err + } + response = &DecryptResponse{Plaintext: plaintext} case *Key_AzureKeyvaultKey: plaintext, err := ks.decryptWithAzureKeyVault(k.AzureKeyvaultKey, req.Ciphertext) if err != nil { @@ -356,6 +370,34 @@ func (ks Server) Decrypt(ctx context.Context, return response, nil } +func (ks *Server) encryptWithPlugin(key *PluginKey, plaintext []byte) ([]byte, error) { + var config map[string]any + _ = json.Unmarshal([]byte(key.Config), &config) + + pluginKey := plugin.NewMasterKey(key.BinaryName, config, key.Timeout, key.InstanceId) + pluginKey.Timeout = key.Timeout + + err := pluginKey.Encrypt(plaintext) + if err != nil { + return nil, err + } + + return []byte(pluginKey.EncryptedKey), nil +} + +func (ks *Server) decryptWithPlugin(key *PluginKey, ciphertext []byte) ([]byte, error) { + var config map[string]any + _ = json.Unmarshal([]byte(key.Config), &config) + + pluginKey := plugin.NewMasterKey(key.BinaryName, config, key.Timeout, key.InstanceId) + pluginKey.Timeout = key.Timeout + + pluginKey.EncryptedKey = string(ciphertext) + + plaintext, err := pluginKey.Decrypt() + return plaintext, err +} + func kmsKeyToMasterKey(key *KmsKey) kms.MasterKey { ctx := make(map[string]*string) for k, v := range key.Context { diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index b203a88d0a..b3278ff1de 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -42,6 +42,7 @@ func NewMasterKey( BinaryName: binaryName, InstanceID: instanceID, PluginConfig: config, + Timeout: timeout, CreationDate: time.Now().UTC(), } } From 38edd4b95e3c5e049f44b7f6d8d879f9728d67d6 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Sun, 7 Jun 2026 15:26:49 -0300 Subject: [PATCH 13/18] feat(plugin-interface): add validation and sanity checks Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin-interface.go | 48 ++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index b3278ff1de..7e604b779e 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "os/exec" "regexp" "strings" @@ -16,6 +17,12 @@ const ( // KeyTypeIdentifier is the string used to identify a Plugin MasterKey // in the metadata of an encrypted file. KeyTypeIdentifier = "plugin" + // TimeoutFallback is the default timeout for plugin execution if not specified in the MasterKey config. + TimeoutFallback = 10 * time.Second + // MaxBinaryNameLength is a sanity check to prevent excessively long binary names that could cause DoSs. + MaxBinaryNameLength = 128 + // MaxBinaryNameLength is another sanity check to prevent empty binary names (e.g., "sops-plugin-" is not a valid plugin binary name) + MinBinaryNameLength = 1 ) // MasterKey is a generic plugin wrapper that satisfies the SOPS MasterKey interface. @@ -104,6 +111,31 @@ func (key *MasterKey) Encrypt(dataKey []byte) error { return key.EncryptContext(context.Background(), dataKey) } +// validateBinaryName checks that the BinaryName field of the MasterKey is valid, not containing command injectable characters +// and do not exceed reasonable length limits to prevent DoSs. +// +// Valid nomenclature: +// - Only alphanumeric characters, dashes, and underscores allowed. +// - Length must be between 1 and 128 characters. +// - The plugin binary path must follow "sops-plugin-" convention. +// - Plugins are expected to be within the user's PATH for flexibility and security. +// - Users are responsible for ensuring that their plugin binaries are secure and trustworthy. +func (key *MasterKey) validateBinaryName() error { + validBinaryName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + if !validBinaryName.MatchString(key.BinaryName) { + return fmt.Errorf("invalid binary name: only alphanumeric, dashes, and underscores allowed") + } + + if len(key.BinaryName) > MaxBinaryNameLength || len(key.BinaryName) < MinBinaryNameLength { + return fmt.Errorf( + "invalid binary name: length must be between %d and %d characters", + MinBinaryNameLength, + MaxBinaryNameLength, + ) + } + return nil +} + func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error { req := map[string]any{ "action": "encrypt", @@ -119,9 +151,9 @@ func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error defer cancel() } - validBinaryName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - if !validBinaryName.MatchString(key.BinaryName) { - return fmt.Errorf("invalid binary name: only alphanumeric, dashes, and underscores allowed") + err := key.validateBinaryName() + if err != nil { + return err } resp, err := executePlugin(ctx, key.BinaryName, req) @@ -153,10 +185,15 @@ func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { if _, ok := ctx.Deadline(); !ok { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, 10*time.Second) + ctx, cancel = context.WithTimeout(ctx, TimeoutFallback) defer cancel() } + err := key.validateBinaryName() + if err != nil { + return nil, err + } + resp, err := executePlugin(ctx, key.BinaryName, req) if err != nil { return nil, err @@ -204,9 +241,10 @@ func executePlugin( } if errors.Is(err, exec.ErrNotFound) { return nil, fmt.Errorf( - "plugin executable not found: %s. Please ensure that %s is installed and in your PATH", + "plugin executable not found: %s. Please ensure that sops-plugin-%s is installed and in your PATH\nAvailable paths: %s", executableName, executableName, + os.Getenv("PATH"), ) } return nil, fmt.Errorf( From 7a8b7ed2a1f09fbcd47ef362b13b62a5f2847825 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Sun, 7 Jun 2026 15:46:59 -0300 Subject: [PATCH 14/18] feat(plugin_tests): add more coverage Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin_test.go | 280 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index 6a0b6d2438..cd9f5ad2e8 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -1,12 +1,16 @@ package plugin import ( + "context" "os" "os/exec" "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const dummyPluginCode = `package main @@ -50,3 +54,279 @@ func TestPluginIPC(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "plain-as-day\n", string(plaintext)) } + +const flexiblePluginCode = `package main +import ( + "encoding/json" + "fmt" + "os" + "time" +) +func main() { + if os.Getenv("MOCK_HANG") == "true" { + time.Sleep(2 * time.Second) + return + } + if os.Getenv("MOCK_INVALID_JSON") == "true" { + fmt.Println("invalid-json-response") + return + } + if os.Getenv("MOCK_ERROR_RESPONSE") == "true" { + fmt.Println("{\"error\": \"plugin failed custom error\"}") + return + } + if os.Getenv("MOCK_EMPTY_RESPONSE") == "true" { + fmt.Println("{}") + return + } + if os.Getenv("MOCK_STDERR_RESPONSE") == "true" { + fmt.Fprintln(os.Stderr, "custom plugin stderr message") + os.Exit(1) + return + } + if os.Getenv("MOCK_STDERR_WITH_SUCCESS") == "true" { + fmt.Fprintln(os.Stderr, "non-fatal warning message") + } + var req map[string]any + if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil { + fmt.Printf("{\"error\": \"decode error: %v\"}\n", err) + return + } + action := req["action"].(string) + if action == "encrypt" { + ciphertext := req["plaintext"].(string) + fmt.Printf("{\"ciphertext\": \"%s\"}\n", ciphertext) + } else if action == "decrypt" { + ciphertext := req["ciphertext"].(string) + fmt.Printf("{\"plaintext\": \"%s\"}\n", ciphertext) + } +} +` + +func setupFlexiblePlugin(t *testing.T) *MasterKey { + tmpDir := t.TempDir() + pluginPath := filepath.Join(tmpDir, "sops-plugin-flexible") + + srcFile := filepath.Join(tmpDir, "main.go") + err := os.WriteFile(srcFile, []byte(flexiblePluginCode), 0o644) + require.NoError(t, err) + + err = exec.Command("go", "build", "-o", pluginPath, srcFile).Run() + require.NoError(t, err, "failed to compile flexible plugin") + + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + return NewMasterKey("flexible", map[string]any{"conf": "val"}, "10s", "flexible") +} + +func TestMasterKeyBasicMethods(t *testing.T) { + key := NewMasterKey("my-binary", map[string]any{"foo": "bar"}, "5s", "my-vault") + + assert.Equal(t, KeyTypeIdentifier, key.TypeToIdentifier()) + assert.Equal(t, "plugin:my-binary", key.ToString()) + assert.Equal(t, "SOPS_PLUGIN_MY_VAULT_", key.GetEnvPrefix()) + + // Test default instance ID + keyDefaultID := NewMasterKey("my-binary", nil, "", "") + assert.Equal(t, "my-binary", keyDefaultID.InstanceID) + assert.Equal(t, "SOPS_PLUGIN_MY_BINARY_", keyDefaultID.GetEnvPrefix()) + + // Test ToMap + key.SetEncryptedDataKey([]byte("encrypted-payload")) + assert.Equal(t, []byte("encrypted-payload"), key.EncryptedDataKey()) + + m := key.ToMap() + assert.Equal(t, "my-binary", m["binary_name"]) + assert.Equal(t, map[string]any{"foo": "bar"}, m["config"]) + assert.Equal(t, "encrypted-payload", m["enc"]) + assert.NotEmpty(t, m["created_at"]) + + // Test NeedsRotation + assert.False(t, key.NeedsRotation()) +} + +func TestMasterKeyValidation(t *testing.T) { + tests := []struct { + name string + binaryName string + wantErr string + }{ + { + name: "valid name", + binaryName: "valid-plugin_123", + wantErr: "", + }, + { + name: "invalid char slash", + binaryName: "plugin/path", + wantErr: "invalid binary name: only alphanumeric, dashes, and underscores allowed", + }, + { + name: "invalid char space", + binaryName: "plugin name", + wantErr: "invalid binary name: only alphanumeric, dashes, and underscores allowed", + }, + { + name: "empty name", + binaryName: "", + wantErr: "invalid binary name: length must be between 1 and 128 characters", + }, + { + name: "too long name", + binaryName: strings.Repeat("a", 129), + wantErr: "invalid binary name: length must be between 1 and 128 characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := NewMasterKey(tt.binaryName, nil, "", "") + err := key.Encrypt([]byte("data")) + if tt.wantErr == "" { + if err != nil { + assert.NotContains(t, err.Error(), "invalid binary name") + } + } else { + assert.EqualError(t, err, tt.wantErr) + } + + _, err = key.Decrypt() + if tt.wantErr == "" { + if err != nil { + assert.NotContains(t, err.Error(), "invalid binary name") + } + } else { + assert.EqualError(t, err, tt.wantErr) + } + }) + } +} + +func TestMasterKeyEncryptIfNeeded(t *testing.T) { + // If EncryptedKey is empty, EncryptIfNeeded should call Encrypt. + // Since no plugin is present, it will fail. + key := NewMasterKey("non-existent-plugin", nil, "", "") + err := key.EncryptIfNeeded([]byte("data")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin executable not found") + + // If EncryptedKey is not empty, EncryptIfNeeded should do nothing and return nil. + key.EncryptedKey = "already-encrypted" + err = key.EncryptIfNeeded([]byte("data")) + assert.NoError(t, err) + assert.Equal(t, "already-encrypted", key.EncryptedKey) +} + +func TestPluginHappyPath(t *testing.T) { + key := setupFlexiblePlugin(t) + + // Encrypt + dataKey := []byte("hello-world") + err := key.Encrypt(dataKey) + assert.NoError(t, err) + assert.NotEmpty(t, key.EncryptedKey) + + // Decrypt + plaintext, err := key.Decrypt() + assert.NoError(t, err) + assert.Equal(t, dataKey, plaintext) +} + +func TestPluginInvalidJSON(t *testing.T) { + key := setupFlexiblePlugin(t) + t.Setenv("MOCK_INVALID_JSON", "true") + + err := key.Encrypt([]byte("test")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "violated IPC contract (invalid JSON)") + + _, err = key.Decrypt() + assert.Error(t, err) + assert.Contains(t, err.Error(), "violated IPC contract (invalid JSON)") +} + +func TestPluginErrorResponse(t *testing.T) { + key := setupFlexiblePlugin(t) + t.Setenv("MOCK_ERROR_RESPONSE", "true") + + err := key.Encrypt([]byte("test")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin sops-plugin-flexible error: plugin failed custom error") + + _, err = key.Decrypt() + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin sops-plugin-flexible error: plugin failed custom error") +} + +func TestPluginEmptyResponse(t *testing.T) { + key := setupFlexiblePlugin(t) + t.Setenv("MOCK_EMPTY_RESPONSE", "true") + + err := key.Encrypt([]byte("test")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin did not return ciphertext") + + _, err = key.Decrypt() + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin did not return plaintext") +} + +func TestPluginTimeout(t *testing.T) { + key := setupFlexiblePlugin(t) + key.Timeout = "100ms" + t.Setenv("MOCK_HANG", "true") + + err := key.Encrypt([]byte("test")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin execution timed out") + + // Decrypt uses default timeout or context deadline. Let's pass a context with timeout. + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err = key.DecryptContext(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin execution timed out") +} + +func TestPluginNotFound(t *testing.T) { + key := NewMasterKey("non-existent-plugin-12345", nil, "", "") + err := key.Encrypt([]byte("test")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin executable not found") +} + +func TestPluginRequestMarshalError(t *testing.T) { + // Put an unmarshallable channel in the plugin config + key := NewMasterKey("flexible", map[string]any{"bad": make(chan int)}, "", "") + err := key.Encrypt([]byte("test")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to marshal plugin request") +} + +func TestPluginStderrResponse(t *testing.T) { + key := setupFlexiblePlugin(t) + t.Setenv("MOCK_STDERR_RESPONSE", "true") + + err := key.Encrypt([]byte("test")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin execution failed") + assert.Contains(t, err.Error(), "custom plugin stderr message") + + _, err = key.Decrypt() + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin execution failed") + assert.Contains(t, err.Error(), "custom plugin stderr message") +} + +func TestPluginStderrWithSuccess(t *testing.T) { + key := setupFlexiblePlugin(t) + t.Setenv("MOCK_STDERR_WITH_SUCCESS", "true") + + dataKey := []byte("hello-world") + err := key.Encrypt(dataKey) + assert.NoError(t, err) + + plaintext, err := key.Decrypt() + assert.NoError(t, err) + assert.Equal(t, dataKey, plaintext) +} From 0822c51ddb4c8d718b44cfa7b3c3ffe4ac5bf477 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Sun, 7 Jun 2026 15:47:47 -0300 Subject: [PATCH 15/18] fix(plugin-interface): make tests pass Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin-interface.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index 7e604b779e..5db924b011 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -70,7 +70,7 @@ func (key *MasterKey) GetEnvPrefix() string { // e.g, if instanceID is "my-vault", env prefix will be "SOPS_PLUGIN_MY_VAULT_", // and users can then use env vars like "SOPS_PLUGIN_MY_VAULT_TOKEN // or "SOPS_PLUGIN_MY_VAULT_KEY" in their plugin implementation. - return "SOPS_PLUGIN" + strings.ToUpper(normalized) + "_" + return "SOPS_PLUGIN_" + strings.ToUpper(normalized) + "_" } func (key MasterKey) ToMap() map[string]any { @@ -121,11 +121,6 @@ func (key *MasterKey) Encrypt(dataKey []byte) error { // - Plugins are expected to be within the user's PATH for flexibility and security. // - Users are responsible for ensuring that their plugin binaries are secure and trustworthy. func (key *MasterKey) validateBinaryName() error { - validBinaryName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - if !validBinaryName.MatchString(key.BinaryName) { - return fmt.Errorf("invalid binary name: only alphanumeric, dashes, and underscores allowed") - } - if len(key.BinaryName) > MaxBinaryNameLength || len(key.BinaryName) < MinBinaryNameLength { return fmt.Errorf( "invalid binary name: length must be between %d and %d characters", @@ -133,6 +128,11 @@ func (key *MasterKey) validateBinaryName() error { MaxBinaryNameLength, ) } + + validBinaryName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + if !validBinaryName.MatchString(key.BinaryName) { + return fmt.Errorf("invalid binary name: only alphanumeric, dashes, and underscores allowed") + } return nil } @@ -236,7 +236,7 @@ func executePlugin( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - if errors.Is(err, context.DeadlineExceeded) { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { return nil, fmt.Errorf("plugin execution timed out (%s)", executableName) } if errors.Is(err, exec.ErrNotFound) { From 53eaebbb59a45ad51897a5201b5cfe227b824e06 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Sun, 7 Jun 2026 15:59:46 -0300 Subject: [PATCH 16/18] lint(stores/server): replace spaces with tabs Signed-off-by: Caio Rocha de Oliveira --- keyservice/server.go | 32 ++++++++++++++-------------- stores/stores.go | 50 ++++++++++++++++++++++---------------------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/keyservice/server.go b/keyservice/server.go index a3fbccf5a6..3b61554a48 100644 --- a/keyservice/server.go +++ b/keyservice/server.go @@ -371,31 +371,31 @@ func (ks Server) Decrypt(ctx context.Context, } func (ks *Server) encryptWithPlugin(key *PluginKey, plaintext []byte) ([]byte, error) { - var config map[string]any - _ = json.Unmarshal([]byte(key.Config), &config) + var config map[string]any + _ = json.Unmarshal([]byte(key.Config), &config) - pluginKey := plugin.NewMasterKey(key.BinaryName, config, key.Timeout, key.InstanceId) - pluginKey.Timeout = key.Timeout + pluginKey := plugin.NewMasterKey(key.BinaryName, config, key.Timeout, key.InstanceId) + pluginKey.Timeout = key.Timeout - err := pluginKey.Encrypt(plaintext) - if err != nil { - return nil, err - } + err := pluginKey.Encrypt(plaintext) + if err != nil { + return nil, err + } - return []byte(pluginKey.EncryptedKey), nil + return []byte(pluginKey.EncryptedKey), nil } func (ks *Server) decryptWithPlugin(key *PluginKey, ciphertext []byte) ([]byte, error) { - var config map[string]any - _ = json.Unmarshal([]byte(key.Config), &config) + var config map[string]any + _ = json.Unmarshal([]byte(key.Config), &config) - pluginKey := plugin.NewMasterKey(key.BinaryName, config, key.Timeout, key.InstanceId) - pluginKey.Timeout = key.Timeout + pluginKey := plugin.NewMasterKey(key.BinaryName, config, key.Timeout, key.InstanceId) + pluginKey.Timeout = key.Timeout - pluginKey.EncryptedKey = string(ciphertext) + pluginKey.EncryptedKey = string(ciphertext) - plaintext, err := pluginKey.Decrypt() - return plaintext, err + plaintext, err := pluginKey.Decrypt() + return plaintext, err } func kmsKeyToMasterKey(key *KmsKey) kms.MasterKey { diff --git a/stores/stores.go b/stores/stores.go index d1d37c07aa..c282a70d77 100644 --- a/stores/stores.go +++ b/stores/stores.go @@ -259,20 +259,20 @@ func ageKeysFromGroup(group sops.KeyGroup) (keys []agekey) { } func pluginKeysFromGroup(group sops.KeyGroup) (keys []pluginkey) { - for _, key := range group { - switch key := key.(type) { - case *plugin.MasterKey: - keys = append(keys, pluginkey{ - BinaryName: key.BinaryName, - InstanceID: key.InstanceID, - Config: key.PluginConfig, - EncryptedDataKey: key.EncryptedKey, - CreatedAt: key.CreationDate.Format(time.RFC3339), - Timeout: key.Timeout, // Salva o timeout resolvido no arquivo - }) - } - } - return keys + for _, key := range group { + switch key := key.(type) { + case *plugin.MasterKey: + keys = append(keys, pluginkey{ + BinaryName: key.BinaryName, + InstanceID: key.InstanceID, + Config: key.PluginConfig, + EncryptedDataKey: key.EncryptedKey, + CreatedAt: key.CreationDate.Format(time.RFC3339), + Timeout: key.Timeout, + }) + } + } + return keys } func hckmsKeysFromGroup(group sops.KeyGroup) (keys []hckmskey) { @@ -444,17 +444,17 @@ func (kmsKey *kmskey) toInternal() (*kms.MasterKey, error) { func (pluginKey *pluginkey) toInternal() (*plugin.MasterKey, error) { creationDate, err := time.Parse(time.RFC3339, pluginKey.CreatedAt) - if err != nil { - return nil, err - } - return &plugin.MasterKey{ - BinaryName: pluginKey.BinaryName, - InstanceID: pluginKey.InstanceID, - PluginConfig: pluginKey.Config, - EncryptedKey: pluginKey.EncryptedDataKey, - CreationDate: creationDate, - Timeout: pluginKey.Timeout, - }, nil + if err != nil { + return nil, err + } + return &plugin.MasterKey{ + BinaryName: pluginKey.BinaryName, + InstanceID: pluginKey.InstanceID, + PluginConfig: pluginKey.Config, + EncryptedKey: pluginKey.EncryptedDataKey, + CreationDate: creationDate, + Timeout: pluginKey.Timeout, + }, nil } func (gcpKmsKey *gcpkmskey) toInternal() (*gcpkms.MasterKey, error) { From fbda3a952d26211e547c435114b7ca822e8e0bcd Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Sun, 7 Jun 2026 16:01:05 -0300 Subject: [PATCH 17/18] fix(plugin-interface): inconsistency Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin-interface.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugin/plugin-interface.go b/plugin/plugin-interface.go index 5db924b011..83aa615529 100644 --- a/plugin/plugin-interface.go +++ b/plugin/plugin-interface.go @@ -185,7 +185,7 @@ func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { if _, ok := ctx.Deadline(); !ok { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, TimeoutFallback) + ctx, cancel = context.WithTimeout(ctx, key.getTimeout()) defer cancel() } @@ -236,7 +236,8 @@ func executePlugin( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { + if errors.Is(err, context.DeadlineExceeded) || + errors.Is(ctx.Err(), context.DeadlineExceeded) { return nil, fmt.Errorf("plugin execution timed out (%s)", executableName) } if errors.Is(err, exec.ErrNotFound) { @@ -279,5 +280,5 @@ func (key *MasterKey) getTimeout() time.Duration { return timeout } } - return 10 * time.Second + return TimeoutFallback } From 65a27ffcede1bc6dd653fb473333cbdf2bb7c95d Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Sun, 7 Jun 2026 16:43:28 -0300 Subject: [PATCH 18/18] test(plugin): add concurrency test Signed-off-by: Caio Rocha de Oliveira --- plugin/plugin_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index cd9f5ad2e8..fe7f556c0e 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -2,10 +2,12 @@ package plugin import ( "context" + "fmt" "os" "os/exec" "path/filepath" "strings" + "sync" "testing" "time" @@ -330,3 +332,37 @@ func TestPluginStderrWithSuccess(t *testing.T) { assert.NoError(t, err) assert.Equal(t, dataKey, plaintext) } + +func TestPluginConcurrency(t *testing.T) { + // Setup the flexible plugin binary compiled once + _ = setupFlexiblePlugin(t) + + const numGoroutines = 10 + var wg sync.WaitGroup + wg.Add(numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + + // Create a distinct MasterKey instance for this goroutine referencing the same binary name. + // This simulates two different processes/calls accessing the same plugin concurrently. + goroutineKey := NewMasterKey("flexible", map[string]any{"conf": "val"}, "10s", "flexible") + + dataKey := []byte(fmt.Sprintf("secret-payload-%d", id)) + + // Test encrypt + err := goroutineKey.Encrypt(dataKey) + assert.NoError(t, err) + assert.NotEmpty(t, goroutineKey.EncryptedKey) + + // Test decrypt + plaintext, err := goroutineKey.Decrypt() + assert.NoError(t, err) + assert.Equal(t, dataKey, plaintext) + }(i) + } + + wg.Wait() +} +