Add Secure Enclave unlocker for hardware-backed secret protection
Adds a new "secure-enclave" unlocker type that stores the vault's long-term private key encrypted by a non-exportable P-256 key held in the Secure Enclave hardware. Decryption (ECDH) is performed inside the SE; the key never leaves the hardware. Uses CryptoTokenKit identities created via sc_auth, which allows SE access from unsigned binaries without Apple Developer Program membership. ECIES (X963SHA256 + AES-GCM) handles encryption and decryption through Security.framework. New package internal/macse/ provides the CGo bridge to Security.framework for SE key creation, ECIES encrypt/decrypt, and key deletion. The SE unlocker directly encrypts the vault long-term key (no intermediate age keypair).
This commit is contained in:
@@ -123,22 +123,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{
|
||||
@@ -292,6 +297,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
|
||||
@@ -382,7 +389,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 {
|
||||
@@ -453,6 +460,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
|
||||
@@ -618,6 +650,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 {
|
||||
|
||||
133
internal/macse/macse_darwin.go
Normal file
133
internal/macse/macse_darwin.go
Normal file
@@ -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 <stdlib.h>
|
||||
#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
|
||||
}
|
||||
29
internal/macse/macse_stub.go
Normal file
29
internal/macse/macse_stub.go
Normal file
@@ -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
|
||||
}
|
||||
163
internal/macse/macse_test.go
Normal file
163
internal/macse/macse_test.go
Normal file
@@ -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")
|
||||
}
|
||||
57
internal/macse/secure_enclave.h
Normal file
57
internal/macse/secure_enclave.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#ifndef SECURE_ENCLAVE_H
|
||||
#define SECURE_ENCLAVE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// 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
|
||||
300
internal/macse/secure_enclave.m
Normal file
300
internal/macse/secure_enclave.m
Normal file
@@ -0,0 +1,300 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <Security/Security.h>
|
||||
#include "secure_enclave.h"
|
||||
#include <string.h>
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
311
internal/secret/seunlocker_darwin.go
Normal file
311
internal/secret/seunlocker_darwin.go
Normal file
@@ -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
|
||||
}
|
||||
63
internal/secret/seunlocker_stub.go
Normal file
63
internal/secret/seunlocker_stub.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user