secret/internal/secret/keychainunlocker.go
sneak a09fa89f30 Fix cross-platform build issues and security vulnerabilities
- Add build tags to keychain implementation files (Darwin-only)
- Create stub implementations for non-Darwin platforms that panic
- Conditionally show keychain support in help text based on platform
- Platform check in UnlockersAdd prevents keychain usage on non-Darwin
- Verified GPG operations already protected against command injection
  via validateGPGKeyID() and proper exec.Command argument passing
- Keychain operations use go-keychain library, no shell commands

The application now builds and runs on Linux/non-Darwin platforms with
keychain functionality properly isolated to macOS only.
2025-07-21 22:05:23 +02:00

551 lines
19 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 using keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
// The vault metadata is corrupt - this is a fatal error
// We cannot continue with a fallback ID as that would mask data corruption
panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err))
}
return fmt.Sprintf("%s-keychain", keychainItemName)
}
// 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
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 {
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")
}