check / check (push) Failing after 2s
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
403 lines
11 KiB
Go
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
|
|
}
|