Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ A flexible Go library for secure secret management with multiple backend provide
- **Thread Safe**: Concurrent access protection with read/write mutexes
- **Comprehensive API**: Full CRUD operations plus metadata and existence checks

## Upgrading to v0.3.0

v0.3.0 is a security and correctness release. It contains breaking changes, each
of which replaces behaviour that failed silently:

| Change | Why | What to do |
|---|---|---|
| `Provider.Metadata()` returns `(Metadata, error)` | Every failure previously returned an empty struct, so a broken command, a timeout and "not configured" were indistinguishable | Handle the new error |
| External configs referencing `{{value}}`/`{{password}}` in a `cmd` are rejected | The value was interpolated into a shell command with no quoting — a command-injection sink that also corrupted ordinary passwords | Move the secret to an `InputTemplate` (stdin) |
| An existing but zero-length vault file is an error | It was read as "no vault here", so the constructor initialized and immediately overwrote it, destroying every secret | Restore from backup, or delete the file to start fresh |
| Operations on a closed vault return `ErrVaultClosed` | They dereferenced nil state and panicked | Nothing, unless you relied on the panic |
| Vault IDs are charset-validated | An ID is interpolated into a filename, and `filepath.Clean` *resolves* traversal rather than sanitizing it | Use IDs matching `^[a-zA-Z0-9][a-zA-Z0-9-_.]*$` |
| Encryption keys must be exactly 32 bytes | `aes.NewCipher` also accepts 16 and 24, silently downgrading an "AES256" vault to AES-128/192 | Regenerate short keys |
| `DeriveKey` returns a parameter-tagged salt | Changing the scrypt cost would otherwise silently change every derived key | Pass the returned salt back verbatim rather than base64-decoding it first |

Local vault files written by earlier versions are read without migration.

## Quick Start

```go
Expand Down Expand Up @@ -63,7 +80,7 @@ Stores secrets in an AES-256 encrypted file with configurable key sources.
```go
provider, _, err := vault.New("my-vault",
vault.WithProvider(vault.ProviderTypeAES256),
vault.WithAESPath("~/secrets.vault"),
vault.WithAESPath("~/.config/flow/vaults"), // a directory, not a file
)
```

Expand All @@ -79,7 +96,7 @@ Uses the [age encryption tool](https://age-encryption.org/) with public key cryp
```go
provider, _, err := vault.New("my-vault",
vault.WithProvider(vault.ProviderTypeAge),
vault.WithAgePath("~/secrets.age"),
vault.WithAgePath("~/.config/flow/vaults"), // a directory, not a file
)
```

Expand Down Expand Up @@ -107,7 +124,7 @@ Stores secrets in plain text JSON files.
```go
provider, _, err := vault.New("my-vault",
vault.WithProvider(vault.ProviderTypeUnencrypted),
vault.WithUnencryptedPath("~/dev-secrets.json"),
vault.WithUnencryptedPath("~/.config/flow/vaults"), // a directory, not a file
)
```

Expand All @@ -122,10 +139,12 @@ config := &vault.Config{
Type: vault.ProviderTypeExternal,
External: &vault.ExternalConfig{
Get: vault.CommandConfig{
CommandTemplate: "bw get password {{key}}",
CommandTemplate: "bw get password '{{key}}'",
},
Set: vault.CommandConfig{
CommandTemplate: "bw create item --name {{key}} --password {{value}}",
CommandTemplate: "bw create item",
// The secret is piped to the command's stdin, never placed in it.
InputTemplate: "{{value}}",
},
// ... other operations
},
Expand All @@ -134,6 +153,14 @@ config := &vault.Config{
provider, err := vault.NewExternalVaultProvider(config)
```

> **The secret value is not available to command templates.** A rendered command
> is parsed and run by a shell and the template engine does no quoting, so
> interpolating a secret there is a command-injection sink and silently corrupts
> any value containing shell metacharacters (`p@$$w0rd` has `$$` replaced by the
> process ID; `correct horse battery` word-splits to `correct`). Configurations
> referencing `{{value}}` or `{{password}}` in a `cmd` are rejected at load —
> use an `InputTemplate` instead.

**External Provider Examples**

Ready-to-use configurations for popular CLI tools are available in the [`examples/`](./examples/) directory:
Expand Down Expand Up @@ -165,7 +192,7 @@ secrets, _ := provider.ListSecrets()
exists, _ := provider.HasSecret("api-key")

// Get vault metadata
metadata := provider.Metadata()
metadata, err := provider.Metadata()
```

### Configuration from File
Expand Down
147 changes: 100 additions & 47 deletions aes.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package vault

import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"

Expand All @@ -22,7 +20,10 @@ const (
type AESState struct {
Metadata `yaml:"metadata"`

Version int `json:"version"`
// yaml, not json: this struct is marshaled with gopkg.in/yaml.v3, which
// ignores json tags and would otherwise have keyed this field as "version"
// only by lowercasing coincidence.
Version int `yaml:"version"`
ID string `yaml:"id"`
Secrets map[string]string `yaml:"secrets"`
}
Expand All @@ -43,13 +44,22 @@ func GenerateEncryptionKey() (string, error) {
return crypto.GenerateKey()
}

// DeriveEncryptionKey derives an AES encryption key from a passphrase
// DeriveEncryptionKey derives an AES encryption key from a passphrase.
//
// An empty sal requests a freshly generated salt. Pass the returned salt back
// verbatim to re-derive the same key; it carries the parameters it was made
// with, so future changes to the defaults cannot alter an existing key.
func DeriveEncryptionKey(passphrase, sal string) (string, string, error) {
key, salt, err := crypto.DeriveKey([]byte(passphrase), []byte(sal))
var salt []byte
if sal != "" {
salt = []byte(sal)
}

key, salt2, err := crypto.DeriveKey([]byte(passphrase), salt)
if err != nil {
return "", "", fmt.Errorf("failed to derive encryption key: %w", err)
}
return key, salt, nil
return key, salt2, nil
}

// ValidateEncryptionKey checks if a key is valid by attempting to encrypt/decrypt test data
Expand Down Expand Up @@ -77,10 +87,14 @@ func NewAES256Vault(cfg *Config) (*AES256Vault, error) {
return nil, fmt.Errorf("AES configuration is required")
}

path := filepath.Join(
filepath.Clean(cfg.Aes.StoragePath),
filepath.Clean(fmt.Sprintf("%s-%s.%s", vaultFileBase, cfg.ID, aesVaultFileExt)),
)
if err := cfg.Validate(); err != nil {
return nil, err
}

path, err := resolveVaultPath(cfg.Aes.StoragePath, cfg.ID, aesVaultFileExt)
if err != nil {
return nil, err
}

vault := &AES256Vault{
id: cfg.ID,
Expand Down Expand Up @@ -119,20 +133,16 @@ func (v *AES256Vault) init() error {
Secrets: make(map[string]string),
}

return v.save()
return withVaultLock(v.fullPath, v.save)
}

// load retrieves the AESState from the vault file, decrypts it, and unmarshals it into an AESState struct.
func (v *AES256Vault) load() error {
data, err := os.ReadFile(filepath.Clean(v.fullPath))
data, exists, err := readVaultFile(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)
return err
}

if len(data) == 0 {
if !exists {
return nil
}

Expand All @@ -147,14 +157,18 @@ func (v *AES256Vault) load() error {
if err := yaml.Unmarshal([]byte(dataStr), &state); err != nil {
return fmt.Errorf("failed to unmarshal vault state: %w", err)
}
if err := checkVaultVersion(state.Version, aesCurrentVaultVersion, v.fullPath); err != nil {
return err
}

v.state = &state
return nil
}

// save encrypts and writes the vault contents to disk
func (v *AES256Vault) save() error {
if v.state == nil {
return nil
return ErrVaultClosed
}

if v.dek == "" {
Expand All @@ -171,41 +185,52 @@ func (v *AES256Vault) save() error {
return fmt.Errorf("failed to encrypt vault state: %w", err)
}

// write to the 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, []byte(encryptedDataStr), 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 writeVaultFileAtomic(v.fullPath, []byte(encryptedDataStr))
}

return nil
// mutate runs a read-modify-write cycle under the cross-process vault lock.
//
// Reloading inside the lock is the point: in-memory state is a snapshot taken
// when the provider was constructed, and every save rewrites the whole file.
// Writing that snapshot back without refreshing silently discards whatever
// another process stored in the meantime.
func (v *AES256Vault) mutate(apply func() error) error {
return withVaultLock(v.fullPath, func() error {
if err := v.load(); err != nil {
return err
}
if err := apply(); err != nil {
return err
}
return v.save()
})
}

func (v *AES256Vault) ID() string {
return v.id
}

func (v *AES256Vault) Metadata() Metadata {
func (v *AES256Vault) Metadata() (Metadata, error) {
v.mu.RLock()
defer v.mu.RUnlock()

if v.state == nil {
return Metadata{}
return Metadata{}, ErrVaultClosed
}
return v.state.Metadata
return v.state.Metadata, nil
}

func (v *AES256Vault) GetSecret(key string) (Secret, error) {
v.mu.RLock()
defer v.mu.RUnlock()

if err := ValidateSecretKey(key); err != nil {
return nil, err
}
if v.state == nil {
return nil, ErrVaultClosed
}

value, exists := v.state.Secrets[key]
if !exists {
return nil, ErrSecretNotFound
Expand All @@ -221,43 +246,68 @@ func (v *AES256Vault) SetSecret(key string, secret Secret) error {
if err := ValidateSecretKey(key); err != nil {
return err
}

if v.state.Secrets == nil {
v.state.Secrets = make(map[string]string)
if v.state == nil {
return ErrVaultClosed
}

v.state.Secrets[key] = secret.PlainTextString()
return v.save()
return v.mutate(func() error {
if v.state.Secrets == nil {
v.state.Secrets = make(map[string]string)
}
v.state.Secrets[key] = secret.PlainTextString()
return nil
})
}

func (v *AES256Vault) DeleteSecret(key string) error {
v.mu.Lock()
defer v.mu.Unlock()

_, exists := v.state.Secrets[key]
if !exists {
return ErrSecretNotFound
if err := ValidateSecretKey(key); err != nil {
return err
}
if v.state == nil {
return ErrVaultClosed
}

delete(v.state.Secrets, key)
return v.save()
// The existence check runs inside mutate, after the reload, so it sees the
// current on-disk contents rather than a stale snapshot.
return v.mutate(func() error {
if _, exists := v.state.Secrets[key]; !exists {
return ErrSecretNotFound
}
delete(v.state.Secrets, key)
return nil
})
}

func (v *AES256Vault) ListSecrets() ([]string, error) {
v.mu.RLock()
defer v.mu.RUnlock()

if v.state == nil {
return nil, ErrVaultClosed
}

keys := make([]string, 0, len(v.state.Secrets))
for k := range v.state.Secrets {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}

func (v *AES256Vault) HasSecret(key string) (bool, error) {
v.mu.RLock()
defer v.mu.RUnlock()

if err := ValidateSecretKey(key); err != nil {
return false, err
}
if v.state == nil {
return false, ErrVaultClosed
}

_, exists := v.state.Secrets[key]
return exists, nil
}
Expand All @@ -267,6 +317,9 @@ func (v *AES256Vault) Close() error {
v.mu.Lock()
defer v.mu.Unlock()

if v.state != nil {
clearSecrets(v.state.Secrets)
}
v.dek = ""
v.state = nil

Expand Down
Loading
Loading