diff --git a/README.md b/README.md index fc50b82..8eaaf0e 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ Creates a new unlocker of the specified type: - `passphrase`: Traditional passphrase-protected unlocker - `pgp`: Uses an existing GPG key for encryption/decryption - `keychain`: macOS Keychain integration (macOS only) +- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only) **Options:** - `--keyid `: GPG key ID (optional for PGP type, uses default key if not specified) @@ -286,11 +287,11 @@ Unlockers provide different authentication methods to access the long-term keys: - Automatic unlocking when Keychain is unlocked - Cross-application integration -4. **Secure Enclave Unlockers** (macOS - planned): +4. **Secure Enclave Unlockers** (macOS): - Hardware-backed key storage using Apple Secure Enclave - - Currently partially implemented but non-functional - - Requires Apple Developer Program membership and code signing entitlements - - Full implementation blocked by entitlement requirements + - Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required) + - ECIES encryption: vault long-term key encrypted directly by SE hardware + - Protected by biometric authentication (Touch ID) or system password Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets. @@ -330,8 +331,7 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te - Hardware token support via PGP/GPG integration - macOS Keychain integration for system-level security -- Secure Enclave support planned (requires paid Apple Developer Program for - signed entitlements to access the SEP and doxxing myself to Apple) +- Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit) ## Examples @@ -385,6 +385,7 @@ secret vault remove personal --force secret unlocker add passphrase # Password-based secret unlocker add pgp --keyid ABCD1234 # GPG key secret unlocker add keychain # macOS Keychain (macOS only) +secret unlocker add secure-enclave # macOS Secure Enclave (macOS only) # List unlockers secret unlocker list @@ -443,7 +444,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt ### Cross-Platform Support -- **macOS**: Full support including Keychain and planned Secure Enclave integration +- **macOS**: Full support including Keychain and Secure Enclave integration - **Linux**: Full support (excluding macOS-specific features) ## Security Considerations @@ -487,7 +488,7 @@ go test -tags=integration -v ./internal/cli # Integration tests ## Features -- **Multiple Authentication Methods**: Supports passphrase, PGP, and macOS Keychain unlockers +- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers - **Vault Isolation**: Complete separation between different vaults - **Per-Secret Encryption**: Each secret has its own encryption key - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases diff --git a/internal/cli/info.go b/internal/cli/info.go index f62805e..e993d91 100644 --- a/internal/cli/info.go +++ b/internal/cli/info.go @@ -1,10 +1,10 @@ package cli import ( - "log" "encoding/json" "fmt" "io" + "log" "path/filepath" "runtime" "strings" diff --git a/internal/cli/init.go b/internal/cli/init.go index 1390506..14590bc 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,8 +1,8 @@ package cli import ( - "log" "fmt" + "log" "log/slog" "os" "path/filepath" diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index f6f6e74..ee66aac 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -1,10 +1,10 @@ package cli import ( - "log" "encoding/json" "fmt" "io" + "log" "path/filepath" "strings" diff --git a/internal/cli/unlockers.go b/internal/cli/unlockers.go index 8615586..0c1d3b0 100644 --- a/internal/cli/unlockers.go +++ b/internal/cli/unlockers.go @@ -1,9 +1,9 @@ package cli import ( - "log" "encoding/json" "fmt" + "log" "os" "os/exec" "path/filepath" diff --git a/internal/cli/vault.go b/internal/cli/vault.go index 0ae5f07..dcd54e0 100644 --- a/internal/cli/vault.go +++ b/internal/cli/vault.go @@ -1,9 +1,9 @@ package cli import ( - "log" "encoding/json" "fmt" + "log" "os" "path/filepath" "strings" diff --git a/internal/cli/version.go b/internal/cli/version.go index a29fbfa..a9ded8b 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -1,8 +1,8 @@ package cli import ( - "log" "fmt" + "log" "path/filepath" "strings" "text/tabwriter" diff --git a/internal/secret/derivation_index_test.go b/internal/secret/derivation_index_test.go index ad86553..653e384 100644 --- a/internal/secret/derivation_index_test.go +++ b/internal/secret/derivation_index_test.go @@ -24,12 +24,12 @@ type realVault struct { func (v *realVault) GetDirectory() (string, error) { return filepath.Join(v.stateDir, "vaults.d", v.name), nil } -func (v *realVault) GetName() string { return v.name } +func (v *realVault) GetName() string { return v.name } func (v *realVault) GetFilesystem() afero.Fs { return v.fs } // Unused by getLongTermPrivateKey — these satisfy VaultInterface. func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") } -func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") } +func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") } func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) { panic("not used") } diff --git a/internal/secret/secret_test.go b/internal/secret/secret_test.go index 3639dd2..a8560a1 100644 --- a/internal/secret/secret_test.go +++ b/internal/secret/secret_test.go @@ -284,11 +284,11 @@ func TestSecretNameValidation(t *testing.T) { {"valid/path/name", true}, {"123valid", true}, {"", false}, - {"Valid-Upper-Name", true}, // uppercase allowed - {"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true}, // real-world uppercase key ID - {"MixedCase/Path/Name", true}, // mixed case with path - {"invalid name", false}, // space not allowed - {"invalid@name", false}, // @ not allowed + {"Valid-Upper-Name", true}, // uppercase allowed + {"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true}, // real-world uppercase key ID + {"MixedCase/Path/Name", true}, // mixed case with path + {"invalid name", false}, // space not allowed + {"invalid@name", false}, // @ not allowed } for _, test := range tests { diff --git a/internal/secret/seunlocker_darwin.go b/internal/secret/seunlocker_darwin.go index e54babd..9d92717 100644 --- a/internal/secret/seunlocker_darwin.go +++ b/internal/secret/seunlocker_darwin.go @@ -60,7 +60,10 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) { 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) + return nil, fmt.Errorf( + "failed to read SE-encrypted long-term key: %w", + err, + ) } 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) decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData) 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 @@ -82,7 +88,10 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) { } 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", @@ -165,7 +174,11 @@ func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err e } // 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{ Directory: directory, Metadata: metadata, @@ -182,13 +195,22 @@ func generateSEKeyLabel(vaultName string) (string, error) { 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. // 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) { +func CreateSecureEnclaveUnlocker( + fs afero.Fs, + stateDir string, +) (*SecureEnclaveUnlocker, error) { if err := checkMacOSAvailable(); err != nil { 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 ltPrivKeyData, err := getLongTermKeyForSE(fs, vault) 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() // 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) + return nil, fmt.Errorf( + "failed to encrypt long-term key with SE: %w", + err, + ) } // 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)) 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) + 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) + return nil, fmt.Errorf( + "failed to write SE-encrypted long-term key: %w", + err, + ) } // Write metadata @@ -274,12 +308,40 @@ func CreateSecureEnclaveUnlocker(fs afero.Fs, stateDir string) (*SecureEnclaveUn // 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) { +func getLongTermKeyForSE( + fs afero.Fs, + vault VaultInterface, +) (*memguard.LockedBuffer, error) { envMnemonic := os.Getenv(EnvMnemonic) if envMnemonic != "" { - ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0) + // Read vault metadata to get the correct derivation index + vaultDir, err := vault.GetDirectory() if err != nil { - return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) + 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 @@ -292,17 +354,29 @@ func getLongTermKeyForSE(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuf currentIdentity, err := currentUnlocker.GetIdentity() 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 - longtermPath := filepath.Join(currentUnlocker.GetDirectory(), "longterm.age") + 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) + 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 { return nil, fmt.Errorf("failed to decrypt long-term key: %w", err) } diff --git a/internal/secret/seunlocker_stub.go b/internal/secret/seunlocker_stub.go index 8c8e785..e1f819a 100644 --- a/internal/secret/seunlocker_stub.go +++ b/internal/secret/seunlocker_stub.go @@ -4,10 +4,16 @@ package secret import ( + "fmt" + "filippo.io/age" "github.com/spf13/afero" ) +var errSENotSupported = fmt.Errorf( + "secure enclave unlockers are only supported on macOS", +) + // SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms. type SecureEnclaveUnlockerMetadata struct { UnlockerMetadata @@ -22,42 +28,57 @@ type SecureEnclaveUnlocker struct { fs afero.Fs } -// GetIdentity panics on non-Darwin platforms. +// GetIdentity returns an error on non-Darwin platforms. func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) { - panic("secure enclave unlockers are only supported on macOS") + return nil, errSENotSupported } -// GetType panics on non-Darwin platforms. +// GetType returns the unlocker type. func (s *SecureEnclaveUnlocker) GetType() string { - panic("secure enclave unlockers are only supported on macOS") + return "secure-enclave" } -// GetMetadata panics on non-Darwin platforms. +// GetMetadata returns the unlocker metadata. func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata { - panic("secure enclave unlockers are only supported on macOS") + return s.Metadata } -// GetDirectory panics on non-Darwin platforms. +// GetDirectory returns the unlocker directory. func (s *SecureEnclaveUnlocker) GetDirectory() string { - panic("secure enclave unlockers are only supported on macOS") + return s.Directory } -// GetID panics on non-Darwin platforms. +// GetID returns the unlocker ID. func (s *SecureEnclaveUnlocker) GetID() string { - panic("secure enclave unlockers are only supported on macOS") + return fmt.Sprintf( + "%s-secure-enclave", + s.Metadata.CreatedAt.Format("2006-01-02.15.04"), + ) } -// Remove panics on non-Darwin platforms. +// Remove returns an error on non-Darwin platforms. func (s *SecureEnclaveUnlocker) Remove() error { - panic("secure enclave unlockers are only supported on macOS") + return errSENotSupported } -// NewSecureEnclaveUnlocker panics on non-Darwin platforms. -func NewSecureEnclaveUnlocker(_ afero.Fs, _ string, _ UnlockerMetadata) *SecureEnclaveUnlocker { - panic("secure enclave unlockers are only supported on macOS") +// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on non-Darwin platforms. +// The returned instance's methods that require macOS functionality will return errors. +func NewSecureEnclaveUnlocker( + fs afero.Fs, + directory string, + metadata UnlockerMetadata, +) *SecureEnclaveUnlocker { + return &SecureEnclaveUnlocker{ + Directory: directory, + Metadata: metadata, + fs: fs, + } } -// CreateSecureEnclaveUnlocker panics on non-Darwin platforms. -func CreateSecureEnclaveUnlocker(_ afero.Fs, _ string) (*SecureEnclaveUnlocker, error) { - panic("secure enclave unlockers are only supported on macOS") +// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms. +func CreateSecureEnclaveUnlocker( + _ afero.Fs, + _ string, +) (*SecureEnclaveUnlocker, error) { + return nil, errSENotSupported } diff --git a/internal/secret/seunlocker_stub_test.go b/internal/secret/seunlocker_stub_test.go new file mode 100644 index 0000000..bac86dc --- /dev/null +++ b/internal/secret/seunlocker_stub_test.go @@ -0,0 +1,90 @@ +//go:build !darwin +// +build !darwin + +package secret + +import ( + "testing" + "time" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSecureEnclaveUnlocker(t *testing.T) { + fs := afero.NewMemMapFs() + dir := "/tmp/test-se-unlocker" + metadata := UnlockerMetadata{ + Type: "secure-enclave", + CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), + Flags: []string{"secure-enclave", "macos"}, + } + + unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata) + require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance") + + // Test GetType returns correct type + assert.Equal(t, "secure-enclave", unlocker.GetType()) + + // Test GetMetadata returns the metadata we passed in + assert.Equal(t, metadata, unlocker.GetMetadata()) + + // Test GetDirectory returns the directory we passed in + assert.Equal(t, dir, unlocker.GetDirectory()) + + // Test GetID returns a formatted string with the creation timestamp + expectedID := "2026-01-15.10.30-secure-enclave" + assert.Equal(t, expectedID, unlocker.GetID()) +} + +func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) { + fs := afero.NewMemMapFs() + metadata := UnlockerMetadata{ + Type: "secure-enclave", + CreatedAt: time.Now().UTC(), + } + + unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata) + + identity, err := unlocker.GetIdentity() + assert.Nil(t, identity) + assert.Error(t, err) + assert.ErrorIs(t, err, errSENotSupported) +} + +func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) { + fs := afero.NewMemMapFs() + metadata := UnlockerMetadata{ + Type: "secure-enclave", + CreatedAt: time.Now().UTC(), + } + + unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata) + + err := unlocker.Remove() + assert.Error(t, err) + assert.ErrorIs(t, err, errSENotSupported) +} + +func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) { + fs := afero.NewMemMapFs() + + unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test") + assert.Nil(t, unlocker) + assert.Error(t, err) + assert.ErrorIs(t, err, errSENotSupported) +} + +func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) { + fs := afero.NewMemMapFs() + metadata := UnlockerMetadata{ + Type: "secure-enclave", + CreatedAt: time.Now().UTC(), + } + + unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata) + + // Verify the stub implements the Unlocker interface + var _ Unlocker = unlocker +} diff --git a/internal/secret/seunlocker_test.go b/internal/secret/seunlocker_test.go new file mode 100644 index 0000000..cc778b1 --- /dev/null +++ b/internal/secret/seunlocker_test.go @@ -0,0 +1,101 @@ +//go:build darwin +// +build darwin + +package secret + +import ( + "testing" + "time" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSecureEnclaveUnlocker(t *testing.T) { + fs := afero.NewMemMapFs() + dir := "/tmp/test-se-unlocker" + metadata := UnlockerMetadata{ + Type: "secure-enclave", + CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), + Flags: []string{"secure-enclave", "macos"}, + } + + unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata) + require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance") + + // Test GetType returns correct type + assert.Equal(t, seUnlockerType, unlocker.GetType()) + + // Test GetMetadata returns the metadata we passed in + assert.Equal(t, metadata, unlocker.GetMetadata()) + + // Test GetDirectory returns the directory we passed in + assert.Equal(t, dir, unlocker.GetDirectory()) +} + +func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) { + fs := afero.NewMemMapFs() + metadata := UnlockerMetadata{ + Type: "secure-enclave", + CreatedAt: time.Now().UTC(), + } + + unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata) + + // Verify the darwin implementation implements the Unlocker interface + var _ Unlocker = unlocker +} + +func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) { + fs := afero.NewMemMapFs() + metadata := UnlockerMetadata{ + Type: "secure-enclave", + CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC), + } + + unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata) + id := unlocker.GetID() + + // ID should contain the timestamp and "secure-enclave" type + assert.Contains(t, id, "2026-03-10.14.30") + assert.Contains(t, id, seUnlockerType) +} + +func TestGenerateSEKeyLabel(t *testing.T) { + label, err := generateSEKeyLabel("test-vault") + require.NoError(t, err) + + // Label should contain the prefix and vault name + assert.Contains(t, label, seKeyLabelPrefix) + assert.Contains(t, label, "test-vault") +} + +func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) { + fs := afero.NewMemMapFs() + dir := "/tmp/test-se-unlocker-missing" + + // Create unlocker directory with metadata but no encrypted key file + require.NoError(t, fs.MkdirAll(dir, DirPerms)) + + metadataJSON := `{ + "type": "secure-enclave", + "createdAt": "2026-01-15T10:30:00Z", + "seKeyLabel": "berlin.sneak.app.secret.se.test", + "seKeyHash": "abc123" + }` + require.NoError(t, afero.WriteFile(fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms)) + + metadata := UnlockerMetadata{ + Type: "secure-enclave", + CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), + } + + unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata) + + // GetIdentity should fail because the encrypted longterm key file is missing + identity, err := unlocker.GetIdentity() + assert.Nil(t, identity) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key") +}