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>
386 lines
10 KiB
Go
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 := s.fs.RemoveAll(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 := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); 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 := afero.WriteFile(fs, metadataPath, metadataBytes, FilePerms); 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
|
|
}
|