check / check (push) Successful in 1m13s
`secret rm ..` resolved to the vault directory and deleted the whole vault; `secret rm .` and `secret rm ""` deleted every secret. rm, mv and import built paths from the name without checking it, and so did the version commands, encrypt and decrypt. vault.ValidateSecretName wraps the existing name rule and returns ErrInvalidSecretName. Each of those commands calls it on the name as given, both names for a move, before building any path. AddSecret and GetSecretVersion use it too, so the rule has one implementation. The regression test snapshots every file under the state directory of two in-memory vaults and requires it unchanged after each rejected command. Model: opus-5-5
828 lines
24 KiB
Go
828 lines
24 KiB
Go
package vault
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"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-zA-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 for path traversal via ".." components
|
|
if slices.Contains(strings.Split(name, "/"), "..") {
|
|
return false
|
|
}
|
|
|
|
// Check the basic pattern
|
|
matched, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-\_\/]+$`, name)
|
|
|
|
return matched
|
|
}
|
|
|
|
// ValidateSecretName returns an error wrapping ErrInvalidSecretName when
|
|
// name is not a valid secret name. Call it on the name exactly as the user
|
|
// gave it, before building any path from it.
|
|
func ValidateSecretName(name string) error {
|
|
if !isValidSecretName(name) {
|
|
return fmt.Errorf(
|
|
"%w '%s': must match pattern [a-z0-9.\\-_/]+",
|
|
ErrInvalidSecretName, name,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddSecret adds a secret to this vault
|
|
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
|
|
if value == nil {
|
|
return ErrNilValueBuffer
|
|
}
|
|
|
|
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
|
|
err := ValidateSecretName(name)
|
|
if err != nil {
|
|
secret.Debug("Invalid secret name provided", "secret_name", name)
|
|
|
|
return err
|
|
}
|
|
|
|
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 for an existing secret and prepare its directory
|
|
exists, previousVersion, err := v.prepareSecretDir(name, secretDir, force)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
// Create the new version and save the encrypted value
|
|
versionName, err := v.createAndSaveVersion(
|
|
name, secretDir, value, previousVersion, &now, exists)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update previous version's notAfter timestamp if it exists
|
|
err = v.updatePreviousVersion(previousVersion, &now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set current symlink to new version
|
|
err = secret.SetCurrentVersion(v.fs, secretDir, versionName)
|
|
if 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")
|
|
|
|
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms)
|
|
if 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 the name and resolve the version to fetch
|
|
version, err := v.resolveSecretVersion(name, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create version object
|
|
secretVersion := secret.NewVersion(v, name, version)
|
|
|
|
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) {
|
|
if !isValidSecretName(name) {
|
|
return nil, fmt.Errorf("%w: %s", ErrInvalidSecretName, name)
|
|
}
|
|
|
|
// 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 %w", name, ErrSecretNotFound)
|
|
}
|
|
|
|
// Create a Secret object
|
|
secretObj := secret.NewSecret(v, name)
|
|
|
|
// Load the metadata from disk
|
|
err = secretObj.LoadMetadata()
|
|
if 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
|
|
err = srcVersion.LoadMetadata(srcIdentity)
|
|
if 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)
|
|
err = destVersion.Save(valueBuffer)
|
|
if 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 and clear it if forced
|
|
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
|
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
|
|
|
err = v.prepareCopyDestination(destSecretDir, destSecretName, force)
|
|
if err != nil {
|
|
return 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' %w", srcSecretName, ErrNoVersions)
|
|
}
|
|
|
|
// 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
|
|
err = v.fs.MkdirAll(destSecretDir, secret.DirPerms)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create destination secret directory: %w", err)
|
|
}
|
|
|
|
// Copy each version and set the current pointer, rolling back on error
|
|
err = v.copyVersionsWithRollback(srcVault, srcIdentity,
|
|
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
|
if err != nil {
|
|
return 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
|
|
}
|
|
|
|
// prepareSecretDir checks for an existing secret directory and prepares it
|
|
// for a new version. It returns whether the secret already existed and the
|
|
// current version to be superseded, if any.
|
|
func (v *Vault) prepareSecretDir(
|
|
name, secretDir string, force bool,
|
|
) (bool, *secret.Version, error) {
|
|
// 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 false, nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
|
}
|
|
|
|
secret.Debug("Secret existence check complete", "exists", exists)
|
|
|
|
if !exists {
|
|
// Create secret directory for new secret
|
|
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
|
|
|
err = v.fs.MkdirAll(secretDir, secret.DirPerms)
|
|
if err != nil {
|
|
secret.Debug("Failed to create secret directory",
|
|
"error", err, "secret_dir", secretDir)
|
|
|
|
return false, nil, fmt.Errorf("failed to create secret directory: %w", err)
|
|
}
|
|
|
|
secret.Debug("Created secret directory successfully")
|
|
|
|
return false, nil, nil
|
|
}
|
|
|
|
if !force {
|
|
secret.Debug("Secret already exists and force not specified",
|
|
"secret_name", name, "secret_dir", secretDir)
|
|
|
|
return true, nil, fmt.Errorf(
|
|
"secret %s %w (use --force to overwrite)",
|
|
name, ErrSecretExists,
|
|
)
|
|
}
|
|
|
|
// Get the current version to update its notAfter timestamp
|
|
var previousVersion *secret.Version
|
|
|
|
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
|
|
}
|
|
|
|
return true, previousVersion, nil
|
|
}
|
|
|
|
// updatePreviousVersion sets the notAfter timestamp on the version being
|
|
// superseded. It is a no-op when previousVersion is nil.
|
|
func (v *Vault) updatePreviousVersion(
|
|
previousVersion *secret.Version, now *time.Time,
|
|
) error {
|
|
if previousVersion == nil {
|
|
return 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
|
|
err = previousVersion.LoadMetadata(ltIdentity)
|
|
if 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)
|
|
err = updateVersionMetadata(v.fs, previousVersion, ltIdentity)
|
|
if err != nil {
|
|
secret.Debug("Failed to update previous version metadata", "error", err)
|
|
|
|
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveSecretVersion validates the secret name, verifies the secret and
|
|
// version exist, and resolves an empty version to the current one.
|
|
func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
|
|
// Validate secret name to prevent path traversal
|
|
err := ValidateSecretName(name)
|
|
if err != nil {
|
|
secret.Debug("Invalid secret name provided", "secret_name", name)
|
|
|
|
return "", err
|
|
}
|
|
|
|
// Get vault directory
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
|
|
|
return "", 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 "", 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 "", fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
|
}
|
|
|
|
// 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 "", fmt.Errorf("failed to get current version: %w", err)
|
|
}
|
|
|
|
version = currentVersion
|
|
|
|
secret.Debug("Using current version", "version", version, "secret_name", name)
|
|
}
|
|
|
|
// 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 "", fmt.Errorf("failed to check if version exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
secret.Debug("Version not found", "version", version, "secret_name", name)
|
|
|
|
return "", fmt.Errorf(
|
|
"version %s %w %s",
|
|
version, ErrVersionNotFound, name,
|
|
)
|
|
}
|
|
|
|
return version, nil
|
|
}
|
|
|
|
// createAndSaveVersion generates a new version name, sets the version
|
|
// timestamps, and saves the encrypted value. When saving fails for a newly
|
|
// created secret, the secret directory is removed again.
|
|
func (v *Vault) createAndSaveVersion(
|
|
name, secretDir string, value *memguard.LockedBuffer,
|
|
previousVersion *secret.Version, now *time.Time, exists bool,
|
|
) (string, error) {
|
|
// 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
|
|
err = newVersion.Save(value)
|
|
if 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)
|
|
}
|
|
|
|
return versionName, nil
|
|
}
|
|
|
|
// copyVersionsWithRollback copies each version of the source secret into the
|
|
// destination directory and sets the current version pointer, removing the
|
|
// partial copy when any step fails.
|
|
func (v *Vault) copyVersionsWithRollback(
|
|
srcVault *Vault, srcIdentity *age.X25519Identity,
|
|
srcSecretName, destSecretName, destSecretDir string,
|
|
versions []string, currentVersion string,
|
|
) error {
|
|
// Copy each version
|
|
for _, versionName := range versions {
|
|
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
|
|
|
|
err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName)
|
|
if 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
|
|
err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion)
|
|
if err != nil {
|
|
_ = v.fs.RemoveAll(destSecretDir)
|
|
|
|
return fmt.Errorf("failed to set current version: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// prepareCopyDestination ensures the destination secret directory can be
|
|
// created, removing an existing secret when force is set.
|
|
func (v *Vault) prepareCopyDestination(
|
|
destSecretDir, destSecretName string, force bool,
|
|
) error {
|
|
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' %w in vault '%s' (use --force to overwrite)",
|
|
destSecretName, ErrSecretExists, v.Name,
|
|
)
|
|
}
|
|
|
|
if exists && force {
|
|
// Remove existing secret
|
|
secret.Debug("Removing existing destination secret", "path", destSecretDir)
|
|
|
|
err = v.fs.RemoveAll(destSecretDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove existing destination secret: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|