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..679c759 100644 --- a/internal/secret/seunlocker_darwin.go +++ b/internal/secret/seunlocker_darwin.go @@ -277,7 +277,25 @@ func CreateSecureEnclaveUnlocker(fs afero.Fs, stateDir string) (*SecureEnclaveUn 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 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) } diff --git a/internal/secret/seunlocker_stub.go b/internal/secret/seunlocker_stub.go index 8c8e785..bfb3fa3 100644 --- a/internal/secret/seunlocker_stub.go +++ b/internal/secret/seunlocker_stub.go @@ -4,10 +4,14 @@ 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 +26,47 @@ 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. +// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms. func CreateSecureEnclaveUnlocker(_ afero.Fs, _ string) (*SecureEnclaveUnlocker, error) { - panic("secure enclave unlockers are only supported on macOS") + 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") +}