secret/internal/vault/secrets.go
clawbot 3fd30bb9e6 Validate secret name in GetSecretVersion to prevent path traversal
Add isValidSecretName() check at the top of GetSecretVersion(), matching
the existing validation in AddSecret(). Without this, crafted secret names
containing path traversal sequences (e.g. '../../../etc/passwd') could be
used to read files outside the vault directory.

Add regression tests for both GetSecretVersion and GetSecret.

Closes #13
2026-02-15 14:03:28 -08:00

647 lines
20 KiB
Go

package vault
import (
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"regexp"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
// ListSecrets returns a list of secret names in this vault
func (v *Vault) ListSecrets() ([]string, error) {
secret.DebugWith("Listing secrets in vault", slog.String("vault_name", v.Name))
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory for secret listing", "error", err, "vault_name", v.Name)
return nil, err
}
secretsDir := filepath.Join(vaultDir, "secrets.d")
// Check if secrets directory exists
exists, err := afero.DirExists(v.fs, secretsDir)
if err != nil {
secret.Debug("Failed to check secrets directory", "error", err, "secrets_dir", secretsDir)
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
}
if !exists {
secret.Debug("Secrets directory does not exist", "secrets_dir", secretsDir, "vault_name", v.Name)
return []string{}, nil
}
// List directories in secrets.d
files, err := afero.ReadDir(v.fs, secretsDir)
if err != nil {
secret.Debug("Failed to read secrets directory", "error", err, "secrets_dir", secretsDir)
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
}
var secrets []string
for _, file := range files {
if file.IsDir() {
// Convert storage name back to secret name
secretName := strings.ReplaceAll(file.Name(), "%", "/")
secrets = append(secrets, secretName)
}
}
secret.DebugWith("Found secrets in vault",
slog.String("vault_name", v.Name),
slog.Int("secret_count", len(secrets)),
slog.Any("secret_names", secrets),
)
return secrets, nil
}
// isValidSecretName validates secret names according to the format [a-z0-9\.\-\_\/]+
// but with additional restrictions:
// - No leading or trailing slashes
// - No double slashes
// - No names starting with dots
func isValidSecretName(name string) bool {
if name == "" {
return false
}
// Check for leading/trailing slashes
if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {
return false
}
// Check for double slashes
if strings.Contains(name, "//") {
return false
}
// Check for names starting with dot
if strings.HasPrefix(name, ".") {
return false
}
// Check the basic pattern
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
return matched
}
// AddSecret adds a secret to this vault
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
if value == nil {
return fmt.Errorf("value buffer is nil")
}
secret.DebugWith("Adding secret to vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.Int("value_length", value.Size()),
slog.Bool("force", force),
)
// Validate secret name
if !isValidSecretName(name) {
secret.Debug("Invalid secret name provided", "secret_name", name)
return fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
}
secret.Debug("Secret name validation passed", "secret_name", name)
secret.Debug("Getting vault directory")
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name)
return err
}
secret.Debug("Got vault directory", "vault_dir", vaultDir)
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
secret.DebugWith("Secret storage details",
slog.String("storage_name", storageName),
slog.String("secret_dir", secretDir),
)
// Check if secret already exists
secret.Debug("Checking if secret already exists", "secret_dir", secretDir)
exists, err := afero.DirExists(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir)
return fmt.Errorf("failed to check if secret exists: %w", err)
}
secret.Debug("Secret existence check complete", "exists", exists)
// Handle existing secret case
now := time.Now()
var previousVersion *secret.Version
if exists {
if !force {
secret.Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir)
return fmt.Errorf("secret %s already exists (use --force to overwrite)", name)
}
// Get the current version to update its notAfter timestamp
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
if err == nil && currentVersionName != "" {
previousVersion = secret.NewVersion(v, name, currentVersionName)
// We'll need to load and update its metadata after we unlock the vault
}
} else {
// Create secret directory for new secret
secret.Debug("Creating secret directory", "secret_dir", secretDir)
if err := v.fs.MkdirAll(secretDir, secret.DirPerms); err != nil {
secret.Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
return fmt.Errorf("failed to create secret directory: %w", err)
}
secret.Debug("Created secret directory successfully")
}
// Generate new version name
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
return fmt.Errorf("failed to generate version name: %w", err)
}
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
// Create new version
newVersion := secret.NewVersion(v, name, versionName)
// Set version timestamps
if previousVersion == nil {
// First version: notBefore = epoch + 1 second
epochPlusOne := time.Unix(1, 0)
newVersion.Metadata.NotBefore = &epochPlusOne
} else {
// New version: notBefore = now
newVersion.Metadata.NotBefore = &now
// We'll update the previous version's notAfter after we save the new version
}
// Save the new version - pass the LockedBuffer directly
if err := newVersion.Save(value); err != nil {
secret.Debug("Failed to save new version", "error", err, "version", versionName)
// Clean up the secret directory if this was a new secret
if !exists {
secret.Debug("Cleaning up secret directory due to save failure", "secret_dir", secretDir)
_ = v.fs.RemoveAll(secretDir)
}
return fmt.Errorf("failed to save version: %w", err)
}
// Update previous version if it exists
if previousVersion != nil {
// Get long-term key to decrypt/encrypt metadata
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
secret.Debug("Failed to get long-term key for metadata update", "error", err)
return fmt.Errorf("failed to get long-term key: %w", err)
}
// Load previous version metadata
if err := previousVersion.LoadMetadata(ltIdentity); err != nil {
secret.Debug("Failed to load previous version metadata", "error", err)
return fmt.Errorf("failed to load previous version metadata: %w", err)
}
// Update notAfter timestamp
previousVersion.Metadata.NotAfter = &now
// Re-save the metadata (we need to implement an update method)
if err := updateVersionMetadata(v.fs, previousVersion, ltIdentity); err != nil {
secret.Debug("Failed to update previous version metadata", "error", err)
return fmt.Errorf("failed to update previous version metadata: %w", err)
}
}
// Set current symlink to new version
if err := secret.SetCurrentVersion(v.fs, secretDir, versionName); err != nil {
secret.Debug("Failed to set current version", "error", err, "version", versionName)
return fmt.Errorf("failed to set current version: %w", err)
}
secret.Debug("Successfully added secret version to vault",
"secret_name", name, "version", versionName,
"vault_name", v.Name)
return nil
}
// updateVersionMetadata updates the metadata of an existing version
func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity) error {
// Read the version's encrypted private key
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
return fmt.Errorf("failed to read encrypted version private key: %w", err)
}
// Decrypt version private key using long-term key
versionPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
return fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
// Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
if err != nil {
return fmt.Errorf("failed to parse version private key: %w", err)
}
// Marshal updated metadata
metadataBytes, err := json.MarshalIndent(version.Metadata, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal version metadata: %w", err)
}
// Encrypt metadata to the version's public key
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
defer metadataBuffer.Destroy()
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
if err != nil {
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
// Write encrypted metadata
metadataPath := filepath.Join(version.Directory, "metadata.age")
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms); err != nil {
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
return nil
}
// GetSecret retrieves a secret from this vault
func (v *Vault) GetSecret(name string) ([]byte, error) {
secret.DebugWith("Getting secret from vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
)
return v.GetSecretVersion(name, "")
}
// GetSecretVersion retrieves a specific version of a secret (empty version means current)
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
secret.DebugWith("Getting secret version from vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.String("version", version),
)
// Validate secret name to prevent path traversal
if !isValidSecretName(name) {
secret.Debug("Invalid secret name provided", "secret_name", name)
return nil, fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
}
// Get vault directory
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
return nil, err
}
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Check if secret exists
exists, err := afero.DirExists(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
return nil, fmt.Errorf("secret %s not found", name)
}
// Determine which version to get
if version == "" {
// Get current version
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to get current version: %w", err)
}
version = currentVersion
secret.Debug("Using current version", "version", version, "secret_name", name)
}
// Create version object
secretVersion := secret.NewVersion(v, name, version)
// Check if version exists
versionPath := filepath.Join(secretDir, "versions", version)
exists, err = afero.DirExists(v.fs, versionPath)
if err != nil {
secret.Debug("Failed to check if version exists", "error", err, "version", version)
return nil, fmt.Errorf("failed to check if version exists: %w", err)
}
if !exists {
secret.Debug("Version not found", "version", version, "secret_name", name)
return nil, fmt.Errorf("version %s not found for secret %s", version, name)
}
secret.Debug("Version exists, proceeding with vault unlock and decryption", "version", version, "secret_name", name)
// Unlock the vault (get long-term key in memory)
longTermIdentity, err := v.UnlockVault()
if err != nil {
secret.Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to unlock vault: %w", err)
}
secret.DebugWith("Successfully unlocked vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.String("version", version),
slog.String("long_term_public_key", longTermIdentity.Recipient().String()),
)
// Get the version's value
secret.Debug("About to call secretVersion.GetValue", "version", version, "secret_name", name)
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
if err != nil {
secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
return nil, fmt.Errorf("failed to decrypt version: %w", err)
}
// Create a copy to return since the buffer will be destroyed
result := make([]byte, decryptedValue.Size())
copy(result, decryptedValue.Bytes())
decryptedValue.Destroy()
secret.DebugWith("Successfully decrypted secret version",
slog.String("secret_name", name),
slog.String("version", version),
slog.String("vault_name", v.Name),
slog.Int("decrypted_length", len(result)),
)
// Debug: Log metadata about the decrypted value without exposing the actual secret
secret.Debug("Vault secret decryption debug info",
"secret_name", name,
"version", version,
"decrypted_value_length", len(result),
"is_empty", len(result) == 0)
return result, nil
}
// UnlockVault unlocks the vault and returns the long-term private key
func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
secret.Debug("Unlocking vault", "vault_name", v.Name)
// If vault is already unlocked, return the cached key
if !v.Locked() {
secret.Debug("Vault already unlocked, returning cached long-term key", "vault_name", v.Name)
return v.longTermKey, nil
}
// Get or derive the long-term key (but don't store it yet)
longTermIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
secret.Debug("Failed to get or derive long-term key", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get long-term key: %w", err)
}
// Now unlock the vault by storing the key in memory
v.Unlock(longTermIdentity)
secret.DebugWith("Successfully unlocked vault",
slog.String("vault_name", v.Name),
slog.String("public_key", longTermIdentity.Recipient().String()),
)
return longTermIdentity, nil
}
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
// First check if the secret exists by checking for the metadata file
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, err
}
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Check if secret directory exists
exists, err := afero.DirExists(v.fs, secretDir)
if err != nil {
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return nil, fmt.Errorf("secret %s not found", name)
}
// Create a Secret object
secretObj := secret.NewSecret(v, name)
// Load the metadata from disk
if err := secretObj.LoadMetadata(); err != nil {
return nil, err
}
return secretObj, nil
}
// CopySecretVersion copies a single version from source to this vault
// It decrypts the value using srcIdentity and re-encrypts for this vault
func (v *Vault) CopySecretVersion(
srcVersion *secret.Version,
srcIdentity *age.X25519Identity,
destSecretName string,
destVersionName string,
) error {
secret.DebugWith("Copying secret version to vault",
slog.String("src_secret", srcVersion.SecretName),
slog.String("src_version", srcVersion.Version),
slog.String("dest_vault", v.Name),
slog.String("dest_secret", destSecretName),
slog.String("dest_version", destVersionName),
)
// Get the decrypted value from source
valueBuffer, err := srcVersion.GetValue(srcIdentity)
if err != nil {
return fmt.Errorf("failed to decrypt source version: %w", err)
}
defer valueBuffer.Destroy()
// Load source metadata
if err := srcVersion.LoadMetadata(srcIdentity); err != nil {
return fmt.Errorf("failed to load source metadata: %w", err)
}
// Create destination version with same name
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
// Copy metadata (preserve original timestamps)
destVersion.Metadata = srcVersion.Metadata
// Save the version (encrypts to this vault's LT key)
if err := destVersion.Save(valueBuffer); err != nil {
return fmt.Errorf("failed to save destination version: %w", err)
}
secret.Debug("Successfully copied secret version",
"src_version", srcVersion.Version,
"dest_version", destVersionName,
"dest_vault", v.Name)
return nil
}
// CopySecretAllVersions copies all versions of a secret from source vault to this vault
// It re-encrypts each version with this vault's long-term key
func (v *Vault) CopySecretAllVersions(
srcVault *Vault,
srcSecretName string,
destSecretName string,
force bool,
) error {
secret.DebugWith("Copying all secret versions between vaults",
slog.String("src_vault", srcVault.Name),
slog.String("src_secret", srcSecretName),
slog.String("dest_vault", v.Name),
slog.String("dest_secret", destSecretName),
slog.Bool("force", force),
)
// Get destination vault directory
destVaultDir, err := v.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get destination vault directory: %w", err)
}
// Check if destination secret already exists
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
exists, err := afero.DirExists(v.fs, destSecretDir)
if err != nil {
return fmt.Errorf("failed to check destination: %w", err)
}
if exists && !force {
return fmt.Errorf("secret '%s' already exists in vault '%s' (use --force to overwrite)",
destSecretName, v.Name)
}
if exists && force {
// Remove existing secret
secret.Debug("Removing existing destination secret", "path", destSecretDir)
if err := v.fs.RemoveAll(destSecretDir); err != nil {
return fmt.Errorf("failed to remove existing destination secret: %w", err)
}
}
// Get source vault's long-term key
srcIdentity, err := srcVault.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to unlock source vault '%s': %w", srcVault.Name, err)
}
// Get source secret directory
srcVaultDir, err := srcVault.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get source vault directory: %w", err)
}
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
// List all versions
versions, err := secret.ListVersions(srcVault.fs, srcSecretDir)
if err != nil {
return fmt.Errorf("failed to list source versions: %w", err)
}
if len(versions) == 0 {
return fmt.Errorf("source secret '%s' has no versions", srcSecretName)
}
// Get current version name
currentVersion, err := secret.GetCurrentVersion(srcVault.fs, srcSecretDir)
if err != nil {
return fmt.Errorf("failed to get current version: %w", err)
}
// Create destination secret directory
if err := v.fs.MkdirAll(destSecretDir, secret.DirPerms); err != nil {
return fmt.Errorf("failed to create destination secret directory: %w", err)
}
// Copy each version
for _, versionName := range versions {
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
if err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName); err != nil {
// Rollback: remove partial copy
secret.Debug("Rolling back partial copy due to error", "error", err)
_ = v.fs.RemoveAll(destSecretDir)
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
}
}
// Set current version
if err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion); err != nil {
_ = v.fs.RemoveAll(destSecretDir)
return fmt.Errorf("failed to set current version: %w", err)
}
secret.DebugWith("Successfully copied all secret versions",
slog.String("src_vault", srcVault.Name),
slog.String("dest_vault", v.Name),
slog.Int("version_count", len(versions)),
)
return nil
}