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).
164 lines
3.7 KiB
Go
164 lines
3.7 KiB
Go
//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")
|
|
}
|