//go:build darwin package secret import ( "encoding/json" "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. func (s *SecureEnclaveUnlocker) GetID() string { hostname, err := os.Hostname() if err != nil { hostname = "unknown" } createdAt := s.Metadata.CreatedAt timestamp := createdAt.Format("2006-01-02.15.04") return fmt.Sprintf("%s-%s-%s", timestamp, hostname, seUnlockerType) } // 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. 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: 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) // Step 2: 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 3: Encrypt the long-term key directly with the SE (ECIES) encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes()) if err != nil { return nil, fmt.Errorf( "failed to encrypt long-term key with SE: %w", err, ) } // Step 4: Prepare the unlocker directory's path and metadata 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) 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 }