From a3d3fb3b69cfdb96f0266a0e00ee9e8546fbe6cd Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 14 Mar 2026 07:36:28 +0100 Subject: [PATCH] secure-enclave-unlocker (#24) Co-authored-by: clawbot Reviewed-on: https://git.eeqj.de/sneak/secret/pulls/24 Reviewed-by: clawbot Co-authored-by: sneak Co-committed-by: sneak --- README.md | 17 +- internal/cli/info.go | 2 +- internal/cli/init.go | 2 +- internal/cli/secrets.go | 2 +- internal/cli/unlockers.go | 62 +++- internal/cli/vault.go | 2 +- internal/cli/version.go | 2 +- internal/macse/macse_darwin.go | 129 ++++++++ internal/macse/macse_stub.go | 29 ++ internal/macse/macse_test.go | 163 ++++++++++ internal/macse/secure_enclave.h | 57 ++++ internal/macse/secure_enclave.m | 300 ++++++++++++++++++ internal/secret/derivation_index_test.go | 4 +- internal/secret/secret_test.go | 10 +- internal/secret/seunlocker_darwin.go | 385 +++++++++++++++++++++++ internal/secret/seunlocker_stub.go | 84 +++++ internal/secret/seunlocker_stub_test.go | 90 ++++++ internal/secret/seunlocker_test.go | 101 ++++++ internal/vault/unlockers.go | 5 + internal/vault/vault.go | 94 +++--- 20 files changed, 1458 insertions(+), 82 deletions(-) create mode 100644 internal/macse/macse_darwin.go create mode 100644 internal/macse/macse_stub.go create mode 100644 internal/macse/macse_test.go create mode 100644 internal/macse/secure_enclave.h create mode 100644 internal/macse/secure_enclave.m create mode 100644 internal/secret/seunlocker_darwin.go create mode 100644 internal/secret/seunlocker_stub.go create mode 100644 internal/secret/seunlocker_stub_test.go create mode 100644 internal/secret/seunlocker_test.go 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 e8026e4..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" @@ -127,22 +127,27 @@ func newUnlockerAddCmd() *cobra.Command { Use --keyid to specify a particular key, otherwise uses your default GPG key.` if runtime.GOOS == "darwin" { - supportedTypes = "passphrase, keychain, pgp" + supportedTypes = "passphrase, keychain, pgp, secure-enclave" typeDescriptions = `Available unlocker types: - passphrase - Traditional password-based encryption - Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key. - The passphrase is never stored in plaintext. + passphrase - Traditional password-based encryption + Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key. + The passphrase is never stored in plaintext. - keychain - macOS Keychain integration (macOS only) - Stores the vault's master key in the macOS Keychain, protected by your login password. - Automatically unlocks when your Keychain is unlocked (e.g., after login). - Provides seamless integration with macOS security features like Touch ID. + keychain - macOS Keychain integration (macOS only) + Stores the vault's master key in the macOS Keychain, protected by your login password. + Automatically unlocks when your Keychain is unlocked (e.g., after login). + Provides seamless integration with macOS security features like Touch ID. - pgp - GNU Privacy Guard (GPG) key-based encryption - Uses your existing GPG key to encrypt/decrypt the vault's master key. - Requires gpg to be installed and configured with at least one secret key. - Use --keyid to specify a particular key, otherwise uses your default GPG key.` + pgp - GNU Privacy Guard (GPG) key-based encryption + Uses your existing GPG key to encrypt/decrypt the vault's master key. + Requires gpg to be installed and configured with at least one secret key. + Use --keyid to specify a particular key, otherwise uses your default GPG key. + + secure-enclave - Apple Secure Enclave hardware protection (macOS only) + Stores the vault's master key encrypted by a non-exportable P-256 key + held in the Secure Enclave. The key never leaves the hardware. + Uses ECIES encryption; decryption is performed inside the SE.` } cmd := &cobra.Command{ @@ -319,6 +324,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error { unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata) case "pgp": unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata) + case "secure-enclave": + unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata) } break @@ -410,7 +417,7 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error // Build the supported types list based on platform supportedTypes := "passphrase, pgp" if runtime.GOOS == "darwin" { - supportedTypes = "passphrase, keychain, pgp" + supportedTypes = "passphrase, keychain, pgp, secure-enclave" } switch unlockerType { @@ -481,6 +488,31 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error return nil + case "secure-enclave": + if runtime.GOOS != "darwin" { + return fmt.Errorf("secure enclave unlockers are only supported on macOS") + } + + seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir) + if err != nil { + return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err) + } + + cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID()) + + vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return fmt.Errorf("failed to get current vault: %w", err) + } + + if err := vlt.SelectUnlocker(seUnlocker.GetID()); err != nil { + cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err) + } else { + cmd.Printf("Automatically selected as current unlocker\n") + } + + return nil + case "pgp": // Get GPG key ID from flag, environment, or default key var gpgKeyID string @@ -656,6 +688,8 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata) case "pgp": unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata) + case "secure-enclave": + unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata) } if unlocker != nil && unlocker.GetID() == unlockerID { 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/macse/macse_darwin.go b/internal/macse/macse_darwin.go new file mode 100644 index 0000000..4d77c3a --- /dev/null +++ b/internal/macse/macse_darwin.go @@ -0,0 +1,129 @@ +//go:build darwin + +// Package macse provides Go bindings for macOS Secure Enclave operations +// using CryptoTokenKit identities created via sc_auth. +// Key creation and deletion shell out to sc_auth (which has SE entitlements). +// Encrypt/decrypt use Security.framework ECIES directly (works unsigned). +package macse + +/* +#cgo CFLAGS: -x objective-c -fobjc-arc +#cgo LDFLAGS: -framework Security -framework Foundation -framework CoreFoundation +#include +#include "secure_enclave.h" +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +const ( + // p256UncompressedKeySize is the size of an uncompressed P-256 public key. + p256UncompressedKeySize = 65 + + // errorBufferSize is the size of the C error message buffer. + errorBufferSize = 512 + + // hashBufferSize is the size of the hash output buffer. + hashBufferSize = 128 + + // maxCiphertextSize is the max buffer for ECIES ciphertext. + // ECIES overhead for P-256: 65 (ephemeral pub) + 16 (GCM tag) + 16 (IV) + plaintext. + maxCiphertextSize = 8192 + + // maxPlaintextSize is the max buffer for decrypted plaintext. + maxPlaintextSize = 8192 +) + +// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth. +// Returns the uncompressed public key bytes (65 bytes) and the identity hash (for deletion). +func CreateKey(label string) (publicKey []byte, hash string, err error) { + pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize) + pubKeyLen := C.int(p256UncompressedKeySize) + var hashBuf [hashBufferSize]C.char + var errBuf [errorBufferSize]C.char + + cLabel := C.CString(label) + defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern + + result := C.se_create_key(cLabel, + &pubKeyBuf[0], &pubKeyLen, + &hashBuf[0], C.int(hashBufferSize), + &errBuf[0], C.int(errorBufferSize)) + + if result != 0 { + return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) + } + + pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction + h := C.GoString(&hashBuf[0]) + + return pk, h, nil +} + +// Encrypt encrypts plaintext using the SE-backed public key via ECIES +// (eciesEncryptionStandardVariableIVX963SHA256AESGCM). +// Encryption uses only the public key; no SE interaction required. +func Encrypt(label string, plaintext []byte) ([]byte, error) { + ciphertextBuf := make([]C.uint8_t, maxCiphertextSize) + ciphertextLen := C.int(maxCiphertextSize) + var errBuf [errorBufferSize]C.char + + cLabel := C.CString(label) + defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern + + result := C.se_encrypt(cLabel, + (*C.uint8_t)(unsafe.Pointer(&plaintext[0])), C.int(len(plaintext)), + &ciphertextBuf[0], &ciphertextLen, + &errBuf[0], C.int(errorBufferSize)) + + if result != 0 { + return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) + } + + out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction + + return out, nil +} + +// Decrypt decrypts ECIES ciphertext using the SE-backed private key. +// The ECDH portion of decryption is performed inside the Secure Enclave. +func Decrypt(label string, ciphertext []byte) ([]byte, error) { + plaintextBuf := make([]C.uint8_t, maxPlaintextSize) + plaintextLen := C.int(maxPlaintextSize) + var errBuf [errorBufferSize]C.char + + cLabel := C.CString(label) + defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern + + result := C.se_decrypt(cLabel, + (*C.uint8_t)(unsafe.Pointer(&ciphertext[0])), C.int(len(ciphertext)), + &plaintextBuf[0], &plaintextLen, + &errBuf[0], C.int(errorBufferSize)) + + if result != 0 { + return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) + } + + out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction + + return out, nil +} + +// DeleteKey removes a CTK identity from the Secure Enclave via sc_auth. +func DeleteKey(hash string) error { + var errBuf [errorBufferSize]C.char + + cHash := C.CString(hash) + defer C.free(unsafe.Pointer(cHash)) //nolint:nlreturn // CGo free pattern + + result := C.se_delete_key(cHash, &errBuf[0], C.int(errorBufferSize)) + + if result != 0 { + return fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) + } + + return nil +} diff --git a/internal/macse/macse_stub.go b/internal/macse/macse_stub.go new file mode 100644 index 0000000..44fe611 --- /dev/null +++ b/internal/macse/macse_stub.go @@ -0,0 +1,29 @@ +//go:build !darwin +// +build !darwin + +// Package macse provides Go bindings for macOS Secure Enclave operations. +package macse + +import "fmt" + +var errNotSupported = fmt.Errorf("secure enclave is only supported on macOS") //nolint:gochecknoglobals + +// CreateKey is not supported on non-darwin platforms. +func CreateKey(_ string) ([]byte, string, error) { + return nil, "", errNotSupported +} + +// Encrypt is not supported on non-darwin platforms. +func Encrypt(_ string, _ []byte) ([]byte, error) { + return nil, errNotSupported +} + +// Decrypt is not supported on non-darwin platforms. +func Decrypt(_ string, _ []byte) ([]byte, error) { + return nil, errNotSupported +} + +// DeleteKey is not supported on non-darwin platforms. +func DeleteKey(_ string) error { + return errNotSupported +} diff --git a/internal/macse/macse_test.go b/internal/macse/macse_test.go new file mode 100644 index 0000000..c56625c --- /dev/null +++ b/internal/macse/macse_test.go @@ -0,0 +1,163 @@ +//go:build darwin +// +build darwin + +package macse + +import ( + "bytes" + "testing" +) + +const testKeyLabel = "berlin.sneak.app.secret.test.se-key" + +// testKeyHash stores the hash of the created test key for cleanup. +var testKeyHash string //nolint:gochecknoglobals + +// skipIfNoSecureEnclave skips the test if SE access is unavailable. +func skipIfNoSecureEnclave(t *testing.T) { + t.Helper() + + probeLabel := "berlin.sneak.app.secret.test.se-probe" + _, hash, err := CreateKey(probeLabel) + if err != nil { + t.Skipf("Secure Enclave unavailable (skipping): %v", err) + } + + if hash != "" { + _ = DeleteKey(hash) + } +} + +func TestCreateAndDeleteKey(t *testing.T) { + skipIfNoSecureEnclave(t) + + if testKeyHash != "" { + _ = DeleteKey(testKeyHash) + } + + pubKey, hash, err := CreateKey(testKeyLabel) + if err != nil { + t.Fatalf("CreateKey failed: %v", err) + } + + testKeyHash = hash + t.Logf("Created key with hash: %s", hash) + + // Verify valid uncompressed P-256 public key + if len(pubKey) != p256UncompressedKeySize { + t.Fatalf("expected public key length %d, got %d", p256UncompressedKeySize, len(pubKey)) + } + + if pubKey[0] != 0x04 { + t.Fatalf("expected uncompressed point prefix 0x04, got 0x%02x", pubKey[0]) + } + + if hash == "" { + t.Fatal("expected non-empty hash") + } + + // Delete the key + if err := DeleteKey(hash); err != nil { + t.Fatalf("DeleteKey failed: %v", err) + } + + testKeyHash = "" + t.Log("Key created, verified, and deleted successfully") +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + skipIfNoSecureEnclave(t) + + _, hash, err := CreateKey(testKeyLabel) + if err != nil { + t.Fatalf("CreateKey failed: %v", err) + } + + testKeyHash = hash + + defer func() { + if testKeyHash != "" { + _ = DeleteKey(testKeyHash) + testKeyHash = "" + } + }() + + // Test data simulating an age private key + plaintext := []byte("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ") + + // Encrypt + ciphertext, err := Encrypt(testKeyLabel, plaintext) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + t.Logf("Plaintext: %d bytes, Ciphertext: %d bytes", len(plaintext), len(ciphertext)) + + if bytes.Equal(ciphertext, plaintext) { + t.Fatal("ciphertext should differ from plaintext") + } + + // Decrypt + decrypted, err := Decrypt(testKeyLabel, ciphertext) + if err != nil { + t.Fatalf("Decrypt failed: %v", err) + } + + if !bytes.Equal(decrypted, plaintext) { + t.Fatalf("decrypted data does not match original plaintext") + } + + t.Log("ECIES encrypt/decrypt round-trip successful") +} + +func TestEncryptProducesDifferentCiphertexts(t *testing.T) { + skipIfNoSecureEnclave(t) + + _, hash, err := CreateKey(testKeyLabel) + if err != nil { + t.Fatalf("CreateKey failed: %v", err) + } + + testKeyHash = hash + + defer func() { + if testKeyHash != "" { + _ = DeleteKey(testKeyHash) + testKeyHash = "" + } + }() + + plaintext := []byte("test-secret-data") + + ct1, err := Encrypt(testKeyLabel, plaintext) + if err != nil { + t.Fatalf("first Encrypt failed: %v", err) + } + + ct2, err := Encrypt(testKeyLabel, plaintext) + if err != nil { + t.Fatalf("second Encrypt failed: %v", err) + } + + // ECIES uses a random ephemeral key each time, so ciphertexts should differ + if bytes.Equal(ct1, ct2) { + t.Fatal("two encryptions of same plaintext should produce different ciphertexts") + } + + // Both should decrypt to the same plaintext + dec1, err := Decrypt(testKeyLabel, ct1) + if err != nil { + t.Fatalf("first Decrypt failed: %v", err) + } + + dec2, err := Decrypt(testKeyLabel, ct2) + if err != nil { + t.Fatalf("second Decrypt failed: %v", err) + } + + if !bytes.Equal(dec1, plaintext) || !bytes.Equal(dec2, plaintext) { + t.Fatal("both ciphertexts should decrypt to original plaintext") + } + + t.Log("ECIES correctly produces different ciphertexts that decrypt to same plaintext") +} diff --git a/internal/macse/secure_enclave.h b/internal/macse/secure_enclave.h new file mode 100644 index 0000000..7fc588b --- /dev/null +++ b/internal/macse/secure_enclave.h @@ -0,0 +1,57 @@ +#ifndef SECURE_ENCLAVE_H +#define SECURE_ENCLAVE_H + +#include + +// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth. +// label: unique identifier for the CTK identity (UTF-8 C string) +// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256) +// pub_key_len: on input, size of pub_key_out; on output, actual size written +// hash_out: output buffer for the identity hash (for deletion) +// hash_out_len: size of hash_out buffer +// error_out: output buffer for error message +// error_out_len: size of error_out buffer +// Returns 0 on success, -1 on failure. +int se_create_key(const char *label, + uint8_t *pub_key_out, int *pub_key_len, + char *hash_out, int hash_out_len, + char *error_out, int error_out_len); + +// se_encrypt encrypts data using the SE-backed public key (ECIES). +// label: label of the CTK identity whose public key to use +// plaintext: data to encrypt +// plaintext_len: length of plaintext +// ciphertext_out: output buffer for the ECIES ciphertext +// ciphertext_len: on input, size of buffer; on output, actual size written +// error_out: output buffer for error message +// error_out_len: size of error_out buffer +// Returns 0 on success, -1 on failure. +int se_encrypt(const char *label, + const uint8_t *plaintext, int plaintext_len, + uint8_t *ciphertext_out, int *ciphertext_len, + char *error_out, int error_out_len); + +// se_decrypt decrypts ECIES ciphertext using the SE-backed private key. +// The ECDH portion of decryption is performed inside the Secure Enclave. +// label: label of the CTK identity whose private key to use +// ciphertext: ECIES ciphertext produced by se_encrypt +// ciphertext_len: length of ciphertext +// plaintext_out: output buffer for decrypted data +// plaintext_len: on input, size of buffer; on output, actual size written +// error_out: output buffer for error message +// error_out_len: size of error_out buffer +// Returns 0 on success, -1 on failure. +int se_decrypt(const char *label, + const uint8_t *ciphertext, int ciphertext_len, + uint8_t *plaintext_out, int *plaintext_len, + char *error_out, int error_out_len); + +// se_delete_key removes a CTK identity from the Secure Enclave via sc_auth. +// hash: the identity hash returned by se_create_key +// error_out: output buffer for error message +// error_out_len: size of error_out buffer +// Returns 0 on success, -1 on failure. +int se_delete_key(const char *hash, + char *error_out, int error_out_len); + +#endif // SECURE_ENCLAVE_H diff --git a/internal/macse/secure_enclave.m b/internal/macse/secure_enclave.m new file mode 100644 index 0000000..9cbfd2a --- /dev/null +++ b/internal/macse/secure_enclave.m @@ -0,0 +1,300 @@ +#import +#import +#include "secure_enclave.h" +#include + +// snprintf_error writes an error message string to the output buffer. +static void snprintf_error(char *error_out, int error_out_len, NSString *msg) { + if (error_out && error_out_len > 0) { + snprintf(error_out, error_out_len, "%s", msg.UTF8String); + } +} + +// lookup_ctk_identity finds a CTK identity by label and returns the private key. +static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int error_out_len) { + NSDictionary *query = @{ + (id)kSecClass: (id)kSecClassIdentity, + (id)kSecAttrLabel: [NSString stringWithUTF8String:label], + (id)kSecMatchLimit: (id)kSecMatchLimitOne, + (id)kSecReturnRef: @YES, + }; + + SecIdentityRef identity = NULL; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&identity); + + if (status != errSecSuccess || !identity) { + NSString *msg = [NSString stringWithFormat:@"CTK identity '%s' not found: OSStatus %d", + label, (int)status]; + snprintf_error(error_out, error_out_len, msg); + return NULL; + } + + SecKeyRef privateKey = NULL; + status = SecIdentityCopyPrivateKey(identity, &privateKey); + CFRelease(identity); + + if (status != errSecSuccess || !privateKey) { + NSString *msg = [NSString stringWithFormat: + @"failed to get private key from CTK identity '%s': OSStatus %d", + label, (int)status]; + snprintf_error(error_out, error_out_len, msg); + return NULL; + } + + return privateKey; +} + +int se_create_key(const char *label, + uint8_t *pub_key_out, int *pub_key_len, + char *hash_out, int hash_out_len, + char *error_out, int error_out_len) { + @autoreleasepool { + NSString *labelStr = [NSString stringWithUTF8String:label]; + + // Shell out to sc_auth (which has SE entitlements) to create the key + NSTask *task = [[NSTask alloc] init]; + task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"]; + task.arguments = @[ + @"create-ctk-identity", + @"-k", @"p-256-ne", + @"-t", @"none", + @"-l", labelStr, + ]; + + NSPipe *stderrPipe = [NSPipe pipe]; + task.standardOutput = [NSPipe pipe]; + task.standardError = stderrPipe; + + NSError *nsError = nil; + if (![task launchAndReturnError:&nsError]) { + NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@", + nsError.localizedDescription]; + snprintf_error(error_out, error_out_len, msg); + return -1; + } + + [task waitUntilExit]; + + if (task.terminationStatus != 0) { + NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile]; + NSString *stderrStr = [[NSString alloc] initWithData:stderrData + encoding:NSUTF8StringEncoding]; + NSString *msg = [NSString stringWithFormat:@"sc_auth failed: %@", + stderrStr ?: @"unknown error"]; + snprintf_error(error_out, error_out_len, msg); + return -1; + } + + // Retrieve the public key from the created identity + SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len); + if (!privateKey) { + return -1; + } + + SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey); + CFRelease(privateKey); + + if (!publicKey) { + snprintf_error(error_out, error_out_len, @"failed to get public key"); + return -1; + } + + CFErrorRef cfError = NULL; + CFDataRef pubKeyData = SecKeyCopyExternalRepresentation(publicKey, &cfError); + CFRelease(publicKey); + + if (!pubKeyData) { + NSError *err = (__bridge_transfer NSError *)cfError; + NSString *msg = [NSString stringWithFormat:@"failed to export public key: %@", + err.localizedDescription]; + snprintf_error(error_out, error_out_len, msg); + return -1; + } + + const UInt8 *bytes = CFDataGetBytePtr(pubKeyData); + CFIndex length = CFDataGetLength(pubKeyData); + + if (length > *pub_key_len) { + CFRelease(pubKeyData); + snprintf_error(error_out, error_out_len, @"public key buffer too small"); + return -1; + } + + memcpy(pub_key_out, bytes, length); + *pub_key_len = (int)length; + CFRelease(pubKeyData); + + // Get the identity hash by parsing sc_auth list output + hash_out[0] = '\0'; + NSTask *listTask = [[NSTask alloc] init]; + listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"]; + listTask.arguments = @[@"list-ctk-identities"]; + + NSPipe *listPipe = [NSPipe pipe]; + listTask.standardOutput = listPipe; + listTask.standardError = [NSPipe pipe]; + + if ([listTask launchAndReturnError:&nsError]) { + [listTask waitUntilExit]; + NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile]; + NSString *listStr = [[NSString alloc] initWithData:listData + encoding:NSUTF8StringEncoding]; + + for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) { + if ([line containsString:labelStr]) { + NSMutableArray *tokens = [NSMutableArray array]; + for (NSString *part in [line componentsSeparatedByCharactersInSet: + [NSCharacterSet whitespaceCharacterSet]]) { + if (part.length > 0) { + [tokens addObject:part]; + } + } + if (tokens.count > 1) { + snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]); + } + break; + } + } + } + + return 0; + } +} + +int se_encrypt(const char *label, + const uint8_t *plaintext, int plaintext_len, + uint8_t *ciphertext_out, int *ciphertext_len, + char *error_out, int error_out_len) { + @autoreleasepool { + SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len); + if (!privateKey) { + return -1; + } + + SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey); + CFRelease(privateKey); + + if (!publicKey) { + snprintf_error(error_out, error_out_len, @"failed to get public key for encryption"); + return -1; + } + + NSData *plaintextData = [NSData dataWithBytes:plaintext length:plaintext_len]; + + CFErrorRef cfError = NULL; + CFDataRef encrypted = SecKeyCreateEncryptedData( + publicKey, + kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM, + (__bridge CFDataRef)plaintextData, + &cfError + ); + CFRelease(publicKey); + + if (!encrypted) { + NSError *nsError = (__bridge_transfer NSError *)cfError; + NSString *msg = [NSString stringWithFormat:@"ECIES encryption failed: %@", + nsError.localizedDescription]; + snprintf_error(error_out, error_out_len, msg); + return -1; + } + + const UInt8 *encBytes = CFDataGetBytePtr(encrypted); + CFIndex encLength = CFDataGetLength(encrypted); + + if (encLength > *ciphertext_len) { + CFRelease(encrypted); + snprintf_error(error_out, error_out_len, @"ciphertext buffer too small"); + return -1; + } + + memcpy(ciphertext_out, encBytes, encLength); + *ciphertext_len = (int)encLength; + CFRelease(encrypted); + + return 0; + } +} + +int se_decrypt(const char *label, + const uint8_t *ciphertext, int ciphertext_len, + uint8_t *plaintext_out, int *plaintext_len, + char *error_out, int error_out_len) { + @autoreleasepool { + SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len); + if (!privateKey) { + return -1; + } + + NSData *ciphertextData = [NSData dataWithBytes:ciphertext length:ciphertext_len]; + + CFErrorRef cfError = NULL; + CFDataRef decrypted = SecKeyCreateDecryptedData( + privateKey, + kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM, + (__bridge CFDataRef)ciphertextData, + &cfError + ); + CFRelease(privateKey); + + if (!decrypted) { + NSError *nsError = (__bridge_transfer NSError *)cfError; + NSString *msg = [NSString stringWithFormat:@"ECIES decryption failed: %@", + nsError.localizedDescription]; + snprintf_error(error_out, error_out_len, msg); + return -1; + } + + const UInt8 *decBytes = CFDataGetBytePtr(decrypted); + CFIndex decLength = CFDataGetLength(decrypted); + + if (decLength > *plaintext_len) { + CFRelease(decrypted); + snprintf_error(error_out, error_out_len, @"plaintext buffer too small"); + return -1; + } + + memcpy(plaintext_out, decBytes, decLength); + *plaintext_len = (int)decLength; + CFRelease(decrypted); + + return 0; + } +} + +int se_delete_key(const char *hash, + char *error_out, int error_out_len) { + @autoreleasepool { + NSTask *task = [[NSTask alloc] init]; + task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"]; + task.arguments = @[ + @"delete-ctk-identity", + @"-h", [NSString stringWithUTF8String:hash], + ]; + + NSPipe *stderrPipe = [NSPipe pipe]; + task.standardOutput = [NSPipe pipe]; + task.standardError = stderrPipe; + + NSError *nsError = nil; + if (![task launchAndReturnError:&nsError]) { + NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@", + nsError.localizedDescription]; + snprintf_error(error_out, error_out_len, msg); + return -1; + } + + [task waitUntilExit]; + + if (task.terminationStatus != 0) { + NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile]; + NSString *stderrStr = [[NSString alloc] initWithData:stderrData + encoding:NSUTF8StringEncoding]; + NSString *msg = [NSString stringWithFormat:@"sc_auth delete failed: %@", + stderrStr ?: @"unknown error"]; + snprintf_error(error_out, error_out_len, msg); + return -1; + } + + return 0; + } +} 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 new file mode 100644 index 0000000..9d92717 --- /dev/null +++ b/internal/secret/seunlocker_darwin.go @@ -0,0 +1,385 @@ +//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 +} diff --git a/internal/secret/seunlocker_stub.go b/internal/secret/seunlocker_stub.go new file mode 100644 index 0000000..e1f819a --- /dev/null +++ b/internal/secret/seunlocker_stub.go @@ -0,0 +1,84 @@ +//go:build !darwin +// +build !darwin + +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 + SEKeyLabel string `json:"seKeyLabel"` + SEKeyHash string `json:"seKeyHash"` +} + +// SecureEnclaveUnlocker is a stub for non-Darwin platforms. +type SecureEnclaveUnlocker struct { + Directory string + Metadata UnlockerMetadata + fs afero.Fs +} + +// GetIdentity returns an error on non-Darwin platforms. +func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) { + return nil, errSENotSupported +} + +// GetType returns the unlocker type. +func (s *SecureEnclaveUnlocker) GetType() string { + return "secure-enclave" +} + +// GetMetadata returns the unlocker metadata. +func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata { + return s.Metadata +} + +// GetDirectory returns the unlocker directory. +func (s *SecureEnclaveUnlocker) GetDirectory() string { + return s.Directory +} + +// GetID returns the unlocker ID. +func (s *SecureEnclaveUnlocker) GetID() string { + return fmt.Sprintf( + "%s-secure-enclave", + s.Metadata.CreatedAt.Format("2006-01-02.15.04"), + ) +} + +// Remove returns an error on non-Darwin platforms. +func (s *SecureEnclaveUnlocker) Remove() error { + return errSENotSupported +} + +// 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 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") +} diff --git a/internal/vault/unlockers.go b/internal/vault/unlockers.go index a20ce41..0faf7eb 100644 --- a/internal/vault/unlockers.go +++ b/internal/vault/unlockers.go @@ -83,6 +83,9 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) { case "keychain": secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type) unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata) + case "secure-enclave": + secret.Debug("Creating secure enclave unlocker instance", "unlocker_type", metadata.Type) + unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata) default: secret.Debug("Unsupported unlocker type", "type", metadata.Type) @@ -166,6 +169,8 @@ func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlock tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata) case "keychain": tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata) + case "secure-enclave": + tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata) default: continue } diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 2243dc7..597c85f 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -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