//go:build darwin // +build darwin package secret import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "time" "filippo.io/age" "git.eeqj.de/sneak/secret/internal/macse" "git.eeqj.de/sneak/secret/pkg/agehd" "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 } // 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) if err := macse.DeleteKey(seKeyHash); 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) if err := RemoveDirAtomic(s.fs, s.Directory); 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() (label string, hash string, err 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 if err := json.Unmarshal(metadataData, &seMetadata); err != nil { return "", "", fmt.Errorf("failed to parse SE metadata: %w", err) } return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil } // NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance. func NewSecureEnclaveUnlocker( fs afero.Fs, directory string, metadata UnlockerMetadata, ) *SecureEnclaveUnlocker { return &SecureEnclaveUnlocker{ Directory: directory, Metadata: metadata, fs: fs, } } // 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) } enrollmentDate := time.Now().UTC().Format("2006-01-02") return fmt.Sprintf( "%s.%s-%s-%s", seKeyLabelPrefix, vaultName, hostname, enrollmentDate, ), 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. func CreateSecureEnclaveUnlocker( fs afero.Fs, stateDir string, ) (*SecureEnclaveUnlocker, error) { if err := checkMacOSAvailable(); 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) 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: Create unlocker directory and write files vaultDir, err := vault.GetDirectory() if err != nil { return nil, fmt.Errorf("failed to get vault directory: %w", err) } unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel)) unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName) if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil { return nil, fmt.Errorf( "failed to create unlocker directory: %w", err, ) } // Write SE-encrypted long-term key ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename) if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil { return nil, fmt.Errorf( "failed to write SE-encrypted long-term key: %w", err, ) } // Write metadata seMetadata := SecureEnclaveUnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{ Type: seUnlockerType, CreatedAt: time.Now().UTC(), Flags: []string{seUnlockerType, "macos"}, }, SEKeyLabel: seKeyLabel, SEKeyHash: seKeyHash, } metadataBytes, err := json.MarshalIndent(seMetadata, "", " ") if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json") if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil { return nil, fmt.Errorf("failed to write metadata: %w", err) } return &SecureEnclaveUnlocker{ Directory: unlockerDir, Metadata: seMetadata.UnlockerMetadata, fs: fs, }, nil } // getLongTermKeyForSE retrieves the vault's long-term private key // either from the mnemonic env var or by unlocking via the current unlocker. func getLongTermKeyForSE( fs afero.Fs, vault VaultInterface, ) (*memguard.LockedBuffer, error) { envMnemonic := os.Getenv(EnvMnemonic) if envMnemonic != "" { // 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 if err := json.Unmarshal(metadataBytes, &metadata); 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( envMnemonic, metadata.DerivationIndex, ) if err != nil { return nil, fmt.Errorf( "failed to derive long-term key from mnemonic: %w", err, ) } return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil } currentUnlocker, err := vault.GetCurrentUnlocker() if err != nil { return nil, fmt.Errorf("failed to get current unlocker: %w", err) } 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 }