Files
secret/internal/secret/seunlocker_darwin.go
T
clawbot 32a61ff963
check / check (push) Waiting to run
Lock the state directory and write vault files atomically (closes #34)
Each command that changes the state directory holds one lock: flock(2)
on `lock` in the state directory, dropped by the kernel if the process
dies, or a process-wide mutex on the in-memory test filesystem. It
covers the state directory, not each vault, because `currentvault`,
`vault create` and cross-vault moves span vaults, and a lock file in a
vault would be deleted by `vault remove` under a waiting command.

Files go through `secret.WriteFileAtomic`; versions, new secrets and
cross-vault copies are built in a temporary directory and renamed into
place; removals rename out of the way first. Left for later: replacing
an unlocker (#71) and deleting
what an interrupted command leaves under a `.tmp-` name
(#75).

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-04 04:57:58 +02:00

386 lines
10 KiB
Go

//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
}