1 Commits
Author SHA1 Message Date
sneak beb4540926 Make the README's storage and file format text match the code (closes #102)
check / check (push) Failing after 2s
The directory tree shows `current` and `currentvault` as plain files holding
a name, a version's metadata as the encrypted `metadata.age`, the real state
directory under the user's configuration directory, and the `lock` file.
`version promote` rewrites `current`. File Formats tells unencrypted vault
and unlocker metadata from encrypted version metadata; `pub.age` is plain
text and vault metadata holds no vault name. Unlocker bullets lose Touch ID
claims the code does not set up, and the Secure Enclave only decrypts.
Per-version keys no longer claim forward secrecy. Testing lists only
`make test`.

Model: opus-5-5
2026-10-04 17:32:01 +00:00
9 changed files with 71 additions and 240 deletions
+2 -3
View File
@@ -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
-16
View File
@@ -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
+5 -24
View File
@@ -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
} }
+4 -14
View File
@@ -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
+35 -50
View File
@@ -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;
} }
} }
-40
View File
@@ -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")
}
+7 -21
View File
@@ -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
} }
-27
View File
@@ -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")
}
+18 -45
View File
@@ -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,