Delete the keychain item or Secure Enclave key of a failed unlocker add (closes #89)
check / check (push) Failing after 2s
check / check (push) Failing after 2s
A Secure Enclave unlocker add gets the long-term key before it creates the Secure Enclave key, so a wrong passphrase creates none, and deletes the key if a later step fails. macse.CreateKey finds the new key's hash right after sc_auth creates it, failing with an error naming the label if it cannot, and deletes the key if getting its public key then fails. A keychain unlocker add writes all of the unlocker's files before it stores the keychain item, and deletes the item if moving the unlocker into place then fails. A failure to delete is reported along with the original error. The Objective-C and macse_darwin.go were only read, never compiled or run; the new tests run only on a Mac. Model: opus-5-5
This commit was merged in pull request #106.
This commit is contained in:
@@ -18,6 +18,22 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-04: A failed `secret unlocker add keychain` or
|
||||
`secret unlocker add secure-enclave` no longer leaves its keychain item or
|
||||
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
|
||||
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
|
||||
Secure Enclave key, so that a wrong passphrase creates none, and deletes the
|
||||
key again if encrypting with it or writing the unlocker then fails.
|
||||
`macse.CreateKey` finds the new key's hash right after `sc_auth` creates
|
||||
it, and fails with an error naming the key's label if it cannot; it deletes
|
||||
the key again if getting its public key then fails. The Objective-C was only
|
||||
read, never compiled or run, and so was `macse_darwin.go`, which is cgo only.
|
||||
`CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
|
||||
among them, before it stores the item in the keychain, and deletes the item
|
||||
again if moving the unlocker into place then fails. A failure to delete is
|
||||
reported along with the first error. The tests of this run only on macOS:
|
||||
the Secure Enclave one in a build with cgo on a Mac with a Secure Enclave,
|
||||
the keychain one in a build with cgo.
|
||||
- 2026-10-04: What a command killed part-way left under a `.tmp-` name
|
||||
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories
|
||||
of `secret.TempDirFor` and the temporary files of
|
||||
|
||||
@@ -15,6 +15,7 @@ package macse
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
@@ -39,10 +40,9 @@ const (
|
||||
|
||||
// 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).
|
||||
// (for deletion). If getting the public key fails, CreateKey deletes the key
|
||||
// again; a failure to delete is returned along with the first error.
|
||||
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
||||
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
||||
pubKeyLen := C.int(p256UncompressedKeySize)
|
||||
var hashBuf [hashBufferSize]C.char
|
||||
var errBuf [errorBufferSize]C.char
|
||||
|
||||
@@ -50,7 +50,6 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||
|
||||
result := C.se_create_key(cLabel,
|
||||
&pubKeyBuf[0], &pubKeyLen,
|
||||
&hashBuf[0], C.int(hashBufferSize),
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
@@ -58,9 +57,29 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
||||
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
}
|
||||
|
||||
h := C.GoString(&hashBuf[0])
|
||||
|
||||
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
||||
pubKeyLen := C.int(p256UncompressedKeySize)
|
||||
|
||||
result = C.se_copy_public_key(cLabel,
|
||||
&pubKeyBuf[0], &pubKeyLen,
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
err = fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
|
||||
deleteErr := DeleteKey(h)
|
||||
if deleteErr != nil {
|
||||
err = errors.Join(err,
|
||||
fmt.Errorf("failed to delete key %s: %w", label, deleteErr))
|
||||
}
|
||||
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
//nolint:nlreturn // CGo result extraction
|
||||
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
||||
h := C.GoString(&hashBuf[0])
|
||||
|
||||
return pk, h, nil
|
||||
}
|
||||
|
||||
@@ -5,20 +5,30 @@
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth.
|
||||
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and
|
||||
// finds its identity hash. If the hash cannot be found, the key exists but
|
||||
// se_create_key fails, with an error naming the label.
|
||||
// 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_copy_public_key copies the public key of a CTK identity.
|
||||
// label: label of the CTK identity
|
||||
// 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
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_copy_public_key(const char *label,
|
||||
uint8_t *pub_key_out, int *pub_key_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
|
||||
|
||||
@@ -47,7 +47,6 @@ static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -87,7 +86,56 @@ int se_create_key(const char *label,
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Retrieve the public key from the created identity
|
||||
// Get the identity hash, which deleting the key needs, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hash_out[0] == '\0') {
|
||||
NSString *msg = [NSString stringWithFormat:
|
||||
@"created key '%s' but found no hash for it in sc_auth list-ctk-identities",
|
||||
label];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int se_copy_public_key(const char *label,
|
||||
uint8_t *pub_key_out, int *pub_key_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;
|
||||
@@ -126,39 +174,6 @@ int se_create_key(const char *label,
|
||||
*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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/macse"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -899,3 +900,42 @@ func TestWriteDirRefusesExistingDir(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecureEnclaveUnlockerFailureDeletesKey makes moving a new Secure
|
||||
// Enclave unlocker into place fail after its Secure Enclave key is created:
|
||||
// the key must be deleted again. Skipped when the add fails before that, as
|
||||
// it does everywhere but in a macOS build with cgo on a Mac with a Secure
|
||||
// Enclave.
|
||||
func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
base := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The unlocker's directory is named se-<label of its Secure Enclave key>
|
||||
var seKeyLabel string
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
if op == opRename && filepath.Base(filepath.Dir(path)) == "unlockers.d" {
|
||||
seKeyLabel = strings.TrimPrefix(filepath.Base(path), "se-")
|
||||
|
||||
return errInjected
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
_, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
|
||||
nil)
|
||||
|
||||
if seKeyLabel == "" {
|
||||
t.Skipf("the add failed before moving the unlocker into place: %v", err)
|
||||
}
|
||||
|
||||
require.ErrorIs(t, err, errInjected)
|
||||
|
||||
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
|
||||
assert.Error(t, err, "Secure Enclave key left behind")
|
||||
}
|
||||
|
||||
@@ -490,6 +490,8 @@ func CreateKeychainUnlocker(
|
||||
|
||||
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
|
||||
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
|
||||
// The data is stored after the unlocker's files are written, and the keychain
|
||||
// item is deleted again if moving the unlocker into place then fails.
|
||||
func writeKeychainUnlocker(
|
||||
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
|
||||
encryptedAgePrivKey, encryptedLtPrivKey []byte,
|
||||
@@ -510,8 +512,10 @@ func writeKeychainUnlocker(
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 8: Write the unlocker's files and store the data in the keychain,
|
||||
// the metadata last
|
||||
// Step 8: Write the unlocker's files, the metadata last, then store the
|
||||
// data in the keychain
|
||||
stored := false
|
||||
|
||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
|
||||
if err != nil {
|
||||
@@ -528,19 +532,29 @@ func writeKeychainUnlocker(
|
||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
err = storeInKeychain(keychainItemName, keychainDataBuffer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store data in keychain: %w", err)
|
||||
}
|
||||
|
||||
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
|
||||
metadataBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
err = storeInKeychain(keychainItemName, keychainDataBuffer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store data in keychain: %w", err)
|
||||
}
|
||||
|
||||
stored = true
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil && stored {
|
||||
deleteErr := deleteFromKeychain(keychainItemName)
|
||||
if deleteErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf(
|
||||
"failed to delete keychain item %s: %w", keychainItemName, deleteErr))
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ package secret
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -185,3 +188,27 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
|
||||
assert.NoError(t, err,
|
||||
"Deleting non-existent keychain item should not return an error")
|
||||
}
|
||||
|
||||
// TestWriteKeychainUnlockerFailureDeletesItem makes moving a new keychain
|
||||
// unlocker into place fail after its data is stored in the keychain: the
|
||||
// keychain item must be deleted again.
|
||||
func TestWriteKeychainUnlockerFailureDeletesItem(t *testing.T) {
|
||||
testItemName := "test-secret-keychain-unlocker-cleanup"
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
|
||||
// Moving the unlocker into a read-only directory fails
|
||||
unlockersDir := filepath.Join(t.TempDir(), "unlockers.d")
|
||||
require.NoError(t, os.Mkdir(unlockersDir, 0o500))
|
||||
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-keychain-data"))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
_, err := writeKeychainUnlocker(afero.NewOsFs(),
|
||||
filepath.Join(unlockersDir, testItemName), testItemName, "age1test",
|
||||
[]byte("test-priv"), []byte("test-longterm"), testBuffer)
|
||||
require.ErrorIs(t, err, os.ErrPermission,
|
||||
"moving the unlocker into place should fail")
|
||||
|
||||
_, err = retrieveFromKeychain(testItemName)
|
||||
assert.Error(t, err, "keychain item left behind")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -216,6 +217,8 @@ func generateSEKeyLabel(vaultName string) (string, error) {
|
||||
// using ECIES. No intermediate age keypair is used.
|
||||
// The long-term key comes from mnemonic when it is not nil, else from the
|
||||
// current unlocker, as getLongTermKeyForSE describes.
|
||||
// The SE key is created once the long-term key is in hand and the unlocker's
|
||||
// path is known, and is deleted again if a later step fails.
|
||||
func CreateSecureEnclaveUnlocker(
|
||||
fs afero.Fs,
|
||||
stateDir string,
|
||||
@@ -237,17 +240,7 @@ func CreateSecureEnclaveUnlocker(
|
||||
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
|
||||
// Step 1: Get the vault's long-term private key
|
||||
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
@@ -257,16 +250,7 @@ func CreateSecureEnclaveUnlocker(
|
||||
}
|
||||
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: Prepare the unlocker directory's path and metadata
|
||||
// Step 2: Prepare the unlocker directory's path
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
@@ -275,6 +259,49 @@ func CreateSecureEnclaveUnlocker(
|
||||
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
||||
|
||||
// Step 3: 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)
|
||||
|
||||
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
|
||||
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
|
||||
ltPrivKeyData)
|
||||
if err != nil {
|
||||
deleteErr := macse.DeleteKey(seKeyHash)
|
||||
if deleteErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf(
|
||||
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return unlocker, nil
|
||||
}
|
||||
|
||||
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
|
||||
// new unlocker into unlockerDir (steps 4 and 5 of
|
||||
// CreateSecureEnclaveUnlocker).
|
||||
func writeSEUnlocker(
|
||||
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
|
||||
ltPrivKeyData *memguard.LockedBuffer,
|
||||
) (*SecureEnclaveUnlocker, error) {
|
||||
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
|
||||
// prepare the metadata
|
||||
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to encrypt long-term key with SE: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
|
||||
Reference in New Issue
Block a user