When removing a keychain unlocker, if the keychain item doesn't exist (e.g., already manually deleted or vault synced from another machine), the removal should still succeed since the goal is to remove the unlocker and the keychain item being gone already satisfies that goal.
567 lines
20 KiB
Go
567 lines
20 KiB
Go
//go:build darwin
|
|
// +build darwin
|
|
|
|
package secret
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/awnumar/memguard"
|
|
keychain "github.com/keybase/go-keychain"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
const (
|
|
agePrivKeyPassphraseLength = 64
|
|
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
|
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant
|
|
)
|
|
|
|
// keychainItemNameRegex validates keychain item names
|
|
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
|
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
|
|
|
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
|
|
type KeychainUnlockerMetadata struct {
|
|
UnlockerMetadata
|
|
// Keychain item name
|
|
KeychainItemName string `json:"keychainItemName"`
|
|
}
|
|
|
|
// KeychainUnlocker represents a macOS Keychain-protected unlocker
|
|
type KeychainUnlocker struct {
|
|
Directory string
|
|
Metadata UnlockerMetadata
|
|
fs afero.Fs
|
|
}
|
|
|
|
// KeychainData represents the data stored in the macOS keychain
|
|
type KeychainData struct {
|
|
AgePublicKey string `json:"agePublicKey"`
|
|
AgePrivKeyPassphrase string `json:"agePrivKeyPassphrase"`
|
|
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
|
|
}
|
|
|
|
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
|
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|
DebugWith("Getting keychain unlocker identity",
|
|
slog.String("unlocker_id", k.GetID()),
|
|
slog.String("unlocker_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, "unlocker_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("unlocker_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, "unlocker_id", k.GetID())
|
|
|
|
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
|
}
|
|
|
|
Debug("Parsed keychain data successfully", "unlocker_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("unlocker_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", "unlocker_id", k.GetID())
|
|
// Create secure buffer for the keychain passphrase
|
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
|
|
defer passphraseBuffer.Destroy()
|
|
|
|
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
|
|
if err != nil {
|
|
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
|
|
|
|
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
|
|
}
|
|
defer agePrivKeyBuffer.Destroy()
|
|
|
|
DebugWith("Successfully decrypted age private key with keychain passphrase",
|
|
slog.String("unlocker_id", k.GetID()),
|
|
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
|
)
|
|
|
|
// Step 6: Parse the decrypted age private key
|
|
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
|
|
|
|
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
|
if err != nil {
|
|
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
|
|
|
|
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
|
}
|
|
|
|
DebugWith("Successfully parsed keychain age identity",
|
|
slog.String("unlocker_id", k.GetID()),
|
|
slog.String("public_key", ageIdentity.Recipient().String()),
|
|
)
|
|
|
|
return ageIdentity, nil
|
|
}
|
|
|
|
// GetType implements Unlocker interface
|
|
func (k *KeychainUnlocker) GetType() string {
|
|
return "keychain"
|
|
}
|
|
|
|
// GetMetadata implements Unlocker interface
|
|
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
|
|
return k.Metadata
|
|
}
|
|
|
|
// GetDirectory implements Unlocker interface
|
|
func (k *KeychainUnlocker) GetDirectory() string {
|
|
return k.Directory
|
|
}
|
|
|
|
// GetID implements Unlocker interface - generates ID from keychain item name
|
|
func (k *KeychainUnlocker) GetID() string {
|
|
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
|
|
// This matches the passphrase unlocker format
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
hostname = "unknown"
|
|
}
|
|
|
|
// Use the creation timestamp from metadata
|
|
createdAt := k.Metadata.CreatedAt
|
|
timestamp := createdAt.Format("2006-01-02.15.04")
|
|
|
|
return fmt.Sprintf("%s-%s-keychain", timestamp, hostname)
|
|
}
|
|
|
|
// Remove implements Unlocker interface - removes the keychain unlocker
|
|
func (k *KeychainUnlocker) 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, "unlocker_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 unlocker directory", "directory", k.Directory)
|
|
if err := k.fs.RemoveAll(k.Directory); err != nil {
|
|
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
|
|
|
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
|
}
|
|
|
|
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
|
|
|
return nil
|
|
}
|
|
|
|
// NewKeychainUnlocker creates a new KeychainUnlocker instance
|
|
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
|
|
return &KeychainUnlocker{
|
|
Directory: directory,
|
|
Metadata: metadata,
|
|
fs: fs,
|
|
}
|
|
}
|
|
|
|
// GetKeychainItemName returns the keychain item name from metadata
|
|
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
|
// Load the metadata
|
|
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
|
|
metadataData, err := afero.ReadFile(k.fs, metadataPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
|
|
}
|
|
|
|
var keychainMetadata KeychainUnlockerMetadata
|
|
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
|
|
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
|
|
}
|
|
|
|
return keychainMetadata.KeychainItemName, nil
|
|
}
|
|
|
|
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
|
|
func generateKeychainUnlockerName(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
|
|
}
|
|
|
|
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker
|
|
// Returns a LockedBuffer to ensure the private key is protected in memory
|
|
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
|
|
// Check if mnemonic is available in environment variable
|
|
envMnemonic := os.Getenv(EnvMnemonic)
|
|
if 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)
|
|
}
|
|
|
|
// Return the private key in a secure buffer
|
|
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
|
}
|
|
|
|
// Get the vault to access current unlocker
|
|
currentUnlocker, err := vault.GetCurrentUnlocker()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
|
}
|
|
|
|
// Get the current unlocker identity
|
|
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
|
|
}
|
|
|
|
// Get encrypted long-term key from current unlocker, handling different types
|
|
var encryptedLtPrivKey []byte
|
|
switch currentUnlocker := currentUnlocker.(type) {
|
|
case *PassphraseUnlocker:
|
|
// Read the encrypted long-term private key from passphrase unlocker
|
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
|
}
|
|
|
|
case *PGPUnlocker:
|
|
// Read the encrypted long-term private key from PGP unlocker
|
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
|
}
|
|
|
|
case *KeychainUnlocker:
|
|
// Read the encrypted long-term private key from another keychain unlocker
|
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
|
|
}
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
|
|
}
|
|
|
|
// Decrypt long-term private key using current unlocker
|
|
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
|
}
|
|
|
|
// Return the decrypted key buffer
|
|
return ltPrivKeyBuffer, nil
|
|
}
|
|
|
|
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
|
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
|
|
// Check if we're on macOS
|
|
if err := checkMacOSAvailable(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get current vault using the GetCurrentVault function from the same package
|
|
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 := generateKeychainUnlockerName(vault.GetName())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
|
}
|
|
|
|
// Create unlocker 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)
|
|
}
|
|
|
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
|
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
|
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
|
}
|
|
|
|
// Step 1: Generate a new age keypair for the keychain unlocker
|
|
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(agePrivKeyPassphraseLength)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
|
|
}
|
|
|
|
// Step 3: Store age recipient as plaintext
|
|
ageRecipient := ageIdentity.Recipient().String()
|
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
|
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
|
}
|
|
|
|
// Step 4: Encrypt age private key with the generated passphrase and store on disk
|
|
// Create secure buffers for both the private key and passphrase
|
|
agePrivKeyStr := ageIdentity.String()
|
|
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
|
|
defer agePrivKeyBuffer.Destroy()
|
|
|
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
|
|
defer passphraseBuffer.Destroy()
|
|
|
|
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, passphraseBuffer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
|
}
|
|
|
|
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
|
|
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); 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 := getLongTermPrivateKey(fs, vault)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer ltPrivKeyData.Destroy()
|
|
|
|
// Step 6: Encrypt long-term private key to the new age unlocker
|
|
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
|
}
|
|
|
|
// Write encrypted long-term private key
|
|
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
|
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
}
|
|
|
|
// Step 7: Prepare keychain data
|
|
keychainData := KeychainData{
|
|
AgePublicKey: ageRecipient,
|
|
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)
|
|
}
|
|
|
|
// Create a secure buffer for keychain data
|
|
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
|
|
defer keychainDataBuffer.Destroy()
|
|
|
|
// Step 8: Store data in keychain
|
|
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
|
|
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
|
|
}
|
|
|
|
// Step 9: Create and write enhanced metadata
|
|
keychainMetadata := KeychainUnlockerMetadata{
|
|
UnlockerMetadata: UnlockerMetadata{
|
|
Type: "keychain",
|
|
CreatedAt: time.Now(),
|
|
Flags: []string{"keychain", "macos"},
|
|
},
|
|
KeychainItemName: keychainItemName,
|
|
}
|
|
|
|
metadataBytes, err := json.MarshalIndent(keychainMetadata, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
|
}
|
|
|
|
if err := afero.WriteFile(fs,
|
|
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
|
metadataBytes, FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
}
|
|
|
|
return &KeychainUnlocker{
|
|
Directory: unlockerDir,
|
|
Metadata: keychainMetadata.UnlockerMetadata,
|
|
fs: fs,
|
|
}, nil
|
|
}
|
|
|
|
// checkMacOSAvailable verifies that we're running on macOS
|
|
func checkMacOSAvailable() error {
|
|
if runtime.GOOS != "darwin" {
|
|
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateKeychainItemName validates that a keychain item name is safe for command execution
|
|
func validateKeychainItemName(itemName string) error {
|
|
if itemName == "" {
|
|
return fmt.Errorf("keychain item name cannot be empty")
|
|
}
|
|
|
|
if !keychainItemNameRegex.MatchString(itemName) {
|
|
return fmt.Errorf("invalid keychain item name format: %s", itemName)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
|
|
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
|
if data == nil {
|
|
return fmt.Errorf("data buffer is nil")
|
|
}
|
|
if err := validateKeychainItemName(itemName); err != nil {
|
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
|
}
|
|
|
|
item := keychain.NewItem()
|
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
item.SetAccount(itemName)
|
|
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
|
item.SetDescription("Secret vault keychain data")
|
|
item.SetData([]byte(data.String()))
|
|
item.SetSynchronizable(keychain.SynchronizableNo)
|
|
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
|
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
|
|
|
|
// First try to delete any existing item
|
|
deleteItem := keychain.NewItem()
|
|
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
|
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
deleteItem.SetAccount(itemName)
|
|
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
|
|
|
// Add the new item
|
|
if err := keychain.AddItem(item); err != nil {
|
|
return fmt.Errorf("failed to store item in keychain: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
|
|
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
|
if err := validateKeychainItemName(itemName); err != nil {
|
|
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
|
}
|
|
|
|
query := keychain.NewItem()
|
|
query.SetSecClass(keychain.SecClassGenericPassword)
|
|
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
query.SetAccount(itemName)
|
|
query.SetMatchLimit(keychain.MatchLimitOne)
|
|
query.SetReturnData(true)
|
|
|
|
results, err := keychain.QueryItem(query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
|
}
|
|
|
|
return results[0].Data, nil
|
|
}
|
|
|
|
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
|
|
// If the item doesn't exist, this function returns nil (not an error) since the goal
|
|
// is to ensure the item is gone, and it already being gone satisfies that goal.
|
|
func deleteFromKeychain(itemName string) error {
|
|
if err := validateKeychainItemName(itemName); err != nil {
|
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
|
}
|
|
|
|
item := keychain.NewItem()
|
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
item.SetAccount(itemName)
|
|
|
|
if err := keychain.DeleteItem(item); err != nil {
|
|
// If the item doesn't exist, that's not an error - the goal is to ensure
|
|
// the item is gone, and it already being gone satisfies that goal.
|
|
// This is important for cleaning up unlocker directories when the keychain
|
|
// item has already been removed (e.g., manually by user, or synced vault
|
|
// from a different machine).
|
|
if err == keychain.ErrorItemNotFound {
|
|
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
|
|
|
|
return 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")
|
|
}
|