Add Secure Enclave unlocker for hardware-backed secret protection
Adds a new "secure-enclave" unlocker type that stores the vault's long-term private key encrypted by a non-exportable P-256 key held in the Secure Enclave hardware. Decryption (ECDH) is performed inside the SE; the key never leaves the hardware. Uses CryptoTokenKit identities created via sc_auth, which allows SE access from unsigned binaries without Apple Developer Program membership. ECIES (X963SHA256 + AES-GCM) handles encryption and decryption through Security.framework. New package internal/macse/ provides the CGo bridge to Security.framework for SE key creation, ECIES encrypt/decrypt, and key deletion. The SE unlocker directly encrypts the vault long-term key (no intermediate age keypair).
This commit is contained in:
@@ -129,55 +129,12 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
slog.String("unlocker_id", unlocker.GetID()),
|
||||
)
|
||||
|
||||
// Get unlocker identity
|
||||
unlockerIdentity, err := unlocker.GetIdentity()
|
||||
// Get the long-term key via the unlocker.
|
||||
// SE unlockers return the long-term key directly from GetIdentity().
|
||||
// Other unlockers return their own identity, used to decrypt longterm.age.
|
||||
ltIdentity, err := v.unlockLongTermKey(unlocker)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get unlocker identity", "error", err, "unlocker_type", unlocker.GetType())
|
||||
|
||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Read encrypted long-term private key from unlocker directory
|
||||
unlockerDir := unlocker.GetDirectory()
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
secret.Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Read encrypted long-term private key",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlocker_type", unlocker.GetType()),
|
||||
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
|
||||
)
|
||||
|
||||
// Decrypt long-term private key using unlocker
|
||||
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
|
||||
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
secret.DebugWith("Successfully decrypted long-term private key",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlocker_type", unlocker.GetType()),
|
||||
slog.Int("decrypted_length", ltPrivKeyBuffer.Size()),
|
||||
)
|
||||
|
||||
// Parse long-term private key
|
||||
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
|
||||
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully obtained long-term identity via unlocker",
|
||||
@@ -194,6 +151,47 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// unlockLongTermKey extracts the vault's long-term key using the given unlocker.
|
||||
// SE unlockers decrypt the long-term key directly; other unlockers use an intermediate identity.
|
||||
func (v *Vault) unlockLongTermKey(unlocker secret.Unlocker) (*age.X25519Identity, error) {
|
||||
if unlocker.GetType() == "secure-enclave" {
|
||||
secret.Debug("SE unlocker: decrypting long-term key directly via Secure Enclave")
|
||||
|
||||
ltIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term key via SE: %w", err)
|
||||
}
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// Standard unlockers: get unlocker identity, then decrypt longterm.age
|
||||
unlockerIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
}
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// GetDirectory returns the vault's directory path
|
||||
func (v *Vault) GetDirectory() (string, error) {
|
||||
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
|
||||
|
||||
Reference in New Issue
Block a user