//go:build darwin package secret import ( "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" "regexp" "time" "filippo.io/age" "github.com/awnumar/memguard" "github.com/spf13/afero" "sneak.berlin/go/secret/pkg/agehd" ) const ( agePrivKeyPassphraseLength = 64 // KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items // //nolint:revive // ALL_CAPS is intentional for this constant KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" // keychainUnlockerType is the metadata type string for keychain unlockers. keychainUnlockerType = "keychain" // macOSFlag is the unlocker metadata flag of the macOS-only unlockers. macOSFlag = "macos" ) // keychainItemNameRegex validates keychain item names // Allows alphanumeric characters, dots, hyphens, and underscores only var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) var ( errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty") errInvalidKeychainItemName = errors.New("invalid keychain item name format") errUnsupportedCurrentUnlocker = errors.New( "unsupported current unlocker type for keychain unlocker creation") ) // 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 } // NewKeychainUnlocker creates a new KeychainUnlocker instance func NewKeychainUnlocker( fs afero.Fs, directory string, metadata UnlockerMetadata, ) *KeychainUnlocker { return &KeychainUnlocker{ Directory: directory, Metadata: metadata, fs: fs, } } // 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()), ) keychainData, err := k.readKeychainData() if err != nil { return nil, err } defer keychainData.AgePrivKeyPassphrase.Destroy() // 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()) agePrivKeyBuffer, err := DecryptWithPassphrase( encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase) 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 keychainUnlockerType } // 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: the name of the unlocker's directory func (k *KeychainUnlocker) GetID() string { return filepath.Base(k.Directory) } // 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) err = deleteFromKeychain(keychainItemName) if 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) err = RemoveDirAtomic(k.fs, k.Directory) if 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 } // 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 err = json.Unmarshal(metadataData, &keychainMetadata) if err != nil { return "", fmt.Errorf("failed to parse keychain metadata: %w", err) } return keychainMetadata.KeychainItemName, nil } // readKeychainData reads and parses the data this unlocker keeps in the // keychain (steps 1 to 3 of GetIdentity). The caller must destroy the // returned AgePrivKeyPassphrase. func (k *KeychainUnlocker) readKeychainData() (*KeychainData, error) { // 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)), ) // Move the keychain data into locked memory; this wipes keychainDataBytes keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes) defer keychainDataBuffer.Destroy() // Step 3: Parse keychain data keychainData, err := decodeKeychainData(keychainDataBuffer) if 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()) return keychainData, 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---