Refactor vault functionality to dedicated package, fix import cycles with interface pattern, fix tests
This commit is contained in:
260
internal/vault/management.go
Normal file
260
internal/vault/management.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Register the GetCurrentVault function with the secret package
|
||||
func init() {
|
||||
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
||||
return GetCurrentVault(fs, stateDir)
|
||||
})
|
||||
}
|
||||
|
||||
// resolveVaultSymlink resolves the currentvault symlink by changing into it and getting the absolute path
|
||||
func resolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
|
||||
secret.Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
|
||||
|
||||
// For real filesystems, we can use os.Chdir and os.Getwd
|
||||
if _, ok := fs.(*afero.OsFs); ok {
|
||||
secret.Debug("Using real filesystem symlink resolution")
|
||||
|
||||
// Check what the symlink points to first
|
||||
secret.Debug("Checking symlink target", "symlink_path", symlinkPath)
|
||||
linkTarget, err := os.Readlink(symlinkPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read symlink target", "error", err, "symlink_path", symlinkPath)
|
||||
// Maybe it's not a symlink, try reading as file
|
||||
secret.Debug("Trying to read as file instead of symlink")
|
||||
targetBytes, err := os.ReadFile(symlinkPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read as file", "error", err)
|
||||
return "", fmt.Errorf("failed to read vault symlink: %w", err)
|
||||
}
|
||||
targetPath := string(targetBytes)
|
||||
secret.Debug("Read target path from file", "target_path", targetPath)
|
||||
return targetPath, nil
|
||||
}
|
||||
|
||||
secret.Debug("Symlink points to", "target", linkTarget)
|
||||
|
||||
// Save current directory so we can restore it
|
||||
secret.Debug("Getting current directory")
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current directory", "error", err)
|
||||
return "", fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
secret.Debug("Got current directory", "original_dir", originalDir)
|
||||
|
||||
// Change to the symlink directory
|
||||
secret.Debug("Changing to symlink directory", "symlink_path", symlinkPath)
|
||||
secret.Debug("About to call os.Chdir - this might hang if symlink is broken")
|
||||
if err := os.Chdir(symlinkPath); err != nil {
|
||||
secret.Debug("Failed to change to symlink directory", "error", err)
|
||||
return "", fmt.Errorf("failed to change to symlink directory: %w", err)
|
||||
}
|
||||
secret.Debug("Changed to symlink directory successfully - os.Chdir completed")
|
||||
|
||||
// Get absolute path of current directory (which is the resolved symlink)
|
||||
secret.Debug("Getting absolute path of current directory")
|
||||
absolutePath, err := os.Getwd()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get absolute path", "error", err)
|
||||
// Try to restore original directory before returning error
|
||||
if restoreErr := os.Chdir(originalDir); restoreErr != nil {
|
||||
secret.Debug("Failed to restore original directory after error", "error", restoreErr)
|
||||
}
|
||||
return "", fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
secret.Debug("Got absolute path", "absolute_path", absolutePath)
|
||||
|
||||
// Restore original directory
|
||||
secret.Debug("Restoring original directory", "original_dir", originalDir)
|
||||
if err := os.Chdir(originalDir); err != nil {
|
||||
secret.Debug("Failed to restore original directory", "error", err)
|
||||
// Don't return error here since we got what we needed
|
||||
} else {
|
||||
secret.Debug("Restored original directory successfully")
|
||||
}
|
||||
|
||||
secret.Debug("resolveVaultSymlink completed successfully", "result", absolutePath)
|
||||
return absolutePath, nil
|
||||
}
|
||||
|
||||
// For in-memory filesystems, read the symlink content directly
|
||||
secret.Debug("Using in-memory filesystem symlink resolution")
|
||||
content, err := afero.ReadFile(fs, symlinkPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read symlink content", "error", err)
|
||||
return "", fmt.Errorf("failed to read vault symlink: %w", err)
|
||||
}
|
||||
|
||||
targetPath := string(content)
|
||||
secret.Debug("Read symlink target from in-memory filesystem", "target_path", targetPath)
|
||||
return targetPath, nil
|
||||
}
|
||||
|
||||
// GetCurrentVault gets the currently selected vault
|
||||
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
||||
secret.Debug("Getting current vault", "state_dir", stateDir)
|
||||
|
||||
// Check if current vault symlink exists
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
|
||||
|
||||
_, err := fs.Stat(currentVaultPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
|
||||
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
||||
}
|
||||
secret.Debug("Current vault symlink exists")
|
||||
|
||||
// Resolve symlink to get target path
|
||||
secret.Debug("Resolving vault symlink")
|
||||
targetPath, err := resolveVaultSymlink(fs, currentVaultPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to resolve vault symlink", "error", err)
|
||||
return nil, fmt.Errorf("failed to resolve vault symlink: %w", err)
|
||||
}
|
||||
secret.Debug("Resolved vault symlink", "target_path", targetPath)
|
||||
|
||||
// Extract vault name from target path
|
||||
vaultName := filepath.Base(targetPath)
|
||||
secret.Debug("Extracted vault name", "vault_name", vaultName)
|
||||
|
||||
secret.Debug("Current vault resolved", "vault_name", vaultName, "target_path", targetPath)
|
||||
|
||||
// Create and return Vault instance
|
||||
secret.Debug("Creating NewVault instance")
|
||||
vault := NewVault(fs, vaultName, stateDir)
|
||||
secret.Debug("Created NewVault instance successfully")
|
||||
|
||||
return vault, nil
|
||||
}
|
||||
|
||||
// ListVaults returns a list of available vault names
|
||||
func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||
|
||||
// Check if vaults directory exists
|
||||
exists, err := afero.DirExists(fs, vaultsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check vaults directory: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// Read directory contents
|
||||
files, err := afero.ReadDir(fs, vaultsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read vaults directory: %w", err)
|
||||
}
|
||||
|
||||
var vaults []string
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
vaults = append(vaults, file.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return vaults, nil
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault with the given name
|
||||
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||
|
||||
// Create vault directory structure
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
||||
|
||||
// Check if vault already exists
|
||||
exists, err := afero.DirExists(fs, vaultDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, fmt.Errorf("vault %s already exists", name)
|
||||
}
|
||||
|
||||
// Create vault directory
|
||||
if err := fs.MkdirAll(vaultDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Create subdirectories
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
if err := fs.MkdirAll(secretsDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
||||
}
|
||||
|
||||
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
|
||||
if err := fs.MkdirAll(unlockKeysDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create unlock keys directory: %w", err)
|
||||
}
|
||||
|
||||
// Select the new vault as current
|
||||
secret.Debug("Selecting newly created vault as current", "name", name)
|
||||
if err := SelectVault(fs, stateDir, name); err != nil {
|
||||
return nil, fmt.Errorf("failed to select new vault: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Successfully created vault", "name", name)
|
||||
return NewVault(fs, name, stateDir), nil
|
||||
}
|
||||
|
||||
// SelectVault selects the given vault as the current vault
|
||||
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
||||
|
||||
// Check if vault exists
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||
exists, err := afero.DirExists(fs, vaultDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("vault %s does not exist", name)
|
||||
}
|
||||
|
||||
// Create/update current vault symlink
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
|
||||
// Remove existing symlink if it exists
|
||||
if exists, _ := afero.Exists(fs, currentVaultPath); exists {
|
||||
secret.Debug("Removing existing current vault symlink", "path", currentVaultPath)
|
||||
if err := fs.Remove(currentVaultPath); err != nil {
|
||||
secret.Debug("Failed to remove existing symlink", "error", err, "path", currentVaultPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new symlink pointing to the vault
|
||||
targetPath := vaultDir
|
||||
secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath)
|
||||
|
||||
// For real filesystems, try to create a real symlink first
|
||||
if _, ok := fs.(*afero.OsFs); ok {
|
||||
if err := os.Symlink(targetPath, currentVaultPath); err != nil {
|
||||
// If symlink creation fails, fall back to writing target path to file
|
||||
secret.Debug("Failed to create real symlink, falling back to file", "error", err)
|
||||
if err := afero.WriteFile(fs, currentVaultPath, []byte(targetPath), 0600); err != nil {
|
||||
return fmt.Errorf("failed to create vault symlink: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For in-memory filesystems, write target path to file
|
||||
if err := afero.WriteFile(fs, currentVaultPath, []byte(targetPath), 0600); err != nil {
|
||||
return fmt.Errorf("failed to create vault symlink: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
secret.Debug("Successfully selected vault", "vault_name", name)
|
||||
return nil
|
||||
}
|
||||
11
internal/vault/metadata.go
Normal file
11
internal/vault/metadata.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
)
|
||||
|
||||
// Alias the metadata types from secret package for convenience
|
||||
type VaultMetadata = secret.VaultMetadata
|
||||
type UnlockKeyMetadata = secret.UnlockKeyMetadata
|
||||
type SecretMetadata = secret.SecretMetadata
|
||||
type Configuration = secret.Configuration
|
||||
447
internal/vault/secrets.go
Normal file
447
internal/vault/secrets.go
Normal file
@@ -0,0 +1,447 @@
|
||||
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/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\.\-\_\/]+
|
||||
func isValidSecretName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
|
||||
return matched
|
||||
}
|
||||
|
||||
// AddSecret adds a secret to this vault
|
||||
func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
secret.DebugWith("Adding secret to vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("value_length", len(value)),
|
||||
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)
|
||||
|
||||
if exists && !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)
|
||||
}
|
||||
|
||||
// Create secret directory
|
||||
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
||||
if err := v.fs.MkdirAll(secretDir, 0700); 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")
|
||||
|
||||
// Step 1: Generate a new keypair for this secret
|
||||
secret.Debug("Generating secret-specific keypair", "secret_name", name)
|
||||
secretIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to generate secret keypair", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to generate secret keypair: %w", err)
|
||||
}
|
||||
|
||||
secretPublicKey := secretIdentity.Recipient().String()
|
||||
secretPrivateKey := secretIdentity.String()
|
||||
|
||||
secret.DebugWith("Generated secret keypair",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("public_key", secretPublicKey),
|
||||
)
|
||||
|
||||
// Step 2: Store the secret's public key
|
||||
pubKeyPath := filepath.Join(secretDir, "pub.age")
|
||||
secret.Debug("Writing secret public key", "path", pubKeyPath)
|
||||
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(secretPublicKey), 0600); err != nil {
|
||||
secret.Debug("Failed to write secret public key", "error", err, "path", pubKeyPath)
|
||||
return fmt.Errorf("failed to write secret public key: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote secret public key successfully")
|
||||
|
||||
// Step 3: Encrypt the secret value to the secret's public key
|
||||
secret.Debug("Encrypting secret value to secret's public key", "secret_name", name)
|
||||
encryptedValue, err := secret.EncryptToRecipient(value, secretIdentity.Recipient())
|
||||
if err != nil {
|
||||
secret.Debug("Failed to encrypt secret value", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to encrypt secret value: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Secret value encrypted",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedValue)),
|
||||
)
|
||||
|
||||
// Step 4: Store the encrypted secret value as value.age
|
||||
valuePath := filepath.Join(secretDir, "value.age")
|
||||
secret.Debug("Writing encrypted secret value", "path", valuePath)
|
||||
if err := afero.WriteFile(v.fs, valuePath, encryptedValue, 0600); err != nil {
|
||||
secret.Debug("Failed to write encrypted secret value", "error", err, "path", valuePath)
|
||||
return fmt.Errorf("failed to write encrypted secret value: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote encrypted secret value successfully")
|
||||
|
||||
// Step 5: Get long-term public key for encrypting the secret's private key
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
secret.Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
ltPubKeyData, err := afero.ReadFile(v.fs, ltPubKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
}
|
||||
secret.Debug("Read long-term public key successfully", "key_length", len(ltPubKeyData))
|
||||
|
||||
secret.Debug("Parsing long-term public key")
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
if err != nil {
|
||||
secret.Debug("Failed to parse long-term public key", "error", err)
|
||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Parsed long-term public key", slog.String("recipient", ltRecipient.String()))
|
||||
|
||||
// Step 6: Encrypt the secret's private key to the long-term public key
|
||||
secret.Debug("Encrypting secret private key to long-term public key", "secret_name", name)
|
||||
encryptedPrivKey, err := secret.EncryptToRecipient([]byte(secretPrivateKey), ltRecipient)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to encrypt secret private key", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to encrypt secret private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Secret private key encrypted",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedPrivKey)),
|
||||
)
|
||||
|
||||
// Step 7: Store the encrypted secret private key as priv.age
|
||||
privKeyPath := filepath.Join(secretDir, "priv.age")
|
||||
secret.Debug("Writing encrypted secret private key", "path", privKeyPath)
|
||||
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, 0600); err != nil {
|
||||
secret.Debug("Failed to write encrypted secret private key", "error", err, "path", privKeyPath)
|
||||
return fmt.Errorf("failed to write encrypted secret private key: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote encrypted secret private key successfully")
|
||||
|
||||
// Step 8: Create and write metadata
|
||||
secret.Debug("Creating secret metadata")
|
||||
now := time.Now()
|
||||
metadata := SecretMetadata{
|
||||
Name: name,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
secret.DebugWith("Creating secret metadata",
|
||||
slog.String("secret_name", metadata.Name),
|
||||
slog.Time("created_at", metadata.CreatedAt),
|
||||
slog.Time("updated_at", metadata.UpdatedAt),
|
||||
)
|
||||
|
||||
secret.Debug("Marshaling secret metadata")
|
||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to marshal secret metadata", "error", err)
|
||||
return fmt.Errorf("failed to marshal secret metadata: %w", err)
|
||||
}
|
||||
secret.Debug("Marshaled secret metadata successfully")
|
||||
|
||||
metadataPath := filepath.Join(secretDir, "secret-metadata.json")
|
||||
secret.Debug("Writing secret metadata", "path", metadataPath)
|
||||
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, 0600); err != nil {
|
||||
secret.Debug("Failed to write secret metadata", "error", err, "path", metadataPath)
|
||||
return fmt.Errorf("failed to write secret metadata: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote secret metadata successfully")
|
||||
|
||||
secret.Debug("Successfully added secret to vault with per-secret key architecture", "secret_name", name, "vault_name", v.Name)
|
||||
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),
|
||||
)
|
||||
|
||||
// Create a secret object to handle file access
|
||||
secretObj := secret.NewSecret(v, name)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := secretObj.Exists()
|
||||
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)
|
||||
}
|
||||
|
||||
secret.Debug("Secret exists, proceeding with vault unlock and decryption", "secret_name", name)
|
||||
|
||||
// Step 1: 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("long_term_public_key", longTermIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 2: Use the unlocked vault to decrypt the secret
|
||||
decryptedValue, err := v.decryptSecretWithLongTermKey(name, longTermIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully decrypted secret with per-secret key architecture",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.Int("decrypted_length", len(decryptedValue)),
|
||||
)
|
||||
|
||||
return decryptedValue, 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
|
||||
}
|
||||
|
||||
// decryptSecretWithLongTermKey decrypts a secret using the provided long-term key
|
||||
func (v *Vault) decryptSecretWithLongTermKey(name string, longTermIdentity *age.X25519Identity) ([]byte, error) {
|
||||
secret.DebugWith("Decrypting secret with long-term key",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("vault_name", v.Name),
|
||||
)
|
||||
|
||||
// Get vault and secret directories
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Step 1: Read the encrypted secret private key from priv.age
|
||||
encryptedSecretPrivKeyPath := filepath.Join(secretDir, "priv.age")
|
||||
secret.Debug("Reading encrypted secret private key", "path", encryptedSecretPrivKeyPath)
|
||||
|
||||
encryptedSecretPrivKey, err := afero.ReadFile(v.fs, encryptedSecretPrivKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read encrypted secret private key", "error", err, "path", encryptedSecretPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Read encrypted secret private key",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedSecretPrivKey)),
|
||||
)
|
||||
|
||||
// Step 2: Decrypt the secret's private key using the long-term private key
|
||||
secret.Debug("Decrypting secret private key with long-term key", "secret_name", name)
|
||||
secretPrivKeyData, err := secret.DecryptWithIdentity(encryptedSecretPrivKey, longTermIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt secret private key", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Parse the secret's private key
|
||||
secret.Debug("Parsing secret private key", "secret_name", name)
|
||||
secretIdentity, err := age.ParseX25519Identity(string(secretPrivKeyData))
|
||||
if err != nil {
|
||||
secret.Debug("Failed to parse secret private key", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to parse secret private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully parsed secret identity",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("public_key", secretIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 4: Read the encrypted secret value from value.age
|
||||
encryptedValuePath := filepath.Join(secretDir, "value.age")
|
||||
secret.Debug("Reading encrypted secret value", "path", encryptedValuePath)
|
||||
|
||||
encryptedValue, err := afero.ReadFile(v.fs, encryptedValuePath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read encrypted secret value", "error", err, "path", encryptedValuePath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret value: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Read encrypted secret value",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedValue)),
|
||||
)
|
||||
|
||||
// Step 5: Decrypt the secret value using the secret's private key
|
||||
secret.Debug("Decrypting secret value with secret's private key", "secret_name", name)
|
||||
decryptedValue, err := secret.DecryptWithIdentity(encryptedValue, secretIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt secret value", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret value: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully decrypted secret value",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("decrypted_length", len(decryptedValue)),
|
||||
)
|
||||
|
||||
return decryptedValue, 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
|
||||
}
|
||||
376
internal/vault/unlock_keys.go
Normal file
376
internal/vault/unlock_keys.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// GetCurrentUnlockKey returns the current unlock key for this vault
|
||||
func (v *Vault) GetCurrentUnlockKey() (secret.UnlockKey, error) {
|
||||
secret.DebugWith("Getting current unlock key", slog.String("vault_name", v.Name))
|
||||
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory for unlock key", "error", err, "vault_name", v.Name)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
|
||||
|
||||
// Check if the symlink exists
|
||||
_, err = v.fs.Stat(currentUnlockKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to stat current unlock key symlink", "error", err, "path", currentUnlockKeyPath)
|
||||
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the symlink to get the target directory
|
||||
var unlockKeyDir string
|
||||
if _, ok := v.fs.(*afero.OsFs); ok {
|
||||
secret.Debug("Resolving unlock key symlink (real filesystem)")
|
||||
// For real filesystems, resolve the symlink properly
|
||||
unlockKeyDir, err = resolveVaultSymlink(v.fs, currentUnlockKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to resolve unlock key symlink", "error", err, "symlink_path", currentUnlockKeyPath)
|
||||
return nil, fmt.Errorf("failed to resolve current unlock key symlink: %w", err)
|
||||
}
|
||||
} else {
|
||||
secret.Debug("Reading unlock key path (mock filesystem)")
|
||||
// Fallback for mock filesystems: read the path from file contents
|
||||
unlockKeyDirBytes, err := afero.ReadFile(v.fs, currentUnlockKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read unlock key path file", "error", err, "path", currentUnlockKeyPath)
|
||||
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
|
||||
}
|
||||
unlockKeyDir = strings.TrimSpace(string(unlockKeyDirBytes))
|
||||
}
|
||||
|
||||
secret.DebugWith("Resolved unlock key directory",
|
||||
slog.String("unlock_key_dir", unlockKeyDir),
|
||||
slog.String("vault_name", v.Name),
|
||||
)
|
||||
|
||||
// Read unlock key metadata
|
||||
metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json")
|
||||
secret.Debug("Reading unlock key metadata", "path", metadataPath)
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read unlock key metadata", "error", err, "path", metadataPath)
|
||||
return nil, fmt.Errorf("failed to read unlock key metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata UnlockKeyMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
secret.Debug("Failed to parse unlock key metadata", "error", err, "path", metadataPath)
|
||||
return nil, fmt.Errorf("failed to parse unlock key metadata: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Parsed unlock key metadata",
|
||||
slog.String("key_id", metadata.ID),
|
||||
slog.String("key_type", metadata.Type),
|
||||
slog.Time("created_at", metadata.CreatedAt),
|
||||
slog.Any("flags", metadata.Flags),
|
||||
)
|
||||
|
||||
// Create unlock key instance using direct constructors with filesystem
|
||||
var unlockKey secret.UnlockKey
|
||||
// Convert our metadata to secret.UnlockKeyMetadata
|
||||
secretMetadata := secret.UnlockKeyMetadata(metadata)
|
||||
switch metadata.Type {
|
||||
case "passphrase":
|
||||
secret.Debug("Creating passphrase unlock key instance", "key_id", metadata.ID)
|
||||
unlockKey = secret.NewPassphraseUnlockKey(v.fs, unlockKeyDir, secretMetadata)
|
||||
case "pgp":
|
||||
secret.Debug("Creating PGP unlock key instance", "key_id", metadata.ID)
|
||||
unlockKey = secret.NewPGPUnlockKey(v.fs, unlockKeyDir, secretMetadata)
|
||||
case "keychain":
|
||||
secret.Debug("Creating keychain unlock key instance", "key_id", metadata.ID)
|
||||
unlockKey = secret.NewKeychainUnlockKey(v.fs, unlockKeyDir, secretMetadata)
|
||||
default:
|
||||
secret.Debug("Unsupported unlock key type", "type", metadata.Type, "key_id", metadata.ID)
|
||||
return nil, fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully created unlock key instance",
|
||||
slog.String("key_type", unlockKey.GetType()),
|
||||
slog.String("key_id", unlockKey.GetID()),
|
||||
slog.String("vault_name", v.Name),
|
||||
)
|
||||
|
||||
return unlockKey, nil
|
||||
}
|
||||
|
||||
// ListUnlockKeys returns a list of available unlock keys for this vault
|
||||
func (v *Vault) ListUnlockKeys() ([]UnlockKeyMetadata, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
|
||||
|
||||
// Check if unlock keys directory exists
|
||||
exists, err := afero.DirExists(v.fs, unlockKeysDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if unlock keys directory exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return []UnlockKeyMetadata{}, nil
|
||||
}
|
||||
|
||||
// List directories in unlock.d
|
||||
files, err := afero.ReadDir(v.fs, unlockKeysDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read unlock keys directory: %w", err)
|
||||
}
|
||||
|
||||
var keys []UnlockKeyMetadata
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
// Read metadata file
|
||||
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
|
||||
exists, err := afero.Exists(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata UnlockKeyMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
keys = append(keys, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// RemoveUnlockKey removes an unlock key from this vault
|
||||
func (v *Vault) RemoveUnlockKey(keyID string) error {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find the key directory and create the unlock key instance
|
||||
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
|
||||
|
||||
// List directories in unlock.d
|
||||
files, err := afero.ReadDir(v.fs, unlockKeysDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read unlock keys directory: %w", err)
|
||||
}
|
||||
|
||||
var unlockKey secret.UnlockKey
|
||||
var keyDir string
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
// Read metadata file
|
||||
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
|
||||
exists, err := afero.Exists(v.fs, metadataPath)
|
||||
if err != nil || !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata UnlockKeyMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if metadata.ID == keyID {
|
||||
keyDir = filepath.Join(unlockKeysDir, file.Name())
|
||||
|
||||
// Convert our metadata to secret.UnlockKeyMetadata
|
||||
secretMetadata := secret.UnlockKeyMetadata(metadata)
|
||||
|
||||
// Create the appropriate unlock key instance
|
||||
switch metadata.Type {
|
||||
case "passphrase":
|
||||
unlockKey = secret.NewPassphraseUnlockKey(v.fs, keyDir, secretMetadata)
|
||||
case "pgp":
|
||||
unlockKey = secret.NewPGPUnlockKey(v.fs, keyDir, secretMetadata)
|
||||
case "keychain":
|
||||
unlockKey = secret.NewKeychainUnlockKey(v.fs, keyDir, secretMetadata)
|
||||
default:
|
||||
return fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if unlockKey == nil {
|
||||
return fmt.Errorf("unlock key with ID %s not found", keyID)
|
||||
}
|
||||
|
||||
// Use the unlock key's Remove method
|
||||
return unlockKey.Remove()
|
||||
}
|
||||
|
||||
// SelectUnlockKey selects an unlock key as current for this vault
|
||||
func (v *Vault) SelectUnlockKey(keyID string) error {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find the unlock key directory by ID
|
||||
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
|
||||
|
||||
// List directories in unlock.d to find the key
|
||||
files, err := afero.ReadDir(v.fs, unlockKeysDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read unlock keys directory: %w", err)
|
||||
}
|
||||
|
||||
var targetKeyDir string
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
// Read metadata file
|
||||
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
|
||||
exists, err := afero.Exists(v.fs, metadataPath)
|
||||
if err != nil || !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata UnlockKeyMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if metadata.ID == keyID {
|
||||
targetKeyDir = filepath.Join(unlockKeysDir, file.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targetKeyDir == "" {
|
||||
return fmt.Errorf("unlock key with ID %s not found", keyID)
|
||||
}
|
||||
|
||||
// Create/update current unlock key symlink
|
||||
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
|
||||
|
||||
// Remove existing symlink if it exists
|
||||
if exists, _ := afero.Exists(v.fs, currentUnlockKeyPath); exists {
|
||||
if err := v.fs.Remove(currentUnlockKeyPath); err != nil {
|
||||
secret.Debug("Failed to remove existing unlock key symlink", "error", err, "path", currentUnlockKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new symlink
|
||||
return afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(targetKeyDir), 0600)
|
||||
}
|
||||
|
||||
// CreatePassphraseKey creates a new passphrase-protected unlock key
|
||||
func (v *Vault) CreatePassphraseKey(passphrase string) (*secret.PassphraseUnlockKey, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Create unlock key directory with timestamp
|
||||
timestamp := time.Now().Format("2006-01-02.15.04")
|
||||
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", "passphrase")
|
||||
if err := v.fs.MkdirAll(unlockKeyDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate new age keypair for unlock key
|
||||
unlockIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate unlock key: %w", err)
|
||||
}
|
||||
|
||||
// Write public key
|
||||
pubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
|
||||
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockIdentity.Recipient().String()), 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlock key public key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt private key with passphrase
|
||||
privKeyData := []byte(unlockIdentity.String())
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt unlock key private key: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted private key
|
||||
privKeyPath := filepath.Join(unlockKeyDir, "priv.age")
|
||||
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted unlock key private key: %w", err)
|
||||
}
|
||||
|
||||
// Create metadata
|
||||
keyID := fmt.Sprintf("%s-passphrase", timestamp)
|
||||
metadata := UnlockKeyMetadata{
|
||||
ID: keyID,
|
||||
Type: "passphrase",
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{},
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json")
|
||||
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to this unlock key if vault is unlocked
|
||||
if !v.Locked() {
|
||||
ltPrivKey := []byte(v.GetLongTermKey().String())
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockIdentity.Recipient())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
|
||||
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Select this unlock key as current
|
||||
if err := v.SelectUnlockKey(keyID); err != nil {
|
||||
return nil, fmt.Errorf("failed to select new unlock key: %w", err)
|
||||
}
|
||||
|
||||
// Convert our metadata to secret.UnlockKeyMetadata for the constructor
|
||||
secretMetadata := secret.UnlockKeyMetadata(metadata)
|
||||
|
||||
return secret.NewPassphraseUnlockKey(v.fs, unlockKeyDir, secretMetadata), nil
|
||||
}
|
||||
163
internal/vault/vault.go
Normal file
163
internal/vault/vault.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Vault represents a secrets vault
|
||||
type Vault struct {
|
||||
Name string
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
|
||||
}
|
||||
|
||||
// NewVault creates a new Vault instance
|
||||
func NewVault(fs afero.Fs, name string, stateDir string) *Vault {
|
||||
return &Vault{
|
||||
Name: name,
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
longTermKey: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Locked returns true if the vault doesn't have a long-term key in memory
|
||||
func (v *Vault) Locked() bool {
|
||||
return v.longTermKey == nil
|
||||
}
|
||||
|
||||
// Unlock sets the long-term key in memory, unlocking the vault
|
||||
func (v *Vault) Unlock(key *age.X25519Identity) {
|
||||
v.longTermKey = key
|
||||
}
|
||||
|
||||
// GetLongTermKey returns the long-term key if available in memory
|
||||
func (v *Vault) GetLongTermKey() *age.X25519Identity {
|
||||
return v.longTermKey
|
||||
}
|
||||
|
||||
// ClearLongTermKey removes the long-term key from memory (locks the vault)
|
||||
func (v *Vault) ClearLongTermKey() {
|
||||
v.longTermKey = nil
|
||||
}
|
||||
|
||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it from available sources
|
||||
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
// If we have it in memory, return it
|
||||
if !v.Locked() {
|
||||
return v.longTermKey, nil
|
||||
}
|
||||
|
||||
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
|
||||
|
||||
// Try to derive from environment mnemonic first
|
||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||
secret.Debug("Using mnemonic from environment for long-term key derivation", "vault_name", v.Name)
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully derived long-term key from mnemonic",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// No mnemonic available, try to use current unlock key
|
||||
secret.Debug("No mnemonic available, using current unlock key to unlock vault", "vault_name", v.Name)
|
||||
|
||||
// Get current unlock key
|
||||
unlockKey, err := v.GetCurrentUnlockKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current unlock key", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Retrieved current unlock key for vault unlock",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.String("unlock_key_id", unlockKey.GetID()),
|
||||
)
|
||||
|
||||
// Get unlock key identity
|
||||
unlockIdentity, err := unlockKey.GetIdentity()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get unlock key identity", "error", err, "unlock_key_type", unlockKey.GetType())
|
||||
return nil, fmt.Errorf("failed to get unlock key identity: %w", err)
|
||||
}
|
||||
|
||||
// Read encrypted long-term private key from unlock key directory
|
||||
unlockKeyDir := unlockKey.GetDirectory()
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
|
||||
secret.Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Read encrypted long-term private key",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
|
||||
)
|
||||
|
||||
// Decrypt long-term private key using unlock key
|
||||
secret.Debug("Decrypting long-term private key with unlock key", "unlock_key_type", unlockKey.GetType())
|
||||
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlock_key_type", unlockKey.GetType())
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully decrypted long-term private key",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.Int("decrypted_length", len(ltPrivKeyData)),
|
||||
)
|
||||
|
||||
// Parse long-term private key
|
||||
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
|
||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
||||
if err != nil {
|
||||
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully obtained long-term identity via unlock key",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// GetDirectory returns the vault's directory path
|
||||
func (v *Vault) GetDirectory() (string, error) {
|
||||
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
|
||||
}
|
||||
|
||||
// GetName returns the vault's name (for VaultInterface compatibility)
|
||||
func (v *Vault) GetName() string {
|
||||
return v.Name
|
||||
}
|
||||
|
||||
// GetFilesystem returns the vault's filesystem (for VaultInterface compatibility)
|
||||
func (v *Vault) GetFilesystem() afero.Fs {
|
||||
return v.fs
|
||||
}
|
||||
237
internal/vault/vault_test.go
Normal file
237
internal/vault/vault_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func TestVaultOperations(t *testing.T) {
|
||||
// Save original environment variables
|
||||
oldMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
oldPassphrase := os.Getenv(secret.EnvUnlockPassphrase)
|
||||
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
if oldMnemonic != "" {
|
||||
os.Setenv(secret.EnvMnemonic, oldMnemonic)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
|
||||
if oldPassphrase != "" {
|
||||
os.Setenv(secret.EnvUnlockPassphrase, oldPassphrase)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set test environment variables
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
os.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
os.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
// Use in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Test vault creation
|
||||
t.Run("CreateVault", func(t *testing.T) {
|
||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
if vlt.GetName() != "test-vault" {
|
||||
t.Errorf("Expected vault name 'test-vault', got '%s'", vlt.GetName())
|
||||
}
|
||||
|
||||
// Check vault directory exists
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
exists, err := afero.DirExists(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check vault directory: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Vault directory should exist")
|
||||
}
|
||||
})
|
||||
|
||||
// Test vault listing
|
||||
t.Run("ListVaults", func(t *testing.T) {
|
||||
vaults, err := ListVaults(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list vaults: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, vault := range vaults {
|
||||
if vault == "test-vault" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("Expected to find 'test-vault' in vault list")
|
||||
}
|
||||
})
|
||||
|
||||
// Test vault selection
|
||||
t.Run("SelectVault", func(t *testing.T) {
|
||||
err := SelectVault(fs, stateDir, "test-vault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to select vault: %v", err)
|
||||
}
|
||||
|
||||
// Test getting current vault
|
||||
currentVault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault: %v", err)
|
||||
}
|
||||
|
||||
if currentVault.GetName() != "test-vault" {
|
||||
t.Errorf("Expected current vault 'test-vault', got '%s'", currentVault.GetName())
|
||||
}
|
||||
})
|
||||
|
||||
// Test secret operations
|
||||
t.Run("SecretOperations", func(t *testing.T) {
|
||||
vlt, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault: %v", err)
|
||||
}
|
||||
|
||||
// First, derive the long-term key from the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Get the public key from the derived identity
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
|
||||
// Get the vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Write the correct public key to the pub.age file
|
||||
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vault with the derived identity
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Now add a secret
|
||||
secretName := "test/secret"
|
||||
secretValue := []byte("test-secret-value")
|
||||
|
||||
err = vlt.AddSecret(secretName, secretValue, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret: %v", err)
|
||||
}
|
||||
|
||||
// List secrets
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, secret := range secrets {
|
||||
if secret == secretName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("Expected to find secret '%s' in list", secretName)
|
||||
}
|
||||
|
||||
// Get secret value
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get secret: %v", err)
|
||||
}
|
||||
|
||||
if string(retrievedValue) != string(secretValue) {
|
||||
t.Errorf("Expected secret value '%s', got '%s'", string(secretValue), string(retrievedValue))
|
||||
}
|
||||
})
|
||||
|
||||
// Test unlock key operations
|
||||
t.Run("UnlockKeyOperations", func(t *testing.T) {
|
||||
vlt, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault: %v", err)
|
||||
}
|
||||
|
||||
// Test vault unlocking (should happen automatically via mnemonic)
|
||||
if vlt.Locked() {
|
||||
_, err := vlt.UnlockVault()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unlock vault: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a passphrase unlock key
|
||||
passphraseKey, err := vlt.CreatePassphraseKey("test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create passphrase key: %v", err)
|
||||
}
|
||||
|
||||
// List unlock keys
|
||||
keys, err := vlt.ListUnlockKeys()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list unlock keys: %v", err)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
t.Errorf("Expected at least one unlock key")
|
||||
}
|
||||
|
||||
// Check key type
|
||||
keyFound := false
|
||||
for _, key := range keys {
|
||||
if key.Type == "passphrase" {
|
||||
keyFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !keyFound {
|
||||
t.Errorf("Expected to find passphrase unlock key")
|
||||
}
|
||||
|
||||
// Test selecting unlock key
|
||||
err = vlt.SelectUnlockKey(passphraseKey.GetID())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to select unlock key: %v", err)
|
||||
}
|
||||
|
||||
// Test getting current unlock key
|
||||
currentKey, err := vlt.GetCurrentUnlockKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current unlock key: %v", err)
|
||||
}
|
||||
|
||||
if currentKey.GetID() != passphraseKey.GetID() {
|
||||
t.Errorf("Expected current unlock key ID '%s', got '%s'", passphraseKey.GetID(), currentKey.GetID())
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user