556 lines
20 KiB
Go
556 lines
20 KiB
Go
package secret
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"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
|
|
}
|
|
|
|
// 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)),
|
|
)
|
|
|
|
// Get or derive the long-term private key
|
|
var ltPrivKeyData []byte
|
|
|
|
// Check if mnemonic is available in environment variable
|
|
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
|
// Use mnemonic directly to derive long-term key
|
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
|
}
|
|
ltPrivKeyData = []byte(ltIdentity.String())
|
|
} else {
|
|
// Get keychain item name and retrieve data
|
|
keychainItemName, err := k.GetKeychainItemName()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
|
}
|
|
|
|
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
|
}
|
|
|
|
var keychainData KeychainData
|
|
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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-<vault>-<hostname>-<date>
|
|
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
|
|
var ltPrivKeyData []byte
|
|
|
|
// Check if mnemonic is available in environment variable
|
|
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
|
// Use mnemonic directly to derive long-term key
|
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
|
}
|
|
ltPrivKeyData = []byte(ltIdentity.String())
|
|
} else {
|
|
// Get the vault to access current unlock key
|
|
currentUnlockKey, err := vault.GetCurrentUnlockKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
|
|
}
|
|
|
|
// Get the current unlock key identity
|
|
currentUnlockIdentity, err := currentUnlockKey.GetIdentity()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current unlock key identity: %w", err)
|
|
}
|
|
|
|
// Get encrypted long-term key from current unlock key, handling different types
|
|
var encryptedLtPrivKey []byte
|
|
switch currentUnlockKey := currentUnlockKey.(type) {
|
|
case *PassphraseUnlockKey:
|
|
// Read the encrypted long-term private key from passphrase unlock key
|
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlock key: %w", err)
|
|
}
|
|
|
|
case *PGPUnlockKey:
|
|
// Read the encrypted long-term private key from PGP unlock key
|
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlock key: %w", err)
|
|
}
|
|
|
|
case *KeychainUnlockKey:
|
|
// Read the encrypted long-term private key from another keychain unlock key
|
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlock key: %w", err)
|
|
}
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unsupported current unlock key type for keychain unlock key creation")
|
|
}
|
|
|
|
// Decrypt long-term private key using current unlock key
|
|
ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
|
}
|
|
}
|
|
|
|
// Step 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")
|
|
}
|