From 8c08c2e748d6ea0d724cfcca4f98bf710bf35f46 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 29 May 2025 08:22:43 -0700 Subject: [PATCH] restoring from chat historyy --- README.md | 31 +- internal/secret/crypto.go | 17 - internal/secret/keychainunlock.go | 516 +++++++++++++ internal/secret/passphraseunlock.go | 22 +- internal/secret/pgpunlock.go | 406 ++++++++++- internal/secret/vault.go | 1042 ++++++++++++++++++++++++++- 6 files changed, 1991 insertions(+), 43 deletions(-) create mode 100644 internal/secret/keychainunlock.go diff --git a/README.md b/README.md index a2e800e..24ea348 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Build from source: ```bash git clone cd secret -go build -o secret ./cmd/secret +make build ``` ## Quick Start @@ -107,7 +107,6 @@ Creates a new unlock key of the specified type: **Types:** - `passphrase`: Traditional passphrase-protected unlock key -- `keychain`: macOS Keychain-protected unlock key (macOS only) - `pgp`: Uses an existing GPG key for encryption/decryption **Options:** @@ -145,7 +144,6 @@ Decrypts data using an Age key stored as a secret. │ ├── default/ │ │ ├── unlock-keys.d/ │ │ │ ├── passphrase/ # Passphrase unlock key -│ │ │ ├── keychain/ # Keychain unlock key (macOS) │ │ │ └── pgp/ # PGP unlock key │ │ ├── secrets.d/ │ │ │ ├── api%key/ # Secret: api/key @@ -174,12 +172,7 @@ Unlock keys provide different authentication methods to access the long-term key - Stored as encrypted Age keys - Cross-platform compatible -2. **Keychain Keys** (macOS only): - - Uses macOS Keychain for secure storage - - Provides seamless authentication on macOS systems - - Age private key encrypted with random passphrase stored in Keychain - -3. **PGP Keys**: +2. **PGP Keys**: - Uses existing GPG key infrastructure - Leverages existing key management workflows - Strong authentication through GPG @@ -214,9 +207,8 @@ Each vault maintains its own set of unlock keys and one long-term key. The long- - Per-secret encryption keys limit exposure if compromised - Long-term keys protected by multiple unlock key layers -### Platform Integration -- macOS Keychain integration for seamless authentication -- GPG integration for existing key management workflows +### Hardware Integration +- Hardware token support via PGP/GPG integration ## Examples @@ -260,7 +252,6 @@ secret vault list ```bash # Add multiple unlock methods secret keys add passphrase # Password-based -secret keys add keychain # macOS Keychain (macOS only) secret keys add pgp --keyid ABCD1234 # GPG key # List unlock keys @@ -305,11 +296,11 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt ### Threat Model - Protects against unauthorized access to secret values - Provides defense against compromise of individual components -- Supports platform-specific authentication where available +- Supports hardware-backed authentication where available ### Best Practices 1. Use strong, unique passphrases for unlock keys -2. Enable platform-specific authentication (Keychain) when available +2. Enable hardware authentication (Keychain, hardware tokens) when available 3. Regularly audit unlock keys and remove unused ones 4. Keep mnemonic phrases securely backed up offline 5. Use separate vaults for different security contexts @@ -317,15 +308,15 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt ### Limitations - Requires access to unlock keys for secret retrieval - Mnemonic phrases must be securely stored and backed up -- Platform-specific features limited to supported platforms +- Hardware features limited to supported platforms ## Development ### Building ```bash -go build -o secret ./cmd/secret # Build binary -go test ./... # Run tests -go vet ./... # Run static analysis +make build # Build binary +make test # Run tests +make lint # Run linter ``` ### Testing @@ -337,7 +328,7 @@ go test ./... # Unit tests ## Features -- **Multiple Authentication Methods**: Supports passphrase-based, keychain-based (macOS), and PGP-based unlock keys +- **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlock keys - **Vault Isolation**: Complete separation between different vaults - **Per-Secret Encryption**: Each secret has its own encryption key - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases diff --git a/internal/secret/crypto.go b/internal/secret/crypto.go index 16f7321..e1909ea 100644 --- a/internal/secret/crypto.go +++ b/internal/secret/crypto.go @@ -93,20 +93,3 @@ func readPassphrase(prompt string) (string, error) { return string(passphrase), nil } - -// decryptSecretWithLongTermKey is a helper that parses a long-term private key and uses it to decrypt secret data -func decryptSecretWithLongTermKey(ltPrivKeyData []byte, encryptedData []byte) ([]byte, error) { - // Parse long-term private key - ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData)) - if err != nil { - return nil, fmt.Errorf("failed to parse long-term private key: %w", err) - } - - // Decrypt secret data using long-term key - decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity) - if err != nil { - return nil, fmt.Errorf("failed to decrypt secret: %w", err) - } - - return decryptedData, nil -} diff --git a/internal/secret/keychainunlock.go b/internal/secret/keychainunlock.go new file mode 100644 index 0000000..38027b8 --- /dev/null +++ b/internal/secret/keychainunlock.go @@ -0,0 +1,516 @@ +package secret + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "time" + + "filippo.io/age" + "github.com/spf13/afero" +) + +// KeychainUnlockKeyMetadata extends UnlockKeyMetadata with keychain-specific data +type KeychainUnlockKeyMetadata struct { + UnlockKeyMetadata + // Age keypair information + AgePublicKey string `json:"age_public_key"` + // Keychain item name + KeychainItemName string `json:"keychain_item_name"` +} + +// KeychainUnlockKey represents a macOS Keychain-protected unlock key +type KeychainUnlockKey struct { + Directory string + Metadata UnlockKeyMetadata + fs afero.Fs +} + +// KeychainData represents the data stored in the macOS keychain +type KeychainData struct { + AgePublicKey string `json:"age_public_key"` + AgePrivKeyPassphrase string `json:"age_priv_key_passphrase"` + EncryptedLongtermKey string `json:"encrypted_longterm_key"` +} + +// GetIdentity implements UnlockKey interface for Keychain-based unlock keys +func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) { + DebugWith("Getting keychain unlock key identity", + slog.String("key_id", k.GetID()), + slog.String("key_type", k.GetType()), + ) + + // Step 1: Get keychain item name + keychainItemName, err := k.GetKeychainItemName() + if err != nil { + Debug("Failed to get keychain item name", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to get keychain item name: %w", err) + } + + // Step 2: Retrieve data from keychain + Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName) + keychainDataBytes, err := retrieveFromKeychain(keychainItemName) + if err != nil { + Debug("Failed to retrieve data from keychain", "error", err, "keychain_item", keychainItemName) + return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err) + } + + DebugWith("Retrieved data from keychain", + slog.String("key_id", k.GetID()), + slog.Int("data_length", len(keychainDataBytes)), + ) + + // Step 3: Parse keychain data + var keychainData KeychainData + if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil { + Debug("Failed to parse keychain data", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to parse keychain data: %w", err) + } + + Debug("Parsed keychain data successfully", "key_id", k.GetID()) + + // Step 4: Read the encrypted age private key from filesystem + agePrivKeyPath := filepath.Join(k.Directory, "priv.age") + Debug("Reading encrypted age private key", "path", agePrivKeyPath) + + encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath) + if err != nil { + Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath) + return nil, fmt.Errorf("failed to read encrypted age private key: %w", err) + } + + DebugWith("Read encrypted age private key", + slog.String("key_id", k.GetID()), + slog.Int("encrypted_length", len(encryptedAgePrivKeyData)), + ) + + // 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) + 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) + } + + DebugWith("Successfully decrypted age private key with keychain passphrase", + slog.String("key_id", k.GetID()), + slog.Int("decrypted_length", len(agePrivKeyData)), + ) + + // Step 6: Parse the decrypted age private key + Debug("Parsing decrypted age private key", "key_id", k.GetID()) + ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData)) + if err != nil { + Debug("Failed to parse age private key", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to parse age private key: %w", err) + } + + DebugWith("Successfully parsed keychain age identity", + slog.String("key_id", k.GetID()), + slog.String("public_key", ageIdentity.Recipient().String()), + ) + + return ageIdentity, nil +} + +// GetType implements UnlockKey interface +func (k *KeychainUnlockKey) GetType() string { + return "keychain" +} + +// GetMetadata implements UnlockKey interface +func (k *KeychainUnlockKey) GetMetadata() UnlockKeyMetadata { + return k.Metadata +} + +// GetDirectory implements UnlockKey interface +func (k *KeychainUnlockKey) GetDirectory() string { + return k.Directory +} + +// GetID implements UnlockKey interface +func (k *KeychainUnlockKey) GetID() string { + return k.Metadata.ID +} + +// ID implements UnlockKey interface - generates ID from keychain item name +func (k *KeychainUnlockKey) ID() string { + // Generate ID using keychain item name + keychainItemName, err := k.GetKeychainItemName() + if err != nil { + // Fallback to metadata ID if we can't read the keychain item name + return k.Metadata.ID + } + return fmt.Sprintf("%s-keychain", keychainItemName) +} + +// Remove implements UnlockKey interface - removes the keychain unlock key +func (k *KeychainUnlockKey) Remove() error { + // Step 1: Get keychain item name + keychainItemName, err := k.GetKeychainItemName() + if err != nil { + Debug("Failed to get keychain item name during removal", "error", err, "key_id", k.GetID()) + return fmt.Errorf("failed to get keychain item name: %w", err) + } + + // Step 2: Remove from keychain + Debug("Removing keychain item", "keychain_item", keychainItemName) + if err := deleteFromKeychain(keychainItemName); err != nil { + Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName) + return fmt.Errorf("failed to remove keychain item: %w", err) + } + + // Step 3: Remove directory + Debug("Removing keychain unlock key directory", "directory", k.Directory) + if err := k.fs.RemoveAll(k.Directory); err != nil { + Debug("Failed to remove keychain unlock key directory", "error", err, "directory", k.Directory) + return fmt.Errorf("failed to remove keychain unlock key directory: %w", err) + } + + Debug("Successfully removed keychain unlock key", "key_id", k.GetID(), "keychain_item", keychainItemName) + return nil +} + +// DecryptLongTermKey decrypts and returns the long-term private key for this vault +func (k *KeychainUnlockKey) DecryptLongTermKey() ([]byte, error) { + DebugWith("Decrypting long-term key with keychain unlock key", + slog.String("key_id", k.GetID()), + slog.String("key_type", k.GetType()), + ) + + // Get keychain item name and retrieve data + keychainItemName, err := k.GetKeychainItemName() + if err != nil { + Debug("Failed to get keychain item name for long-term decryption", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to get keychain item name: %w", err) + } + + keychainDataBytes, err := retrieveFromKeychain(keychainItemName) + if err != nil { + Debug("Failed to retrieve data from keychain for long-term decryption", "error", err, "keychain_item", keychainItemName) + return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err) + } + + var keychainData KeychainData + if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil { + Debug("Failed to parse keychain data for long-term decryption", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to parse keychain data: %w", err) + } + + // Decrypt the long-term private key using the encrypted data from keychain + encryptedLtPrivKey, err := hex.DecodeString(keychainData.EncryptedLongtermKey) + if err != nil { + Debug("Failed to decode encrypted long-term key from keychain", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to decode encrypted long-term key: %w", err) + } + + // Get our unlock key identity to decrypt the long-term key + unlockIdentity, err := k.GetIdentity() + if err != nil { + Debug("Failed to get keychain unlock identity for long-term decryption", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to get unlock identity: %w", err) + } + + // Decrypt long-term private key using our unlock key + ltPrivKeyData, err := decryptWithIdentity(encryptedLtPrivKey, unlockIdentity) + if err != nil { + Debug("Failed to decrypt long-term private key with keychain unlock key", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) + } + + DebugWith("Successfully decrypted long-term private key with keychain unlock key", + slog.String("key_id", k.GetID()), + slog.Int("decrypted_length", len(ltPrivKeyData)), + ) + + return ltPrivKeyData, nil +} + +// DecryptSecret decrypts a secret using this keychain unlock key's long-term key management +func (k *KeychainUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) { + DebugWith("Decrypting secret with keychain unlock key", + slog.String("secret_name", secret.Name), + slog.String("key_id", k.GetID()), + slog.String("key_type", k.GetType()), + ) + + // Let the secret read its own encrypted data + encryptedData, err := secret.GetEncryptedData() + if err != nil { + Debug("Failed to get encrypted secret data for keychain decryption", "error", err, "secret_name", secret.Name) + return nil, fmt.Errorf("failed to get encrypted secret data: %w", err) + } + + DebugWith("Retrieved encrypted secret data for keychain decryption", + slog.String("secret_name", secret.Name), + slog.String("key_id", k.GetID()), + slog.Int("encrypted_length", len(encryptedData)), + ) + + // Decrypt long-term private key using our unlock key + ltPrivKeyData, err := k.DecryptLongTermKey() + if err != nil { + Debug("Failed to decrypt long-term private key for secret decryption", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) + } + + // Parse long-term private key + Debug("Parsing long-term private key", "key_id", k.GetID()) + ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData)) + if err != nil { + Debug("Failed to parse long-term private key", "error", err, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to parse long-term private key: %w", err) + } + + DebugWith("Successfully parsed long-term identity", + slog.String("key_id", k.GetID()), + slog.String("public_key", ltIdentity.Recipient().String()), + ) + + // Decrypt secret data using long-term key + Debug("Decrypting secret data with long-term key", "secret_name", secret.Name, "key_id", k.GetID()) + decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity) + if err != nil { + Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", secret.Name, "key_id", k.GetID()) + return nil, fmt.Errorf("failed to decrypt secret: %w", err) + } + + DebugWith("Successfully decrypted secret with keychain unlock key", + slog.String("secret_name", secret.Name), + slog.String("key_id", k.GetID()), + slog.Int("decrypted_length", len(decryptedData)), + ) + + return decryptedData, nil +} + +// NewKeychainUnlockKey creates a new KeychainUnlockKey instance +func NewKeychainUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *KeychainUnlockKey { + return &KeychainUnlockKey{ + Directory: directory, + Metadata: metadata, + fs: fs, + } +} + +// GetKeychainItemName returns the keychain item name from metadata +func (k *KeychainUnlockKey) GetKeychainItemName() (string, error) { + // Load the metadata + metadataPath := filepath.Join(k.Directory, "unlock-metadata.json") + metadataData, err := afero.ReadFile(k.fs, metadataPath) + if err != nil { + return "", fmt.Errorf("failed to read keychain metadata: %w", err) + } + + var keychainMetadata KeychainUnlockKeyMetadata + if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil { + return "", fmt.Errorf("failed to parse keychain metadata: %w", err) + } + + return keychainMetadata.KeychainItemName, nil +} + +// generateKeychainUnlockKeyName generates a unique name for the keychain unlock key +func generateKeychainUnlockKeyName(vaultName string) (string, error) { + hostname, err := os.Hostname() + if err != nil { + return "", fmt.Errorf("failed to get hostname: %w", err) + } + + // Format: secret--- + enrollmentDate := time.Now().Format("2006-01-02") + return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil +} + +// CreateKeychainUnlockKey creates a new keychain unlock key and stores it in the vault +func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey, error) { + // Check if we're on macOS + if err := checkMacOSAvailable(); err != nil { + return nil, err + } + + // Get current vault + vault, err := GetCurrentVault(fs, stateDir) + if err != nil { + return nil, fmt.Errorf("failed to get current vault: %w", err) + } + + // Generate the keychain item name + keychainItemName, err := generateKeychainUnlockKeyName(vault.Name) + if err != nil { + return nil, fmt.Errorf("failed to generate keychain item name: %w", err) + } + + // Create unlock key directory using the keychain item name as the directory name + vaultDir, err := vault.GetDirectory() + if err != nil { + return nil, fmt.Errorf("failed to get vault directory: %w", err) + } + + unlockKeyDir := filepath.Join(vaultDir, "unlock.d", keychainItemName) + if err := fs.MkdirAll(unlockKeyDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create unlock key directory: %w", err) + } + + // Step 1: Generate a new age keypair for the keychain unlock key + ageIdentity, err := age.GenerateX25519Identity() + if err != nil { + return nil, fmt.Errorf("failed to generate age keypair: %w", err) + } + + // Step 2: Generate a random passphrase for encrypting the age private key + agePrivKeyPassphrase, err := generateRandomPassphrase(64) + if err != nil { + return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err) + } + + // 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 { + 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) + 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 { + return nil, fmt.Errorf("failed to write encrypted age private key: %w", err) + } + + // Step 5: Get or derive the long-term private key + ltPrivKeyData, err := vault.GetLongTermKey() + if err != nil { + return nil, fmt.Errorf("failed to get 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()) + 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 { + return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err) + } + + // Step 7: Prepare keychain data + keychainData := KeychainData{ + AgePublicKey: agePublicKeyString, + AgePrivKeyPassphrase: agePrivKeyPassphrase, + EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge), + } + + keychainDataBytes, err := json.Marshal(keychainData) + if err != nil { + return nil, fmt.Errorf("failed to marshal keychain data: %w", err) + } + + // Step 8: Store data in keychain + if err := storeInKeychain(keychainItemName, keychainDataBytes); err != nil { + return nil, fmt.Errorf("failed to store data in keychain: %w", err) + } + + // Step 9: Create and write enhanced metadata + // Generate the key ID directly using the keychain item name + keyID := fmt.Sprintf("%s-keychain", keychainItemName) + + keychainMetadata := KeychainUnlockKeyMetadata{ + UnlockKeyMetadata: UnlockKeyMetadata{ + ID: keyID, + Type: "keychain", + CreatedAt: time.Now(), + Flags: []string{"keychain", "macos"}, + }, + AgePublicKey: agePublicKeyString, + KeychainItemName: keychainItemName, + } + + metadataBytes, err := json.MarshalIndent(keychainMetadata, "", " ") + if err != nil { + 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 { + return nil, fmt.Errorf("failed to write unlock key metadata: %w", err) + } + + return &KeychainUnlockKey{ + Directory: unlockKeyDir, + Metadata: keychainMetadata.UnlockKeyMetadata, + fs: fs, + }, nil +} + +// checkMacOSAvailable verifies that we're running on macOS and security command is available +func checkMacOSAvailable() error { + cmd := exec.Command("security", "help") + if err := cmd.Run(); err != nil { + return fmt.Errorf("macOS security command not available: %w (keychain unlock keys are only supported on macOS)", err) + } + return nil +} + +// storeInKeychain stores data in the macOS keychain using the security command +func storeInKeychain(itemName string, data []byte) error { + cmd := exec.Command("security", "add-generic-password", + "-a", itemName, + "-s", itemName, + "-w", string(data), + "-U") // Update if exists + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to store item in keychain: %w", err) + } + + return nil +} + +// retrieveFromKeychain retrieves data from the macOS keychain using the security command +func retrieveFromKeychain(itemName string) ([]byte, error) { + cmd := exec.Command("security", "find-generic-password", + "-a", itemName, + "-s", itemName, + "-w") // Return password only + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err) + } + + // Remove trailing newline if present + if len(output) > 0 && output[len(output)-1] == '\n' { + output = output[:len(output)-1] + } + + return output, nil +} + +// deleteFromKeychain removes an item from the macOS keychain using the security command +func deleteFromKeychain(itemName string) error { + cmd := exec.Command("security", "delete-generic-password", + "-a", itemName, + "-s", itemName) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to delete item from keychain: %w", err) + } + + return nil +} + +// generateRandomPassphrase generates a random passphrase for encrypting the age private key +func generateRandomPassphrase(length int) (string, error) { + return generateRandomString(length, "0123456789abcdef") +} diff --git a/internal/secret/passphraseunlock.go b/internal/secret/passphraseunlock.go index 3799564..b2a4a8a 100644 --- a/internal/secret/passphraseunlock.go +++ b/internal/secret/passphraseunlock.go @@ -194,7 +194,7 @@ func (p *PassphraseUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) { slog.String("key_type", p.GetType()), ) - // Get encrypted secret data + // Get our unlock key encrypted data encryptedData, err := secret.GetEncryptedData() if err != nil { Debug("Failed to get encrypted secret data for passphrase decryption", "error", err, "secret_name", secret.Name) @@ -214,11 +214,25 @@ func (p *PassphraseUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) { return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) } - // Use helper to parse long-term key and decrypt secret - decryptedData, err := decryptSecretWithLongTermKey(ltPrivKeyData, encryptedData) + // Parse long-term private key + Debug("Parsing long-term private key", "key_id", p.GetID()) + ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData)) + if err != nil { + Debug("Failed to parse long-term private key", "error", err, "key_id", p.GetID()) + return nil, fmt.Errorf("failed to parse long-term private key: %w", err) + } + + DebugWith("Successfully parsed long-term identity", + slog.String("key_id", p.GetID()), + slog.String("public_key", ltIdentity.Recipient().String()), + ) + + // Decrypt secret data using long-term key + Debug("Decrypting secret data with long-term key", "secret_name", secret.Name, "key_id", p.GetID()) + decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity) if err != nil { Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", secret.Name, "key_id", p.GetID()) - return nil, err + return nil, fmt.Errorf("failed to decrypt secret: %w", err) } DebugWith("Successfully decrypted secret with passphrase unlock key", diff --git a/internal/secret/pgpunlock.go b/internal/secret/pgpunlock.go index 0519ecb..b116d01 100644 --- a/internal/secret/pgpunlock.go +++ b/internal/secret/pgpunlock.go @@ -1 +1,405 @@ - \ No newline at end of file +package secret + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "filippo.io/age" + "github.com/spf13/afero" +) + +// PGPUnlockKeyMetadata extends UnlockKeyMetadata with PGP-specific data +type PGPUnlockKeyMetadata struct { + UnlockKeyMetadata + // GPG key ID used for encryption + GPGKeyID string `json:"gpg_key_id"` + // Age keypair information + AgePublicKey string `json:"age_public_key"` + AgeRecipient string `json:"age_recipient"` +} + +// PGPUnlockKey represents a PGP-protected unlock key +type PGPUnlockKey struct { + Directory string + Metadata UnlockKeyMetadata + fs afero.Fs +} + +// GetIdentity implements UnlockKey interface for PGP-based unlock keys +func (p *PGPUnlockKey) GetIdentity() (*age.X25519Identity, error) { + DebugWith("Getting PGP unlock key identity", + slog.String("key_id", p.GetID()), + slog.String("key_type", p.GetType()), + ) + + // Step 1: Read the encrypted age private key from filesystem + agePrivKeyPath := filepath.Join(p.Directory, "priv.age.gpg") + Debug("Reading PGP-encrypted age private key", "path", agePrivKeyPath) + + encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath) + if err != nil { + Debug("Failed to read PGP-encrypted age private key", "error", err, "path", agePrivKeyPath) + return nil, fmt.Errorf("failed to read encrypted age private key: %w", err) + } + + DebugWith("Read PGP-encrypted age private key", + slog.String("key_id", p.GetID()), + slog.Int("encrypted_length", len(encryptedAgePrivKeyData)), + ) + + // Step 2: Decrypt the age private key using GPG + Debug("Decrypting age private key with GPG", "key_id", p.GetID()) + agePrivKeyData, err := gpgDecrypt(encryptedAgePrivKeyData) + if err != nil { + Debug("Failed to decrypt age private key with GPG", "error", err, "key_id", p.GetID()) + return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err) + } + + DebugWith("Successfully decrypted age private key with GPG", + slog.String("key_id", p.GetID()), + slog.Int("decrypted_length", len(agePrivKeyData)), + ) + + // Step 3: Parse the decrypted age private key + Debug("Parsing decrypted age private key", "key_id", p.GetID()) + ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData)) + if err != nil { + Debug("Failed to parse age private key", "error", err, "key_id", p.GetID()) + return nil, fmt.Errorf("failed to parse age private key: %w", err) + } + + DebugWith("Successfully parsed PGP age identity", + slog.String("key_id", p.GetID()), + slog.String("public_key", ageIdentity.Recipient().String()), + ) + + return ageIdentity, nil +} + +// GetType implements UnlockKey interface +func (p *PGPUnlockKey) GetType() string { + return "pgp" +} + +// GetMetadata implements UnlockKey interface +func (p *PGPUnlockKey) GetMetadata() UnlockKeyMetadata { + return p.Metadata +} + +// GetDirectory implements UnlockKey interface +func (p *PGPUnlockKey) GetDirectory() string { + return p.Directory +} + +// GetID implements UnlockKey interface +func (p *PGPUnlockKey) GetID() string { + return p.Metadata.ID +} + +// ID implements UnlockKey interface - generates ID from GPG key ID +func (p *PGPUnlockKey) ID() string { + // Generate ID using GPG key ID: -pgp + gpgKeyID, err := p.GetGPGKeyID() + if err != nil { + // Fallback to metadata ID if we can't read the GPG key ID + return p.Metadata.ID + } + return fmt.Sprintf("%s-pgp", gpgKeyID) +} + +// Remove implements UnlockKey interface - removes the PGP unlock key +func (p *PGPUnlockKey) Remove() error { + // For PGP keys, we just need to remove the directory + // No external resources (like keychain items) to clean up + if err := p.fs.RemoveAll(p.Directory); err != nil { + return fmt.Errorf("failed to remove PGP unlock key directory: %w", err) + } + return nil +} + +// DecryptLongTermKey decrypts and returns the long-term private key for this vault +func (p *PGPUnlockKey) DecryptLongTermKey() ([]byte, error) { + DebugWith("Decrypting long-term key with PGP unlock key", + slog.String("key_id", p.GetID()), + slog.String("key_type", p.GetType()), + ) + + // Get our age identity + unlockIdentity, err := p.GetIdentity() + if err != nil { + Debug("Failed to get PGP unlock identity for long-term decryption", "error", err, "key_id", p.GetID()) + return nil, fmt.Errorf("failed to get unlock identity: %w", err) + } + + // Read encrypted long-term private key + encryptedLtPrivKeyPath := filepath.Join(p.Directory, "longterm.age") + Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath) + + encryptedLtPrivKey, err := afero.ReadFile(p.fs, encryptedLtPrivKeyPath) + if err != nil { + Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath) + return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err) + } + + DebugWith("Read encrypted long-term private key", + slog.String("key_id", p.GetID()), + slog.Int("encrypted_length", len(encryptedLtPrivKey)), + ) + + // Decrypt long-term private key using our unlock key + Debug("Decrypting long-term private key with PGP unlock key", "key_id", p.GetID()) + ltPrivKeyData, err := decryptWithIdentity(encryptedLtPrivKey, unlockIdentity) + if err != nil { + Debug("Failed to decrypt long-term private key", "error", err, "key_id", p.GetID()) + return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) + } + + DebugWith("Successfully decrypted long-term private key", + slog.String("key_id", p.GetID()), + slog.Int("decrypted_length", len(ltPrivKeyData)), + ) + + return ltPrivKeyData, nil +} + +// DecryptSecret decrypts a secret using this PGP unlock key's long-term key management +func (p *PGPUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) { + DebugWith("Decrypting secret with PGP unlock key", + slog.String("secret_name", secret.Name), + slog.String("key_id", p.GetID()), + slog.String("key_type", p.GetType()), + ) + + // Let the secret read its own encrypted data + encryptedData, err := secret.GetEncryptedData() + if err != nil { + Debug("Failed to get encrypted secret data for PGP decryption", "error", err, "secret_name", secret.Name) + return nil, fmt.Errorf("failed to get encrypted secret data: %w", err) + } + + DebugWith("Retrieved encrypted secret data for PGP decryption", + slog.String("secret_name", secret.Name), + slog.String("key_id", p.GetID()), + slog.Int("encrypted_length", len(encryptedData)), + ) + + // Decrypt long-term private key using our unlock key + ltPrivKeyData, err := p.DecryptLongTermKey() + if err != nil { + Debug("Failed to decrypt long-term private key for secret decryption", "error", err, "key_id", p.GetID()) + return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) + } + + // Parse long-term private key + Debug("Parsing long-term private key", "key_id", p.GetID()) + ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData)) + if err != nil { + Debug("Failed to parse long-term private key", "error", err, "key_id", p.GetID()) + return nil, fmt.Errorf("failed to parse long-term private key: %w", err) + } + + DebugWith("Successfully parsed long-term identity", + slog.String("key_id", p.GetID()), + slog.String("public_key", ltIdentity.Recipient().String()), + ) + + // Decrypt secret data using long-term key + Debug("Decrypting secret data with long-term key", "secret_name", secret.Name, "key_id", p.GetID()) + decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity) + if err != nil { + Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", secret.Name, "key_id", p.GetID()) + return nil, fmt.Errorf("failed to decrypt secret: %w", err) + } + + DebugWith("Successfully decrypted secret with PGP unlock key", + slog.String("secret_name", secret.Name), + slog.String("key_id", p.GetID()), + slog.Int("decrypted_length", len(decryptedData)), + ) + + return decryptedData, nil +} + +// NewPGPUnlockKey creates a new PGPUnlockKey instance +func NewPGPUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *PGPUnlockKey { + return &PGPUnlockKey{ + Directory: directory, + Metadata: metadata, + fs: fs, + } +} + +// GetGPGKeyID returns the GPG key ID from metadata +func (p *PGPUnlockKey) GetGPGKeyID() (string, error) { + // Load the metadata + metadataPath := filepath.Join(p.Directory, "unlock-metadata.json") + metadataData, err := afero.ReadFile(p.fs, metadataPath) + if err != nil { + return "", fmt.Errorf("failed to read PGP metadata: %w", err) + } + + var pgpMetadata PGPUnlockKeyMetadata + if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil { + return "", fmt.Errorf("failed to parse PGP metadata: %w", err) + } + + return pgpMetadata.GPGKeyID, nil +} + +// generatePGPUnlockKeyName generates a unique name for the PGP unlock key based on hostname and date +func generatePGPUnlockKeyName() (string, error) { + hostname, err := os.Hostname() + if err != nil { + return "", fmt.Errorf("failed to get hostname: %w", err) + } + + // Format: hostname-pgp-YYYY-MM-DD + enrollmentDate := time.Now().Format("2006-01-02") + return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil +} + +// CreatePGPUnlockKey creates a new PGP unlock key and stores it in the vault +func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlockKey, error) { + // Check if GPG is available + if err := checkGPGAvailable(); err != nil { + return nil, err + } + + // Get current vault + vault, err := GetCurrentVault(fs, stateDir) + if err != nil { + return nil, fmt.Errorf("failed to get current vault: %w", err) + } + + // Generate the unlock key name based on hostname and date + unlockKeyName, err := generatePGPUnlockKeyName() + if err != nil { + return nil, fmt.Errorf("failed to generate unlock key name: %w", err) + } + + // Create unlock key directory using the generated name + vaultDir, err := vault.GetDirectory() + if err != nil { + return nil, fmt.Errorf("failed to get vault directory: %w", err) + } + + unlockKeyDir := filepath.Join(vaultDir, "unlock.d", unlockKeyName) + if err := fs.MkdirAll(unlockKeyDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create unlock key directory: %w", err) + } + + // Step 1: Generate a new age keypair for the PGP unlock key + ageIdentity, err := age.GenerateX25519Identity() + if err != nil { + return nil, fmt.Errorf("failed to generate age keypair: %w", err) + } + + // 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 { + return nil, fmt.Errorf("failed to write age public key: %w", err) + } + + // Step 3: Get or derive the long-term private key + ltPrivKeyData, err := vault.GetLongTermKey() + if err != nil { + return nil, fmt.Errorf("failed to get 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()) + 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 { + 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 + agePrivateKeyBytes := []byte(ageIdentity.String()) + encryptedAgePrivKey, err := gpgEncrypt(agePrivateKeyBytes, gpgKeyID) + if err != nil { + return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err) + } + + agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age.gpg") + if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, 0600); err != nil { + return nil, fmt.Errorf("failed to write encrypted age private key: %w", err) + } + + // Step 6: Create and write enhanced metadata + // Generate the key ID directly using the GPG key ID + keyID := fmt.Sprintf("%s-pgp", gpgKeyID) + + pgpMetadata := PGPUnlockKeyMetadata{ + UnlockKeyMetadata: UnlockKeyMetadata{ + ID: keyID, + Type: "pgp", + CreatedAt: time.Now(), + Flags: []string{"gpg", "encrypted"}, + }, + GPGKeyID: gpgKeyID, + AgePublicKey: agePublicKeyString, + AgeRecipient: ageIdentity.Recipient().String(), + } + + metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ") + if err != nil { + 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 { + return nil, fmt.Errorf("failed to write unlock key metadata: %w", err) + } + + return &PGPUnlockKey{ + Directory: unlockKeyDir, + Metadata: pgpMetadata.UnlockKeyMetadata, + fs: fs, + }, nil +} + +// checkGPGAvailable verifies that GPG is available +func checkGPGAvailable() error { + cmd := exec.Command("gpg", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("GPG not available: %w (make sure 'gpg' command is installed and in PATH)", err) + } + return nil +} + +// gpgEncrypt encrypts data to the specified GPG key ID +func gpgEncrypt(data []byte, keyID string) ([]byte, error) { + cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID) + cmd.Stdin = strings.NewReader(string(data)) + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("GPG encryption failed: %w", err) + } + + return output, nil +} + +// gpgDecrypt decrypts GPG-encrypted data +func gpgDecrypt(encryptedData []byte) ([]byte, error) { + cmd := exec.Command("gpg", "--quiet", "--decrypt") + cmd.Stdin = strings.NewReader(string(encryptedData)) + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("GPG decryption failed: %w", err) + } + + return output, nil +} diff --git a/internal/secret/vault.go b/internal/secret/vault.go index 0519ecb..c7d1716 100644 --- a/internal/secret/vault.go +++ b/internal/secret/vault.go @@ -1 +1,1041 @@ - \ No newline at end of file +package secret + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "filippo.io/age" + "git.eeqj.de/sneak/secret/pkg/agehd" + "github.com/spf13/afero" +) + +// VaultMetadata contains information about a vault +type VaultMetadata struct { + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + Description string `json:"description,omitempty"` +} + +// UnlockKeyMetadata contains information about an unlock key +type UnlockKeyMetadata struct { + ID string `json:"id"` + Type string `json:"type"` // passphrase, pgp, keychain + CreatedAt time.Time `json:"createdAt"` + Flags []string `json:"flags,omitempty"` +} + +// SecretMetadata contains information about a secret +type SecretMetadata struct { + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Configuration represents the global configuration +type Configuration struct { + Version int `json:"version"` + Config struct { + RequireAuth bool `json:"requireAuth"` + } `json:"configuration"` +} + +// Vault represents a secrets vault +type Vault struct { + Name string + fs afero.Fs + stateDir string +} + +// NewVault creates a new Vault instance +func NewVault(fs afero.Fs, name string, stateDir string) *Vault { + return &Vault{ + Name: name, + fs: fs, + stateDir: stateDir, + } +} + +// resolveVaultSymlink resolves the currentvault symlink by changing into it and getting the absolute path +func resolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) { + // For real filesystems, we can use os.Chdir and os.Getwd + if _, ok := fs.(*afero.OsFs); ok { + // Save current directory + originalDir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + // Change to the symlink directory + err = os.Chdir(symlinkPath) + if err != nil { + return "", fmt.Errorf("failed to change into vault symlink: %w", err) + } + + // Get absolute path of current directory + absolutePath, err := os.Getwd() + if err != nil { + // Try to restore original directory before returning error + if restoreErr := os.Chdir(originalDir); restoreErr != nil { + return "", fmt.Errorf("failed to get absolute path: %w (and failed to restore directory: %v)", err, restoreErr) + } + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + // Restore original directory + err = os.Chdir(originalDir) + if err != nil { + return "", fmt.Errorf("failed to restore original directory: %w", err) + } + + return absolutePath, nil + } else { + // Fallback for mock filesystems: read the path from file contents + targetBytes, err := afero.ReadFile(fs, symlinkPath) + if err != nil { + return "", fmt.Errorf("failed to read vault path: %w", err) + } + return strings.TrimSpace(string(targetBytes)), nil + } +} + +// GetCurrentVault returns the currently selected vault +func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) { + DebugWith("Getting current vault", slog.String("state_dir", stateDir)) + + currentVaultPath := filepath.Join(stateDir, "currentvault") + + // Check if the symlink exists + _, err := fs.Stat(currentVaultPath) + if err != nil { + Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath) + return nil, fmt.Errorf("failed to read current vault symlink: %w", err) + } + + // Resolve the symlink to get the target directory + targetPath, err := resolveVaultSymlink(fs, currentVaultPath) + if err != nil { + Debug("Failed to resolve vault symlink", "error", err, "symlink_path", currentVaultPath) + return nil, err + } + + // Extract vault name from the target path + // Target path should be something like "/state/vaults.d/vaultname" + vaultName := filepath.Base(targetPath) + + DebugWith("Current vault resolved", + slog.String("vault_name", vaultName), + slog.String("target_path", targetPath), + ) + + return NewVault(fs, vaultName, stateDir), nil +} + +// ListVaults returns a list of all available vaults +func ListVaults(fs afero.Fs, stateDir string) ([]string, error) { + DebugWith("Listing vaults", slog.String("state_dir", stateDir)) + + vaultsDir := filepath.Join(stateDir, "vaults.d") + + // Check if vaults directory exists + exists, err := afero.DirExists(fs, vaultsDir) + if err != nil { + Debug("Failed to check vaults directory", "error", err, "vaults_dir", vaultsDir) + return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err) + } + if !exists { + Debug("Vaults directory does not exist", "vaults_dir", vaultsDir) + return []string{}, nil + } + + // List directories in vaults.d + files, err := afero.ReadDir(fs, vaultsDir) + if err != nil { + Debug("Failed to read vaults directory", "error", err, "vaults_dir", vaultsDir) + return nil, fmt.Errorf("failed to read vaults directory: %w", err) + } + + var vaults []string + for _, file := range files { + if file.IsDir() { + vaults = append(vaults, file.Name()) + } + } + + DebugWith("Found vaults", + slog.Int("count", len(vaults)), + slog.Any("vault_names", vaults), + ) + + return vaults, nil +} + +// CreateVault creates a new vault +func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) { + DebugWith("Creating new vault", slog.String("name", name), slog.String("state_dir", stateDir)) + + vaultDir := filepath.Join(stateDir, "vaults.d", name) + + // Check if vault already exists + exists, err := afero.DirExists(fs, vaultDir) + if err != nil { + Debug("Failed to check if vault exists", "error", err, "vault_dir", vaultDir) + return nil, fmt.Errorf("failed to check if vault exists: %w", err) + } + if exists { + Debug("Vault already exists", "name", name) + return nil, fmt.Errorf("vault %s already exists", name) + } + + // Create vault directory and subdirectories + Debug("Creating vault directory structure", "vault_dir", vaultDir) + if err := fs.MkdirAll(vaultDir, 0700); err != nil { + Debug("Failed to create vault directory", "error", err, "vault_dir", vaultDir) + return nil, fmt.Errorf("failed to create vault directory: %w", err) + } + + secretsDir := filepath.Join(vaultDir, "secrets.d") + if err := fs.MkdirAll(secretsDir, 0700); err != nil { + Debug("Failed to create secrets directory", "error", err, "secrets_dir", secretsDir) + return nil, fmt.Errorf("failed to create secrets directory: %w", err) + } + + unlockKeysDir := filepath.Join(vaultDir, "unlock.d") + if err := fs.MkdirAll(unlockKeysDir, 0700); err != nil { + Debug("Failed to create unlock keys directory", "error", err, "unlock_keys_dir", unlockKeysDir) + return nil, fmt.Errorf("failed to create unlock keys directory: %w", err) + } + + // Automatically select the newly created vault as current + Debug("Selecting newly created vault as current", "name", name) + if err := SelectVault(fs, stateDir, name); err != nil { + Debug("Failed to select newly created vault", "error", err, "name", name) + return nil, fmt.Errorf("failed to select newly created vault: %w", err) + } + + Debug("Successfully created vault", "name", name) + return NewVault(fs, name, stateDir), nil +} + +// SelectVault sets the current vault +func SelectVault(fs afero.Fs, stateDir string, name string) error { + DebugWith("Selecting vault", slog.String("vault_name", name), slog.String("state_dir", stateDir)) + + vaultDir := filepath.Join(stateDir, "vaults.d", name) + + // Check if vault exists + exists, err := afero.DirExists(fs, vaultDir) + if err != nil { + Debug("Failed to check if vault exists during selection", "error", err, "vault_dir", vaultDir) + return fmt.Errorf("failed to check if vault exists: %w", err) + } + if !exists { + Debug("Vault does not exist for selection", "vault_name", name, "vault_dir", vaultDir) + return fmt.Errorf("vault %s does not exist", name) + } + + // Write current vault symlink to vault directory + currentVaultPath := filepath.Join(stateDir, "currentvault") + + // Remove existing symlink if it exists + _, err = fs.Stat(currentVaultPath) + if err == nil { + Debug("Removing existing current vault symlink", "path", currentVaultPath) + if err := fs.Remove(currentVaultPath); err != nil { + Debug("Failed to remove existing vault symlink", "error", err, "path", currentVaultPath) + return fmt.Errorf("failed to remove existing current vault symlink: %w", err) + } + } + + // Create new symlink to vault directory + if linker, ok := fs.(afero.Linker); ok { + Debug("Creating vault symlink", "target", vaultDir, "link", currentVaultPath) + if err := linker.SymlinkIfPossible(vaultDir, currentVaultPath); err != nil { + Debug("Failed to create vault symlink", "error", err, "target", vaultDir, "link", currentVaultPath) + return fmt.Errorf("failed to create symlink for current vault: %w", err) + } + } else { + Debug("Creating vault path file (symlinks not supported)", "target", vaultDir, "file", currentVaultPath) + // Fallback: write the vault directory path as a regular file + if err := afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), 0600); err != nil { + Debug("Failed to write vault path file", "error", err, "target", vaultDir, "file", currentVaultPath) + return fmt.Errorf("failed to write current vault path: %w", err) + } + } + + Debug("Successfully selected vault", "vault_name", name) + return nil +} + +// GetDirectory returns the filesystem path to this vault +func (v *Vault) GetDirectory() (string, error) { + return filepath.Join(v.stateDir, "vaults.d", v.Name), nil +} + +// ListSecrets returns a list of secret names in this vault +func (v *Vault) ListSecrets() ([]string, error) { + DebugWith("Listing secrets in vault", slog.String("vault_name", v.Name)) + + vaultDir, err := v.GetDirectory() + if err != nil { + Debug("Failed to get vault directory for secret listing", "error", err, "vault_name", v.Name) + return nil, err + } + + secretsDir := filepath.Join(vaultDir, "secrets.d") + + // Check if secrets directory exists + exists, err := afero.DirExists(v.fs, secretsDir) + if err != nil { + Debug("Failed to check secrets directory", "error", err, "secrets_dir", secretsDir) + return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err) + } + if !exists { + Debug("Secrets directory does not exist", "secrets_dir", secretsDir, "vault_name", v.Name) + return []string{}, nil + } + + // List directories in secrets.d + files, err := afero.ReadDir(v.fs, secretsDir) + if err != nil { + Debug("Failed to read secrets directory", "error", err, "secrets_dir", secretsDir) + return nil, fmt.Errorf("failed to read secrets directory: %w", err) + } + + var secrets []string + for _, file := range files { + if file.IsDir() { + // Convert storage name back to secret name + secretName := strings.ReplaceAll(file.Name(), "%", "/") + secrets = append(secrets, secretName) + } + } + + DebugWith("Found secrets in vault", + slog.String("vault_name", v.Name), + slog.Int("secret_count", len(secrets)), + slog.Any("secret_names", secrets), + ) + + return secrets, nil +} + +// AddSecret adds a secret to this vault +func (v *Vault) AddSecret(name string, value []byte, force bool) error { + DebugWith("Adding secret to vault", + slog.String("vault_name", v.Name), + slog.String("secret_name", name), + slog.Int("value_length", len(value)), + slog.Bool("force", force), + ) + + vaultDir, err := v.GetDirectory() + if err != nil { + Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name) + return err + } + + // Convert slashes to percent signs for storage + storageName := strings.ReplaceAll(name, "/", "%") + secretDir := filepath.Join(vaultDir, "secrets.d", storageName) + + DebugWith("Secret storage details", + slog.String("storage_name", storageName), + slog.String("secret_dir", secretDir), + ) + + // Check if secret already exists + exists, err := afero.DirExists(v.fs, secretDir) + if err != nil { + Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir) + return fmt.Errorf("failed to check if secret exists: %w", err) + } + if exists && !force { + Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir) + return fmt.Errorf("secret %s already exists (use --force to overwrite)", name) + } + + // Create secret directory + Debug("Creating secret directory", "secret_dir", secretDir) + if err := v.fs.MkdirAll(secretDir, 0700); err != nil { + Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir) + return fmt.Errorf("failed to create secret directory: %w", err) + } + + // Get long-term public key for encryption + ltPubKeyPath := filepath.Join(vaultDir, "pub.age") + Debug("Reading long-term public key", "path", ltPubKeyPath) + + ltPubKeyData, err := afero.ReadFile(v.fs, ltPubKeyPath) + if err != nil { + Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath) + return fmt.Errorf("failed to read long-term public key: %w", err) + } + + ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData)) + if err != nil { + Debug("Failed to parse long-term public key", "error", err) + return fmt.Errorf("failed to parse long-term public key: %w", err) + } + + DebugWith("Parsed long-term public key", slog.String("recipient", ltRecipient.String())) + + // Encrypt secret data + Debug("Encrypting secret data") + encryptedData, err := encryptToRecipient(value, ltRecipient) + if err != nil { + Debug("Failed to encrypt secret", "error", err) + return fmt.Errorf("failed to encrypt secret: %w", err) + } + + DebugWith("Secret encrypted", slog.Int("encrypted_length", len(encryptedData))) + + // Write encrypted secret + secretPath := filepath.Join(secretDir, "secret.age") + Debug("Writing encrypted secret", "path", secretPath) + if err := afero.WriteFile(v.fs, secretPath, encryptedData, 0600); err != nil { + Debug("Failed to write encrypted secret", "error", err, "path", secretPath) + return fmt.Errorf("failed to write encrypted secret: %w", err) + } + + // Create and write metadata + now := time.Now() + metadata := SecretMetadata{ + Name: name, + CreatedAt: now, + UpdatedAt: now, + } + + DebugWith("Creating secret metadata", + slog.String("secret_name", metadata.Name), + slog.Time("created_at", metadata.CreatedAt), + slog.Time("updated_at", metadata.UpdatedAt), + ) + + metadataBytes, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + Debug("Failed to marshal secret metadata", "error", err) + return fmt.Errorf("failed to marshal secret metadata: %w", err) + } + + metadataPath := filepath.Join(secretDir, "secret-metadata.json") + Debug("Writing secret metadata", "path", metadataPath) + if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, 0600); err != nil { + Debug("Failed to write secret metadata", "error", err, "path", metadataPath) + return fmt.Errorf("failed to write secret metadata: %w", err) + } + + Debug("Successfully added secret to vault", "secret_name", name, "vault_name", v.Name) + return nil +} + +// GetSecret retrieves a secret from this vault +func (v *Vault) GetSecret(name string) ([]byte, error) { + DebugWith("Getting secret from vault", + slog.String("vault_name", v.Name), + slog.String("secret_name", name), + ) + + // Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption + if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" { + Debug("Using mnemonic from environment for secret decryption") + + // Use mnemonic directly to derive long-term key + ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0) + if err != nil { + Debug("Failed to derive long-term key from environment mnemonic", "error", err) + return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) + } + + // Create a secret object to read the encrypted data + secret := NewSecret(v, name) + + // Check if secret exists + exists, err := secret.Exists() + if err != nil { + Debug("Failed to check if secret exists", "error", err, "secret_name", name) + return nil, fmt.Errorf("failed to check if secret exists: %w", err) + } + if !exists { + Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name) + return nil, fmt.Errorf("secret %s not found", name) + } + + Debug("Secret exists, reading encrypted data", "secret_name", name) + + // Read encrypted secret data + encryptedData, err := secret.GetEncryptedData() + if err != nil { + Debug("Failed to get encrypted secret data", "error", err, "secret_name", name) + return nil, err + } + + DebugWith("Retrieved encrypted secret data", + slog.String("secret_name", name), + slog.Int("encrypted_length", len(encryptedData)), + ) + + // Decrypt secret data + Debug("Decrypting secret with long-term key", "secret_name", name) + decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity) + if err != nil { + Debug("Failed to decrypt secret", "error", err, "secret_name", name) + return nil, fmt.Errorf("failed to decrypt secret: %w", err) + } + + DebugWith("Successfully decrypted secret", + slog.String("secret_name", name), + slog.Int("decrypted_length", len(decryptedData)), + ) + + return decryptedData, nil + } + + Debug("Using unlock key for secret decryption", "secret_name", name) + + // Use unlock key to decrypt the secret + unlockKey, err := v.GetCurrentUnlockKey() + if err != nil { + Debug("Failed to get current unlock key", "error", err, "vault_name", v.Name) + return nil, fmt.Errorf("failed to get current unlock key: %w", err) + } + + DebugWith("Retrieved current unlock key", + slog.String("unlock_key_type", unlockKey.GetType()), + slog.String("unlock_key_id", unlockKey.GetID()), + ) + + // Create a secret object + secret := NewSecret(v, name) + + // Check if secret exists + exists, err := secret.Exists() + if err != nil { + Debug("Failed to check if secret exists via unlock key", "error", err, "secret_name", name) + return nil, fmt.Errorf("failed to check if secret exists: %w", err) + } + if !exists { + Debug("Secret not found via unlock key", "secret_name", name, "vault_name", v.Name) + return nil, fmt.Errorf("secret %s not found", name) + } + + Debug("Decrypting secret via unlock key", "secret_name", name, "unlock_key_type", unlockKey.GetType()) + + // Let the unlock key handle decryption + decryptedData, err := unlockKey.DecryptSecret(secret) + if err != nil { + Debug("Failed to decrypt secret via unlock key", "error", err, "secret_name", name, "unlock_key_type", unlockKey.GetType()) + return nil, err + } + + DebugWith("Successfully decrypted secret via unlock key", + slog.String("secret_name", name), + slog.String("unlock_key_type", unlockKey.GetType()), + slog.Int("decrypted_length", len(decryptedData)), + ) + + return decryptedData, nil +} + +// GetSecretObject retrieves a Secret object with metadata loaded from this vault +func (v *Vault) GetSecretObject(name string) (*Secret, error) { + // First check if the secret exists by checking for the metadata file + vaultDir, err := v.GetDirectory() + if err != nil { + return nil, err + } + + // Convert slashes to percent signs for storage + storageName := strings.ReplaceAll(name, "/", "%") + secretDir := filepath.Join(vaultDir, "secrets.d", storageName) + + // Check if secret directory exists + exists, err := afero.DirExists(v.fs, secretDir) + if err != nil { + return nil, fmt.Errorf("failed to check if secret exists: %w", err) + } + if !exists { + return nil, fmt.Errorf("secret %s not found", name) + } + + // Create a Secret object + secret := NewSecret(v, name) + + // Load the metadata from disk + if err := secret.LoadMetadata(); err != nil { + return nil, err + } + + return secret, nil +} + +// GetCurrentUnlockKey returns the current unlock key for this vault +func (v *Vault) GetCurrentUnlockKey() (UnlockKey, error) { + DebugWith("Getting current unlock key", slog.String("vault_name", v.Name)) + + vaultDir, err := v.GetDirectory() + if err != nil { + Debug("Failed to get vault directory for unlock key", "error", err, "vault_name", v.Name) + return nil, err + } + + currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key") + + // Check if the symlink exists + _, err = v.fs.Stat(currentUnlockKeyPath) + if err != nil { + Debug("Failed to stat current unlock key symlink", "error", err, "path", currentUnlockKeyPath) + return nil, fmt.Errorf("failed to read current unlock key: %w", err) + } + + // Resolve the symlink to get the target directory + var unlockKeyDir string + if _, ok := v.fs.(*afero.OsFs); ok { + Debug("Resolving unlock key symlink (real filesystem)") + // For real filesystems, resolve the symlink properly + unlockKeyDir, err = resolveVaultSymlink(v.fs, currentUnlockKeyPath) + if err != nil { + Debug("Failed to resolve unlock key symlink", "error", err, "symlink_path", currentUnlockKeyPath) + return nil, fmt.Errorf("failed to resolve current unlock key symlink: %w", err) + } + } else { + Debug("Reading unlock key path (mock filesystem)") + // Fallback for mock filesystems: read the path from file contents + unlockKeyDirBytes, err := afero.ReadFile(v.fs, currentUnlockKeyPath) + if err != nil { + Debug("Failed to read unlock key path file", "error", err, "path", currentUnlockKeyPath) + return nil, fmt.Errorf("failed to read current unlock key: %w", err) + } + unlockKeyDir = strings.TrimSpace(string(unlockKeyDirBytes)) + } + + DebugWith("Resolved unlock key directory", + slog.String("unlock_key_dir", unlockKeyDir), + slog.String("vault_name", v.Name), + ) + + // Read unlock key metadata + metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json") + Debug("Reading unlock key metadata", "path", metadataPath) + + metadataBytes, err := afero.ReadFile(v.fs, metadataPath) + if err != nil { + Debug("Failed to read unlock key metadata", "error", err, "path", metadataPath) + return nil, fmt.Errorf("failed to read unlock key metadata: %w", err) + } + + var metadata UnlockKeyMetadata + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + Debug("Failed to parse unlock key metadata", "error", err, "path", metadataPath) + return nil, fmt.Errorf("failed to parse unlock key metadata: %w", err) + } + + DebugWith("Parsed unlock key metadata", + slog.String("key_id", metadata.ID), + slog.String("key_type", metadata.Type), + slog.Time("created_at", metadata.CreatedAt), + slog.Any("flags", metadata.Flags), + ) + + // Create unlock key instance using direct constructors with filesystem + var unlockKey UnlockKey + switch metadata.Type { + case "passphrase": + Debug("Creating passphrase unlock key instance", "key_id", metadata.ID) + unlockKey = NewPassphraseUnlockKey(v.fs, unlockKeyDir, metadata) + case "pgp": + Debug("Creating PGP unlock key instance", "key_id", metadata.ID) + unlockKey = NewPGPUnlockKey(v.fs, unlockKeyDir, metadata) + case "keychain": + Debug("Creating keychain unlock key instance", "key_id", metadata.ID) + unlockKey = NewKeychainUnlockKey(v.fs, unlockKeyDir, metadata) + default: + Debug("Unsupported unlock key type", "type", metadata.Type, "key_id", metadata.ID) + return nil, fmt.Errorf("unsupported unlock key type: %s", metadata.Type) + } + + DebugWith("Successfully created unlock key instance", + slog.String("key_type", unlockKey.GetType()), + slog.String("key_id", unlockKey.GetID()), + slog.String("vault_name", v.Name), + ) + + return unlockKey, nil +} + +// ListUnlockKeys returns a list of available unlock keys for this vault +func (v *Vault) ListUnlockKeys() ([]UnlockKeyMetadata, error) { + vaultDir, err := v.GetDirectory() + if err != nil { + return nil, err + } + + unlockKeysDir := filepath.Join(vaultDir, "unlock.d") + + // Check if unlock keys directory exists + exists, err := afero.DirExists(v.fs, unlockKeysDir) + if err != nil { + return nil, fmt.Errorf("failed to check if unlock keys directory exists: %w", err) + } + if !exists { + return []UnlockKeyMetadata{}, nil + } + + // List directories in unlock.d + files, err := afero.ReadDir(v.fs, unlockKeysDir) + if err != nil { + return nil, fmt.Errorf("failed to read unlock keys directory: %w", err) + } + + var keys []UnlockKeyMetadata + for _, file := range files { + if file.IsDir() { + // Read metadata file + metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json") + exists, err := afero.Exists(v.fs, metadataPath) + if err != nil { + continue + } + if !exists { + continue + } + + metadataBytes, err := afero.ReadFile(v.fs, metadataPath) + if err != nil { + continue + } + + var metadata UnlockKeyMetadata + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + continue + } + + keys = append(keys, metadata) + } + } + + return keys, nil +} + +// RemoveUnlockKey removes an unlock key from this vault +func (v *Vault) RemoveUnlockKey(keyID string) error { + vaultDir, err := v.GetDirectory() + if err != nil { + return err + } + + // Find the key directory and create the unlock key instance + unlockKeysDir := filepath.Join(vaultDir, "unlock.d") + + // List directories in unlock.d + files, err := afero.ReadDir(v.fs, unlockKeysDir) + if err != nil { + return fmt.Errorf("failed to read unlock keys directory: %w", err) + } + + var unlockKey UnlockKey + var keyDir string + for _, file := range files { + if file.IsDir() { + // Read metadata file + metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json") + exists, err := afero.Exists(v.fs, metadataPath) + if err != nil || !exists { + continue + } + + metadataBytes, err := afero.ReadFile(v.fs, metadataPath) + if err != nil { + continue + } + + var metadata UnlockKeyMetadata + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + continue + } + + if metadata.ID == keyID { + keyDir = filepath.Join(unlockKeysDir, file.Name()) + + // Create the appropriate unlock key instance + switch metadata.Type { + case "passphrase": + unlockKey = NewPassphraseUnlockKey(v.fs, keyDir, metadata) + case "pgp": + unlockKey = NewPGPUnlockKey(v.fs, keyDir, metadata) + case "keychain": + unlockKey = NewKeychainUnlockKey(v.fs, keyDir, metadata) + default: + return fmt.Errorf("unsupported unlock key type: %s", metadata.Type) + } + break + } + } + } + + if unlockKey == nil { + return fmt.Errorf("unlock key %s not found", keyID) + } + + // Check if this is the current unlock key + currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key") + currentKeyData, err := afero.ReadFile(v.fs, currentUnlockKeyPath) + if err == nil && string(currentKeyData) == keyDir { + // This is the current unlock key, so we need to remove the symlink + if err := v.fs.Remove(currentUnlockKeyPath); err != nil { + return fmt.Errorf("failed to remove current unlock key link: %w", err) + } + } + + // Use the unlock key's Remove method to handle type-specific cleanup + if err := unlockKey.Remove(); err != nil { + return fmt.Errorf("failed to remove unlock key: %w", err) + } + + return nil +} + +// SelectUnlockKey sets the current unlock key for this vault +func (v *Vault) SelectUnlockKey(keyID string) error { + vaultDir, err := v.GetDirectory() + if err != nil { + return err + } + + // Find the key directory + unlockKeysDir := filepath.Join(vaultDir, "unlock.d") + + // List directories in unlock.d + files, err := afero.ReadDir(v.fs, unlockKeysDir) + if err != nil { + return fmt.Errorf("failed to read unlock keys directory: %w", err) + } + + var keyDir string + for _, file := range files { + if file.IsDir() { + // Read metadata file + metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json") + exists, err := afero.Exists(v.fs, metadataPath) + if err != nil || !exists { + continue + } + + metadataBytes, err := afero.ReadFile(v.fs, metadataPath) + if err != nil { + continue + } + + var metadata UnlockKeyMetadata + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + continue + } + + if metadata.ID == keyID { + keyDir = filepath.Join(unlockKeysDir, file.Name()) + break + } + } + } + + if keyDir == "" { + return fmt.Errorf("unlock key %s not found", keyID) + } + + // Set as current unlock key + currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key") + + // Remove existing symlink if it exists + _, err = v.fs.Stat(currentUnlockKeyPath) + if err == nil { + if err := v.fs.Remove(currentUnlockKeyPath); err != nil { + return fmt.Errorf("failed to remove existing current unlock key link: %w", err) + } + } + + // Create new symlink or write path directly if symlinks aren't supported + if linker, ok := v.fs.(afero.Linker); ok { + if err := linker.SymlinkIfPossible(keyDir, currentUnlockKeyPath); err != nil { + return fmt.Errorf("failed to create symlink for current unlock key: %w", err) + } + } else { + // Fallback: write the path as a regular file + if err := afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(keyDir), 0600); err != nil { + return fmt.Errorf("failed to write current unlock key path: %w", err) + } + } + + return nil +} + +// CreatePassphraseKey creates a new passphrase-protected unlock key for this vault +func (v *Vault) CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, error) { + vaultDir, err := v.GetDirectory() + if err != nil { + return nil, fmt.Errorf("failed to get vault directory: %w", err) + } + + // Generate a new identity + identity, err := age.GenerateX25519Identity() + if err != nil { + return nil, fmt.Errorf("failed to generate key pair: %w", err) + } + + publicKey := identity.Recipient().String() + privateKey := identity.String() + + // Create unlock key directory + unlockKeyDir := filepath.Join(vaultDir, "unlock.d", "passphrase") + if err := v.fs.MkdirAll(unlockKeyDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create unlock key directory: %w", err) + } + + // Write public key + if err := afero.WriteFile(v.fs, filepath.Join(unlockKeyDir, "pub.age"), []byte(publicKey), 0600); err != nil { + return nil, fmt.Errorf("failed to write public key: %w", err) + } + + // Create a temporary PassphraseUnlockKey with proper metadata to generate the ID + now := time.Now() + tempMetadata := UnlockKeyMetadata{ + Type: "passphrase", + CreatedAt: now, + } + tempKey := &PassphraseUnlockKey{ + Directory: unlockKeyDir, + Metadata: tempMetadata, + fs: v.fs, + } + keyID := tempKey.ID() + + // Encrypt private key with passphrase + encryptedPrivateKey, err := encryptWithPassphrase([]byte(privateKey), passphrase) + if err != nil { + return nil, fmt.Errorf("failed to encrypt private key with passphrase: %w", err) + } + + // Write encrypted private key + if err := afero.WriteFile(v.fs, filepath.Join(unlockKeyDir, "priv.age"), encryptedPrivateKey, 0600); err != nil { + return nil, fmt.Errorf("failed to write encrypted private key: %w", err) + } + + // Get or derive the long-term private key + ltPrivKeyData, err := v.GetLongTermKey() + if err != nil { + return nil, fmt.Errorf("failed to get long-term private key: %w", err) + } + + // Encrypt the long-term private key to the new unlock key + encryptedLtPrivKey, err := encryptToRecipient(ltPrivKeyData, identity.Recipient()) + if err != nil { + return nil, fmt.Errorf("failed to encrypt long-term private key to new unlock key: %w", err) + } + + // Write the encrypted long-term private key + if err := afero.WriteFile(v.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, 0600); err != nil { + return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err) + } + + // Create and write metadata + metadata := UnlockKeyMetadata{ + ID: keyID, + Type: "passphrase", + CreatedAt: now, + } + + metadataBytes, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err) + } + + if err := afero.WriteFile(v.fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, 0600); err != nil { + return nil, fmt.Errorf("failed to write unlock key metadata: %w", err) + } + + // Set as current unlock key + currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key") + + // Remove existing symlink if it exists + _, err = v.fs.Stat(currentUnlockKeyPath) + if err == nil { + if err := v.fs.Remove(currentUnlockKeyPath); err != nil { + return nil, fmt.Errorf("failed to remove existing current unlock key link: %w", err) + } + } + + // Create new symlink or write path directly if symlinks aren't supported + if linker, ok := v.fs.(afero.Linker); ok { + if err := linker.SymlinkIfPossible(unlockKeyDir, currentUnlockKeyPath); err != nil { + return nil, fmt.Errorf("failed to create symlink for current unlock key: %w", err) + } + } else { + // Fallback: write the path as a regular file + if err := afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(unlockKeyDir), 0600); err != nil { + return nil, fmt.Errorf("failed to write current unlock key path: %w", err) + } + } + + return &PassphraseUnlockKey{ + Directory: unlockKeyDir, + Metadata: metadata, + fs: v.fs, + }, nil +} + +// GetLongTermKey returns the long-term private key for this vault +func (v *Vault) GetLongTermKey() ([]byte, error) { + DebugWith("Getting long-term key for vault", slog.String("vault_name", v.Name)) + + // Check if mnemonic is available in environment variable for direct derivation + if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" { + Debug("Using mnemonic from environment to derive long-term key", "vault_name", v.Name) + + // Use mnemonic directly to derive long-term key + ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0) + if err != nil { + Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name) + return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) + } + + ltPrivKeyData := []byte(ltIdentity.String()) + DebugWith("Successfully derived long-term key from mnemonic", + slog.String("vault_name", v.Name), + slog.Int("key_length", len(ltPrivKeyData)), + ) + return ltPrivKeyData, nil + } + + Debug("Using current unlock key to decrypt long-term key", "vault_name", v.Name) + + // Get current unlock key + currentUnlockKey, err := v.GetCurrentUnlockKey() + if err != nil { + Debug("Failed to get current unlock key", "error", err, "vault_name", v.Name) + return nil, fmt.Errorf("failed to get current unlock key: %w", err) + } + + DebugWith("Retrieved current unlock key for long-term decryption", + slog.String("vault_name", v.Name), + slog.String("unlock_key_type", currentUnlockKey.GetType()), + slog.String("unlock_key_id", currentUnlockKey.GetID()), + ) + + // Use the unlock key's DecryptLongTermKey method + ltPrivKeyData, err := currentUnlockKey.DecryptLongTermKey() + if err != nil { + Debug("Failed to decrypt long-term key with current unlock key", "error", err, "vault_name", v.Name) + return nil, fmt.Errorf("failed to decrypt long-term key: %w", err) + } + + DebugWith("Successfully decrypted long-term key via current unlock key", + slog.String("vault_name", v.Name), + slog.String("unlock_key_type", currentUnlockKey.GetType()), + slog.Int("key_length", len(ltPrivKeyData)), + ) + + return ltPrivKeyData, nil +}