1 Commits

Author SHA1 Message Date
a3d3fb3b69 secure-enclave-unlocker (#24)
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #24
Reviewed-by: clawbot <clawbot@noreply.example.org>
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-03-14 07:36:28 +01:00
2 changed files with 89 additions and 21 deletions

View File

@@ -60,7 +60,10 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
encryptedPath := filepath.Join(s.Directory, seLongtermFilename) encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
encryptedData, err := afero.ReadFile(s.fs, encryptedPath) encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read SE-encrypted long-term key: %w", err) return nil, fmt.Errorf(
"failed to read SE-encrypted long-term key: %w",
err,
)
} }
DebugWith("Read SE-encrypted long-term key", DebugWith("Read SE-encrypted long-term key",
@@ -70,7 +73,10 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Decrypt using the Secure Enclave (ECDH happens inside SE hardware) // Decrypt using the Secure Enclave (ECDH happens inside SE hardware)
decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData) decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term key with SE: %w", err) return nil, fmt.Errorf(
"failed to decrypt long-term key with SE: %w",
err,
)
} }
// Parse the decrypted long-term private key // Parse the decrypted long-term private key
@@ -82,7 +88,10 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse long-term private key: %w", err) return nil, fmt.Errorf(
"failed to parse long-term private key: %w",
err,
)
} }
DebugWith("Successfully decrypted long-term key via SE", DebugWith("Successfully decrypted long-term key via SE",
@@ -165,7 +174,11 @@ func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err e
} }
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance. // NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *SecureEnclaveUnlocker { func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{ return &SecureEnclaveUnlocker{
Directory: directory, Directory: directory,
Metadata: metadata, Metadata: metadata,
@@ -182,13 +195,22 @@ func generateSEKeyLabel(vaultName string) (string, error) {
enrollmentDate := time.Now().UTC().Format("2006-01-02") enrollmentDate := time.Now().UTC().Format("2006-01-02")
return fmt.Sprintf("%s.%s-%s-%s", seKeyLabelPrefix, vaultName, hostname, enrollmentDate), nil return fmt.Sprintf(
"%s.%s-%s-%s",
seKeyLabelPrefix,
vaultName,
hostname,
enrollmentDate,
), nil
} }
// CreateSecureEnclaveUnlocker creates a new SE unlocker. // CreateSecureEnclaveUnlocker creates a new SE unlocker.
// The vault's long-term private key is encrypted directly by the Secure Enclave // The vault's long-term private key is encrypted directly by the Secure Enclave
// using ECIES. No intermediate age keypair is used. // using ECIES. No intermediate age keypair is used.
func CreateSecureEnclaveUnlocker(fs afero.Fs, stateDir string) (*SecureEnclaveUnlocker, error) { func CreateSecureEnclaveUnlocker(
fs afero.Fs,
stateDir string,
) (*SecureEnclaveUnlocker, error) {
if err := checkMacOSAvailable(); err != nil { if err := checkMacOSAvailable(); err != nil {
return nil, err return nil, err
} }
@@ -216,14 +238,20 @@ func CreateSecureEnclaveUnlocker(fs afero.Fs, stateDir string) (*SecureEnclaveUn
// Step 2: Get the vault's long-term private key // Step 2: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault) ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get long-term private key: %w", err) return nil, fmt.Errorf(
"failed to get long-term private key: %w",
err,
)
} }
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
// Step 3: Encrypt the long-term key directly with the SE (ECIES) // Step 3: Encrypt the long-term key directly with the SE (ECIES)
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes()) encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term key with SE: %w", err) return nil, fmt.Errorf(
"failed to encrypt long-term key with SE: %w",
err,
)
} }
// Step 4: Create unlocker directory and write files // Step 4: Create unlocker directory and write files
@@ -235,13 +263,19 @@ func CreateSecureEnclaveUnlocker(fs afero.Fs, stateDir string) (*SecureEnclaveUn
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel)) unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName) unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil { if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err) return nil, fmt.Errorf(
"failed to create unlocker directory: %w",
err,
)
} }
// Write SE-encrypted long-term key // Write SE-encrypted long-term key
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename) ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil { if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write SE-encrypted long-term key: %w", err) return nil, fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
} }
// Write metadata // Write metadata
@@ -274,7 +308,10 @@ func CreateSecureEnclaveUnlocker(fs afero.Fs, stateDir string) (*SecureEnclaveUn
// getLongTermKeyForSE retrieves the vault's long-term private key // getLongTermKeyForSE retrieves the vault's long-term private key
// either from the mnemonic env var or by unlocking via the current unlocker. // either from the mnemonic env var or by unlocking via the current unlocker.
func getLongTermKeyForSE(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) { func getLongTermKeyForSE(
fs afero.Fs,
vault VaultInterface,
) (*memguard.LockedBuffer, error) {
envMnemonic := os.Getenv(EnvMnemonic) envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" { if envMnemonic != "" {
// Read vault metadata to get the correct derivation index // Read vault metadata to get the correct derivation index
@@ -295,9 +332,16 @@ func getLongTermKeyForSE(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuf
} }
// Use mnemonic with the vault's actual derivation index // Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(
envMnemonic,
metadata.DerivationIndex,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w",
err,
)
} }
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
@@ -310,17 +354,29 @@ func getLongTermKeyForSE(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuf
currentIdentity, err := currentUnlocker.GetIdentity() currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err) return nil, fmt.Errorf(
"failed to get current unlocker identity: %w",
err,
)
} }
// All unlocker types store longterm.age in their directory // All unlocker types store longterm.age in their directory
longtermPath := filepath.Join(currentUnlocker.GetDirectory(), "longterm.age") longtermPath := filepath.Join(
currentUnlocker.GetDirectory(),
"longterm.age",
)
encryptedLtKey, err := afero.ReadFile(fs, longtermPath) encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key: %w", err) return nil, fmt.Errorf(
"failed to read encrypted long-term key: %w",
err,
)
} }
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtKey, currentIdentity) ltPrivKeyBuffer, err := DecryptWithIdentity(
encryptedLtKey,
currentIdentity,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err) return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
} }

View File

@@ -10,7 +10,9 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
var errSENotSupported = fmt.Errorf("secure enclave unlockers are only supported on macOS") var errSENotSupported = fmt.Errorf(
"secure enclave unlockers are only supported on macOS",
)
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms. // SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
type SecureEnclaveUnlockerMetadata struct { type SecureEnclaveUnlockerMetadata struct {
@@ -48,7 +50,10 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
// GetID returns the unlocker ID. // GetID returns the unlocker ID.
func (s *SecureEnclaveUnlocker) GetID() string { func (s *SecureEnclaveUnlocker) GetID() string {
return fmt.Sprintf("%s-secure-enclave", s.Metadata.CreatedAt.Format("2006-01-02.15.04")) return fmt.Sprintf(
"%s-secure-enclave",
s.Metadata.CreatedAt.Format("2006-01-02.15.04"),
)
} }
// Remove returns an error on non-Darwin platforms. // Remove returns an error on non-Darwin platforms.
@@ -58,7 +63,11 @@ func (s *SecureEnclaveUnlocker) Remove() error {
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on non-Darwin platforms. // NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on non-Darwin platforms.
// The returned instance's methods that require macOS functionality will return errors. // The returned instance's methods that require macOS functionality will return errors.
func NewSecureEnclaveUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *SecureEnclaveUnlocker { func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{ return &SecureEnclaveUnlocker{
Directory: directory, Directory: directory,
Metadata: metadata, Metadata: metadata,
@@ -67,6 +76,9 @@ func NewSecureEnclaveUnlocker(fs afero.Fs, directory string, metadata UnlockerMe
} }
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms. // CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
func CreateSecureEnclaveUnlocker(_ afero.Fs, _ string) (*SecureEnclaveUnlocker, error) { func CreateSecureEnclaveUnlocker(
_ afero.Fs,
_ string,
) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported return nil, errSENotSupported
} }