Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb4540926 |
@@ -519,9 +519,8 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
- **age Files**: Standard age encryption format (.age extension), except
|
- **age Files**: Standard age encryption format (.age extension), except
|
||||||
`pub.age`, which holds an age public key as text
|
`pub.age`, which holds an age public key as text
|
||||||
- **Metadata**: `vault-metadata.json` and `unlocker-metadata.json` are
|
- **Metadata**: `vault-metadata.json` and `unlocker-metadata.json` are
|
||||||
unencrypted JSON with a creation time, and `unlocker-metadata.json` also
|
unencrypted JSON with timestamps and type information; a version's
|
||||||
records the unlocker's type; a version's `metadata.age` is JSON encrypted to
|
`metadata.age` is JSON encrypted to the version's public key
|
||||||
the version's public key
|
|
||||||
- **Vault Metadata**: JSON containing creation time, derivation index, and the
|
- **Vault Metadata**: JSON containing creation time, derivation index, and the
|
||||||
public key hashes described below
|
public key hashes described below
|
||||||
|
|
||||||
|
|||||||
@@ -29,22 +29,6 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
|||||||
the Secure Enclave only decrypts; per-version keys give no forward
|
the Secure Enclave only decrypts; per-version keys give no forward
|
||||||
secrecy; `pub.age` is not age-encrypted; vault metadata holds no vault
|
secrecy; `pub.age` is not age-encrypted; vault metadata holds no vault
|
||||||
name. Testing lists only `make test`.
|
name. Testing lists only `make test`.
|
||||||
- 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,7 +15,6 @@ package macse
|
|||||||
import "C"
|
import "C"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
)
|
)
|
||||||
@@ -40,9 +39,10 @@ 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). If getting the public key fails, CreateKey deletes the key
|
// (for deletion).
|
||||||
// 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,6 +50,7 @@ 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))
|
||||||
|
|
||||||
@@ -57,29 +58,9 @@ 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,30 +5,20 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and
|
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth.
|
||||||
// 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,6 +47,7 @@ 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 {
|
||||||
@@ -86,56 +87,7 @@ int se_create_key(const char *label,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the identity hash, which deleting the key needs, by parsing
|
// Retrieve the public key from the created identity
|
||||||
// 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;
|
||||||
@@ -174,6 +126,39 @@ int se_copy_public_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,7 +8,6 @@ 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"
|
||||||
@@ -900,42 +899,3 @@ 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,8 +490,6 @@ 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,
|
||||||
@@ -512,10 +510,8 @@ 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, the metadata last, then store the
|
// Step 8: Write the unlocker's files and store the data in the keychain,
|
||||||
// data in the keychain
|
// the metadata last
|
||||||
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 {
|
||||||
@@ -532,29 +528,19 @@ 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,13 +4,10 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -188,27 +185,3 @@ 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,7 +4,6 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -217,8 +216,6 @@ 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,
|
||||||
@@ -240,26 +237,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: Get the vault's long-term private key
|
// Step 1: Create P-256 key in the Secure Enclave via sc_auth
|
||||||
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"failed to get long-term private key: %w",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
defer ltPrivKeyData.Destroy()
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
Debug("Creating Secure Enclave key", "label", seKeyLabel)
|
||||||
|
|
||||||
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
||||||
@@ -269,31 +247,17 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
|
|
||||||
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
||||||
|
|
||||||
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
|
// Step 2: Get the vault's long-term private key
|
||||||
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
|
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
||||||
ltPrivKeyData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deleteErr := macse.DeleteKey(seKeyHash)
|
return nil, fmt.Errorf(
|
||||||
if deleteErr != nil {
|
"failed to get long-term private key: %w",
|
||||||
err = errors.Join(err, fmt.Errorf(
|
err,
|
||||||
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
|
)
|
||||||
}
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
return unlocker, nil
|
// Step 3: Encrypt the long-term key directly with the SE (ECIES)
|
||||||
}
|
|
||||||
|
|
||||||
// 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())
|
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -302,6 +266,15 @@ func writeSEUnlocker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 4: Prepare the unlocker directory's path and metadata
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
|
||||||
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
||||||
|
|
||||||
seMetadata := SecureEnclaveUnlockerMetadata{
|
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: seUnlockerType,
|
||||||
|
|||||||
Reference in New Issue
Block a user