diff --git a/internal/cli/unlockers.go b/internal/cli/unlockers.go index e8026e4..8615586 100644 --- a/internal/cli/unlockers.go +++ b/internal/cli/unlockers.go @@ -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/macse/macse_darwin.go b/internal/macse/macse_darwin.go new file mode 100644 index 0000000..603607e --- /dev/null +++ b/internal/macse/macse_darwin.go @@ -0,0 +1,133 @@ +//go:build darwin +// +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) { + cLabel := C.CString(label) + + defer C.free(unsafe.Pointer(cLabel)) + + pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize) + pubKeyLen := C.int(p256UncompressedKeySize) + + var hashBuf [hashBufferSize]C.char + var errBuf [errorBufferSize]C.char + + 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) + 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) { + cLabel := C.CString(label) + + defer C.free(unsafe.Pointer(cLabel)) + + ciphertextBuf := make([]C.uint8_t, maxCiphertextSize) + ciphertextLen := C.int(maxCiphertextSize) + + var errBuf [errorBufferSize]C.char + + 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])) + } + + return C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen), 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) { + cLabel := C.CString(label) + + defer C.free(unsafe.Pointer(cLabel)) + + plaintextBuf := make([]C.uint8_t, maxPlaintextSize) + plaintextLen := C.int(maxPlaintextSize) + + var errBuf [errorBufferSize]C.char + + 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])) + } + + return C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen), nil +} + +// DeleteKey removes a CTK identity from the Secure Enclave via sc_auth. +func DeleteKey(hash string) error { + cHash := C.CString(hash) + + defer C.free(unsafe.Pointer(cHash)) + + var errBuf [errorBufferSize]C.char + + 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/seunlocker_darwin.go b/internal/secret/seunlocker_darwin.go new file mode 100644 index 0000000..e54babd --- /dev/null +++ b/internal/secret/seunlocker_darwin.go @@ -0,0 +1,311 @@ +//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 != "" { + ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0) + 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..8c8e785 --- /dev/null +++ b/internal/secret/seunlocker_stub.go @@ -0,0 +1,63 @@ +//go:build !darwin +// +build !darwin + +package secret + +import ( + "filippo.io/age" + "github.com/spf13/afero" +) + +// 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 panics on non-Darwin platforms. +func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) { + panic("secure enclave unlockers are only supported on macOS") +} + +// GetType panics on non-Darwin platforms. +func (s *SecureEnclaveUnlocker) GetType() string { + panic("secure enclave unlockers are only supported on macOS") +} + +// GetMetadata panics on non-Darwin platforms. +func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata { + panic("secure enclave unlockers are only supported on macOS") +} + +// GetDirectory panics on non-Darwin platforms. +func (s *SecureEnclaveUnlocker) GetDirectory() string { + panic("secure enclave unlockers are only supported on macOS") +} + +// GetID panics on non-Darwin platforms. +func (s *SecureEnclaveUnlocker) GetID() string { + panic("secure enclave unlockers are only supported on macOS") +} + +// Remove panics on non-Darwin platforms. +func (s *SecureEnclaveUnlocker) Remove() error { + panic("secure enclave unlockers are only supported on macOS") +} + +// NewSecureEnclaveUnlocker panics on non-Darwin platforms. +func NewSecureEnclaveUnlocker(_ afero.Fs, _ string, _ UnlockerMetadata) *SecureEnclaveUnlocker { + panic("secure enclave unlockers are only supported on macOS") +} + +// CreateSecureEnclaveUnlocker panics on non-Darwin platforms. +func CreateSecureEnclaveUnlocker(_ afero.Fs, _ string) (*SecureEnclaveUnlocker, error) { + panic("secure enclave unlockers are only supported on macOS") +} 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