Compare commits
	
		
			3 Commits
		
	
	
		
			8cc15fde3d
			...
			85d7ef21eb
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 85d7ef21eb | |||
| a4d7225036 | |||
| 8dc2e9d748 | 
@ -6,7 +6,6 @@ import (
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"syscall"
 | 
			
		||||
 | 
			
		||||
	"filippo.io/age"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
@ -15,7 +14,6 @@ import (
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
	"github.com/tyler-smith/go-bip39"
 | 
			
		||||
	"golang.org/x/term"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func newInitCmd() *cobra.Command {
 | 
			
		||||
@ -38,7 +36,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
 | 
			
		||||
	stateDir := cli.GetStateDir()
 | 
			
		||||
	secret.DebugWith("Creating state directory", slog.String("path", stateDir))
 | 
			
		||||
 | 
			
		||||
	if err := cli.fs.MkdirAll(stateDir, 0700); err != nil {
 | 
			
		||||
	if err := cli.fs.MkdirAll(stateDir, secret.DirPerms); err != nil {
 | 
			
		||||
		secret.Debug("Failed to create state directory", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to create state directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
@ -103,7 +101,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
 | 
			
		||||
	vaultDir := filepath.Join(stateDir, "vaults.d", "default")
 | 
			
		||||
	ltPubKey := ltIdentity.Recipient().String()
 | 
			
		||||
	secret.DebugWith("Storing long-term public key", slog.String("pubkey", ltPubKey), slog.String("vault_dir", vaultDir))
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), secret.FilePerms); err != nil {
 | 
			
		||||
		secret.Debug("Failed to write long-term public key", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to write long-term public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
@ -156,7 +154,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write encrypted long-term private key
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, secret.FilePerms); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -173,45 +171,24 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
 | 
			
		||||
// and prompts for confirmation. Falls back to regular input when not on a terminal.
 | 
			
		||||
// This version adds confirmation (read twice) for creating new unlock keys
 | 
			
		||||
func readSecurePassphrase(prompt string) (string, error) {
 | 
			
		||||
	// Check if stdin is a terminal
 | 
			
		||||
	if !term.IsTerminal(int(syscall.Stdin)) {
 | 
			
		||||
		// Not a terminal - never read passphrases from piped input for security reasons
 | 
			
		||||
		return "", fmt.Errorf("cannot read passphrase from non-terminal stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE environment variable or run interactively")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if stderr is a terminal - if not, we can't prompt interactively
 | 
			
		||||
	if !term.IsTerminal(int(syscall.Stderr)) {
 | 
			
		||||
		return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE environment variable")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Terminal input - use secure password reading with confirmation
 | 
			
		||||
	fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
 | 
			
		||||
 | 
			
		||||
	// Read first passphrase
 | 
			
		||||
	passphrase1, err := term.ReadPassword(int(syscall.Stdin))
 | 
			
		||||
	// Get the first passphrase
 | 
			
		||||
	passphrase1, err := secret.ReadPassphrase(prompt)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", fmt.Errorf("failed to read passphrase: %w", err)
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
	fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
 | 
			
		||||
 | 
			
		||||
	// Read confirmation passphrase
 | 
			
		||||
	fmt.Fprint(os.Stderr, "Confirm passphrase: ") // Write prompt to stderr, not stdout
 | 
			
		||||
	passphrase2, err := term.ReadPassword(int(syscall.Stdin))
 | 
			
		||||
	passphrase2, err := secret.ReadPassphrase("Confirm passphrase: ")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", fmt.Errorf("failed to read passphrase confirmation: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
 | 
			
		||||
 | 
			
		||||
	// Compare passphrases
 | 
			
		||||
	if string(passphrase1) != string(passphrase2) {
 | 
			
		||||
	if passphrase1 != passphrase2 {
 | 
			
		||||
		return "", fmt.Errorf("passphrases do not match")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(passphrase1) == 0 {
 | 
			
		||||
		return "", fmt.Errorf("passphrase cannot be empty")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return string(passphrase1), nil
 | 
			
		||||
	return passphrase1, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -14,6 +14,10 @@ import (
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Import from init.go
 | 
			
		||||
 | 
			
		||||
// ... existing imports ...
 | 
			
		||||
 | 
			
		||||
func newKeysCmd() *cobra.Command {
 | 
			
		||||
	cmd := &cobra.Command{
 | 
			
		||||
		Use:   "keys",
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,7 @@
 | 
			
		||||
package secret
 | 
			
		||||
 | 
			
		||||
import "os"
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// AppID is the unique identifier for this application
 | 
			
		||||
	AppID = "berlin.sneak.pkg.secret"
 | 
			
		||||
@ -10,3 +12,12 @@ const (
 | 
			
		||||
	EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
 | 
			
		||||
	EnvGPGKeyID         = "SB_GPG_KEY_ID"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// File system permission constants
 | 
			
		||||
const (
 | 
			
		||||
	// DirPerms is the permission used for directories (read-write-execute for owner only)
 | 
			
		||||
	DirPerms os.FileMode = 0700
 | 
			
		||||
 | 
			
		||||
	// FilePerms is the permission used for sensitive files (read-write for owner only)
 | 
			
		||||
	FilePerms os.FileMode = 0600
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,7 @@ import (
 | 
			
		||||
 | 
			
		||||
// EncryptToRecipient encrypts data to a recipient using age
 | 
			
		||||
func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
 | 
			
		||||
	Debug("encryptToRecipient starting", "data_length", len(data))
 | 
			
		||||
	Debug("EncryptToRecipient starting", "data_length", len(data))
 | 
			
		||||
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
	Debug("Creating age encryptor")
 | 
			
		||||
@ -39,22 +39,12 @@ func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
 | 
			
		||||
	Debug("Closed encryptor successfully")
 | 
			
		||||
 | 
			
		||||
	result := buf.Bytes()
 | 
			
		||||
	Debug("encryptToRecipient completed successfully", "result_length", len(result))
 | 
			
		||||
	Debug("EncryptToRecipient completed successfully", "result_length", len(result))
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// encryptToRecipient encrypts data to a recipient using age (internal version)
 | 
			
		||||
func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
 | 
			
		||||
	return EncryptToRecipient(data, recipient)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DecryptWithIdentity decrypts data with an identity using age (public version)
 | 
			
		||||
// DecryptWithIdentity decrypts data with an identity using age
 | 
			
		||||
func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
 | 
			
		||||
	return decryptWithIdentity(data, identity)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// decryptWithIdentity decrypts data with an identity using age
 | 
			
		||||
func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
 | 
			
		||||
	r, err := age.Decrypt(bytes.NewReader(data), identity)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create decryptor: %w", err)
 | 
			
		||||
@ -68,34 +58,29 @@ func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption (public version)
 | 
			
		||||
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
 | 
			
		||||
func EncryptWithPassphrase(data []byte, passphrase string) ([]byte, error) {
 | 
			
		||||
	return encryptWithPassphrase(data, passphrase)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// encryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
 | 
			
		||||
func encryptWithPassphrase(data []byte, passphrase string) ([]byte, error) {
 | 
			
		||||
	recipient, err := age.NewScryptRecipient(passphrase)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return encryptToRecipient(data, recipient)
 | 
			
		||||
	return EncryptToRecipient(data, recipient)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// decryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
 | 
			
		||||
func decryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) {
 | 
			
		||||
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
 | 
			
		||||
func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) {
 | 
			
		||||
	identity, err := age.NewScryptIdentity(passphrase)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return decryptWithIdentity(encryptedData, identity)
 | 
			
		||||
	return DecryptWithIdentity(encryptedData, identity)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// readPassphrase reads a passphrase securely from the terminal without echoing
 | 
			
		||||
// ReadPassphrase reads a passphrase securely from the terminal without echoing
 | 
			
		||||
// This version is for unlocking and doesn't require confirmation
 | 
			
		||||
func readPassphrase(prompt string) (string, error) {
 | 
			
		||||
func ReadPassphrase(prompt string) (string, error) {
 | 
			
		||||
	// Check if stdin is a terminal
 | 
			
		||||
	if !term.IsTerminal(int(syscall.Stdin)) {
 | 
			
		||||
		// Not a terminal - never read passphrases from piped input for security reasons
 | 
			
		||||
 | 
			
		||||
@ -91,7 +91,7 @@ func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) {
 | 
			
		||||
 | 
			
		||||
	// Step 5: Decrypt the age private key using the passphrase from keychain
 | 
			
		||||
	Debug("Decrypting age private key with keychain passphrase", "key_id", k.GetID())
 | 
			
		||||
	agePrivKeyData, err := decryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
 | 
			
		||||
	agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "key_id", k.GetID())
 | 
			
		||||
		return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
 | 
			
		||||
@ -240,7 +240,7 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	unlockKeyDir := filepath.Join(vaultDir, "unlock.d", keychainItemName)
 | 
			
		||||
	if err := fs.MkdirAll(unlockKeyDir, 0700); err != nil {
 | 
			
		||||
	if err := fs.MkdirAll(unlockKeyDir, DirPerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -259,19 +259,19 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
 | 
			
		||||
	// Step 3: Store age public key as plaintext
 | 
			
		||||
	agePublicKeyString := ageIdentity.Recipient().String()
 | 
			
		||||
	agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
 | 
			
		||||
	if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write age public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 4: Encrypt age private key with the generated passphrase and store on disk
 | 
			
		||||
	agePrivateKeyBytes := []byte(ageIdentity.String())
 | 
			
		||||
	encryptedAgePrivKey, err := encryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase)
 | 
			
		||||
	encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age")
 | 
			
		||||
	if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -328,21 +328,21 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Decrypt long-term private key using current unlock key
 | 
			
		||||
		ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
 | 
			
		||||
		ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 6: Encrypt long-term private key to the new age unlock key
 | 
			
		||||
	encryptedLtPrivKeyToAge, err := encryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
 | 
			
		||||
	encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write encrypted long-term private key
 | 
			
		||||
	ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
 | 
			
		||||
	if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -383,7 +383,7 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
 | 
			
		||||
		return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										193
									
								
								internal/secret/passphrase_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								internal/secret/passphrase_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,193 @@
 | 
			
		||||
package secret_test
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"filippo.io/age"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
 | 
			
		||||
	// Skip this test if CI=true is set, as it uses real filesystem
 | 
			
		||||
	if os.Getenv("CI") == "true" {
 | 
			
		||||
		t.Skip("Skipping test with real filesystem in CI environment")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create a temporary directory for our tests
 | 
			
		||||
	tempDir, err := os.MkdirTemp("", "secret-passphrase-test-")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to create temp dir: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer os.RemoveAll(tempDir) // Clean up after test
 | 
			
		||||
 | 
			
		||||
	// Use the real filesystem
 | 
			
		||||
	fs := afero.NewOsFs()
 | 
			
		||||
 | 
			
		||||
	// Test data
 | 
			
		||||
	testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
			
		||||
	testPassphrase := "test-passphrase-123"
 | 
			
		||||
 | 
			
		||||
	// Create the directory structure
 | 
			
		||||
	keyDir := filepath.Join(tempDir, "unlock-key")
 | 
			
		||||
	if err := os.MkdirAll(keyDir, secret.DirPerms); err != nil {
 | 
			
		||||
		t.Fatalf("Failed to create key directory: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set up test metadata
 | 
			
		||||
	metadata := secret.UnlockKeyMetadata{
 | 
			
		||||
		ID:        "test-passphrase",
 | 
			
		||||
		Type:      "passphrase",
 | 
			
		||||
		CreatedAt: time.Now(),
 | 
			
		||||
		Flags:     []string{},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create passphrase unlock key
 | 
			
		||||
	unlockKey := secret.NewPassphraseUnlockKey(fs, keyDir, metadata)
 | 
			
		||||
 | 
			
		||||
	// Generate a test age identity
 | 
			
		||||
	ageIdentity, err := age.GenerateX25519Identity()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to generate age identity: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	agePrivateKey := ageIdentity.String()
 | 
			
		||||
	agePublicKey := ageIdentity.Recipient().String()
 | 
			
		||||
 | 
			
		||||
	// Test writing public key
 | 
			
		||||
	t.Run("WritePublicKey", func(t *testing.T) {
 | 
			
		||||
		pubKeyPath := filepath.Join(keyDir, "pub.age")
 | 
			
		||||
		if err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write public key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the file exists
 | 
			
		||||
		exists, err := afero.Exists(fs, pubKeyPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to check if public key exists: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		if !exists {
 | 
			
		||||
			t.Errorf("Public key file should exist at %s", pubKeyPath)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test encrypting private key with passphrase
 | 
			
		||||
	t.Run("EncryptPrivateKey", func(t *testing.T) {
 | 
			
		||||
		privKeyData := []byte(agePrivateKey)
 | 
			
		||||
		encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, testPassphrase)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to encrypt private key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		privKeyPath := filepath.Join(keyDir, "priv.age")
 | 
			
		||||
		if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write encrypted private key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the file exists
 | 
			
		||||
		exists, err := afero.Exists(fs, privKeyPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to check if private key exists: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		if !exists {
 | 
			
		||||
			t.Errorf("Encrypted private key file should exist at %s", privKeyPath)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test writing long-term key
 | 
			
		||||
	t.Run("WriteLongTermKey", func(t *testing.T) {
 | 
			
		||||
		// Derive a long-term identity from the test mnemonic
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term identity: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Encrypt long-term private key to the unlock key's recipient
 | 
			
		||||
		recipient, err := age.ParseX25519Recipient(agePublicKey)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to parse recipient: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ltPrivKeyData := []byte(ltIdentity.String())
 | 
			
		||||
		encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, recipient)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to encrypt long-term private key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ltPrivKeyPath := filepath.Join(keyDir, "longterm.age")
 | 
			
		||||
		if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write encrypted long-term private key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the file exists
 | 
			
		||||
		exists, err := afero.Exists(fs, ltPrivKeyPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to check if long-term key exists: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		if !exists {
 | 
			
		||||
			t.Errorf("Encrypted long-term key file should exist at %s", ltPrivKeyPath)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Save original environment variables and set test ones
 | 
			
		||||
	oldPassphrase := os.Getenv(secret.EnvUnlockPassphrase)
 | 
			
		||||
	os.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
 | 
			
		||||
 | 
			
		||||
	// Clean up after test
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if oldPassphrase != "" {
 | 
			
		||||
			os.Setenv(secret.EnvUnlockPassphrase, oldPassphrase)
 | 
			
		||||
		} else {
 | 
			
		||||
			os.Unsetenv(secret.EnvUnlockPassphrase)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Test getting identity from environment variable
 | 
			
		||||
	t.Run("GetIdentityFromEnv", func(t *testing.T) {
 | 
			
		||||
		identity, err := unlockKey.GetIdentity()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get identity from env: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the identity matches what we expect
 | 
			
		||||
		expectedPubKey := ageIdentity.Recipient().String()
 | 
			
		||||
		actualPubKey := identity.Recipient().String()
 | 
			
		||||
		if actualPubKey != expectedPubKey {
 | 
			
		||||
			t.Errorf("Public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Unset the environment variable to test interactive prompt
 | 
			
		||||
	os.Unsetenv(secret.EnvUnlockPassphrase)
 | 
			
		||||
 | 
			
		||||
	// Test getting identity from prompt (this would require mocking the prompt)
 | 
			
		||||
	// For real integration tests, we'd need to provide a way to mock the passphrase input
 | 
			
		||||
	// Here we'll just verify the error is what we expect when no passphrase is available
 | 
			
		||||
	t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
 | 
			
		||||
		// This should fail since we're not in an interactive terminal
 | 
			
		||||
		_, err := unlockKey.GetIdentity()
 | 
			
		||||
		if err == nil {
 | 
			
		||||
			t.Errorf("Should have failed to get identity without passphrase env var")
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test removing the unlock key
 | 
			
		||||
	t.Run("RemoveUnlockKey", func(t *testing.T) {
 | 
			
		||||
		err := unlockKey.Remove()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to remove unlock key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the directory is gone
 | 
			
		||||
		exists, err := afero.DirExists(fs, keyDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to check if key directory exists: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		if exists {
 | 
			
		||||
			t.Errorf("Key directory should not exist after removal")
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
@ -15,6 +15,7 @@ type PassphraseUnlockKey struct {
 | 
			
		||||
	Directory  string
 | 
			
		||||
	Metadata   UnlockKeyMetadata
 | 
			
		||||
	fs         afero.Fs
 | 
			
		||||
	Passphrase string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetIdentity implements UnlockKey interface for passphrase-based unlock keys
 | 
			
		||||
@ -24,6 +25,28 @@ func (p *PassphraseUnlockKey) GetIdentity() (*age.X25519Identity, error) {
 | 
			
		||||
		slog.String("key_type", p.GetType()),
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	// First check if we already have the passphrase
 | 
			
		||||
	passphraseStr := p.Passphrase
 | 
			
		||||
	if passphraseStr == "" {
 | 
			
		||||
		Debug("No passphrase in memory, checking environment")
 | 
			
		||||
		// Check environment variable for passphrase
 | 
			
		||||
		passphraseStr = os.Getenv(EnvUnlockPassphrase)
 | 
			
		||||
		if passphraseStr == "" {
 | 
			
		||||
			Debug("No passphrase in environment, prompting user")
 | 
			
		||||
			// Prompt for passphrase
 | 
			
		||||
			var err error
 | 
			
		||||
			passphraseStr, err = ReadPassphrase("Enter unlock passphrase: ")
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				Debug("Failed to read passphrase", "error", err, "key_id", p.GetID())
 | 
			
		||||
				return nil, fmt.Errorf("failed to read passphrase: %w", err)
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			Debug("Using passphrase from environment", "key_id", p.GetID())
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		Debug("Using in-memory passphrase", "key_id", p.GetID())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Read encrypted private key of unlock key
 | 
			
		||||
	unlockKeyPrivPath := filepath.Join(p.Directory, "priv.age")
 | 
			
		||||
	Debug("Reading encrypted passphrase unlock key", "path", unlockKeyPrivPath)
 | 
			
		||||
@ -39,25 +62,10 @@ func (p *PassphraseUnlockKey) GetIdentity() (*age.X25519Identity, error) {
 | 
			
		||||
		slog.Int("encrypted_length", len(encryptedPrivKeyData)),
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	// Get passphrase for decrypting the unlock key
 | 
			
		||||
	var passphraseStr string
 | 
			
		||||
	if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" {
 | 
			
		||||
		Debug("Using passphrase from environment variable", "key_id", p.GetID())
 | 
			
		||||
		passphraseStr = envPassphrase
 | 
			
		||||
	} else {
 | 
			
		||||
		Debug("Prompting for passphrase", "key_id", p.GetID())
 | 
			
		||||
		// Prompt for passphrase
 | 
			
		||||
		passphraseStr, err = readPassphrase("Enter passphrase to unlock vault: ")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			Debug("Failed to read passphrase", "error", err, "key_id", p.GetID())
 | 
			
		||||
			return nil, fmt.Errorf("failed to read passphrase: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	Debug("Decrypting unlock key private key with passphrase", "key_id", p.GetID())
 | 
			
		||||
 | 
			
		||||
	// Decrypt the unlock key private key with passphrase
 | 
			
		||||
	privKeyData, err := decryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
 | 
			
		||||
	privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		Debug("Failed to decrypt unlock key private key", "error", err, "key_id", p.GetID())
 | 
			
		||||
		return nil, fmt.Errorf("failed to decrypt unlock key private key: %w", err)
 | 
			
		||||
 | 
			
		||||
@ -188,7 +188,7 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	unlockKeyDir := filepath.Join(vaultDir, "unlock.d", unlockKeyName)
 | 
			
		||||
	if err := fs.MkdirAll(unlockKeyDir, 0700); err != nil {
 | 
			
		||||
	if err := fs.MkdirAll(unlockKeyDir, DirPerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -201,7 +201,7 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
 | 
			
		||||
	// Step 2: Store age public key as plaintext
 | 
			
		||||
	agePublicKeyString := ageIdentity.Recipient().String()
 | 
			
		||||
	agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
 | 
			
		||||
	if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write age public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -250,26 +250,26 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
 | 
			
		||||
			return nil, fmt.Errorf("unsupported current unlock key type for PGP unlock key creation")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Decrypt long-term private key using current unlock key
 | 
			
		||||
		ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
 | 
			
		||||
		// Step 6: Decrypt long-term private key using current unlock key
 | 
			
		||||
		ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 4: Encrypt long-term private key to the new age unlock key
 | 
			
		||||
	encryptedLtPrivKeyToAge, err := encryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
 | 
			
		||||
	// Step 7: Encrypt long-term private key to the new age unlock key
 | 
			
		||||
	encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write encrypted long-term private key
 | 
			
		||||
	ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
 | 
			
		||||
	if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 5: Encrypt age private key to the GPG key ID
 | 
			
		||||
	// Step 8: Encrypt age private key to the GPG key ID
 | 
			
		||||
	agePrivateKeyBytes := []byte(ageIdentity.String())
 | 
			
		||||
	encryptedAgePrivKey, err := gpgEncrypt(agePrivateKeyBytes, gpgKeyID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@ -277,11 +277,11 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age.gpg")
 | 
			
		||||
	if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 6: Create and write enhanced metadata
 | 
			
		||||
	// Step 9: Create and write enhanced metadata
 | 
			
		||||
	// Generate the key ID directly using the GPG key ID
 | 
			
		||||
	keyID := fmt.Sprintf("%s-pgp", gpgKeyID)
 | 
			
		||||
 | 
			
		||||
@ -302,7 +302,7 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
 | 
			
		||||
		return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										226
									
								
								internal/secret/pgpunlock_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								internal/secret/pgpunlock_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,226 @@
 | 
			
		||||
package secret_test
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestPGPUnlockKeyWithRealFS(t *testing.T) {
 | 
			
		||||
	// Skip tests if gpg is not available
 | 
			
		||||
	if _, err := exec.LookPath("gpg"); err != nil {
 | 
			
		||||
		t.Skip("GPG not available, skipping PGP unlock key tests")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create a temporary directory for our tests
 | 
			
		||||
	tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to create temp dir: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer os.RemoveAll(tempDir) // Clean up after test
 | 
			
		||||
 | 
			
		||||
	// Create a temporary GNUPGHOME
 | 
			
		||||
	gnupgHomeDir := filepath.Join(tempDir, "gnupg")
 | 
			
		||||
	if err := os.MkdirAll(gnupgHomeDir, 0700); err != nil {
 | 
			
		||||
		t.Fatalf("Failed to create GNUPGHOME: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Save original GNUPGHOME
 | 
			
		||||
	origGnupgHome := os.Getenv("GNUPGHOME")
 | 
			
		||||
 | 
			
		||||
	// Set new GNUPGHOME
 | 
			
		||||
	os.Setenv("GNUPGHOME", gnupgHomeDir)
 | 
			
		||||
 | 
			
		||||
	// Clean up environment after test
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if origGnupgHome != "" {
 | 
			
		||||
			os.Setenv("GNUPGHOME", origGnupgHome)
 | 
			
		||||
		} else {
 | 
			
		||||
			os.Unsetenv("GNUPGHOME")
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Create GPG batch file for key generation
 | 
			
		||||
	batchFile := filepath.Join(tempDir, "gen-key-batch")
 | 
			
		||||
	batchContent := `%echo Generating a test key
 | 
			
		||||
Key-Type: RSA
 | 
			
		||||
Key-Length: 2048
 | 
			
		||||
Name-Real: Test User
 | 
			
		||||
Name-Email: test@example.com
 | 
			
		||||
Expire-Date: 0
 | 
			
		||||
Passphrase: test123
 | 
			
		||||
%commit
 | 
			
		||||
%echo Key generation completed
 | 
			
		||||
`
 | 
			
		||||
	if err := os.WriteFile(batchFile, []byte(batchContent), 0600); err != nil {
 | 
			
		||||
		t.Fatalf("Failed to write batch file: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Generate GPG key
 | 
			
		||||
	t.Log("Generating GPG key...")
 | 
			
		||||
	cmd := exec.Command("gpg", "--batch", "--gen-key", batchFile)
 | 
			
		||||
	output, err := cmd.CombinedOutput()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to generate GPG key: %v\nOutput: %s", err, output)
 | 
			
		||||
	}
 | 
			
		||||
	t.Log("GPG key generated successfully")
 | 
			
		||||
 | 
			
		||||
	// Get the key ID
 | 
			
		||||
	cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
 | 
			
		||||
	output, err = cmd.CombinedOutput()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to list GPG keys: %v\nOutput: %s", err, output)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse output to get key ID
 | 
			
		||||
	var keyID string
 | 
			
		||||
	lines := strings.Split(string(output), "\n")
 | 
			
		||||
	for _, line := range lines {
 | 
			
		||||
		if strings.HasPrefix(line, "sec:") {
 | 
			
		||||
			fields := strings.Split(line, ":")
 | 
			
		||||
			if len(fields) >= 5 {
 | 
			
		||||
				keyID = fields[4]
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if keyID == "" {
 | 
			
		||||
		t.Fatalf("Failed to find GPG key ID in output: %s", output)
 | 
			
		||||
	}
 | 
			
		||||
	t.Logf("Generated GPG key ID: %s", keyID)
 | 
			
		||||
 | 
			
		||||
	// Export GNUPGHOME variable to ensure subprocesses inherit it
 | 
			
		||||
	err = os.Setenv("GNUPGHOME", gnupgHomeDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to set GNUPGHOME environment variable: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Use the real filesystem
 | 
			
		||||
	fs := afero.NewOsFs()
 | 
			
		||||
 | 
			
		||||
	// Test data
 | 
			
		||||
	testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
			
		||||
 | 
			
		||||
	// Save original environment variable
 | 
			
		||||
	oldMnemonic := os.Getenv(secret.EnvMnemonic)
 | 
			
		||||
	oldGPGKeyID := os.Getenv(secret.EnvGPGKeyID)
 | 
			
		||||
 | 
			
		||||
	// Set test environment variables
 | 
			
		||||
	os.Setenv(secret.EnvMnemonic, testMnemonic)
 | 
			
		||||
	os.Setenv(secret.EnvGPGKeyID, keyID)
 | 
			
		||||
 | 
			
		||||
	// Clean up after test
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if oldMnemonic != "" {
 | 
			
		||||
			os.Setenv(secret.EnvMnemonic, oldMnemonic)
 | 
			
		||||
		} else {
 | 
			
		||||
			os.Unsetenv(secret.EnvMnemonic)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if oldGPGKeyID != "" {
 | 
			
		||||
			os.Setenv(secret.EnvGPGKeyID, oldGPGKeyID)
 | 
			
		||||
		} else {
 | 
			
		||||
			os.Unsetenv(secret.EnvGPGKeyID)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Create the directory structure for test
 | 
			
		||||
	keyDir := filepath.Join(tempDir, "unlock-key")
 | 
			
		||||
	if err := os.MkdirAll(keyDir, secret.DirPerms); err != nil {
 | 
			
		||||
		t.Fatalf("Failed to create key directory: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set up test metadata
 | 
			
		||||
	metadata := secret.UnlockKeyMetadata{
 | 
			
		||||
		ID:        fmt.Sprintf("%s-pgp", keyID),
 | 
			
		||||
		Type:      "pgp",
 | 
			
		||||
		CreatedAt: time.Now(),
 | 
			
		||||
		Flags:     []string{"gpg", "encrypted"},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// We'll skip the CreatePGPUnlockKey test since it requires registered vault functions
 | 
			
		||||
	t.Run("CreatePGPUnlockKey", func(t *testing.T) {
 | 
			
		||||
		t.Skip("Skipping test that requires registered vault functions")
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Create a PGP unlock key for the remaining tests
 | 
			
		||||
	unlockKey := secret.NewPGPUnlockKey(fs, keyDir, metadata)
 | 
			
		||||
 | 
			
		||||
	// Test getting GPG key ID
 | 
			
		||||
	t.Run("GetGPGKeyID", func(t *testing.T) {
 | 
			
		||||
		// Create PGP metadata with GPG key ID
 | 
			
		||||
		type PGPUnlockKeyMetadata struct {
 | 
			
		||||
			secret.UnlockKeyMetadata
 | 
			
		||||
			GPGKeyID string `json:"gpg_key_id"`
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		pgpMetadata := PGPUnlockKeyMetadata{
 | 
			
		||||
			UnlockKeyMetadata: metadata,
 | 
			
		||||
			GPGKeyID:          keyID,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Write metadata file
 | 
			
		||||
		metadataPath := filepath.Join(keyDir, "unlock-metadata.json")
 | 
			
		||||
		metadataBytes, err := json.MarshalIndent(pgpMetadata, "", "  ")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to marshal metadata: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write metadata: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get GPG key ID
 | 
			
		||||
		retrievedKeyID, err := unlockKey.GetGPGKeyID()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get GPG key ID: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify key ID
 | 
			
		||||
		if retrievedKeyID != keyID {
 | 
			
		||||
			t.Errorf("Expected GPG key ID '%s', got '%s'", keyID, retrievedKeyID)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test getting identity from PGP unlock key
 | 
			
		||||
	t.Run("GetIdentity", func(t *testing.T) {
 | 
			
		||||
		// For this test, we'll do a simplified version since GPG operations
 | 
			
		||||
		// can be tricky in automated tests
 | 
			
		||||
		t.Skip("Skipping GetIdentity test due to complex GPG operations in automated testing")
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test removing the unlock key
 | 
			
		||||
	t.Run("RemoveUnlockKey", func(t *testing.T) {
 | 
			
		||||
		// Ensure key directory exists before removal
 | 
			
		||||
		keyExists, err := afero.DirExists(fs, keyDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to check if key directory exists: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		if !keyExists {
 | 
			
		||||
			t.Fatalf("Key directory does not exist: %s", keyDir)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Remove unlock key
 | 
			
		||||
		err = unlockKey.Remove()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to remove unlock key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify directory is gone
 | 
			
		||||
		keyExists, err = afero.DirExists(fs, keyDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to check if key directory exists: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		if keyExists {
 | 
			
		||||
			t.Errorf("Key directory still exists after removal: %s", keyDir)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
@ -149,9 +149,9 @@ func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) {
 | 
			
		||||
		return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Decrypt the vault's long-term private key using the unlock key
 | 
			
		||||
	Debug("Decrypting vault's long-term private key with unlock key", "secret_name", s.Name)
 | 
			
		||||
	ltPrivKeyData, err := decryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
 | 
			
		||||
	// Decrypt the encrypted long-term private key using the unlock key
 | 
			
		||||
	Debug("Decrypting long-term private key using unlock key", "secret_name", s.Name)
 | 
			
		||||
	ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)
 | 
			
		||||
		return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
 | 
			
		||||
@ -198,11 +198,11 @@ func (s *Secret) decryptWithLongTermKey(ltIdentity *age.X25519Identity) ([]byte,
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	// Step 2: Decrypt the secret's private key using the vault's long-term private key
 | 
			
		||||
	Debug("Decrypting secret's private key with vault's long-term key", "secret_name", s.Name)
 | 
			
		||||
	secretPrivKeyData, err := decryptWithIdentity(encryptedSecretPrivKey, ltIdentity)
 | 
			
		||||
	Debug("Decrypting secret private key using long-term key", "secret_name", s.Name)
 | 
			
		||||
	secretPrivKeyData, err := DecryptWithIdentity(encryptedSecretPrivKey, ltIdentity)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		Debug("Failed to decrypt secret's private key", "error", err, "secret_name", s.Name)
 | 
			
		||||
		return nil, fmt.Errorf("failed to decrypt secret's private key: %w", err)
 | 
			
		||||
		Debug("Failed to decrypt secret private key", "error", err, "secret_name", s.Name)
 | 
			
		||||
		return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse the secret's private key
 | 
			
		||||
@ -234,8 +234,8 @@ func (s *Secret) decryptWithLongTermKey(ltIdentity *age.X25519Identity) ([]byte,
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	// Step 4: Decrypt the secret's value using the secret's private key
 | 
			
		||||
	Debug("Decrypting secret value with secret's private key", "secret_name", s.Name)
 | 
			
		||||
	decryptedValue, err := decryptWithIdentity(encryptedValue, secretIdentity)
 | 
			
		||||
	Debug("Decrypting value using secret key", "secret_name", s.Name)
 | 
			
		||||
	decryptedValue, err := DecryptWithIdentity(encryptedValue, secretIdentity)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		Debug("Failed to decrypt secret value", "error", err, "secret_name", s.Name)
 | 
			
		||||
		return nil, fmt.Errorf("failed to decrypt secret value: %w", err)
 | 
			
		||||
 | 
			
		||||
@ -25,10 +25,10 @@ func (m *MockVault) GetDirectory() (string, error) {
 | 
			
		||||
func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
 | 
			
		||||
	// Simplified implementation for testing
 | 
			
		||||
	secretDir := filepath.Join(m.directory, "secrets.d", name)
 | 
			
		||||
	if err := m.fs.MkdirAll(secretDir, 0700); err != nil {
 | 
			
		||||
	if err := m.fs.MkdirAll(secretDir, DirPerms); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return afero.WriteFile(m.fs, filepath.Join(secretDir, "value.age"), value, 0600)
 | 
			
		||||
	return afero.WriteFile(m.fs, filepath.Join(secretDir, "value.age"), value, FilePerms)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *MockVault) GetName() string {
 | 
			
		||||
@ -70,7 +70,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
 | 
			
		||||
	vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
 | 
			
		||||
 | 
			
		||||
	// Create vault directory structure
 | 
			
		||||
	err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), 0700)
 | 
			
		||||
	err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to create vault directory: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
@ -95,7 +95,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	// Set current vault
 | 
			
		||||
	currentVaultPath := filepath.Join(baseDir, "currentvault")
 | 
			
		||||
	err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), 0600)
 | 
			
		||||
	err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to set current vault: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										425
									
								
								internal/vault/integration_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										425
									
								
								internal/vault/integration_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,425 @@
 | 
			
		||||
package vault_test
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/vault"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestVaultWithRealFilesystem(t *testing.T) {
 | 
			
		||||
	// Create a temporary directory for our tests
 | 
			
		||||
	tempDir, err := os.MkdirTemp("", "secret-test-")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to create temp dir: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer os.RemoveAll(tempDir) // Clean up after test
 | 
			
		||||
 | 
			
		||||
	// Use the real filesystem
 | 
			
		||||
	fs := afero.NewOsFs()
 | 
			
		||||
 | 
			
		||||
	// Test mnemonic
 | 
			
		||||
	testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
			
		||||
 | 
			
		||||
	// Save original environment variables
 | 
			
		||||
	oldMnemonic := os.Getenv(secret.EnvMnemonic)
 | 
			
		||||
	oldPassphrase := os.Getenv(secret.EnvUnlockPassphrase)
 | 
			
		||||
 | 
			
		||||
	// Set test environment variables
 | 
			
		||||
	os.Setenv(secret.EnvMnemonic, testMnemonic)
 | 
			
		||||
	os.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
 | 
			
		||||
 | 
			
		||||
	// 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)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Test symlink handling
 | 
			
		||||
	t.Run("SymlinkHandling", func(t *testing.T) {
 | 
			
		||||
		stateDir := filepath.Join(tempDir, "symlink-test")
 | 
			
		||||
		if err := os.MkdirAll(stateDir, 0700); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create state dir: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create a test vault
 | 
			
		||||
		vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create vault: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get the vault directory
 | 
			
		||||
		vaultDir, err := vlt.GetDirectory()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get vault directory: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create a symlink to the vault directory in a different location
 | 
			
		||||
		symlinkPath := filepath.Join(tempDir, "test-symlink")
 | 
			
		||||
		if err := os.Symlink(vaultDir, symlinkPath); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create symlink: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Test that we can resolve the symlink correctly
 | 
			
		||||
		resolvedPath, err := vault.ResolveVaultSymlink(fs, symlinkPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to resolve symlink: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// On some platforms, the resolved path might have different case or format
 | 
			
		||||
		// We'll use filepath.EvalSymlinks to get the canonical path for comparison
 | 
			
		||||
		expectedPath, err := filepath.EvalSymlinks(vaultDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to evaluate symlink: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		actualPath, err := filepath.EvalSymlinks(resolvedPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to evaluate resolved path: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if actualPath != expectedPath {
 | 
			
		||||
			t.Errorf("Expected symlink to resolve to %s, got %s", expectedPath, actualPath)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test secret operations with deeply nested paths
 | 
			
		||||
	t.Run("DeepPathSecrets", func(t *testing.T) {
 | 
			
		||||
		stateDir := filepath.Join(tempDir, "deep-path-test")
 | 
			
		||||
		if err := os.MkdirAll(stateDir, 0700); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create state dir: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create a test vault
 | 
			
		||||
		vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create vault: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Derive long-term key from mnemonic
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get the vault directory
 | 
			
		||||
		vaultDir, err := vlt.GetDirectory()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get vault directory: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Write long-term public key
 | 
			
		||||
		ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
 | 
			
		||||
		pubKey := ltIdentity.Recipient().String()
 | 
			
		||||
		if err := afero.WriteFile(fs, ltPubKeyPath, []byte(pubKey), secret.FilePerms); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write long-term public key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Unlock the vault
 | 
			
		||||
		vlt.Unlock(ltIdentity)
 | 
			
		||||
 | 
			
		||||
		// Create a secret with a deeply nested path
 | 
			
		||||
		deepPath := "api/credentials/production/database/primary"
 | 
			
		||||
		secretValue := []byte("supersecretdbpassword")
 | 
			
		||||
 | 
			
		||||
		err = vlt.AddSecret(deepPath, secretValue, false)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to add secret with deep path: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// List secrets and verify our deep path secret is there
 | 
			
		||||
		secrets, err := vlt.ListSecrets()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to list secrets: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		found := false
 | 
			
		||||
		for _, s := range secrets {
 | 
			
		||||
			if s == deepPath {
 | 
			
		||||
				found = true
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !found {
 | 
			
		||||
			t.Errorf("Deep path secret not found in listed secrets")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Retrieve the secret and verify its value
 | 
			
		||||
		retrievedValue, err := vlt.GetSecret(deepPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to retrieve deep path secret: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if string(retrievedValue) != string(secretValue) {
 | 
			
		||||
			t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
 | 
			
		||||
				string(secretValue), string(retrievedValue))
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test key caching in GetOrDeriveLongTermKey
 | 
			
		||||
	t.Run("KeyCaching", func(t *testing.T) {
 | 
			
		||||
		stateDir := filepath.Join(tempDir, "key-cache-test")
 | 
			
		||||
		if err := os.MkdirAll(stateDir, 0700); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create state dir: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create a test vault
 | 
			
		||||
		vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create vault: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Derive long-term key from mnemonic
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get the vault directory
 | 
			
		||||
		vaultDir, err := vlt.GetDirectory()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get vault directory: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Write long-term public key
 | 
			
		||||
		ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
 | 
			
		||||
		pubKey := ltIdentity.Recipient().String()
 | 
			
		||||
		if err := afero.WriteFile(fs, ltPubKeyPath, []byte(pubKey), secret.FilePerms); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write long-term public key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the vault is locked initially
 | 
			
		||||
		if !vlt.Locked() {
 | 
			
		||||
			t.Errorf("Vault should be locked initially")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// First call to GetOrDeriveLongTermKey should derive and cache the key
 | 
			
		||||
		firstKey, err := vlt.GetOrDeriveLongTermKey()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get long-term key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the vault is now unlocked
 | 
			
		||||
		if vlt.Locked() {
 | 
			
		||||
			t.Errorf("Vault should be unlocked after GetOrDeriveLongTermKey")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Second call should return the cached key without re-deriving
 | 
			
		||||
		secondKey, err := vlt.GetOrDeriveLongTermKey()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get cached long-term key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify both keys are the same instance
 | 
			
		||||
		if firstKey != secondKey {
 | 
			
		||||
			t.Errorf("Second key call should return same instance as first call")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the public key matches what we expect
 | 
			
		||||
		expectedPubKey := ltIdentity.Recipient().String()
 | 
			
		||||
		actualPubKey := firstKey.Recipient().String()
 | 
			
		||||
		if actualPubKey != expectedPubKey {
 | 
			
		||||
			t.Errorf("Public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Now clear the key and verify it's locked again
 | 
			
		||||
		vlt.ClearLongTermKey()
 | 
			
		||||
		if !vlt.Locked() {
 | 
			
		||||
			t.Errorf("Vault should be locked after clearing key")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get the key again and verify it works
 | 
			
		||||
		thirdKey, err := vlt.GetOrDeriveLongTermKey()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to re-derive long-term key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the public key still matches
 | 
			
		||||
		actualPubKey = thirdKey.Recipient().String()
 | 
			
		||||
		if actualPubKey != expectedPubKey {
 | 
			
		||||
			t.Errorf("Re-derived public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test vault name validation
 | 
			
		||||
	t.Run("VaultNameValidation", func(t *testing.T) {
 | 
			
		||||
		stateDir := filepath.Join(tempDir, "name-validation-test")
 | 
			
		||||
		if err := os.MkdirAll(stateDir, 0700); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create state dir: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Test valid vault names
 | 
			
		||||
		validNames := []string{
 | 
			
		||||
			"default",
 | 
			
		||||
			"test-vault",
 | 
			
		||||
			"production.vault",
 | 
			
		||||
			"vault_123",
 | 
			
		||||
			"a-very-long-vault-name-with-dashes",
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for _, name := range validNames {
 | 
			
		||||
			_, err := vault.CreateVault(fs, stateDir, name)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Errorf("Failed to create vault with valid name %q: %v", name, err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Test invalid vault names
 | 
			
		||||
		invalidNames := []string{
 | 
			
		||||
			"",             // Empty
 | 
			
		||||
			"UPPERCASE",    // Uppercase not allowed
 | 
			
		||||
			"invalid/name", // Slashes not allowed in vault names
 | 
			
		||||
			"invalid name", // Spaces not allowed
 | 
			
		||||
			"invalid@name", // Special chars not allowed
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for _, name := range invalidNames {
 | 
			
		||||
			_, err := vault.CreateVault(fs, stateDir, name)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				t.Errorf("Expected error creating vault with invalid name %q, but got none", name)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test multiple vaults and switching between them
 | 
			
		||||
	t.Run("MultipleVaults", func(t *testing.T) {
 | 
			
		||||
		stateDir := filepath.Join(tempDir, "multi-vault-test")
 | 
			
		||||
		if err := os.MkdirAll(stateDir, 0700); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create state dir: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create three vaults
 | 
			
		||||
		vaultNames := []string{"vault1", "vault2", "vault3"}
 | 
			
		||||
		for _, name := range vaultNames {
 | 
			
		||||
			_, err := vault.CreateVault(fs, stateDir, name)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatalf("Failed to create vault %s: %v", name, err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// List vaults and verify all three are there
 | 
			
		||||
		vaults, err := vault.ListVaults(fs, stateDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to list vaults: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(vaults) != 3 {
 | 
			
		||||
			t.Errorf("Expected 3 vaults, got %d", len(vaults))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Test switching between vaults
 | 
			
		||||
		for _, name := range vaultNames {
 | 
			
		||||
			// Select the vault
 | 
			
		||||
			if err := vault.SelectVault(fs, stateDir, name); err != nil {
 | 
			
		||||
				t.Fatalf("Failed to select vault %s: %v", name, err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Get current vault and verify it's the one we selected
 | 
			
		||||
			currentVault, err := vault.GetCurrentVault(fs, stateDir)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatalf("Failed to get current vault after selecting %s: %v", name, err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if currentVault.GetName() != name {
 | 
			
		||||
				t.Errorf("Expected current vault to be %s, got %s", name, currentVault.GetName())
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test adding a secret in one vault and verifying it's not visible in another
 | 
			
		||||
	t.Run("VaultIsolation", func(t *testing.T) {
 | 
			
		||||
		stateDir := filepath.Join(tempDir, "isolation-test")
 | 
			
		||||
		if err := os.MkdirAll(stateDir, 0700); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create state dir: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create two vaults
 | 
			
		||||
		vault1, err := vault.CreateVault(fs, stateDir, "vault1")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create vault1: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		vault2, err := vault.CreateVault(fs, stateDir, "vault2")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create vault2: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Derive long-term key from mnemonic
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Setup both vaults with the same long-term key
 | 
			
		||||
		for _, vlt := range []*vault.Vault{vault1, vault2} {
 | 
			
		||||
			vaultDir, err := vlt.GetDirectory()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatalf("Failed to get vault directory: %v", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
 | 
			
		||||
			pubKey := ltIdentity.Recipient().String()
 | 
			
		||||
			if err := afero.WriteFile(fs, ltPubKeyPath, []byte(pubKey), secret.FilePerms); err != nil {
 | 
			
		||||
				t.Fatalf("Failed to write long-term public key: %v", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			vlt.Unlock(ltIdentity)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Add a secret to vault1
 | 
			
		||||
		secretName := "test-secret"
 | 
			
		||||
		secretValue := []byte("secret in vault1")
 | 
			
		||||
		if err := vault1.AddSecret(secretName, secretValue, false); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to add secret to vault1: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the secret exists in vault1
 | 
			
		||||
		vault1Secrets, err := vault1.ListSecrets()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to list secrets in vault1: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		found := false
 | 
			
		||||
		for _, s := range vault1Secrets {
 | 
			
		||||
			if s == secretName {
 | 
			
		||||
				found = true
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !found {
 | 
			
		||||
			t.Errorf("Secret not found in vault1")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the secret does NOT exist in vault2
 | 
			
		||||
		vault2Secrets, err := vault2.ListSecrets()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to list secrets in vault2: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		found = false
 | 
			
		||||
		for _, s := range vault2Secrets {
 | 
			
		||||
			if s == secretName {
 | 
			
		||||
				found = true
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if found {
 | 
			
		||||
			t.Errorf("Secret from vault1 should not be visible in vault2")
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
@ -27,115 +27,149 @@ func isValidVaultName(name string) bool {
 | 
			
		||||
	return matched
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// resolveVaultSymlink resolves the currentvault symlink by reading either the symlink target or file contents
 | 
			
		||||
// ResolveVaultSymlink resolves the currentvault symlink by reading either the symlink target or file contents
 | 
			
		||||
// This function is designed to work on both Unix and Windows systems, as well as with in-memory filesystems
 | 
			
		||||
func resolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
 | 
			
		||||
func ResolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
 | 
			
		||||
	secret.Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
 | 
			
		||||
 | 
			
		||||
	// First try to handle the path as a real symlink (works on Unix systems)
 | 
			
		||||
	if _, ok := fs.(*afero.OsFs); ok {
 | 
			
		||||
		secret.Debug("Trying real filesystem symlink resolution")
 | 
			
		||||
		secret.Debug("Using real filesystem symlink resolution")
 | 
			
		||||
 | 
			
		||||
		// Check if it's a real symlink first (will work on Unix)
 | 
			
		||||
		linkTarget, err := os.Readlink(symlinkPath)
 | 
			
		||||
		// Check if the symlink exists
 | 
			
		||||
		secret.Debug("Checking symlink target", "symlink_path", symlinkPath)
 | 
			
		||||
		target, err := os.Readlink(symlinkPath)
 | 
			
		||||
		if err == nil {
 | 
			
		||||
			// Successfully read as symlink (Unix path)
 | 
			
		||||
			secret.Debug("Successfully read as real symlink", "target", linkTarget)
 | 
			
		||||
			secret.Debug("Symlink points to", "target", target)
 | 
			
		||||
 | 
			
		||||
			// Convert relative paths to absolute if needed
 | 
			
		||||
			if !filepath.IsAbs(linkTarget) {
 | 
			
		||||
				linkTarget = filepath.Join(filepath.Dir(symlinkPath), linkTarget)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			secret.Debug("Resolved symlink path", "path", linkTarget)
 | 
			
		||||
			return linkTarget, nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		secret.Debug("Not a real symlink or on Windows, trying as regular file", "error", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// For Windows or in-memory filesystems, or when symlink reading fails:
 | 
			
		||||
	// Read the path from a regular file (our fallback approach)
 | 
			
		||||
	secret.Debug("Reading symlink target from file")
 | 
			
		||||
	content, err := afero.ReadFile(fs, symlinkPath)
 | 
			
		||||
			// On real filesystem, we need to handle relative symlinks
 | 
			
		||||
			// by resolving them relative to the symlink's directory
 | 
			
		||||
			if !filepath.IsAbs(target) {
 | 
			
		||||
				// Get the current directory before changing
 | 
			
		||||
				originalDir, err := os.Getwd()
 | 
			
		||||
				if err != nil {
 | 
			
		||||
		secret.Debug("Failed to read from file", "error", err)
 | 
			
		||||
		return "", fmt.Errorf("failed to read vault path: %w", err)
 | 
			
		||||
					return "", fmt.Errorf("failed to get current directory: %w", err)
 | 
			
		||||
				}
 | 
			
		||||
				secret.Debug("Got current directory", "original_dir", originalDir)
 | 
			
		||||
 | 
			
		||||
				// Change to the symlink's directory
 | 
			
		||||
				symlinkDir := filepath.Dir(symlinkPath)
 | 
			
		||||
				secret.Debug("Changing to symlink directory", "symlink_path", symlinkDir)
 | 
			
		||||
				secret.Debug("About to call os.Chdir - this might hang if symlink is broken")
 | 
			
		||||
				if err := os.Chdir(symlinkDir); err != nil {
 | 
			
		||||
					return "", fmt.Errorf("failed to change to symlink directory: %w", err)
 | 
			
		||||
				}
 | 
			
		||||
				secret.Debug("Changed to symlink directory successfully - os.Chdir completed")
 | 
			
		||||
 | 
			
		||||
				// Get the absolute path of the target
 | 
			
		||||
				secret.Debug("Getting absolute path of current directory")
 | 
			
		||||
				absolutePath, err := os.Getwd()
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					// Try to restore original directory before returning error
 | 
			
		||||
					_ = os.Chdir(originalDir)
 | 
			
		||||
					return "", fmt.Errorf("failed to get absolute path: %w", err)
 | 
			
		||||
				}
 | 
			
		||||
				secret.Debug("Got absolute path", "absolute_path", absolutePath)
 | 
			
		||||
 | 
			
		||||
				// Restore the original directory
 | 
			
		||||
				secret.Debug("Restoring original directory", "original_dir", originalDir)
 | 
			
		||||
				if err := os.Chdir(originalDir); err != nil {
 | 
			
		||||
					return "", fmt.Errorf("failed to restore original directory: %w", err)
 | 
			
		||||
				}
 | 
			
		||||
				secret.Debug("Restored original directory successfully")
 | 
			
		||||
 | 
			
		||||
				// Use the absolute path of the target
 | 
			
		||||
				target = absolutePath
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
	targetPath := string(content)
 | 
			
		||||
	secret.Debug("Read target path from file", "target_path", targetPath)
 | 
			
		||||
	return targetPath, nil
 | 
			
		||||
			secret.Debug("resolveVaultSymlink completed successfully", "result", target)
 | 
			
		||||
			return target, nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Fallback: treat it as a regular file containing the target path
 | 
			
		||||
	secret.Debug("Fallback: trying to read regular file with target path")
 | 
			
		||||
 | 
			
		||||
	fileData, err := afero.ReadFile(fs, symlinkPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to read target path file", "error", err)
 | 
			
		||||
		return "", fmt.Errorf("failed to read vault symlink: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	target := string(fileData)
 | 
			
		||||
	secret.Debug("Read target path from file", "target", target)
 | 
			
		||||
 | 
			
		||||
	secret.Debug("resolveVaultSymlink completed via fallback", "result", target)
 | 
			
		||||
	return target, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetCurrentVault gets the currently selected vault
 | 
			
		||||
// GetCurrentVault gets the current vault from the file system
 | 
			
		||||
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
 | 
			
		||||
	secret.Debug("Getting current vault", "state_dir", stateDir)
 | 
			
		||||
 | 
			
		||||
	// Check if current vault symlink exists
 | 
			
		||||
	// Check if the current vault symlink exists
 | 
			
		||||
	currentVaultPath := filepath.Join(stateDir, "currentvault")
 | 
			
		||||
	secret.Debug("Checking current vault symlink", "path", currentVaultPath)
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
	// Resolve the symlink to get the actual vault directory
 | 
			
		||||
	secret.Debug("Resolving vault symlink")
 | 
			
		||||
	targetPath, err := resolveVaultSymlink(fs, currentVaultPath)
 | 
			
		||||
	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)
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	secret.Debug("Resolved vault symlink", "target_path", targetPath)
 | 
			
		||||
 | 
			
		||||
	// Extract vault name from target path
 | 
			
		||||
	// Extract the vault name from the path
 | 
			
		||||
	// The path will be something like "/path/to/vaults.d/default"
 | 
			
		||||
	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
 | 
			
		||||
	// Create and return the vault
 | 
			
		||||
	return NewVault(fs, stateDir, vaultName), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ListVaults returns a list of available vault names
 | 
			
		||||
// ListVaults lists all vaults in the state directory
 | 
			
		||||
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)
 | 
			
		||||
		return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	if !exists {
 | 
			
		||||
		return []string{}, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Read directory contents
 | 
			
		||||
	files, err := afero.ReadDir(fs, vaultsDir)
 | 
			
		||||
	// Read the vaults directory
 | 
			
		||||
	entries, err := afero.ReadDir(fs, vaultsDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to read vaults directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Extract vault names
 | 
			
		||||
	var vaults []string
 | 
			
		||||
	for _, file := range files {
 | 
			
		||||
		if file.IsDir() {
 | 
			
		||||
			vaults = append(vaults, file.Name())
 | 
			
		||||
	for _, entry := range entries {
 | 
			
		||||
		if entry.IsDir() {
 | 
			
		||||
			vaults = append(vaults, entry.Name())
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return vaults, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CreateVault creates a new vault with the given name
 | 
			
		||||
// CreateVault creates a new vault
 | 
			
		||||
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
 | 
			
		||||
	secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
 | 
			
		||||
 | 
			
		||||
@ -150,39 +184,32 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
 | 
			
		||||
	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 {
 | 
			
		||||
	// Create main vault directory
 | 
			
		||||
	if err := fs.MkdirAll(vaultDir, secret.DirPerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create vault directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create subdirectories
 | 
			
		||||
	// Create secrets directory
 | 
			
		||||
	secretsDir := filepath.Join(vaultDir, "secrets.d")
 | 
			
		||||
	if err := fs.MkdirAll(secretsDir, 0700); err != nil {
 | 
			
		||||
	if err := fs.MkdirAll(secretsDir, secret.DirPerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create secrets directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create unlock keys directory
 | 
			
		||||
	unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
 | 
			
		||||
	if err := fs.MkdirAll(unlockKeysDir, 0700); err != nil {
 | 
			
		||||
	if err := fs.MkdirAll(unlockKeysDir, secret.DirPerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create unlock keys directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Select the new vault as current
 | 
			
		||||
	// Select the newly created 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)
 | 
			
		||||
		return nil, fmt.Errorf("failed to select vault: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create and return the vault
 | 
			
		||||
	secret.Debug("Successfully created vault", "name", name)
 | 
			
		||||
	return NewVault(fs, name, stateDir), nil
 | 
			
		||||
	return NewVault(fs, stateDir, name), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SelectVault selects the given vault as the current vault
 | 
			
		||||
@ -206,35 +233,32 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
 | 
			
		||||
		return fmt.Errorf("vault %s does not exist", name)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create/update current vault symlink
 | 
			
		||||
	// Create or update the current vault symlink/file
 | 
			
		||||
	currentVaultPath := filepath.Join(stateDir, "currentvault")
 | 
			
		||||
	targetPath := filepath.Join(stateDir, "vaults.d", name)
 | 
			
		||||
 | 
			
		||||
	// Remove existing symlink if it exists
 | 
			
		||||
	if exists, _ := afero.Exists(fs, currentVaultPath); exists {
 | 
			
		||||
	// First try to remove existing symlink if it exists
 | 
			
		||||
	if _, err := fs.Stat(currentVaultPath); err == nil {
 | 
			
		||||
		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)
 | 
			
		||||
		}
 | 
			
		||||
		// Ignore errors from Remove as we'll try to create/update it anyway.
 | 
			
		||||
		// On some systems, removing a symlink may fail but the subsequent create may still succeed.
 | 
			
		||||
		_ = fs.Remove(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
 | 
			
		||||
	// Try to create a real symlink first (works on Unix systems)
 | 
			
		||||
	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)
 | 
			
		||||
		secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath)
 | 
			
		||||
		if err := os.Symlink(targetPath, currentVaultPath); err == nil {
 | 
			
		||||
			secret.Debug("Successfully selected vault", "vault_name", name)
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
		// If symlink creation fails, fall back to regular file
 | 
			
		||||
	}
 | 
			
		||||
	} 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)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	// Fallback: create a regular file with the target path
 | 
			
		||||
	secret.Debug("Fallback: creating regular file with target path", "target", targetPath)
 | 
			
		||||
	if err := afero.WriteFile(fs, currentVaultPath, []byte(targetPath), secret.FilePerms); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to select vault: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	secret.Debug("Successfully selected vault", "vault_name", name)
 | 
			
		||||
 | 
			
		||||
@ -120,7 +120,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
 | 
			
		||||
 | 
			
		||||
	// Create secret directory
 | 
			
		||||
	secret.Debug("Creating secret directory", "secret_dir", secretDir)
 | 
			
		||||
	if err := v.fs.MkdirAll(secretDir, 0700); err != nil {
 | 
			
		||||
	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)
 | 
			
		||||
	}
 | 
			
		||||
@ -145,7 +145,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
 | 
			
		||||
	// 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 {
 | 
			
		||||
	if err := afero.WriteFile(v.fs, pubKeyPath, []byte(secretPublicKey), secret.FilePerms); 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)
 | 
			
		||||
	}
 | 
			
		||||
@ -167,7 +167,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
 | 
			
		||||
	// 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 {
 | 
			
		||||
	if err := afero.WriteFile(v.fs, valuePath, encryptedValue, secret.FilePerms); 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)
 | 
			
		||||
	}
 | 
			
		||||
@ -209,7 +209,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
 | 
			
		||||
	// 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 {
 | 
			
		||||
	if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); 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)
 | 
			
		||||
	}
 | 
			
		||||
@ -240,7 +240,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
 | 
			
		||||
 | 
			
		||||
	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 {
 | 
			
		||||
	if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
 | 
			
		||||
		secret.Debug("Failed to write secret metadata", "error", err, "path", metadataPath)
 | 
			
		||||
		return fmt.Errorf("failed to write secret metadata: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -37,7 +37,7 @@ func (v *Vault) GetCurrentUnlockKey() (secret.UnlockKey, error) {
 | 
			
		||||
	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)
 | 
			
		||||
		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)
 | 
			
		||||
@ -288,7 +288,7 @@ func (v *Vault) SelectUnlockKey(keyID string) error {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create new symlink
 | 
			
		||||
	return afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(targetKeyDir), 0600)
 | 
			
		||||
	return afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(targetKeyDir), secret.FilePerms)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CreatePassphraseKey creates a new passphrase-protected unlock key
 | 
			
		||||
@ -301,7 +301,7 @@ func (v *Vault) CreatePassphraseKey(passphrase string) (*secret.PassphraseUnlock
 | 
			
		||||
	// 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 {
 | 
			
		||||
	if err := v.fs.MkdirAll(unlockKeyDir, secret.DirPerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -313,7 +313,7 @@ func (v *Vault) CreatePassphraseKey(passphrase string) (*secret.PassphraseUnlock
 | 
			
		||||
 | 
			
		||||
	// Write public key
 | 
			
		||||
	pubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
 | 
			
		||||
	if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockIdentity.Recipient().String()), 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockIdentity.Recipient().String()), secret.FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write unlock key public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -326,7 +326,7 @@ func (v *Vault) CreatePassphraseKey(passphrase string) (*secret.PassphraseUnlock
 | 
			
		||||
 | 
			
		||||
	// Write encrypted private key
 | 
			
		||||
	privKeyPath := filepath.Join(unlockKeyDir, "priv.age")
 | 
			
		||||
	if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, 0600); err != nil {
 | 
			
		||||
	if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write encrypted unlock key private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -346,8 +346,8 @@ func (v *Vault) CreatePassphraseKey(passphrase string) (*secret.PassphraseUnlock
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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)
 | 
			
		||||
	if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Encrypt long-term private key to this unlock key if vault is unlocked
 | 
			
		||||
@ -359,7 +359,7 @@ func (v *Vault) CreatePassphraseKey(passphrase string) (*secret.PassphraseUnlock
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
 | 
			
		||||
		if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, 0600); err != nil {
 | 
			
		||||
		if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -21,13 +21,16 @@ type Vault struct {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewVault creates a new Vault instance
 | 
			
		||||
func NewVault(fs afero.Fs, name string, stateDir string) *Vault {
 | 
			
		||||
	return &Vault{
 | 
			
		||||
func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
 | 
			
		||||
	secret.Debug("Creating NewVault instance")
 | 
			
		||||
	v := &Vault{
 | 
			
		||||
		Name:        name,
 | 
			
		||||
		fs:          fs,
 | 
			
		||||
		stateDir:    stateDir,
 | 
			
		||||
		longTermKey: nil,
 | 
			
		||||
	}
 | 
			
		||||
	secret.Debug("Created NewVault instance successfully")
 | 
			
		||||
	return v
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Locked returns true if the vault doesn't have a long-term key in memory
 | 
			
		||||
 | 
			
		||||
@ -128,7 +128,7 @@ func TestVaultOperations(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
		// Write the correct public key to the pub.age file
 | 
			
		||||
		pubKeyPath := filepath.Join(vaultDir, "pub.age")
 | 
			
		||||
		err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), 0600)
 | 
			
		||||
		err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write long-term public key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user