Files
secret/internal/secret/keychainunlocker.go
T
sneak 4a32252504
check / check (push) Waiting to run
Type-check and lint the macOS build from Linux (closes #50)
script/lint-darwin (make lint-darwin; run by script/check, and its
commands by the Dockerfile lint stage) runs go vet and golangci-lint
with GOOS=darwin and cgo off. Compiling cgo for macOS needs Apple's SDK,
so the three functions that call go-keychain, which is cgo there, move
to keychainunlocker_cgo.go; a macOS build without cgo gets
keychainunlocker_nocgo.go and the macse stub, whose errors name the
missing macOS build with cgo. The rest of the keychain unlocker and its
plain-Go tests are now checked; their findings are fixed without
changing behaviour, and lines over 88 columns in the unchecked files
are wrapped.

Model: opus-5-5
2026-10-04 15:59:24 +00:00

581 lines
18 KiB
Go

//go:build darwin
package secret
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"runtime"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
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 (
errNotMacOS = errors.New(
"keychain unlockers are only supported on macOS")
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 - 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)
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-<vault>-<hostname>-<time>
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
}
// getLongTermPrivateKey derives the long-term private key from mnemonic when
// it is not nil, else gets it through the current unlocker, which is given
// passphrase when it is a passphrase unlocker.
// Returns a LockedBuffer to ensure the private key is protected in memory
func getLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
if mnemonic != nil {
return deriveLongTermPrivateKey(fs, vault, mnemonic)
}
// 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)
}
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
// 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, errUnsupportedCurrentUnlocker
}
// 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
}
// deriveLongTermPrivateKey derives the long-term private key from mnemonic at
// the vault's derivation index, for getLongTermPrivateKey and
// getLongTermKeyForSE.
func deriveLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
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
}
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
// vault. The long-term key comes from mnemonic when it is not nil, else from
// the current unlocker, as getLongTermPrivateKey describes.
func CreateKeychainUnlocker(
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
// Check if we're on macOS
err := checkMacOSAvailable()
if 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)
}
// The unlocker directory is named after the keychain item
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)
// 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)
}
ageRecipient := ageIdentity.Recipient().String()
// 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)
}
defer agePrivKeyPassphrase.Destroy()
// Step 3: Encrypt age private key with the generated passphrase
// Create a secure buffer for the private key
agePrivKeyStr := ageIdentity.String()
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
defer agePrivKeyBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase(
agePrivKeyBuffer, agePrivKeyPassphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
}
// Step 4: Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, err
}
defer ltPrivKeyData.Destroy()
// Step 5: 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)
}
// Step 6: Prepare keychain data
keychainData := KeychainData{
AgePublicKey: ageRecipient,
AgePrivKeyPassphrase: agePrivKeyPassphrase,
EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge),
}
keychainDataBuffer, err := keychainData.encode()
if err != nil {
return nil, fmt.Errorf("failed to encode keychain data: %w", err)
}
defer keychainDataBuffer.Destroy()
return writeKeychainUnlocker(fs, unlockerDir, keychainItemName, ageRecipient,
encryptedAgePrivKey, encryptedLtPrivKeyToAge, keychainDataBuffer)
}
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
func writeKeychainUnlocker(
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
encryptedAgePrivKey, encryptedLtPrivKey []byte,
keychainDataBuffer *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
// Step 7: Prepare enhanced metadata
keychainMetadata := KeychainUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: keychainUnlockerType,
CreatedAt: time.Now(),
Flags: []string{keychainUnlockerType, macOSFlag},
},
KeychainItemName: keychainItemName,
}
metadataBytes, err := json.MarshalIndent(keychainMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
}
// Step 8: Write the unlocker's files and store the data in the keychain,
// the metadata last
err = WriteDir(fs, unlockerDir, func(dir string) error {
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
if err != nil {
return fmt.Errorf("failed to write age recipient: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted age private key: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
err = storeInKeychain(keychainItemName, keychainDataBuffer)
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
}
return nil
})
if err != nil {
return nil, 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("%w, current OS: %s", errNotMacOS, runtime.GOOS)
}
return nil
}
// validateKeychainItemName validates that a keychain item name is safe for
// command execution
func validateKeychainItemName(itemName string) error {
if itemName == "" {
return errKeychainItemNameEmpty
}
if !keychainItemNameRegex.MatchString(itemName) {
return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
}
return nil
}