Files
secret/internal/secret/seunlocker_darwin.go
T
clawbot 0e6a4afb71
check / check (push) Failing after 2s
Make an unlocker's ID the name of its directory (closes #98)
Keychain and Secure Enclave unlocker IDs were the creation time to the
minute plus the host name, and passphrase unlocker IDs the time to the
minute, so two created within one minute shared an ID, and `unlocker
select`, `unlocker remove` and the selection after `unlocker add` acted on
the older one. Every unlocker's ID is now its directory name, unique in
its vault. `vault.ListUnlockers` returns each unlocker's metadata keyed by
that name, so `unlocker list` and shell completion no longer find IDs by
matching metadata. PGP unlocker IDs were `pgp-<fingerprint>`; a second
PGP unlocker for one key is refused by comparing fingerprints in metadata.

Model: opus-5-5
2026-10-04 21:25:00 +02:00

403 lines
11 KiB
Go

//go:build darwin
package secret
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
const (
// seKeyLabelPrefix is the prefix for Secure Enclave CTK identity labels.
seKeyLabelPrefix = "berlin.sneak.app.secret.se"
// seUnlockerType is the metadata type string for Secure Enclave unlockers.
seUnlockerType = "secure-enclave"
// seLongtermFilename is the filename for the SE-encrypted vault long-term private key.
seLongtermFilename = "longterm.age.se"
)
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata
SEKeyLabel string `json:"seKeyLabel"`
SEKeyHash string `json:"seKeyHash"`
}
// SecureEnclaveUnlocker represents a Secure Enclave-protected unlocker.
type SecureEnclaveUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for SE-based unlockers.
// Decrypts the vault's long-term private key directly using the Secure Enclave.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting SE unlocker identity",
slog.String("unlocker_id", s.GetID()),
)
// Get SE key label from metadata
seKeyLabel, _, err := s.getSEKeyInfo()
if err != nil {
return nil, fmt.Errorf("failed to get SE key info: %w", err)
}
// Read ECIES-encrypted long-term private key from disk
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
if err != nil {
return nil, fmt.Errorf(
"failed to read SE-encrypted long-term key: %w",
err,
)
}
DebugWith("Read SE-encrypted long-term key",
slog.Int("encrypted_length", len(encryptedData)),
)
// Decrypt using the Secure Enclave (ECDH happens inside SE hardware)
decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData)
if err != nil {
return nil, fmt.Errorf(
"failed to decrypt long-term key with SE: %w",
err,
)
}
// Parse the decrypted long-term private key
ltIdentity, err := age.ParseX25519Identity(string(decryptedData))
// Clear sensitive data immediately
for i := range decryptedData {
decryptedData[i] = 0
}
if err != nil {
return nil, fmt.Errorf(
"failed to parse long-term private key: %w",
err,
)
}
DebugWith("Successfully decrypted long-term key via SE",
slog.String("unlocker_id", s.GetID()),
)
return ltIdentity, nil
}
// GetType implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetType() string {
return seUnlockerType
}
// GetMetadata implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
return s.Metadata
}
// GetDirectory implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory
}
// GetID implements Unlocker interface: the name of the unlocker's directory.
func (s *SecureEnclaveUnlocker) GetID() string {
return filepath.Base(s.Directory)
}
// Remove implements Unlocker interface.
func (s *SecureEnclaveUnlocker) Remove() error {
_, seKeyHash, err := s.getSEKeyInfo()
if err != nil {
Debug("Failed to get SE key info during removal", "error", err)
return fmt.Errorf("failed to get SE key info: %w", err)
}
if seKeyHash != "" {
Debug("Deleting SE key", "hash", seKeyHash)
err = macse.DeleteKey(seKeyHash)
if err != nil {
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
return fmt.Errorf("failed to delete SE key: %w", err)
}
}
Debug("Removing SE unlocker directory", "directory", s.Directory)
err = RemoveDirAtomic(s.fs, s.Directory)
if err != nil {
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
}
Debug("Successfully removed SE unlocker", "unlocker_id", s.GetID())
return nil
}
// getSEKeyInfo reads the SE key label and hash from metadata.
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(s.fs, metadataPath)
if err != nil {
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
}
var seMetadata SecureEnclaveUnlockerMetadata
err = json.Unmarshal(metadataData, &seMetadata)
if err != nil {
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
}
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
}
// generateSEKeyLabel generates a unique label for the SE CTK identity.
func generateSEKeyLabel(vaultName string) (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf(
"%s.%s-%s-%s",
seKeyLabelPrefix,
vaultName,
hostname,
enrollmentTime,
), nil
}
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
// The vault's long-term private key is encrypted directly by the Secure Enclave
// using ECIES. No intermediate age keypair is used.
// The long-term key comes from mnemonic when it is not nil, else from the
// current unlocker, as getLongTermKeyForSE describes.
// The SE key is created once the long-term key is in hand and the unlocker's
// path is known, and is deleted again if a later step fails.
func CreateSecureEnclaveUnlocker(
fs afero.Fs,
stateDir string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
err := checkMacOSAvailable()
if err != nil {
return nil, err
}
vault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
// Generate SE key label
seKeyLabel, err := generateSEKeyLabel(vault.GetName())
if err != nil {
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
}
// Step 1: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, fmt.Errorf(
"failed to get long-term private key: %w",
err,
)
}
defer ltPrivKeyData.Destroy()
// Step 2: Prepare the unlocker directory's path
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
// Step 3: Create P-256 key in the Secure Enclave via sc_auth
Debug("Creating Secure Enclave key", "label", seKeyLabel)
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err)
}
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
ltPrivKeyData)
if err != nil {
deleteErr := macse.DeleteKey(seKeyHash)
if deleteErr != nil {
err = errors.Join(err, fmt.Errorf(
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
}
return nil, err
}
return unlocker, nil
}
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
// new unlocker into unlockerDir (steps 4 and 5 of
// CreateSecureEnclaveUnlocker).
func writeSEUnlocker(
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
ltPrivKeyData *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
// prepare the metadata
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
if err != nil {
return nil, fmt.Errorf(
"failed to encrypt long-term key with SE: %w",
err,
)
}
seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
Flags: []string{seUnlockerType, macOSFlag},
},
SEKeyLabel: seKeyLabel,
SEKeyHash: seKeyHash,
}
metadataBytes, err := json.MarshalIndent(seMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
// Step 5: Write the SE-encrypted long-term key, then the metadata
err = WriteDir(fs, unlockerDir, func(dir string) error {
return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
})
if err != nil {
return nil, err
}
return &SecureEnclaveUnlocker{
Directory: unlockerDir,
Metadata: seMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// writeSEUnlockerFiles writes the files of a new SE unlocker into dir: the
// SE-encrypted long-term key, then the metadata.
func writeSEUnlockerFiles(
fs afero.Fs, dir string, encryptedLtKey, metadataBytes []byte,
) error {
err := WriteFileAtomic(fs, filepath.Join(dir, seLongtermFilename),
encryptedLtKey)
if err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
err = WriteFileAtomic(fs,
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
// from mnemonic when it is not nil, else through the current unlocker, which
// is given passphrase when it is a passphrase unlocker.
func getLongTermKeyForSE(
fs afero.Fs,
vault VaultInterface,
mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
if mnemonic != nil {
return deriveLongTermPrivateKey(fs, vault, mnemonic)
}
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
}
currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf(
"failed to get current unlocker identity: %w",
err,
)
}
// All unlocker types store longterm.age in their directory
longtermPath := filepath.Join(
currentUnlocker.GetDirectory(),
"longterm.age",
)
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
if err != nil {
return nil, fmt.Errorf(
"failed to read encrypted long-term key: %w",
err,
)
}
ltPrivKeyBuffer, err := DecryptWithIdentity(
encryptedLtKey,
currentIdentity,
)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
}
return ltPrivKeyBuffer, nil
}