Compare commits

...

3 Commits

17 changed files with 1066 additions and 210 deletions

View File

@ -6,7 +6,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
@ -15,7 +14,6 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"golang.org/x/term"
) )
func newInitCmd() *cobra.Command { func newInitCmd() *cobra.Command {
@ -38,7 +36,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
stateDir := cli.GetStateDir() stateDir := cli.GetStateDir()
secret.DebugWith("Creating state directory", slog.String("path", stateDir)) 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) secret.Debug("Failed to create state directory", "error", err)
return fmt.Errorf("failed to create state directory: %w", 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") vaultDir := filepath.Join(stateDir, "vaults.d", "default")
ltPubKey := ltIdentity.Recipient().String() ltPubKey := ltIdentity.Recipient().String()
secret.DebugWith("Storing long-term public key", slog.String("pubkey", ltPubKey), slog.String("vault_dir", vaultDir)) 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) secret.Debug("Failed to write long-term public key", "error", err)
return fmt.Errorf("failed to write long-term public key: %w", 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 // 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) 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 // 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) { func readSecurePassphrase(prompt string) (string, error) {
// Check if stdin is a terminal // Get the first passphrase
if !term.IsTerminal(int(syscall.Stdin)) { passphrase1, err := secret.ReadPassphrase(prompt)
// 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))
if err != nil { 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 // Read confirmation passphrase
fmt.Fprint(os.Stderr, "Confirm passphrase: ") // Write prompt to stderr, not stdout passphrase2, err := secret.ReadPassphrase("Confirm passphrase: ")
passphrase2, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read passphrase confirmation: %w", err) 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 // Compare passphrases
if string(passphrase1) != string(passphrase2) { if passphrase1 != passphrase2 {
return "", fmt.Errorf("passphrases do not match") return "", fmt.Errorf("passphrases do not match")
} }
if len(passphrase1) == 0 { return passphrase1, nil
return "", fmt.Errorf("passphrase cannot be empty")
}
return string(passphrase1), nil
} }

View File

@ -14,6 +14,10 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Import from init.go
// ... existing imports ...
func newKeysCmd() *cobra.Command { func newKeysCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "keys", Use: "keys",

View File

@ -1,5 +1,7 @@
package secret package secret
import "os"
const ( const (
// AppID is the unique identifier for this application // AppID is the unique identifier for this application
AppID = "berlin.sneak.pkg.secret" AppID = "berlin.sneak.pkg.secret"
@ -10,3 +12,12 @@ const (
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
EnvGPGKeyID = "SB_GPG_KEY_ID" 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
)

View File

@ -13,7 +13,7 @@ import (
// EncryptToRecipient encrypts data to a recipient using age // EncryptToRecipient encrypts data to a recipient using age
func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) { 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 var buf bytes.Buffer
Debug("Creating age encryptor") Debug("Creating age encryptor")
@ -39,22 +39,12 @@ func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
Debug("Closed encryptor successfully") Debug("Closed encryptor successfully")
result := buf.Bytes() result := buf.Bytes()
Debug("encryptToRecipient completed successfully", "result_length", len(result)) Debug("EncryptToRecipient completed successfully", "result_length", len(result))
return result, nil return result, nil
} }
// encryptToRecipient encrypts data to a recipient using age (internal version) // DecryptWithIdentity decrypts data with an identity using age
func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
return EncryptToRecipient(data, recipient)
}
// DecryptWithIdentity decrypts data with an identity using age (public version)
func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) { 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) r, err := age.Decrypt(bytes.NewReader(data), identity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create decryptor: %w", err) 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 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) { 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) recipient, err := age.NewScryptRecipient(passphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err) 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 // DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
func decryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) { func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) {
identity, err := age.NewScryptIdentity(passphrase) identity, err := age.NewScryptIdentity(passphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create scrypt identity: %w", err) 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 // 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 // Check if stdin is a terminal
if !term.IsTerminal(int(syscall.Stdin)) { if !term.IsTerminal(int(syscall.Stdin)) {
// Not a terminal - never read passphrases from piped input for security reasons // Not a terminal - never read passphrases from piped input for security reasons

View File

@ -91,7 +91,7 @@ func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) {
// Step 5: Decrypt the age private key using the passphrase from keychain // Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase", "key_id", k.GetID()) 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 { if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "key_id", k.GetID()) 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) 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) 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) 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 // Step 3: Store age public key as plaintext
agePublicKeyString := ageIdentity.Recipient().String() agePublicKeyString := ageIdentity.Recipient().String()
agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age") 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) 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 // Step 4: Encrypt age private key with the generated passphrase and store on disk
agePrivateKeyBytes := []byte(ageIdentity.String()) agePrivateKeyBytes := []byte(ageIdentity.String())
encryptedAgePrivKey, err := encryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase) encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err) return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
} }
agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age") 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) 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 // Decrypt long-term private key using current unlock key
ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity) ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) 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 // 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 { if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err) return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err)
} }
// Write encrypted long-term private key // Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age") 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) 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) 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) return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
} }

View 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")
}
})
}

View File

@ -12,9 +12,10 @@ import (
// PassphraseUnlockKey represents a passphrase-protected unlock key // PassphraseUnlockKey represents a passphrase-protected unlock key
type PassphraseUnlockKey struct { type PassphraseUnlockKey struct {
Directory string Directory string
Metadata UnlockKeyMetadata Metadata UnlockKeyMetadata
fs afero.Fs fs afero.Fs
Passphrase string
} }
// GetIdentity implements UnlockKey interface for passphrase-based unlock keys // 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()), 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 // Read encrypted private key of unlock key
unlockKeyPrivPath := filepath.Join(p.Directory, "priv.age") unlockKeyPrivPath := filepath.Join(p.Directory, "priv.age")
Debug("Reading encrypted passphrase unlock key", "path", unlockKeyPrivPath) 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)), 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()) Debug("Decrypting unlock key private key with passphrase", "key_id", p.GetID())
// Decrypt the unlock key private key with passphrase // Decrypt the unlock key private key with passphrase
privKeyData, err := decryptWithPassphrase(encryptedPrivKeyData, passphraseStr) privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
if err != nil { if err != nil {
Debug("Failed to decrypt unlock key private key", "error", err, "key_id", p.GetID()) 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) return nil, fmt.Errorf("failed to decrypt unlock key private key: %w", err)

View File

@ -188,7 +188,7 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
} }
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", unlockKeyName) 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) 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 // Step 2: Store age public key as plaintext
agePublicKeyString := ageIdentity.Recipient().String() agePublicKeyString := ageIdentity.Recipient().String()
agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age") 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) 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") return nil, fmt.Errorf("unsupported current unlock key type for PGP unlock key creation")
} }
// Decrypt long-term private key using current unlock key // Step 6: Decrypt long-term private key using current unlock key
ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity) ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) 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 // Step 7: 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 { if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err) return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err)
} }
// Write encrypted long-term private key // Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age") 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) 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()) agePrivateKeyBytes := []byte(ageIdentity.String())
encryptedAgePrivKey, err := gpgEncrypt(agePrivateKeyBytes, gpgKeyID) encryptedAgePrivKey, err := gpgEncrypt(agePrivateKeyBytes, gpgKeyID)
if err != nil { if err != nil {
@ -277,11 +277,11 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
} }
agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age.gpg") 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) 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 // Generate the key ID directly using the GPG key ID
keyID := fmt.Sprintf("%s-pgp", gpgKeyID) 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) 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) return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
} }

View 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)
}
})
}

View File

@ -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) 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 // Decrypt the encrypted long-term private key using the unlock key
Debug("Decrypting vault's long-term private key with unlock key", "secret_name", s.Name) Debug("Decrypting long-term private key using unlock key", "secret_name", s.Name)
ltPrivKeyData, err := decryptWithIdentity(encryptedLtPrivKey, unlockIdentity) ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil { if err != nil {
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name) 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) 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 // 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) Debug("Decrypting secret private key using long-term key", "secret_name", s.Name)
secretPrivKeyData, err := decryptWithIdentity(encryptedSecretPrivKey, ltIdentity) secretPrivKeyData, err := DecryptWithIdentity(encryptedSecretPrivKey, ltIdentity)
if err != nil { if err != nil {
Debug("Failed to decrypt secret's private key", "error", err, "secret_name", s.Name) Debug("Failed to decrypt secret private key", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to decrypt secret's private key: %w", err) return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
} }
// Parse the secret's private key // 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 // 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) Debug("Decrypting value using secret key", "secret_name", s.Name)
decryptedValue, err := decryptWithIdentity(encryptedValue, secretIdentity) decryptedValue, err := DecryptWithIdentity(encryptedValue, secretIdentity)
if err != nil { if err != nil {
Debug("Failed to decrypt secret value", "error", err, "secret_name", s.Name) Debug("Failed to decrypt secret value", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to decrypt secret value: %w", err) return nil, fmt.Errorf("failed to decrypt secret value: %w", err)

View File

@ -25,10 +25,10 @@ func (m *MockVault) GetDirectory() (string, error) {
func (m *MockVault) AddSecret(name string, value []byte, force bool) error { func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
// Simplified implementation for testing // Simplified implementation for testing
secretDir := filepath.Join(m.directory, "secrets.d", name) 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 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 { func (m *MockVault) GetName() string {
@ -70,7 +70,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault") vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
// Create vault directory structure // 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 { if err != nil {
t.Fatalf("Failed to create vault directory: %v", err) t.Fatalf("Failed to create vault directory: %v", err)
} }
@ -95,7 +95,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
// Set current vault // Set current vault
currentVaultPath := filepath.Join(baseDir, "currentvault") currentVaultPath := filepath.Join(baseDir, "currentvault")
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), 0600) err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
if err != nil { if err != nil {
t.Fatalf("Failed to set current vault: %v", err) t.Fatalf("Failed to set current vault: %v", err)
} }

View 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")
}
})
}

View File

@ -27,115 +27,149 @@ func isValidVaultName(name string) bool {
return matched 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 // 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) secret.Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
// First try to handle the path as a real symlink (works on Unix systems) // First try to handle the path as a real symlink (works on Unix systems)
if _, ok := fs.(*afero.OsFs); ok { 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) // Check if the symlink exists
linkTarget, err := os.Readlink(symlinkPath) secret.Debug("Checking symlink target", "symlink_path", symlinkPath)
target, err := os.Readlink(symlinkPath)
if err == nil { if err == nil {
// Successfully read as symlink (Unix path) secret.Debug("Symlink points to", "target", target)
secret.Debug("Successfully read as real symlink", "target", linkTarget)
// Convert relative paths to absolute if needed // On real filesystem, we need to handle relative symlinks
if !filepath.IsAbs(linkTarget) { // by resolving them relative to the symlink's directory
linkTarget = filepath.Join(filepath.Dir(symlinkPath), linkTarget) if !filepath.IsAbs(target) {
// Get the current directory before changing
originalDir, err := os.Getwd()
if err != nil {
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
} }
secret.Debug("Resolved symlink path", "path", linkTarget) secret.Debug("resolveVaultSymlink completed successfully", "result", target)
return linkTarget, nil return target, 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: // Fallback: treat it as a regular file containing the target path
// Read the path from a regular file (our fallback approach) secret.Debug("Fallback: trying to read regular file with target path")
secret.Debug("Reading symlink target from file")
content, err := afero.ReadFile(fs, symlinkPath) fileData, err := afero.ReadFile(fs, symlinkPath)
if err != nil { if err != nil {
secret.Debug("Failed to read from file", "error", err) secret.Debug("Failed to read target path file", "error", err)
return "", fmt.Errorf("failed to read vault path: %w", err) return "", fmt.Errorf("failed to read vault symlink: %w", err)
} }
targetPath := string(content) target := string(fileData)
secret.Debug("Read target path from file", "target_path", targetPath) secret.Debug("Read target path from file", "target", target)
return targetPath, nil
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) { func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
secret.Debug("Getting current vault", "state_dir", stateDir) 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") 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) _, err := fs.Stat(currentVaultPath)
if err != nil { if err != nil {
secret.Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath) secret.Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
return nil, fmt.Errorf("failed to read current vault symlink: %w", err) return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
} }
secret.Debug("Current vault symlink exists") 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") secret.Debug("Resolving vault symlink")
targetPath, err := resolveVaultSymlink(fs, currentVaultPath) targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
if err != nil { if err != nil {
secret.Debug("Failed to resolve vault symlink", "error", err) return nil, err
return nil, fmt.Errorf("failed to resolve vault symlink: %w", err)
} }
secret.Debug("Resolved vault symlink", "target_path", targetPath) 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) vaultName := filepath.Base(targetPath)
secret.Debug("Extracted vault name", "vault_name", vaultName) secret.Debug("Extracted vault name", "vault_name", vaultName)
secret.Debug("Current vault resolved", "vault_name", vaultName, "target_path", targetPath) secret.Debug("Current vault resolved", "vault_name", vaultName, "target_path", targetPath)
// Create and return Vault instance // Create and return the vault
secret.Debug("Creating NewVault instance") return NewVault(fs, stateDir, vaultName), nil
vault := NewVault(fs, vaultName, stateDir)
secret.Debug("Created NewVault instance successfully")
return vault, 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) { func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
vaultsDir := filepath.Join(stateDir, "vaults.d") vaultsDir := filepath.Join(stateDir, "vaults.d")
// Check if vaults directory exists // Check if vaults directory exists
exists, err := afero.DirExists(fs, vaultsDir) exists, err := afero.DirExists(fs, vaultsDir)
if err != nil { 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 { if !exists {
return []string{}, nil return []string{}, nil
} }
// Read directory contents // Read the vaults directory
files, err := afero.ReadDir(fs, vaultsDir) entries, err := afero.ReadDir(fs, vaultsDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read vaults directory: %w", err) return nil, fmt.Errorf("failed to read vaults directory: %w", err)
} }
// Extract vault names
var vaults []string var vaults []string
for _, file := range files { for _, entry := range entries {
if file.IsDir() { if entry.IsDir() {
vaults = append(vaults, file.Name()) vaults = append(vaults, entry.Name())
} }
} }
return vaults, nil 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) { func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir) 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) vaultDir := filepath.Join(stateDir, "vaults.d", name)
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir) secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
// Check if vault already exists // Create main vault directory
exists, err := afero.DirExists(fs, vaultDir) if err := fs.MkdirAll(vaultDir, secret.DirPerms); err != nil {
if err != nil {
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
}
if exists {
return nil, fmt.Errorf("vault %s already exists", name)
}
// Create vault directory
if err := fs.MkdirAll(vaultDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create vault directory: %w", err) return nil, fmt.Errorf("failed to create vault directory: %w", err)
} }
// Create subdirectories // Create secrets directory
secretsDir := filepath.Join(vaultDir, "secrets.d") 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) return nil, fmt.Errorf("failed to create secrets directory: %w", err)
} }
// Create unlock keys directory
unlockKeysDir := filepath.Join(vaultDir, "unlock.d") 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) 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) secret.Debug("Selecting newly created vault as current", "name", name)
if err := SelectVault(fs, stateDir, name); err != nil { 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) 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 // 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) 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") currentVaultPath := filepath.Join(stateDir, "currentvault")
targetPath := filepath.Join(stateDir, "vaults.d", name)
// Remove existing symlink if it exists // First try to remove existing symlink if it exists
if exists, _ := afero.Exists(fs, currentVaultPath); exists { if _, err := fs.Stat(currentVaultPath); err == nil {
secret.Debug("Removing existing current vault symlink", "path", currentVaultPath) secret.Debug("Removing existing current vault symlink", "path", currentVaultPath)
if err := fs.Remove(currentVaultPath); err != nil { // Ignore errors from Remove as we'll try to create/update it anyway.
secret.Debug("Failed to remove existing symlink", "error", err, "path", currentVaultPath) // 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 // Try to create a real symlink first (works on Unix systems)
targetPath := vaultDir
secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath)
// For real filesystems, try to create a real symlink first
if _, ok := fs.(*afero.OsFs); ok { if _, ok := fs.(*afero.OsFs); ok {
if err := os.Symlink(targetPath, currentVaultPath); err != nil { secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath)
// If symlink creation fails, fall back to writing target path to file if err := os.Symlink(targetPath, currentVaultPath); err == nil {
secret.Debug("Failed to create real symlink, falling back to file", "error", err) secret.Debug("Successfully selected vault", "vault_name", name)
if err := afero.WriteFile(fs, currentVaultPath, []byte(targetPath), 0600); err != nil { return nil
return fmt.Errorf("failed to create vault symlink: %w", err)
}
}
} else {
// For in-memory filesystems, write target path to file
if err := afero.WriteFile(fs, currentVaultPath, []byte(targetPath), 0600); err != nil {
return fmt.Errorf("failed to create vault symlink: %w", err)
} }
// If symlink creation fails, fall back to regular file
}
// 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) secret.Debug("Successfully selected vault", "vault_name", name)

View File

@ -120,7 +120,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
// Create secret directory // Create secret directory
secret.Debug("Creating secret directory", "secret_dir", secretDir) 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) secret.Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
return fmt.Errorf("failed to create secret directory: %w", err) 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 // Step 2: Store the secret's public key
pubKeyPath := filepath.Join(secretDir, "pub.age") pubKeyPath := filepath.Join(secretDir, "pub.age")
secret.Debug("Writing secret public key", "path", pubKeyPath) 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) secret.Debug("Failed to write secret public key", "error", err, "path", pubKeyPath)
return fmt.Errorf("failed to write secret public key: %w", err) 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 // Step 4: Store the encrypted secret value as value.age
valuePath := filepath.Join(secretDir, "value.age") valuePath := filepath.Join(secretDir, "value.age")
secret.Debug("Writing encrypted secret value", "path", valuePath) 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) secret.Debug("Failed to write encrypted secret value", "error", err, "path", valuePath)
return fmt.Errorf("failed to write encrypted secret value: %w", err) 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 // Step 7: Store the encrypted secret private key as priv.age
privKeyPath := filepath.Join(secretDir, "priv.age") privKeyPath := filepath.Join(secretDir, "priv.age")
secret.Debug("Writing encrypted secret private key", "path", privKeyPath) 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) 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) 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") metadataPath := filepath.Join(secretDir, "secret-metadata.json")
secret.Debug("Writing secret metadata", "path", metadataPath) 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) secret.Debug("Failed to write secret metadata", "error", err, "path", metadataPath)
return fmt.Errorf("failed to write secret metadata: %w", err) return fmt.Errorf("failed to write secret metadata: %w", err)
} }

View File

@ -37,7 +37,7 @@ func (v *Vault) GetCurrentUnlockKey() (secret.UnlockKey, error) {
if _, ok := v.fs.(*afero.OsFs); ok { if _, ok := v.fs.(*afero.OsFs); ok {
secret.Debug("Resolving unlock key symlink (real filesystem)") secret.Debug("Resolving unlock key symlink (real filesystem)")
// For real filesystems, resolve the symlink properly // For real filesystems, resolve the symlink properly
unlockKeyDir, err = resolveVaultSymlink(v.fs, currentUnlockKeyPath) unlockKeyDir, err = ResolveVaultSymlink(v.fs, currentUnlockKeyPath)
if err != nil { if err != nil {
secret.Debug("Failed to resolve unlock key symlink", "error", err, "symlink_path", currentUnlockKeyPath) 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) 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 // 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 // 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 // Create unlock key directory with timestamp
timestamp := time.Now().Format("2006-01-02.15.04") timestamp := time.Now().Format("2006-01-02.15.04")
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", "passphrase") 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) 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 // Write public key
pubKeyPath := filepath.Join(unlockKeyDir, "pub.age") 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) 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 // Write encrypted private key
privKeyPath := filepath.Join(unlockKeyDir, "priv.age") 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) 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") metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json")
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, 0600); err != nil { if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write metadata: %w", err) 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 // 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") 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) return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
} }
} }

View File

@ -21,13 +21,16 @@ type Vault struct {
} }
// NewVault creates a new Vault instance // NewVault creates a new Vault instance
func NewVault(fs afero.Fs, name string, stateDir string) *Vault { func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
return &Vault{ secret.Debug("Creating NewVault instance")
v := &Vault{
Name: name, Name: name,
fs: fs, fs: fs,
stateDir: stateDir, stateDir: stateDir,
longTermKey: nil, 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 // Locked returns true if the vault doesn't have a long-term key in memory

View File

@ -128,7 +128,7 @@ func TestVaultOperations(t *testing.T) {
// Write the correct public key to the pub.age file // Write the correct public key to the pub.age file
pubKeyPath := filepath.Join(vaultDir, "pub.age") 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 { if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err) t.Fatalf("Failed to write long-term public key: %v", err)
} }