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
|
# 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
|
- 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
|
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories
|
||||||
of `secret.TempDirFor` and the temporary files of
|
of `secret.TempDirFor` and the temporary files of
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ package macse
|
|||||||
import "C"
|
import "C"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
)
|
)
|
||||||
@@ -39,10 +40,9 @@ const (
|
|||||||
|
|
||||||
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
|
// 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
|
// 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) {
|
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 hashBuf [hashBufferSize]C.char
|
||||||
var errBuf [errorBufferSize]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
|
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||||
|
|
||||||
result := C.se_create_key(cLabel,
|
result := C.se_create_key(cLabel,
|
||||||
&pubKeyBuf[0], &pubKeyLen,
|
|
||||||
&hashBuf[0], C.int(hashBufferSize),
|
&hashBuf[0], C.int(hashBufferSize),
|
||||||
&errBuf[0], C.int(errorBufferSize))
|
&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]))
|
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
|
//nolint:nlreturn // CGo result extraction
|
||||||
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
||||||
h := C.GoString(&hashBuf[0])
|
|
||||||
|
|
||||||
return pk, h, nil
|
return pk, h, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,30 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#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)
|
// 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: output buffer for the identity hash (for deletion)
|
||||||
// hash_out_len: size of hash_out buffer
|
// hash_out_len: size of hash_out buffer
|
||||||
// error_out: output buffer for error message
|
// error_out: output buffer for error message
|
||||||
// error_out_len: size of error_out buffer
|
// error_out_len: size of error_out buffer
|
||||||
// Returns 0 on success, -1 on failure.
|
// Returns 0 on success, -1 on failure.
|
||||||
int se_create_key(const char *label,
|
int se_create_key(const char *label,
|
||||||
uint8_t *pub_key_out, int *pub_key_len,
|
|
||||||
char *hash_out, int hash_out_len,
|
char *hash_out, int hash_out_len,
|
||||||
char *error_out, int error_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).
|
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
||||||
// label: label of the CTK identity whose public key to use
|
// label: label of the CTK identity whose public key to use
|
||||||
// plaintext: data to encrypt
|
// 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,
|
int se_create_key(const char *label,
|
||||||
uint8_t *pub_key_out, int *pub_key_len,
|
|
||||||
char *hash_out, int hash_out_len,
|
char *hash_out, int hash_out_len,
|
||||||
char *error_out, int error_out_len) {
|
char *error_out, int error_out_len) {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
@@ -87,7 +86,56 @@ int se_create_key(const char *label,
|
|||||||
return -1;
|
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);
|
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -126,39 +174,6 @@ int se_create_key(const char *label,
|
|||||||
*pub_key_len = (int)length;
|
*pub_key_len = (int)length;
|
||||||
CFRelease(pubKeyData);
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/macse"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"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
|
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
|
||||||
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
|
// 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(
|
func writeKeychainUnlocker(
|
||||||
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
|
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
|
||||||
encryptedAgePrivKey, encryptedLtPrivKey []byte,
|
encryptedAgePrivKey, encryptedLtPrivKey []byte,
|
||||||
@@ -510,8 +512,10 @@ func writeKeychainUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
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,
|
// Step 8: Write the unlocker's files, the metadata last, then store the
|
||||||
// the metadata last
|
// data in the keychain
|
||||||
|
stored := false
|
||||||
|
|
||||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||||
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
|
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -528,19 +532,29 @@ func writeKeychainUnlocker(
|
|||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
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"),
|
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
|
||||||
metadataBytes)
|
metadataBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -185,3 +188,27 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
|
|||||||
assert.NoError(t, err,
|
assert.NoError(t, err,
|
||||||
"Deleting non-existent keychain item should not return an error")
|
"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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -216,6 +217,8 @@ func generateSEKeyLabel(vaultName string) (string, error) {
|
|||||||
// using ECIES. No intermediate age keypair is used.
|
// using ECIES. No intermediate age keypair is used.
|
||||||
// The long-term key comes from mnemonic when it is not nil, else from the
|
// The long-term key comes from mnemonic when it is not nil, else from the
|
||||||
// current unlocker, as getLongTermKeyForSE describes.
|
// 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(
|
func CreateSecureEnclaveUnlocker(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
stateDir string,
|
stateDir string,
|
||||||
@@ -237,17 +240,7 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
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
|
// Step 1: Get the vault's long-term private key
|
||||||
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, mnemonic, passphrase)
|
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -257,16 +250,7 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
}
|
}
|
||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 3: Encrypt the long-term key directly with the SE (ECIES)
|
// Step 2: Prepare the unlocker directory's path
|
||||||
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
|
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
@@ -275,6 +259,49 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
|
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
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{
|
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: seUnlockerType,
|
||||||
|
|||||||
Reference in New Issue
Block a user