diff --git a/.gitignore b/.gitignore index 0107da0..52d3f3b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.age *.txt playground/* +.claude/ \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml index b190250..22a8868 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -85,6 +85,9 @@ linters: - linters: - gocritic source: //noinspection + - linters: [gocritic] + text: "exitAfterDefer: (.*)" + path: main\.go - linters: - lll path: mocks\.go diff --git a/README.md b/README.md index 062d547..8e228c9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,15 @@ Go Reference

-A Go package for secure secret storage with multiple encryption backends. Made for [flow](https://github.com/jahvon/flow) but can be used independently. +A flexible Go library for secure secret management with multiple backend providers. Made for [flow](https://github.com/jahvon/flow) but can be used independently. + +## Features + +- **Multiple Provider Support**: Choose from local encrypted storage, system keyring, or external CLI tools +- **Pluggable Architecture**: Easy to extend with custom providers +- **Type Safety**: Strong typing for secrets with secure memory handling +- **Thread Safe**: Concurrent access protection with read/write mutexes +- **Comprehensive API**: Full CRUD operations plus metadata and existence checks ## Quick Start @@ -45,36 +53,125 @@ func main() { } ``` -## Providers +## Provider Types + +### Local Encrypted Providers -### AES256 Provider +#### AES256 Provider +Stores secrets in an AES-256 encrypted file with configurable key sources. -Symmetric encryption using AES-256. Best for when you want a single encryption key shared across users / systems. +```go +provider, _, err := vault.New("my-vault", + vault.WithProvider(vault.ProviderTypeAES256), + vault.WithAESPath("~/secrets.vault"), +) +``` **Key Generation:** ```go key, err := vault.GenerateEncryptionKey() -if err != nil { - panic(err) -} -// Store this key securely and configure vault to use it +// Store this key securely (environment variable, HSM, etc.) ``` -### Age Provider +#### Age Provider +Uses the [age encryption tool](https://age-encryption.org/) with public key cryptography. -Asymmetric encryption using the [age encryption format](https://github.com/FiloSottile/age). Best for when you may have multiple users or need the ability to add/remove recipients. +```go +provider, _, err := vault.New("my-vault", + vault.WithProvider(vault.ProviderTypeAge), + vault.WithAgePath("~/secrets.age"), +) +``` **Key Generation:** ```bash -# Generate age key pair - see https://github.com/FiloSottile/age for details -age-keygen -o key.txt -# Public key: age1ql3blv6a5y... -# Private key in key.txt +age-keygen -o ~/.age/identity.txt +# Add recipients to vault configuration +``` + +#### Keyring Provider +Integrates with the operating system's secure keyring. + +```go +provider, _, err := vault.New("my-vault", + vault.WithProvider(vault.ProviderTypeKeyring), + vault.WithKeyringService("my-app-secrets"), +) +``` + +No additional setup required - uses OS authentication. + +#### Unencrypted Provider +Stores secrets in plain text JSON files. + +```go +provider, _, err := vault.New("my-vault", + vault.WithProvider(vault.ProviderTypeUnencrypted), + vault.WithUnencryptedPath("~/dev-secrets.json"), +) ``` -## Encrypted Files +### External CLI Providers -Both vault types create a single encrypted file at the specified path: +#### External Provider +Integrates with any CLI tool for secret management. Supports popular tools like Bitwarden, 1Password, HashiCorp Vault, AWS SSM, and more. -- **AES256**: `vault-{id}.enc` -- **Age**: `vault-{id}.age` +```go +config := &vault.Config{ + ID: "bitwarden", + Type: vault.ProviderTypeExternal, + External: &vault.ExternalConfig{ + Get: vault.CommandConfig{ + CommandTemplate: "bw get password {{key}}", + }, + Set: vault.CommandConfig{ + CommandTemplate: "bw create item --name {{key}} --password {{value}}", + }, + // ... other operations + }, +} + +provider, err := vault.NewExternalVaultProvider(config) +``` + +**External Provider Examples** + +Ready-to-use configurations for popular CLI tools are available in the [`examples/`](./examples/) directory: + +- **[Bitwarden](./examples/providers/bitwarden.json)** +- **[1Password](./examples/providers/1password.json)** +- **[AWS SSM](./examples/providers/aws-ssm.json)** +- **[pass](./examples/providers/pass.json)** + +See the [examples README](./examples/README.md) for detailed setup instructions. + +## Usage + +### Basic Operations + +```go +// Store a secret +secret := vault.NewSecretValue([]byte("my-secret-value")) +err = provider.SetSecret("api-key", secret) + +// Retrieve the secret +retrieved, err := provider.GetSecret("api-key") +fmt.Println("Secret:", retrieved.PlainTextString()) + +// List all secrets +secrets, _ := provider.ListSecrets() + +// Check if secret exists +exists, _ := provider.HasSecret("api-key") + +// Get vault metadata +metadata := provider.Metadata() +``` + +### Configuration from File + +```go +// Load configuration from JSON +config, err := vault.LoadConfigJSON("vault-config.json") +provider, _, err := vault.New(config.ID, vault.WithProvider(config.Type)) +``` diff --git a/config.go b/config.go index dc1214b..b25eccc 100644 --- a/config.go +++ b/config.go @@ -5,23 +5,26 @@ import ( "fmt" "os" "path/filepath" - "time" ) type ProviderType string const ( - ProviderTypeAES256 ProviderType = "aes256" - ProviderTypeAge ProviderType = "age" - ProviderTypeExternal ProviderType = "external" + ProviderTypeAES256 ProviderType = "aes256" + ProviderTypeAge ProviderType = "age" + ProviderTypeExternal ProviderType = "external" + ProviderTypeKeyring ProviderType = "keyring" + ProviderTypeUnencrypted ProviderType = "unencrypted" ) type Config struct { - ID string `json:"id"` - Type ProviderType `json:"type"` - Age *AgeConfig `json:"age,omitempty"` - Aes *AesConfig `json:"aes,omitempty"` - External *ExternalConfig `json:"external,omitempty"` + ID string `json:"id"` + Type ProviderType `json:"type"` + Age *AgeConfig `json:"age,omitempty"` + Aes *AesConfig `json:"aes,omitempty"` + External *ExternalConfig `json:"external,omitempty"` + Keyring *KeyringConfig `json:"keyring,omitempty"` + Unencrypted *UnencryptedConfig `json:"unencrypted,omitempty"` } func (c *Config) Validate() error { @@ -45,6 +48,16 @@ func (c *Config) Validate() error { return fmt.Errorf("%w: external configuration required for external vault", ErrInvalidConfig) } return c.External.Validate() + case ProviderTypeKeyring: + if c.Keyring == nil { + return fmt.Errorf("%w: keyring configuration required for keyring vault provider", ErrInvalidConfig) + } + return c.Keyring.Validate() + case ProviderTypeUnencrypted: + if c.Unencrypted == nil { + return fmt.Errorf("%w: unencrypted configuration required for unencrypted vault provider", ErrInvalidConfig) + } + return c.Unencrypted.Validate() default: return fmt.Errorf("%w: unsupported vault type: %s", ErrInvalidConfig, c.Type) } @@ -167,33 +180,71 @@ func (c *AesConfig) Validate() error { return nil } -// CommandSet defines the command templates for external vault operations -type CommandSet struct { - Get string `json:"get"` - Set string `json:"set"` - Delete string `json:"delete"` - List string `json:"list"` - Exists string `json:"exists,omitempty"` +// CommandConfig represents a command template to be executed with its arguments +type CommandConfig struct { + // CommandTemplate for building command arguments + CommandTemplate string `json:"cmd"` + // OutputTemplate for parsing command output + OutputTemplate string `json:"output,omitempty"` + // InputTemplate for providing input to the command + InputTemplate string `json:"input,omitempty"` } // ExternalConfig contains external (cli command-based) vault configuration type ExternalConfig struct { - // Command templates for operations - Commands CommandSet `json:"commands"` + // Get CommandConfig for the get operation + Get CommandConfig `json:"get,omitempty"` + // Set CommandConfig for the set operation + Set CommandConfig `json:"set,omitempty"` + // Delete CommandConfig for the delete operation + Delete CommandConfig `json:"delete,omitempty"` + // List CommandConfig for the list operation + List CommandConfig `json:"list,omitempty"` + ListSeparator string `json:"separator,omitempty"` + // Exists CommandConfig for the exists operation + Exists CommandConfig `json:"exists,omitempty"` + // Metadata CommandConfig for the metadata operation + Metadata CommandConfig `json:"metadata,omitempty"` // Environment variables for commands Environment map[string]string `json:"environment,omitempty"` - // Timeout for command execution - Timeout time.Duration `json:"timeout,omitempty"` + // Timeout duration string for command execution + Timeout string `json:"timeout,omitempty"` // WorkingDir for command execution WorkingDir string `json:"working_dir,omitempty"` } func (c *ExternalConfig) Validate() error { - if c.Commands.Get == "" || c.Commands.Set == "" { - return fmt.Errorf("%w: get and set commands are required for external vault", ErrInvalidConfig) + if c.Get.CommandTemplate == "" || c.Set.CommandTemplate == "" { + return fmt.Errorf("%w: get and set args template required for external vault", ErrInvalidConfig) + } + return nil +} + +// UnencryptedConfig contains unencrypted (plain text) vault configuration +type UnencryptedConfig struct { + // Storage location for the vault file + StoragePath string `json:"storage_path"` +} + +func (c *UnencryptedConfig) Validate() error { + if c.StoragePath == "" { + return fmt.Errorf("%w: storage path is required for unencrypted vault", ErrInvalidConfig) + } + return nil +} + +// KeyringConfig contains keyring vault configuration +type KeyringConfig struct { + // Service name used for keyring operations + Service string `json:"service"` +} + +func (c *KeyringConfig) Validate() error { + if c.Service == "" { + return fmt.Errorf("%w: service name is required for keyring vault", ErrInvalidConfig) } return nil } diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..1254e00 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,86 @@ +# External Vault Provider Examples + +This directory contains ready-to-use configurations for popular CLI tools. + +## Available Configurations + +- **[Bitwarden](./providers/bitwarden.json)** +- **[1Password](./providers/1password.json)** +- **[pass](./providers/pass.json)** +- **[AWS SSM Parameter Store](./providers/aws-ssm.json)** + +## Quick Start + +```bash +# Test a configuration +./test-provider.sh providers/bitwarden.json + +# Run the Go example +go run main.go providers/pass.json +``` + +## Setup Instructions + +### Authentication Requirements + +Each tool requires prior authentication: + +- **Bitwarden**: `bw login && bw unlock` +- **1Password**: `op signin` +- **AWS SSM**: `aws configure` +- **pass**: Configure GPG keys + +### Environment Variables + +| Provider | Required Variables | +|----------|-------------------| +| Bitwarden | `BW_SESSION` | +| 1Password | `OP_SERVICE_ACCOUNT_TOKEN` | +| AWS SSM | `AWS_REGION` (+ credentials) | +| pass | `PASSWORD_STORE_DIR` (optional) | + +## Configuration Structure + +Each configuration follows this pattern: + +```json +{ + "id": "provider-name", + "type": "external", + "external": { + "cmd": "cli-command", + "get": { + "cmd": "subcommand {{key}}", + "output": "{{output}}" + }, + "set": { + "cmd": "subcommand {{key}} {{value}}" + }, + "list": { + "cmd": "list-subcommand" + }, + "delete": { + "cmd": "delete-subcommand {{key}}" + }, + "exists": { + "cmd": "check-subcommand {{key}}" + }, + "metadata": { + "cmd": "status-subcommand" + }, + "environment": { + "ENV_VAR": "$ENV_VAR" + }, + "timeout": "30s" + } +} +``` + +## Template Variables + +Available in `cmd` and `output` fields: + +- `{{key}}` - The secret key/name +- `{{value}}` - The secret value (for set operations) +- `{{env["VariableName"]}}`- Environment variable value +- `{{output}}` - Raw command output (for output templates) diff --git a/examples/main.go b/examples/main.go new file mode 100644 index 0000000..89b8084 --- /dev/null +++ b/examples/main.go @@ -0,0 +1,158 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/flowexec/vault" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: go run main.go ") + fmt.Println("Example: go run main.go providers/bitwarden.json") + fmt.Println() + listProviders() + os.Exit(1) + } + + configPath := os.Args[1] + if _, err := os.Stat(configPath); os.IsNotExist(err) { + fmt.Printf("Error: Configuration file '%s' not found\n", configPath) + os.Exit(1) + } + + fmt.Printf("Testing vault provider configuration: %s\n", configPath) + fmt.Println() + + config, err := vault.LoadConfigJSON(configPath) + if err != nil { + log.Fatalf("%v", err) + } + + fmt.Printf("Provider ID: %s\n", config.ID) + fmt.Println() + + if err := checkEnvironmentVariables(configPath); err != nil { + fmt.Printf("Warning: Could not check environment variables: %v\n", err) + } + fmt.Println() + + provider, _, err := vault.New(config.ID, + vault.WithProvider(vault.ProviderTypeExternal), + vault.WithExternalConfig(config.External), + ) + if err != nil { + log.Fatalf("%v", err) + } + defer provider.Close() + + fmt.Printf("Using vault provider: %s\n", provider.ID()) + + fmt.Println("Setting test secret...") + testSecret := vault.NewSecretValue([]byte("test-secret-value-123")) + err = provider.SetSecret("test-key", testSecret) + if err != nil { + log.Fatalf("%v", err) + } else { + fmt.Println("Secret set successfully") + } + + fmt.Println("Checking if secret exists...") + exists, err := provider.HasSecret("test-key") + if err != nil { + log.Fatalf("%v", err) + } else { + fmt.Printf("Secret exists: %t\n", exists) + } + + fmt.Println("Retrieving secret...") + retrievedSecret, err := provider.GetSecret("test-key") + if err != nil { + log.Fatalf("%v", err) + } else { + fmt.Printf("Retrieved secret: %s\n", retrievedSecret.String()) + fmt.Printf("Secret length: %d characters\n", len(retrievedSecret.PlainTextString())) + } + + fmt.Println("Listing all secrets...") + secrets, err := provider.ListSecrets() + if err != nil { + log.Fatalf("%v", err) + } else { + fmt.Printf("Found %d secrets:\n", len(secrets)) + for i, secret := range secrets { + fmt.Printf(" %d. %s\n", i+1, secret) + } + } + + fmt.Println("Getting vault metadata...") + metadata := provider.Metadata() + fmt.Printf("Metadata: %s\n", metadata.RawData) + + fmt.Println("Cleaning up test secret...") + err = provider.DeleteSecret("test-key") + if err != nil { + log.Fatalf("%v", err) + } else { + fmt.Println("Test secret deleted successfully") + } + + fmt.Println("Testing completed successfully") +} + +func listProviders() { + fmt.Println("Available providers:") + matches, err := filepath.Glob("providers/*.json") + if err != nil { + fmt.Printf("Error listing providers: %v\n", err) + return + } + for _, match := range matches { + fmt.Printf(" %s\n", strings.TrimPrefix(match, "providers/")) + } +} + +func checkEnvironmentVariables(configPath string) error { + file, err := os.Open(filepath.Clean(configPath)) + if err != nil { + return err + } + defer file.Close() + + var config map[string]interface{} + if err := json.NewDecoder(file).Decode(&config); err != nil { + return err + } + + fmt.Println("Checking required environment variables:") + + external, ok := config["external"].(map[string]interface{}) + if !ok { + fmt.Println(" No external configuration found") + return nil + } + + environment, ok := external["environment"].(map[string]interface{}) + if !ok { + fmt.Println(" No environment variables required") + return nil + } + + for _, value := range environment { + if valueStr, isStr := value.(string); isStr && strings.HasPrefix(valueStr, "$") { + envVar := strings.TrimPrefix(valueStr, "$") + if os.Getenv(envVar) != "" { + fmt.Printf(" %s is set\n", envVar) + } else { + fmt.Printf(" Warning: %s is not set\n", envVar) + } + } + } + fmt.Println() + return nil +} diff --git a/examples/providers/1password.json b/examples/providers/1password.json new file mode 100644 index 0000000..fac2bd2 --- /dev/null +++ b/examples/providers/1password.json @@ -0,0 +1,31 @@ +{ + "id": "onepassword", + "type": "external", + "external": { + "get": { + "cmd": "op read \"op://Private/{{key}}/password\"", + "output": "{{output}}" + }, + "set": { + "cmd": "op item create --category Login --title {{key}} password={{value}} --vault Private" + }, + "delete": { + "cmd": "op item delete {{key}} --vault Private" + }, + "list": { + "cmd": "op item list --vault Private --format json", + "output": "{{ map(fromJSON(output), {.title}) | join(\"\\n\") }}" + }, + "exists": { + "cmd": "op item get {{key}} --vault Private" + }, + "metadata": { + "cmd": "op account list", + "output": "Account: {{output}}" + }, + "environment": { + "OP_SERVICE_ACCOUNT_TOKEN": "$OP_SERVICE_ACCOUNT_TOKEN" + }, + "timeout": "30s" + } +} \ No newline at end of file diff --git a/examples/providers/aws-ssm.json b/examples/providers/aws-ssm.json new file mode 100644 index 0000000..67b482e --- /dev/null +++ b/examples/providers/aws-ssm.json @@ -0,0 +1,34 @@ +{ + "id": "aws-ssm", + "type": "external", + "external": { + "get": { + "cmd": "aws ssm get-parameter --name /{{key}} --with-decryption --query Parameter.Value --output text", + "output": "{{output}}" + }, + "set": { + "cmd": "aws ssm put-parameter --name /{{key}} --value {{value}} --type SecureString --overwrite" + }, + "delete": { + "cmd": "aws ssm delete-parameter --name /{{key}}" + }, + "list": { + "cmd": "aws ssm describe-parameters --query Parameters[].Name --output text", + "output": "{{output}}" + }, + "listSeparator": "\t", + "exists": { + "cmd": "aws ssm get-parameter --name /{{key}}" + }, + "metadata": { + "cmd": "aws sts get-caller-identity --output json", + "output": "Account: {{fromJSON(output)[\"Account\"]}}, User: {{fromJSON(output)[\"Arn\"]}}" + }, + "environment": { + "AWS_REGION": "$AWS_REGION", + "AWS_ACCESS_KEY_ID": "$AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY": "$AWS_SECRET_ACCESS_KEY" + }, + "timeout": "60s" + } +} \ No newline at end of file diff --git a/examples/providers/bitwarden.json b/examples/providers/bitwarden.json new file mode 100644 index 0000000..fb5bde7 --- /dev/null +++ b/examples/providers/bitwarden.json @@ -0,0 +1,31 @@ +{ + "id": "bitwarden", + "type": "external", + "external": { + "get": { + "cmd": "bw get password {{key}}", + "output": "{{output}}" + }, + "set": { + "cmd": "if bw get item \"{{key}}\" >/dev/null 2>&1; then bw get item \"{{key}}\" | jq '.login.password=\"{{value}}\"' | bw encode | bw edit item $(bw get item \"{{key}}\" | jq -r .id); else echo '{\"object\":\"item\",\"type\":1,\"name\":\"{{key}}\",\"login\":{\"username\":\"{{key}}\",\"password\":\"{{value}}\"}}' | bw encode | bw create item; fi" + }, + "delete": { + "cmd": "bw delete item $(bw get item {{key}} | jq -r .id)" + }, + "list": { + "cmd": "bw list items --search \"\" --pretty", + "output": "{{ map(fromJSON(output), {.name}) | join(\"\\n\") }}" + }, + "exists": { + "cmd": "bw get item {{key}}" + }, + "metadata": { + "cmd": "bw status", + "output": "Status: {{output}}" + }, + "environment": { + "BW_SESSION": "$BW_SESSION" + }, + "timeout": "30s" + } +} \ No newline at end of file diff --git a/examples/providers/pass.json b/examples/providers/pass.json new file mode 100644 index 0000000..b8f6d7a --- /dev/null +++ b/examples/providers/pass.json @@ -0,0 +1,33 @@ +{ + "id": "pass", + "type": "external", + "external": { + "get": { + "cmd": "pass show {{key}}", + "output": "{{output}}" + }, + "set": { + "cmd": "pass insert -e {{key}}", + "input": "{{value}}" + }, + "delete": { + "cmd": "pass rm -f {{key}}" + }, + "list": { + "cmd": "pass ls", + "output": "{{output}}" + }, + "exists": { + "cmd": "pass show {{key}}" + }, + "metadata": { + "cmd": "pass git log --oneline -1", + "output": "Last change: {{output}}" + }, + "environment": { + "PASSWORD_STORE_DIR": "$PASSWORD_STORE_DIR", + "GPG_TTY": "$(tty)" + }, + "timeout": "30s" + } +} \ No newline at end of file diff --git a/external.go b/external.go index 57c79e0..aab2425 100644 --- a/external.go +++ b/external.go @@ -1,32 +1,472 @@ package vault +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/jahvon/expression" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/interp" + "mvdan.cc/sh/v3/syntax" +) + type ExternalVaultProvider struct { + ctx context.Context + mu sync.RWMutex + id string + execute func(ctx context.Context, cmd, input, dir string, envList []string) (string, error) + + cfg *ExternalConfig +} + +func NewExternalVaultProvider(cfg *Config) (*ExternalVaultProvider, error) { + if cfg.External == nil { + return nil, fmt.Errorf("external configuration is required") + } + + vault := &ExternalVaultProvider{ + ctx: context.Background(), + id: cfg.ID, + cfg: cfg.External, + execute: execute, + } + + return vault, nil } func (v *ExternalVaultProvider) ID() string { - panic("not implemented yet") + return v.id } -func (v *ExternalVaultProvider) GetSecret(_ string) (Secret, error) { - panic("not implemented yet") +func (v *ExternalVaultProvider) GetSecret(key string) (Secret, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + if err := ValidateSecretKey(key); err != nil { + return nil, err + } + + if v.cfg.Get.CommandTemplate == "" { + return nil, fmt.Errorf("get operation not configured") + } + + cmd, err := v.renderCmdTemplate(v.cfg.Get.CommandTemplate, key) + if err != nil { + return nil, fmt.Errorf("failed to render get cmd: %w", err) + } + + var input string + if v.cfg.Get.InputTemplate != "" { + input, err = v.renderInputTemplate(v.cfg.Get.InputTemplate, key) + if err != nil { + return nil, fmt.Errorf("failed to render input template: %w", err) + } + } + + output, err := v.executeCommand(cmd, input) + if err != nil { + return nil, fmt.Errorf("failed to get secret: %w", err) + } + + var secretValue string + if v.cfg.Get.OutputTemplate != "" { + secretValue, err = v.renderOutputTemplate(v.cfg.Get.OutputTemplate, output) + if err != nil { + return nil, fmt.Errorf("failed to parse output: %w", err) + } + } else { + secretValue = strings.TrimSpace(output) + } + + return NewSecretValue([]byte(secretValue)), nil } func (v *ExternalVaultProvider) SetSecret(key string, value Secret) error { - panic("not implemented yet") + v.mu.Lock() + defer v.mu.Unlock() + + if err := ValidateSecretKey(key); err != nil { + return err + } + + if v.cfg.Set.CommandTemplate == "" { + return fmt.Errorf("set operation not configured") + } + + cmd, err := v.renderCmdTemplateWithValue(v.cfg.Set.CommandTemplate, key, value.PlainTextString()) + if err != nil { + return fmt.Errorf("failed to render set cmd: %w", err) + } + + var input string + if v.cfg.Set.InputTemplate != "" { + input, err = v.renderInputTemplate(v.cfg.Get.InputTemplate, key) + if err != nil { + return fmt.Errorf("failed to render input template: %w", err) + } + } + + out, err := v.executeCommand(cmd, input) + if err != nil { + return fmt.Errorf("failed to set secret: %w stdErr: %s", err, out) + } + + return nil } func (v *ExternalVaultProvider) DeleteSecret(key string) error { - panic("not implemented yet") + v.mu.Lock() + defer v.mu.Unlock() + + if err := ValidateSecretKey(key); err != nil { + return err + } + + if v.cfg.Delete.CommandTemplate == "" { + return fmt.Errorf("delete operation not configured") + } + + cmd, err := v.renderCmdTemplate(v.cfg.Delete.CommandTemplate, key) + if err != nil { + return fmt.Errorf("failed to render delete cmd: %w", err) + } + + var input string + if v.cfg.Delete.InputTemplate != "" { + input, err = v.renderInputTemplate(v.cfg.Get.InputTemplate, key) + if err != nil { + return fmt.Errorf("failed to render input template: %w", err) + } + } + + if _, err := v.executeCommand(cmd, input); err != nil { + return fmt.Errorf("failed to delete secret: %w", err) + } + + return nil } func (v *ExternalVaultProvider) ListSecrets() ([]string, error) { - panic("not implemented yet") + v.mu.RLock() + defer v.mu.RUnlock() + + if v.cfg.List.CommandTemplate == "" { + return nil, fmt.Errorf("list operation not configured") + } + + cmd, err := v.renderCmdTemplate(v.cfg.List.CommandTemplate, "") + if err != nil { + return nil, fmt.Errorf("failed to render list cmd: %w", err) + } + + var input string + if v.cfg.List.InputTemplate != "" { + input, err = v.renderInputTemplate(v.cfg.Get.InputTemplate, "") + if err != nil { + return nil, fmt.Errorf("failed to render input template: %w", err) + } + } + + output, err := v.executeCommand(cmd, input) + if err != nil { + return nil, fmt.Errorf("failed to list secrets: %w", err) + } + + var secretsList string + if v.cfg.List.OutputTemplate != "" { + secretsList, err = v.renderOutputTemplate(v.cfg.List.OutputTemplate, output) + if err != nil { + return nil, fmt.Errorf("failed to parse list output: %w", err) + } + } else { + secretsList = strings.TrimSpace(output) + } + + if secretsList == "" { + return []string{}, nil + } + + sep := v.cfg.ListSeparator + if sep == "" { + sep = "\n" + } + secrets := strings.Split(secretsList, sep) + var result []string + for _, secret := range secrets { + secret = strings.TrimSpace(secret) + if secret != "" { + result = append(result, secret) + } + } + + return result, nil } func (v *ExternalVaultProvider) HasSecret(key string) (bool, error) { - panic("not implemented yet") + v.mu.RLock() + defer v.mu.RUnlock() + + if err := ValidateSecretKey(key); err != nil { + return false, err + } + + if v.cfg.Exists.CommandTemplate != "" { + cmd, err := v.renderCmdTemplate(v.cfg.Exists.CommandTemplate, key) + if err != nil { + return false, fmt.Errorf("failed to render exists cmd: %w", err) + } + + var input string + if v.cfg.Exists.InputTemplate != "" { + input, err = v.renderInputTemplate(v.cfg.Exists.InputTemplate, key) + if err != nil { + return false, fmt.Errorf("failed to render input template: %w", err) + } + } + + _, err = v.executeCommand(cmd, input) + // typically, exists commands return non-zero exit code if secret doesn't exist + return err == nil, nil + } + + _, err := v.GetSecret(key) + if err != nil { + if strings.Contains(err.Error(), "not found") || + strings.Contains(err.Error(), "not exist") || + strings.Contains(err.Error(), "not in") { + return false, nil + } + return false, err + } + return true, nil } func (v *ExternalVaultProvider) Close() error { return nil } + +func (v *ExternalVaultProvider) SetExecutionFunc( + fn func(ctx context.Context, cmd, input, dir string, envList []string) (string, error), +) { + v.execute = fn +} + +func (v *ExternalVaultProvider) Metadata() Metadata { + v.mu.RLock() + defer v.mu.RUnlock() + + if v.cfg.Metadata.CommandTemplate == "" { + return Metadata{} + } + + cmd, err := v.renderCmdTemplate(v.cfg.Metadata.CommandTemplate, "") + if err != nil { + return Metadata{} + } + var input string + if v.cfg.List.InputTemplate != "" { + input, err = v.renderInputTemplate(v.cfg.Metadata.InputTemplate, "") + if err != nil { + return Metadata{} + } + } + + output, err := v.executeCommand(cmd, input) + if err != nil { + return Metadata{} + } + + var metadataOutput string + if v.cfg.Metadata.OutputTemplate != "" { + metadataOutput, err = v.renderOutputTemplate(v.cfg.Metadata.OutputTemplate, output) + if err != nil { + return Metadata{} + } + } else { + metadataOutput = strings.TrimSpace(output) + } + + return Metadata{RawData: metadataOutput} +} + +func (v *ExternalVaultProvider) executeCommand(cmd, input string) (string, error) { + ctx := v.ctx + if v.cfg.Timeout != "" { + var cancel context.CancelFunc + dur, parseErr := time.ParseDuration(v.cfg.Timeout) + if parseErr != nil { + return "", fmt.Errorf("invalid timeout duration: %w", parseErr) + } + ctx, cancel = context.WithTimeout(v.ctx, dur) + defer cancel() + } + + output, runErr := v.execute(ctx, cmd, input, v.cfg.WorkingDir, v.environmentToSlice()) + if runErr != nil { + return "", fmt.Errorf("command failed: %w, stderr: %s", runErr, output) + } + + return output, nil +} + +func (v *ExternalVaultProvider) environmentToSlice() []string { + var envSlice []string + for key, value := range expandEnv(v.cfg.Environment) { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", key, value)) + } + return envSlice +} + +func (v *ExternalVaultProvider) renderCmdTemplate(template, key string) (string, error) { + data := expression.Data{ + "env": expandEnv(v.cfg.Environment), + "key": key, + "ref": key, + "id": key, + "name": key, + "template": template, + } + + template = os.ExpandEnv(template) + tmpl := expression.NewTemplate(fmt.Sprintf("%s-args-template", v.id), data) + err := tmpl.Parse(template) + if err != nil { + return "", fmt.Errorf("parsing args template: %w", err) + } + + result, err := tmpl.ExecuteToString() + if err != nil { + return "", fmt.Errorf("evaluating args template: %w", err) + } + return result, nil +} + +func (v *ExternalVaultProvider) renderCmdTemplateWithValue(template, key, value string) (string, error) { + data := expression.Data{ + "env": expandEnv(v.cfg.Environment), + "key": key, + "ref": key, + "id": key, + "name": key, + "value": value, + "password": value, + "template": template, + } + + template = os.ExpandEnv(template) + tmpl := expression.NewTemplate(fmt.Sprintf("%s-args-template", v.id), data) + err := tmpl.Parse(template) + if err != nil { + return "", fmt.Errorf("parsing args template: %w", err) + } + + result, err := tmpl.ExecuteToString() + if err != nil { + return "", fmt.Errorf("evaluating args template: %w", err) + } + return result, nil +} + +func (v *ExternalVaultProvider) renderInputTemplate(template, input string) (string, error) { + data := expression.Data{ + "env": expandEnv(v.cfg.Environment), + "input": input, + "template": template, + } + + template = os.ExpandEnv(template) + tmpl := expression.NewTemplate(fmt.Sprintf("%s-input-template", v.id), data) + err := tmpl.Parse(template) + if err != nil { + return "", fmt.Errorf("parsing input template: %w", err) + } + + result, err := tmpl.ExecuteToString() + if err != nil { + return "", fmt.Errorf("evaluating input template: %w", err) + } + return result, nil +} + +func (v *ExternalVaultProvider) renderOutputTemplate(template, output string) (string, error) { + data := expression.Data{ + "env": expandEnv(v.cfg.Environment), + "output": output, + "template": template, + } + + template = os.ExpandEnv(template) + tmpl := expression.NewTemplate(fmt.Sprintf("%s-output-template", v.id), data) + err := tmpl.Parse(template) + if err != nil { + return "", fmt.Errorf("parsing output template: %w", err) + } + + result, err := tmpl.ExecuteToString() + if err != nil { + return "", fmt.Errorf("evaluating output template: %w", err) + } + return result, nil +} + +func execute(ctx context.Context, cmd, input, dir string, envList []string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + parser := syntax.NewParser() + reader := strings.NewReader(strings.TrimSpace(cmd)) + prog, err := parser.Parse(reader, "") + if err != nil { + return "", fmt.Errorf("unable to parse command - %w", err) + } + + if envList == nil { + envList = make([]string, 0) + } + envList = append(os.Environ(), envList...) + + stdInBuffer := strings.NewReader(input) + stdOutBuffer := &strings.Builder{} + stdErrBuffer := &strings.Builder{} + + runner, err := interp.New( + interp.Dir(dir), + interp.Env(expand.ListEnviron(envList...)), + interp.StdIO( + stdInBuffer, + stdOutBuffer, + stdErrBuffer, + ), + ) + if err != nil { + return "", fmt.Errorf("unable to create runner - %w", err) + } + + err = runner.Run(ctx, prog) + if err != nil { + var exitStatus interp.ExitStatus + if errors.As(err, &exitStatus) { + return stdErrBuffer.String(), fmt.Errorf("command exited with non-zero status %w", exitStatus) + } + return stdErrBuffer.String(), fmt.Errorf("encountered an error executing command - %w", err) + } + output := stdOutBuffer.String() + if stderr := stdErrBuffer.String(); stderr != "" { + output += "\n" + stderr + } + return strings.TrimSpace(output), nil +} + +func expandEnv(env map[string]string) map[string]string { + for k, v := range env { + if strings.Contains(v, "$") || strings.Contains(v, "{") { + env[k] = os.ExpandEnv(v) + } + } + return env +} diff --git a/external_test.go b/external_test.go new file mode 100644 index 0000000..54a967c --- /dev/null +++ b/external_test.go @@ -0,0 +1,432 @@ +package vault_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/flowexec/vault" +) + +func TestNewExternalVaultProvider(t *testing.T) { + tests := []struct { + name string + config *vault.Config + wantErr bool + }{ + { + name: "valid config", + config: &vault.Config{ + ID: "test-vault", + Type: vault.ProviderTypeExternal, + External: &vault.ExternalConfig{ + Get: vault.CommandConfig{ + CommandTemplate: "vault kv get -format=json {{key}}", + }, + Set: vault.CommandConfig{ + CommandTemplate: "vault kv put {{key}} value={{value}}", + }, + }, + }, + wantErr: false, + }, + { + name: "missing external config", + config: &vault.Config{ + ID: "test-vault", + Type: vault.ProviderTypeExternal, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := vault.NewExternalVaultProvider(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("NewExternalVaultProvider() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && provider == nil { + t.Error("NewExternalVaultProvider() returned nil provider") + } + if !tt.wantErr && provider.ID() != tt.config.ID { + t.Errorf("NewExternalVaultProvider() ID = %v, want %v", provider.ID(), tt.config.ID) + } + }) + } +} + +func TestExternalVaultProvider_GetSecret(t *testing.T) { + config := &vault.Config{ + ID: "test-vault", + Type: vault.ProviderTypeExternal, + External: &vault.ExternalConfig{ + Get: vault.CommandConfig{ + CommandTemplate: "vault kv get -format=json {{key}}", + }, + }, + } + + provider, err := vault.NewExternalVaultProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + tests := []struct { + name string + key string + mockOutputs map[string]string + mockErrors map[string]error + wantSecret string + wantErr bool + errorContains string + }{ + { + name: "successful get", + key: "test-key", + mockOutputs: map[string]string{ + "vault kv get -format=json test-key": "secret-value", + }, + wantSecret: "secret-value", + wantErr: false, + }, + { + name: "command fails", + key: "test-key", + mockErrors: map[string]error{ + "vault kv get -format=json test-key": fmt.Errorf("command failed"), + }, + wantErr: true, + errorContains: "failed to get secret", + }, + { + name: "invalid key", + key: "", + wantErr: true, + errorContains: "invalid secret key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testProvider := provider + if tt.name == "get operation not configured" { + testConfig := &vault.Config{ + ID: "test-vault", + Type: vault.ProviderTypeExternal, + External: &vault.ExternalConfig{}, + } + var err error + testProvider, err = vault.NewExternalVaultProvider(testConfig) + if err != nil { + t.Fatalf("Failed to create test provider: %v", err) + } + } + + testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors)) + + secret, err := testProvider.GetSecret(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("GetSecret() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err != nil && tt.errorContains != "" { + if !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("GetSecret() error = %v, want error containing %v", err, tt.errorContains) + } + return + } + + if !tt.wantErr && secret.PlainTextString() != tt.wantSecret { + t.Errorf("GetSecret() secret = %v, want %v", secret.PlainTextString(), tt.wantSecret) + } + }) + } +} + +func TestExternalVaultProvider_SetSecret(t *testing.T) { + config := &vault.Config{ + ID: "test-vault", + Type: vault.ProviderTypeExternal, + External: &vault.ExternalConfig{ + Set: vault.CommandConfig{ + CommandTemplate: "vault kv put {{key}} value={{value}}", + }, + }, + } + + provider, err := vault.NewExternalVaultProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + tests := []struct { + name string + key string + value string + mockOutputs map[string]string + mockErrors map[string]error + wantErr bool + errorContains string + }{ + { + name: "successful set", + key: "test-key", + value: "test-value", + mockOutputs: map[string]string{ + "vault kv put test-key value=test-value": "success", + }, + wantErr: false, + }, + { + name: "command fails", + key: "test-key", + value: "test-value", + mockErrors: map[string]error{ + "vault kv put test-key value=test-value": fmt.Errorf("command failed"), + }, + wantErr: true, + errorContains: "failed to set secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testProvider := provider + testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors)) + + secret := vault.NewSecretValue([]byte(tt.value)) + err := testProvider.SetSecret(tt.key, secret) + if (err != nil) != tt.wantErr { + t.Errorf("SetSecret() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err != nil && tt.errorContains != "" { + if !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("SetSecret() error = %v, want error containing %v", err, tt.errorContains) + } + } + }) + } +} + +func TestExternalVaultProvider_ListSecrets(t *testing.T) { + config := &vault.Config{ + ID: "test-vault", + Type: vault.ProviderTypeExternal, + External: &vault.ExternalConfig{ + List: vault.CommandConfig{ + CommandTemplate: "vault kv list", + }, + }, + } + + provider, err := vault.NewExternalVaultProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + tests := []struct { + name string + mockOutputs map[string]string + mockErrors map[string]error + wantSecrets []string + wantErr bool + }{ + { + name: "successful list", + mockOutputs: map[string]string{ + "vault kv list": "secret1\nsecret2\nsecret3", + }, + wantSecrets: []string{"secret1", "secret2", "secret3"}, + wantErr: false, + }, + { + name: "empty list", + mockOutputs: map[string]string{ + "vault kv list": "", + }, + wantSecrets: []string{}, + wantErr: false, + }, + { + name: "command fails", + mockErrors: map[string]error{ + "vault kv list": fmt.Errorf("command failed"), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testProvider := provider + testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors)) + + secrets, err := testProvider.ListSecrets() + if (err != nil) != tt.wantErr { + t.Errorf("ListSecrets() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + if len(secrets) != len(tt.wantSecrets) { + t.Errorf("ListSecrets() returned %d secrets, want %d", len(secrets), len(tt.wantSecrets)) + return + } + for i, secret := range secrets { + if secret != tt.wantSecrets[i] { + t.Errorf("ListSecrets() secret[%d] = %v, want %v", i, secret, tt.wantSecrets[i]) + } + } + } + }) + } +} + +func TestExternalVaultProvider_HasSecret(t *testing.T) { + config := &vault.Config{ + ID: "test-vault", + Type: vault.ProviderTypeExternal, + External: &vault.ExternalConfig{ + Exists: vault.CommandConfig{ + CommandTemplate: "vault kv get {{key}}", + }, + }, + } + + provider, err := vault.NewExternalVaultProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + tests := []struct { + name string + key string + mockOutputs map[string]string + mockErrors map[string]error + wantExists bool + wantErr bool + }{ + { + name: "secret exists", + key: "existing-key", + mockOutputs: map[string]string{ + "vault kv get existing-key": "some-value", + }, + wantExists: true, + wantErr: false, + }, + { + name: "secret does not exist", + key: "nonexistent-key", + mockErrors: map[string]error{ + "vault kv get nonexistent-key": fmt.Errorf("not found"), + }, + wantExists: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testProvider := provider + testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors)) + + exists, err := testProvider.HasSecret(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("HasSecret() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if exists != tt.wantExists { + t.Errorf("HasSecret() = %v, want %v", exists, tt.wantExists) + } + }) + } +} + +func TestExternalVaultProvider_Metadata(t *testing.T) { + tests := []struct { + name string + config *vault.ExternalConfig + mockOutputs map[string]string + mockErrors map[string]error + wantRawData string + }{ + { + name: "successful metadata retrieval", + config: &vault.ExternalConfig{ + Metadata: vault.CommandConfig{ + CommandTemplate: "vault status", + }, + }, + mockOutputs: map[string]string{ + "vault status": "vault is healthy", + }, + wantRawData: "vault is healthy", + }, + { + name: "no metadata command configured", + config: &vault.ExternalConfig{}, + wantRawData: "", + }, + { + name: "metadata command fails", + config: &vault.ExternalConfig{ + Metadata: vault.CommandConfig{ + CommandTemplate: "vault status", + }, + }, + mockErrors: map[string]error{ + "vault status": fmt.Errorf("command failed"), + }, + wantRawData: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &vault.Config{ + ID: "test-vault", + Type: vault.ProviderTypeExternal, + External: tt.config, + } + + provider, err := vault.NewExternalVaultProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + testProvider := provider + testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors)) + + metadata := testProvider.Metadata() + if metadata.RawData != tt.wantRawData { + t.Errorf("Metadata().RawData = %v, want %v", metadata.RawData, tt.wantRawData) + } + }) + } +} + +// mockCommandContext creates mock commands for testing +func mockCommandContext( + outputs map[string]string, + errors map[string]error, +) func(ctx context.Context, cmd, input, dir string, envList []string) (string, error) { + return func(ctx context.Context, cmd, input, dir string, envList []string) (string, error) { + for _, output := range outputs { + return output, nil + } + + for range errors { + return "", fmt.Errorf("mock error") + } + + return "mock", nil + } +} diff --git a/go.mod b/go.mod index 7ab804e..2715aa9 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,22 @@ module github.com/flowexec/vault -go 1.24 +go 1.24.0 require ( filippo.io/age v1.2.1 - golang.org/x/crypto v0.39.0 + github.com/jahvon/expression v0.1.1 + github.com/zalando/go-keyring v0.2.6 + golang.org/x/crypto v0.41.0 + gopkg.in/yaml.v3 v3.0.1 + mvdan.cc/sh/v3 v3.12.0 ) -require gopkg.in/yaml.v3 v3.0.1 - require ( - github.com/kr/pretty v0.3.1 // indirect - golang.org/x/sys v0.33.0 // indirect + al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/expr-lang/expr v1.17.5 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index c6fe5ce..1d7c6f1 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,27 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= +github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/jahvon/expression v0.1.1 h1:Impbzo6to4gLYAL/W11KYhAwap3grp14yShh3jJiKNk= +github.com/jahvon/expression v0.1.1/go.mod h1:4HJB2k+epW5vFeptF6ILlXbFRQ+CuCyCSO4QdnGT3AE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -10,16 +29,26 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI= +mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg= diff --git a/keyring.go b/keyring.go new file mode 100644 index 0000000..47ed228 --- /dev/null +++ b/keyring.go @@ -0,0 +1,274 @@ +package vault + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "sync" + "time" + + "github.com/zalando/go-keyring" +) + +// KeyringVault manages operations on a keyring-based vault that stores secrets in the system keyring. +type KeyringVault struct { + mu sync.RWMutex + id string + service string + + metadata Metadata +} + +func NewKeyringVault(cfg *Config) (*KeyringVault, error) { + if cfg.Keyring == nil { + return nil, fmt.Errorf("keyring configuration is required") + } + + vault := &KeyringVault{ + id: cfg.ID, + service: cfg.Keyring.Service, + } + + // Try to load metadata or initialize if not exists + if err := vault.loadMetadata(); err != nil { + if err := vault.initMetadata(); err != nil { + return nil, fmt.Errorf("failed to initialize keyring vault metadata: %w", err) + } + } + + return vault, nil +} + +func (v *KeyringVault) metadataKey() string { + return fmt.Sprintf("%s-metadata", v.id) +} + +func (v *KeyringVault) secretKey(key string) string { + return fmt.Sprintf("%s-secret-%s", v.id, key) +} + +func (v *KeyringVault) secretsListKey() string { + return fmt.Sprintf("%s-secrets-list", v.id) +} + +func (v *KeyringVault) initMetadata() error { + now := time.Now() + v.metadata = Metadata{ + Created: now, + LastModified: now, + } + + return v.saveMetadata() +} + +func (v *KeyringVault) loadMetadata() error { + data, err := keyring.Get(v.service, v.metadataKey()) + if err != nil { + return err + } + + var metadata Metadata + if err := json.Unmarshal([]byte(data), &metadata); err != nil { + return fmt.Errorf("failed to unmarshal metadata: %w", err) + } + + v.metadata = metadata + return nil +} + +func (v *KeyringVault) saveMetadata() error { + v.metadata.LastModified = time.Now() + + data, err := json.Marshal(v.metadata) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + return keyring.Set(v.service, v.metadataKey(), string(data)) +} + +func (v *KeyringVault) loadSecretsList() ([]string, error) { + data, err := keyring.Get(v.service, v.secretsListKey()) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return []string{}, nil + } + return nil, err + } + + var secrets []string + if err := json.Unmarshal([]byte(data), &secrets); err != nil { + return nil, fmt.Errorf("failed to unmarshal secrets list: %w", err) + } + + return secrets, nil +} + +func (v *KeyringVault) saveSecretsList(secrets []string) error { + data, err := json.Marshal(secrets) + if err != nil { + return fmt.Errorf("failed to marshal secrets list: %w", err) + } + + return keyring.Set(v.service, v.secretsListKey(), string(data)) +} + +func (v *KeyringVault) addSecretToList(key string) error { + secrets, err := v.loadSecretsList() + if err != nil { + return err + } + + // Check if secret already exists in list + for _, s := range secrets { + if s == key { + return nil // Already exists + } + } + + secrets = append(secrets, key) + sort.Strings(secrets) + + return v.saveSecretsList(secrets) +} + +func (v *KeyringVault) removeSecretFromList(key string) error { + secrets, err := v.loadSecretsList() + if err != nil { + return err + } + + // Remove the secret from the list + for i, s := range secrets { + if s == key { + secrets = append(secrets[:i], secrets[i+1:]...) + break + } + } + + return v.saveSecretsList(secrets) +} + +func (v *KeyringVault) ID() string { + return v.id +} + +func (v *KeyringVault) Metadata() Metadata { + v.mu.RLock() + defer v.mu.RUnlock() + + return v.metadata +} + +func (v *KeyringVault) GetSecret(key string) (Secret, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + if err := ValidateSecretKey(key); err != nil { + return nil, err + } + + data, err := keyring.Get(v.service, v.secretKey(key)) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return nil, ErrSecretNotFound + } + return nil, fmt.Errorf("failed to get secret from keyring: %w", err) + } + + return NewSecretValue([]byte(data)), nil +} + +func (v *KeyringVault) SetSecret(key string, secret Secret) error { + v.mu.Lock() + defer v.mu.Unlock() + + if err := ValidateSecretKey(key); err != nil { + return err + } + + if err := keyring.Set(v.service, v.secretKey(key), secret.PlainTextString()); err != nil { + return fmt.Errorf("failed to set secret in keyring: %w", err) + } + + if err := v.addSecretToList(key); err != nil { + return fmt.Errorf("failed to update secrets list: %w", err) + } + + return v.saveMetadata() +} + +func (v *KeyringVault) DeleteSecret(key string) error { + v.mu.Lock() + defer v.mu.Unlock() + + if err := ValidateSecretKey(key); err != nil { + return err + } + + // Check if secret exists first + _, err := keyring.Get(v.service, v.secretKey(key)) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return ErrSecretNotFound + } + return fmt.Errorf("failed to check secret existence: %w", err) + } + + if err := keyring.Delete(v.service, v.secretKey(key)); err != nil { + return fmt.Errorf("failed to delete secret from keyring: %w", err) + } + + if err := v.removeSecretFromList(key); err != nil { + return fmt.Errorf("failed to update secrets list: %w", err) + } + + return v.saveMetadata() +} + +func (v *KeyringVault) ListSecrets() ([]string, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + secrets, err := v.loadSecretsList() + if err != nil { + return nil, fmt.Errorf("failed to load secrets list: %w", err) + } + + // Return a copy to prevent external modification + result := make([]string, len(secrets)) + copy(result, secrets) + + return result, nil +} + +func (v *KeyringVault) HasSecret(key string) (bool, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + if err := ValidateSecretKey(key); err != nil { + return false, err + } + + _, err := keyring.Get(v.service, v.secretKey(key)) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return false, nil + } + return false, fmt.Errorf("failed to check secret existence: %w", err) + } + + return true, nil +} + +func (v *KeyringVault) Close() error { + // Keyring doesn't need explicit cleanup + // Just clear the in-memory metadata + v.mu.Lock() + defer v.mu.Unlock() + + v.metadata = Metadata{} + + return nil +} diff --git a/keyring_test.go b/keyring_test.go new file mode 100644 index 0000000..6e8d1b6 --- /dev/null +++ b/keyring_test.go @@ -0,0 +1,377 @@ +package vault_test + +import ( + "errors" + "testing" + + "github.com/zalando/go-keyring" + + "github.com/flowexec/vault" +) + +const testKeyringService = "flowexec-vault-test" + +func TestKeyringVault_New(t *testing.T) { + keyring.MockInit() + vlt, cfg, err := vault.New("test-keyring-vault", + vault.WithProvider(vault.ProviderTypeKeyring), + vault.WithKeyringService(testKeyringService), + ) + if err != nil { + t.Fatalf("Failed to create keyring vault: %v", err) + } + defer vlt.Close() + + if vlt.ID() != "test-keyring-vault" { + t.Errorf("Expected vault ID 'test-keyring-vault', got '%s'", vlt.ID()) + } + + if cfg.Type != vault.ProviderTypeKeyring { + t.Errorf("Expected provider type '%s', got '%s'", vault.ProviderTypeKeyring, cfg.Type) + } + + if cfg.Keyring == nil { + t.Fatal("Expected keyring config to be set") + } + + if cfg.Keyring.Service != testKeyringService { + t.Errorf("Expected service name '%s', got '%s'", testKeyringService, cfg.Keyring.Service) + } +} + +func TestKeyringVault_SecretOperations(t *testing.T) { + keyring.MockInit() + vlt, _, err := vault.New("test-keyring-vault", + vault.WithProvider(vault.ProviderTypeKeyring), + vault.WithKeyringService(testKeyringService), + ) + if err != nil { + t.Fatalf("Failed to create keyring vault: %v", err) + } + defer func() { + secrets, _ := vlt.ListSecrets() + for _, key := range secrets { + _ = vlt.DeleteSecret(key) + } + vlt.Close() + }() + + // Test setting a secret + testKey := "test-keyring-key" + testValue := "test-keyring-value" + secret := vault.NewSecretValue([]byte(testValue)) + + err = vlt.SetSecret(testKey, secret) + if err != nil { + t.Fatalf("Failed to set secret: %v", err) + } + + // Test getting the secret + retrievedSecret, err := vlt.GetSecret(testKey) + if err != nil { + t.Fatalf("Failed to get secret: %v", err) + } + + if retrievedSecret.PlainTextString() != testValue { + t.Errorf("Expected secret value '%s', got '%s'", testValue, retrievedSecret.PlainTextString()) + } + + // Test HasSecret + exists, err := vlt.HasSecret(testKey) + if err != nil { + t.Fatalf("Failed to check if secret exists: %v", err) + } + if !exists { + t.Error("Expected secret to exist") + } + + // Test ListSecrets + secrets, err := vlt.ListSecrets() + if err != nil { + t.Fatalf("Failed to list secrets: %v", err) + } + if len(secrets) != 1 { + t.Errorf("Expected 1 secret, got %d", len(secrets)) + } + if secrets[0] != testKey { + t.Errorf("Expected secret key '%s', got '%s'", testKey, secrets[0]) + } + + // Test deleting the secret + err = vlt.DeleteSecret(testKey) + if err != nil { + t.Fatalf("Failed to delete secret: %v", err) + } + + // Verify secret is gone + exists, err = vlt.HasSecret(testKey) + if err != nil { + t.Fatalf("Failed to check if secret exists after deletion: %v", err) + } + if exists { + t.Error("Expected secret to not exist after deletion") + } + + // Test getting non-existent secret + _, err = vlt.GetSecret(testKey) + if !errors.Is(err, vault.ErrSecretNotFound) { + t.Errorf("Expected ErrSecretNotFound, got %v", err) + } + + // Test deleting non-existent secret + err = vlt.DeleteSecret(testKey) + if !errors.Is(err, vault.ErrSecretNotFound) { + t.Errorf("Expected ErrSecretNotFound when deleting non-existent secret, got %v", err) + } +} + +func TestKeyringVault_MultipleSecrets(t *testing.T) { + keyring.MockInit() + vlt, _, err := vault.New("test-keyring-vault-multiple", + vault.WithProvider(vault.ProviderTypeKeyring), + vault.WithKeyringService(testKeyringService), + ) + if err != nil { + t.Fatalf("Failed to create keyring vault: %v", err) + } + defer func() { + // Clean up any remaining secrets before closing + secrets, _ := vlt.ListSecrets() + for _, key := range secrets { + _ = vlt.DeleteSecret(key) + } + vlt.Close() + }() + + // Set multiple secrets + secrets := map[string]string{ + "API_KEY": "secret-api-key", + "DATABASE_URL": "postgresql://user:pass@host:5432/db", + "DEBUG": "true", + "QUOTED_VALUE": "value with spaces", + } + + for key, value := range secrets { + err = vlt.SetSecret(key, vault.NewSecretValue([]byte(value))) + if err != nil { + t.Fatalf("Failed to set secret %s: %v", key, err) + } + } + + // Verify all secrets can be retrieved + for key, expectedValue := range secrets { + secret, err := vlt.GetSecret(key) + if err != nil { + t.Fatalf("Failed to get secret %s: %v", key, err) + } + if secret.PlainTextString() != expectedValue { + t.Errorf("Expected secret value '%s' for key '%s', got '%s'", expectedValue, key, secret.PlainTextString()) + } + } + + // Test ListSecrets returns all keys in sorted order + listedKeys, err := vlt.ListSecrets() + if err != nil { + t.Fatalf("Failed to list secrets: %v", err) + } + + expectedKeys := []string{"API_KEY", "DATABASE_URL", "DEBUG", "QUOTED_VALUE"} + if len(listedKeys) != len(expectedKeys) { + t.Errorf("Expected %d secrets, got %d", len(expectedKeys), len(listedKeys)) + } + + for i, expectedKey := range expectedKeys { + if i >= len(listedKeys) { + t.Errorf("Expected key '%s' at position %d, but list is too short", expectedKey, i) + continue + } + if listedKeys[i] != expectedKey { + t.Errorf("Expected key '%s' at position %d, got '%s'", expectedKey, i, listedKeys[i]) + } + } + + // Clean up - delete all secrets + for key := range secrets { + err = vlt.DeleteSecret(key) + if err != nil { + t.Fatalf("Failed to delete secret %s: %v", key, err) + } + } + + // Verify all secrets are gone + listedKeys, err = vlt.ListSecrets() + if err != nil { + t.Fatalf("Failed to list secrets after cleanup: %v", err) + } + if len(listedKeys) != 0 { + t.Errorf("Expected 0 secrets after cleanup, got %d", len(listedKeys)) + } +} + +func TestKeyringVault_Persistence(t *testing.T) { + keyring.MockInit() + testKey := "persistent-keyring-key" + testValue := "persistent-keyring-value" + + // Create first vault instance and add a secret + vault1, _, err := vault.New("test-keyring-persistence", + vault.WithProvider(vault.ProviderTypeKeyring), + vault.WithKeyringService(testKeyringService), + ) + if err != nil { + t.Fatalf("Failed to create first vault: %v", err) + } + + err = vault1.SetSecret(testKey, vault.NewSecretValue([]byte(testValue))) + if err != nil { + t.Fatalf("Failed to set secret in first vault: %v", err) + } + vault1.Close() + + // Create second vault instance with same configuration + vault2, _, err := vault.New("test-keyring-persistence", + vault.WithProvider(vault.ProviderTypeKeyring), + vault.WithKeyringService(testKeyringService), + ) + if err != nil { + t.Fatalf("Failed to create second vault: %v", err) + } + defer func() { + // Clean up + _ = vault2.DeleteSecret(testKey) + vault2.Close() + }() + + // Verify secret persists + retrievedSecret, err := vault2.GetSecret(testKey) + if err != nil { + t.Fatalf("Failed to get secret from second vault: %v", err) + } + + if retrievedSecret.PlainTextString() != testValue { + t.Errorf("Expected persisted secret value '%s', got '%s'", testValue, retrievedSecret.PlainTextString()) + } + + // Verify metadata is accessible + metadata := vault2.Metadata() + if metadata.Created.IsZero() { + t.Error("Expected creation time to be set") + } + if metadata.LastModified.IsZero() { + t.Error("Expected last modified time to be set") + } +} + +func TestKeyringVault_Metadata(t *testing.T) { + keyring.MockInit() + vlt, _, err := vault.New("test-keyring-metadata", + vault.WithProvider(vault.ProviderTypeKeyring), + vault.WithKeyringService(testKeyringService), + ) + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + defer vlt.Close() + + metadata := vlt.Metadata() + if metadata.Created.IsZero() { + t.Error("Expected creation time to be set") + } + if metadata.LastModified.IsZero() { + t.Error("Expected last modified time to be set") + } + + // Add a secret and verify last modified time is updated + oldModified := metadata.LastModified + + err = vlt.SetSecret("test-key", vault.NewSecretValue([]byte("test-value"))) + if err != nil { + t.Fatalf("Failed to set secret: %v", err) + } + + newMetadata := vlt.Metadata() + if !newMetadata.LastModified.After(oldModified) { + t.Error("Expected last modified time to be updated after setting secret") + } + + // Clean up + _ = vlt.DeleteSecret("test-key") +} + +func TestKeyringVault_InvalidKeyValidation(t *testing.T) { + keyring.MockInit() + vlt, _, err := vault.New("test-keyring-validation", + vault.WithProvider(vault.ProviderTypeKeyring), + vault.WithKeyringService(testKeyringService), + ) + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + defer vlt.Close() + + // Test empty key + err = vlt.SetSecret("", vault.NewSecretValue([]byte("value"))) + if err == nil { + t.Error("Expected error when setting secret with empty key") + } + + _, err = vlt.GetSecret("") + if err == nil { + t.Error("Expected error when getting secret with empty key") + } + + err = vlt.DeleteSecret("") + if err == nil { + t.Error("Expected error when deleting secret with empty key") + } + + _, err = vlt.HasSecret("") + if err == nil { + t.Error("Expected error when checking secret with empty key") + } +} + +func TestKeyringVault_SortedOutput(t *testing.T) { + keyring.MockInit() + vlt, _, err := vault.New("test-keyring-sorted", + vault.WithProvider(vault.ProviderTypeKeyring), + vault.WithKeyringService(testKeyringService), + ) + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + defer func() { + // Clean up any remaining secrets before closing + secrets, _ := vlt.ListSecrets() + for _, key := range secrets { + _ = vlt.DeleteSecret(key) + } + vlt.Close() + }() + + // Add secrets in non-alphabetical order + secretKeys := []string{"zebra", "alpha", "beta", "gamma"} + for _, key := range secretKeys { + err = vlt.SetSecret(key, vault.NewSecretValue([]byte("value-"+key))) + if err != nil { + t.Fatalf("Failed to set secret %s: %v", key, err) + } + } + + // List secrets and verify they are sorted + listedKeys, err := vlt.ListSecrets() + if err != nil { + t.Fatalf("Failed to list secrets: %v", err) + } + + expectedOrder := []string{"alpha", "beta", "gamma", "zebra"} + if len(listedKeys) != len(expectedOrder) { + t.Fatalf("Expected %d keys, got %d", len(expectedOrder), len(listedKeys)) + } + + for i, expected := range expectedOrder { + if listedKeys[i] != expected { + t.Errorf("Expected key at position %d to be '%s', got '%s'", i, expected, listedKeys[i]) + } + } +} diff --git a/local.go b/local.go index a9a4d82..80cc138 100644 --- a/local.go +++ b/local.go @@ -21,6 +21,7 @@ var ( type Metadata struct { Created time.Time `json:"created"` LastModified time.Time `json:"lastModified"` + RawData string `json:"data,omitempty"` } // validateSecurePath checks if a path is safe to use diff --git a/unencrypted.go b/unencrypted.go new file mode 100644 index 0000000..681b741 --- /dev/null +++ b/unencrypted.go @@ -0,0 +1,221 @@ +package vault + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +const ( + unencryptedCurrentVaultVersion = 1 + unencryptedVaultFileExt = "json" +) + +// UnencryptedState represents the state of the unencrypted vault. +type UnencryptedState struct { + Metadata `json:"metadata"` + + Version int `json:"version"` + ID string `json:"id"` + Secrets map[string]string `json:"secrets"` +} + +// UnencryptedVault manages operations on an instance of an unencrypted vault that stores secrets in JSON format. +type UnencryptedVault struct { + mu sync.RWMutex + id string + fullPath string + + state *UnencryptedState +} + +func NewUnencryptedVault(cfg *Config) (*UnencryptedVault, error) { + if cfg.Unencrypted == nil { + return nil, fmt.Errorf("unencrypted configuration is required") + } + + path := filepath.Join( + filepath.Clean(cfg.Unencrypted.StoragePath), + filepath.Clean(fmt.Sprintf("%s-%s.%s", vaultFileBase, cfg.ID, unencryptedVaultFileExt)), + ) + + vault := &UnencryptedVault{ + id: cfg.ID, + fullPath: path, + } + + if err := vault.load(); err != nil { + return nil, fmt.Errorf("failed to load vault: %w", err) + } + + if vault.state == nil { + if err := vault.init(); err != nil { + return nil, fmt.Errorf("failed to initialize vault: %w", err) + } + } + + return vault, nil +} + +func (v *UnencryptedVault) init() error { + now := time.Now() + v.state = &UnencryptedState{ + Version: unencryptedCurrentVaultVersion, + ID: v.id, + Metadata: Metadata{ + Created: now, + LastModified: now, + }, + Secrets: make(map[string]string), + } + + return v.save() +} + +// load retrieves the vault contents from the file and parses it into the state. +func (v *UnencryptedVault) load() error { + data, err := os.ReadFile(filepath.Clean(v.fullPath)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("%w: failed to read vault file %s: %w", ErrVaultNotFound, v.fullPath, err) + } + + if len(data) == 0 { + return nil + } + + // Parse the JSON format + var state UnencryptedState + if err := json.Unmarshal(data, &state); err != nil { + return fmt.Errorf("failed to parse vault file: %w", err) + } + + v.state = &state + return nil +} + +// save writes the vault contents to disk in JSON format +func (v *UnencryptedVault) save() error { + if v.state == nil { + return nil + } + + v.state.LastModified = time.Now() + + // Marshal to JSON with indentation for readability + data, err := json.MarshalIndent(v.state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal vault state: %w", err) + } + + // Write to file atomically + if err := os.MkdirAll(filepath.Dir(v.fullPath), 0750); err != nil { + return fmt.Errorf("failed to create vault directory: %w", err) + } + + tempFile := v.fullPath + ".tmp" + if err := os.WriteFile(tempFile, data, 0600); err != nil { + return fmt.Errorf("failed to write temp vault file: %w", err) + } + + if err := os.Rename(tempFile, v.fullPath); err != nil { + _ = os.Remove(tempFile) + return fmt.Errorf("failed to move vault file: %w", err) + } + + return nil +} + +func (v *UnencryptedVault) ID() string { + return v.id +} + +func (v *UnencryptedVault) Metadata() Metadata { + v.mu.RLock() + defer v.mu.RUnlock() + + if v.state == nil { + return Metadata{} + } + return v.state.Metadata +} + +func (v *UnencryptedVault) GetSecret(key string) (Secret, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + value, exists := v.state.Secrets[key] + if !exists { + return nil, ErrSecretNotFound + } + + return NewSecretValue([]byte(value)), nil +} + +func (v *UnencryptedVault) SetSecret(key string, secret Secret) error { + v.mu.Lock() + defer v.mu.Unlock() + + if err := ValidateSecretKey(key); err != nil { + return err + } + + if v.state.Secrets == nil { + v.state.Secrets = make(map[string]string) + } + + v.state.Secrets[key] = secret.PlainTextString() + return v.save() +} + +func (v *UnencryptedVault) DeleteSecret(key string) error { + v.mu.Lock() + defer v.mu.Unlock() + + _, exists := v.state.Secrets[key] + if !exists { + return ErrSecretNotFound + } + + delete(v.state.Secrets, key) + return v.save() +} + +func (v *UnencryptedVault) ListSecrets() ([]string, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + keys := make([]string, 0, len(v.state.Secrets)) + for k := range v.state.Secrets { + keys = append(keys, k) + } + + // Sort for deterministic output + sort.Strings(keys) + return keys, nil +} + +func (v *UnencryptedVault) HasSecret(key string) (bool, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + _, exists := v.state.Secrets[key] + return exists, nil +} + +func (v *UnencryptedVault) Close() error { + // clear the secret state from memory + v.mu.Lock() + defer v.mu.Unlock() + + v.state = nil + + return nil +} diff --git a/unencrypted_test.go b/unencrypted_test.go new file mode 100644 index 0000000..a7c88c6 --- /dev/null +++ b/unencrypted_test.go @@ -0,0 +1,353 @@ +package vault_test + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/flowexec/vault" +) + +func TestUnencryptedVault_New(t *testing.T) { + tempDir := t.TempDir() + + vlt, cfg, err := vault.New("test-vault", + vault.WithProvider(vault.ProviderTypeUnencrypted), + vault.WithUnencryptedPath(tempDir), + ) + if err != nil { + t.Fatalf("Failed to create unencrypted vault: %v", err) + } + defer vlt.Close() + + if vlt.ID() != "test-vault" { + t.Errorf("Expected vault ID 'test-vault', got '%s'", vlt.ID()) + } + + if cfg.Type != vault.ProviderTypeUnencrypted { + t.Errorf("Expected provider type '%s', got '%s'", vault.ProviderTypeUnencrypted, cfg.Type) + } + + if cfg.Unencrypted == nil { + t.Fatal("Expected unencrypted config to be set") + } + + if cfg.Unencrypted.StoragePath != tempDir { + t.Errorf("Expected storage path '%s', got '%s'", tempDir, cfg.Unencrypted.StoragePath) + } +} + +func TestUnencryptedVault_SecretOperations(t *testing.T) { + tempDir := t.TempDir() + + vlt, _, err := vault.New("test-vault", + vault.WithProvider(vault.ProviderTypeUnencrypted), + vault.WithUnencryptedPath(tempDir), + ) + if err != nil { + t.Fatalf("Failed to create unencrypted vault: %v", err) + } + defer vlt.Close() + + // Test setting a secret + testKey := "test-key" + testValue := "test-value" + secret := vault.NewSecretValue([]byte(testValue)) + + err = vlt.SetSecret(testKey, secret) + if err != nil { + t.Fatalf("Failed to set secret: %v", err) + } + + // Test getting the secret + retrievedSecret, err := vlt.GetSecret(testKey) + if err != nil { + t.Fatalf("Failed to get secret: %v", err) + } + + if retrievedSecret.PlainTextString() != testValue { + t.Errorf("Expected secret value '%s', got '%s'", testValue, retrievedSecret.PlainTextString()) + } + + // Test HasSecret + exists, err := vlt.HasSecret(testKey) + if err != nil { + t.Fatalf("Failed to check if secret exists: %v", err) + } + if !exists { + t.Error("Expected secret to exist") + } + + // Test ListSecrets + secrets, err := vlt.ListSecrets() + if err != nil { + t.Fatalf("Failed to list secrets: %v", err) + } + if len(secrets) != 1 { + t.Errorf("Expected 1 secret, got %d", len(secrets)) + } + if secrets[0] != testKey { + t.Errorf("Expected secret key '%s', got '%s'", testKey, secrets[0]) + } + + // Test deleting the secret + err = vlt.DeleteSecret(testKey) + if err != nil { + t.Fatalf("Failed to delete secret: %v", err) + } + + // Verify secret is gone + exists, err = vlt.HasSecret(testKey) + if err != nil { + t.Fatalf("Failed to check if secret exists after deletion: %v", err) + } + if exists { + t.Error("Expected secret to not exist after deletion") + } + + // Test getting non-existent secret + _, err = vlt.GetSecret(testKey) + if !errors.Is(err, vault.ErrSecretNotFound) { + t.Errorf("Expected ErrSecretNotFound, got %v", err) + } + + // Test deleting non-existent secret + err = vlt.DeleteSecret(testKey) + if !errors.Is(err, vault.ErrSecretNotFound) { + t.Errorf("Expected ErrSecretNotFound when deleting non-existent secret, got %v", err) + } +} + +func TestUnencryptedVault_FileFormat(t *testing.T) { + tempDir := t.TempDir() + + vlt, _, err := vault.New("test-vault", + vault.WithProvider(vault.ProviderTypeUnencrypted), + vault.WithUnencryptedPath(tempDir), + ) + if err != nil { + t.Fatalf("Failed to create unencrypted vault: %v", err) + } + defer vlt.Close() + + // Set multiple secrets + secrets := map[string]string{ + "API_KEY": "secret-api-key", + "DATABASE_URL": "postgresql://user:pass@host:5432/db", + "DEBUG": "true", + "QUOTED_VALUE": "value with spaces", + } + + for key, value := range secrets { + err = vlt.SetSecret(key, vault.NewSecretValue([]byte(value))) + if err != nil { + t.Fatalf("Failed to set secret %s: %v", key, err) + } + } + + // Read the file directly to verify JSON format + vaultFilePath := filepath.Join(tempDir, "vault-test-vault.json") + content, err := os.ReadFile(vaultFilePath) + if err != nil { + t.Fatalf("Failed to read vault file: %v", err) + } + + contentStr := string(content) + t.Logf("Vault file content:\n%s", contentStr) + + // Parse the JSON to verify structure + var vaultData map[string]interface{} + if err := json.Unmarshal(content, &vaultData); err != nil { + t.Fatalf("Expected valid JSON format, got parse error: %v", err) + } + + // Verify required fields exist + if vaultData["id"] != "test-vault" { + t.Errorf("Expected vault ID 'test-vault', got %v", vaultData["id"]) + } + if vaultData["version"] != float64(1) { + t.Errorf("Expected version 1, got %v", vaultData["version"]) + } + + // Verify metadata exists + metadata, metadataExists := vaultData["metadata"].(map[string]interface{}) + if !metadataExists { + t.Fatal("Expected metadata field in vault file") + } + if _, createdExists := metadata["created"]; !createdExists { + t.Error("Expected created timestamp in metadata") + } + if _, lastModifiedExists := metadata["lastModified"]; !lastModifiedExists { + t.Error("Expected lastModified timestamp in metadata") + } + + // Verify secrets are properly stored + secretsData, secretsExists := vaultData["secrets"].(map[string]interface{}) + if !secretsExists { + t.Fatal("Expected secrets field in vault file") + } + + for key, expectedValue := range secrets { + actualValue, valueExists := secretsData[key] + if !valueExists { + t.Errorf("Expected secret key '%s' to be present", key) + continue + } + if actualValue != expectedValue { + t.Errorf("Expected secret value '%s' for key '%s', got '%v'", expectedValue, key, actualValue) + } + } +} + +func TestUnencryptedVault_Persistence(t *testing.T) { + tempDir := t.TempDir() + + // Create first vault instance and add a secret + vault1, _, err := vault.New("test-vault", + vault.WithProvider(vault.ProviderTypeUnencrypted), + vault.WithUnencryptedPath(tempDir), + ) + if err != nil { + t.Fatalf("Failed to create first vault: %v", err) + } + + testKey := "persistent-key" + testValue := "persistent-value" + err = vault1.SetSecret(testKey, vault.NewSecretValue([]byte(testValue))) + if err != nil { + t.Fatalf("Failed to set secret in first vault: %v", err) + } + vault1.Close() + + // Create second vault instance with same configuration + vault2, _, err := vault.New("test-vault", + vault.WithProvider(vault.ProviderTypeUnencrypted), + vault.WithUnencryptedPath(tempDir), + ) + if err != nil { + t.Fatalf("Failed to create second vault: %v", err) + } + defer vault2.Close() + + // Verify secret persists + retrievedSecret, err := vault2.GetSecret(testKey) + if err != nil { + t.Fatalf("Failed to get secret from second vault: %v", err) + } + + if retrievedSecret.PlainTextString() != testValue { + t.Errorf("Expected persisted secret value '%s', got '%s'", testValue, retrievedSecret.PlainTextString()) + } + + // Verify metadata is preserved + metadata := vault2.Metadata() + if metadata.Created.IsZero() { + t.Error("Expected creation time to be preserved") + } + if metadata.LastModified.IsZero() { + t.Error("Expected last modified time to be preserved") + } +} + +func TestUnencryptedVault_Metadata(t *testing.T) { + tempDir := t.TempDir() + + vlt, _, err := vault.New("test-vault", + vault.WithProvider(vault.ProviderTypeUnencrypted), + vault.WithUnencryptedPath(tempDir), + ) + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + defer vlt.Close() + + metadata := vlt.Metadata() + if metadata.Created.IsZero() { + t.Error("Expected creation time to be set") + } + if metadata.LastModified.IsZero() { + t.Error("Expected last modified time to be set") + } + + // Add a secret and verify last modified time is updated + oldModified := metadata.LastModified + time.Sleep(10 * time.Millisecond) // Ensure some time passes + + err = vlt.SetSecret("test-key", vault.NewSecretValue([]byte("test-value"))) + if err != nil { + t.Fatalf("Failed to set secret: %v", err) + } + + newMetadata := vlt.Metadata() + if !newMetadata.LastModified.After(oldModified) { + t.Error("Expected last modified time to be updated after setting secret") + } +} + +func TestUnencryptedVault_SortedOutput(t *testing.T) { + tempDir := t.TempDir() + + vlt, _, err := vault.New("test-vault", + vault.WithProvider(vault.ProviderTypeUnencrypted), + vault.WithUnencryptedPath(tempDir), + ) + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + defer vlt.Close() + + // Add secrets in non-alphabetical order + secretKeys := []string{"zebra", "alpha", "beta", "gamma"} + for _, key := range secretKeys { + err = vlt.SetSecret(key, vault.NewSecretValue([]byte("value-"+key))) + if err != nil { + t.Fatalf("Failed to set secret %s: %v", key, err) + } + } + + // List secrets and verify they are sorted + listedKeys, err := vlt.ListSecrets() + if err != nil { + t.Fatalf("Failed to list secrets: %v", err) + } + + expectedOrder := []string{"alpha", "beta", "gamma", "zebra"} + if len(listedKeys) != len(expectedOrder) { + t.Fatalf("Expected %d keys, got %d", len(expectedOrder), len(listedKeys)) + } + + for i, expected := range expectedOrder { + if listedKeys[i] != expected { + t.Errorf("Expected key at position %d to be '%s', got '%s'", i, expected, listedKeys[i]) + } + } + + // Verify JSON format maintains sorted order (JSON doesn't guarantee key order, + // but our implementation should maintain sorted keys in memory) + vaultFilePath := filepath.Join(tempDir, "vault-test-vault.json") + content, err := os.ReadFile(vaultFilePath) + if err != nil { + t.Fatalf("Failed to read vault file: %v", err) + } + + // Parse JSON to verify secrets are present + var vaultData map[string]interface{} + if err := json.Unmarshal(content, &vaultData); err != nil { + t.Fatalf("Expected valid JSON format, got parse error: %v", err) + } + + secretsData, secretsExists := vaultData["secrets"].(map[string]interface{}) + if !secretsExists { + t.Fatal("Expected secrets field in JSON") + } + + // Verify all expected keys are present in the JSON + for _, expectedKey := range expectedOrder { + if _, keyExists := secretsData[expectedKey]; !keyExists { + t.Errorf("Expected secret key '%s' to be present in JSON", expectedKey) + } + } +} diff --git a/vault.go b/vault.go index 5094aaa..a681699 100644 --- a/vault.go +++ b/vault.go @@ -39,8 +39,15 @@ func New(id string, opts ...Option) (Provider, *Config, error) { case ProviderTypeAES256: provider, err := NewAES256Vault(config) return provider, config, err + case ProviderTypeKeyring: + provider, err := NewKeyringVault(config) + return provider, config, err + case ProviderTypeUnencrypted: + provider, err := NewUnencryptedVault(config) + return provider, config, err case ProviderTypeExternal: - return nil, nil, fmt.Errorf("external vault provider not implemented yet") + provider, err := NewExternalVaultProvider(config) + return provider, config, err } return nil, nil, fmt.Errorf("unsupported vault type: %s", config.Type) } @@ -72,7 +79,27 @@ func WithAESPath(path string) Option { } } -// WithLocalPath sets the local vault storage path (works for both Age and AES based on provider type) +// WithUnencryptedPath sets the unencrypted vault storage path +func WithUnencryptedPath(path string) Option { + return func(c *Config) { + if c.Unencrypted == nil { + c.Unencrypted = &UnencryptedConfig{} + } + c.Unencrypted.StoragePath = path + } +} + +// WithKeyringService sets the keyring service name +func WithKeyringService(service string) Option { + return func(c *Config) { + if c.Keyring == nil { + c.Keyring = &KeyringConfig{} + } + c.Keyring.Service = service + } +} + +// WithLocalPath sets the local vault storage path (works for Age, AES, and Unencrypted based on provider type) func WithLocalPath(path string) Option { return func(c *Config) { //nolint:exhaustive @@ -81,6 +108,8 @@ func WithLocalPath(path string) Option { WithAgePath(path)(c) case ProviderTypeAES256: WithAESPath(path)(c) + case ProviderTypeUnencrypted: + WithUnencryptedPath(path)(c) } } }