From 2d6675a4522d45b13d163053a0df69916936b932 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 00:39:35 +0200 Subject: [PATCH] Remove the keychain and Secure Enclave unlockers (closes #132) Neither unlocker works without macOS code signing and entitlements, which secret does not have, so both were research and leave the build. `secret unlocker add` takes only passphrase and pgp on every platform; keychain and secure-enclave fail as unknown types and are gone from --help and shell completion. internal/macse, their files in internal/secret with their tests, and their branches in internal/vault and internal/cli are deleted; github.com/keybase/go-keychain leaves go.mod and go.sum. README and Dockerfile comments no longer mention them. The research stays at ef0ae90, tagged v1.0.0-rc1. Judgement call: derivation_index_test.go goes with getLongTermPrivateKey, its only subject, which only these two unlockers used. Unverified: not built or run on a Mac; only the GOOS=darwin vet and lint ran. Model: opus-5-5 --- Dockerfile | 5 +- README.md | 41 +- TODO.md | 8 + go.mod | 1 - go.sum | 2 - internal/cli/unlockers.go | 106 +--- internal/cli/unlockers_add_test.go | 38 ++ internal/cli/unlockers_ids_test.go | 3 +- internal/macse/macse_darwin.go | 152 ------ internal/macse/macse_stub.go | 28 -- internal/macse/macse_test.go | 164 ------- internal/macse/secure_enclave.h | 69 --- internal/macse/secure_enclave.m | 317 ------------ internal/secret/atomic_test.go | 40 -- internal/secret/constants.go | 6 +- internal/secret/crypto.go | 3 +- internal/secret/derivation_index_test.go | 118 ----- internal/secret/keychaindata.go | 142 ------ internal/secret/keychaindata_test.go | 118 ----- internal/secret/keychainunlocker.go | 561 ---------------------- internal/secret/keychainunlocker_cgo.go | 104 ---- internal/secret/keychainunlocker_nocgo.go | 30 -- internal/secret/keychainunlocker_stub.go | 84 ---- internal/secret/keychainunlocker_test.go | 215 --------- internal/secret/metadata.go | 2 +- internal/secret/passphraseunlocker.go | 1 - internal/secret/pgpunlocker.go | 1 - internal/secret/seunlocker_darwin.go | 397 --------------- internal/secret/seunlocker_stub.go | 88 ---- internal/secret/seunlocker_stub_test.go | 99 ---- internal/secret/seunlocker_test.go | 117 ----- internal/secret/validation_darwin_test.go | 57 --- internal/vault/unlockers.go | 16 +- internal/vault/vault.go | 20 +- 34 files changed, 66 insertions(+), 3087 deletions(-) delete mode 100644 internal/macse/macse_darwin.go delete mode 100644 internal/macse/macse_stub.go delete mode 100644 internal/macse/macse_test.go delete mode 100644 internal/macse/secure_enclave.h delete mode 100644 internal/macse/secure_enclave.m delete mode 100644 internal/secret/derivation_index_test.go delete mode 100644 internal/secret/keychaindata.go delete mode 100644 internal/secret/keychaindata_test.go delete mode 100644 internal/secret/keychainunlocker.go delete mode 100644 internal/secret/keychainunlocker_cgo.go delete mode 100644 internal/secret/keychainunlocker_nocgo.go delete mode 100644 internal/secret/keychainunlocker_stub.go delete mode 100644 internal/secret/keychainunlocker_test.go delete mode 100644 internal/secret/seunlocker_darwin.go delete mode 100644 internal/secret/seunlocker_stub.go delete mode 100644 internal/secret/seunlocker_stub_test.go delete mode 100644 internal/secret/seunlocker_test.go delete mode 100644 internal/secret/validation_darwin_test.go diff --git a/Dockerfile b/Dockerfile index 15f9e01..3c03d0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,7 @@ RUN go vet ./... RUN golangci-lint run --config .golangci.yml ./... # The same checks on the code as a macOS build compiles it, which a Linux # build never compiles. Cgo is off, because compiling cgo code for macOS -# needs Apple's SDK headers. That leaves out the files built only with cgo -# on macOS: the keychain unlocker's calls into the keychain -# (keychainunlocker_cgo.go, and keychainunlocker_test.go) and the Secure -# Enclave bindings (internal/macse). Nothing on Linux checks those. +# needs Apple's SDK headers. RUN GOOS=darwin CGO_ENABLED=0 go vet ./... RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./... diff --git a/README.md b/README.md index f4840e6..0bb262a 100644 --- a/README.md +++ b/README.md @@ -223,8 +223,6 @@ Creates a new unlocker of the specified type: - `passphrase`: Traditional passphrase-protected unlocker - `pgp`: Uses an existing GPG key for encryption/decryption -- `keychain`: macOS Keychain integration (macOS only) -- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only) **Options:** @@ -345,21 +343,6 @@ Unlockers provide different authentication methods to access the long-term keys: - Leverages existing key management workflows - Strong authentication through GPG -3. **Keychain Unlockers** (macOS only): - - Stores unlock keys in macOS Keychain - - Kept on this Mac only: the keychain item is never synced to other devices - - Automatic unlocking when Keychain is unlocked - - Cross-application integration - -4. **Secure Enclave Unlockers** (macOS): - - Hardware-backed key storage using Apple Secure Enclave - - Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer - Program required) - - ECIES encryption: the vault long-term key is encrypted directly to the SE - key, and only the SE can decrypt it - - The SE key cannot leave this Mac; using it asks for no Touch ID or - password - Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets. @@ -412,9 +395,6 @@ variables. ### Hardware Integration - Hardware token support via PGP/GPG integration -- macOS Keychain integration for system-level security -- Secure Enclave integration for hardware-backed key protection (macOS, via - `sc_auth` / CryptoTokenKit) ## Examples @@ -468,8 +448,6 @@ secret vault remove personal --force # Add multiple unlock methods secret unlocker add passphrase # Password-based secret unlocker add pgp --keyid ABCD1234 # GPG key -secret unlocker add keychain # macOS Keychain (macOS only) -secret unlocker add secure-enclave # macOS Secure Enclave (macOS only) # List unlockers secret unlocker list @@ -538,12 +516,8 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt ### Cross-Platform Support -- **macOS**: Full support including Keychain and Secure Enclave integration -- **Linux**: Full support (excluding macOS-specific features) - -The keychain and Secure Enclave unlockers need a macOS build with cgo. A macOS -build without cgo, such as one cross-compiled from Linux, offers them but fails -to add or use them. +- **macOS**: Full support +- **Linux**: Full support ## Security Considerations @@ -556,7 +530,7 @@ to add or use them. ### Best Practices 1. Use strong, unique passphrases for unlockers -2. Enable hardware authentication (Keychain, hardware tokens) when available +2. Enable hardware authentication (hardware tokens) when available 3. Regularly audit unlockers and remove unused ones 4. Keep mnemonic phrases securely backed up offline 5. Use separate vaults for different security contexts @@ -565,7 +539,6 @@ to add or use them. - Requires access to unlockers for secret retrieval - Mnemonic phrases must be securely stored and backed up -- Hardware features limited to supported platforms ## Development @@ -610,10 +583,7 @@ provide: rerun passes - `script/lint` — build the `lint` phase of the `Dockerfile`, which runs `go vet` and `golangci-lint`, then both again on the code as a macOS build - compiles it (`GOOS=darwin`), which a Linux build never compiles; cgo is off - there, so the keychain unlocker's calls into the keychain - (`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`) - and the Secure Enclave bindings (`internal/macse`) are not checked + compiles it (`GOOS=darwin`, with cgo off), which a Linux build never compiles - `script/fmt` — format all Go code with `go fmt` and every markdown file with prettier (4-space tabs, `proseWrap: always`) (writes) - `script/fmt-check` — check the same formatting without writing, on the host @@ -629,8 +599,7 @@ provide: ## Features -- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, - and Secure Enclave unlockers +- **Multiple Authentication Methods**: Supports passphrase and PGP unlockers - **Vault Isolation**: Complete separation between different vaults - **Per-Secret Encryption**: Each secret has its own encryption key - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases diff --git a/TODO.md b/TODO.md index 7a1e24e..5964487 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,14 @@ https://git.eeqj.de/sneak/secret/milestone/12 # Completed Steps +- 2026-10-12: The keychain and Secure Enclave unlockers are gone from the code + and the documents (https://git.eeqj.de/sneak/secret/issues/132). They were + research: neither works without macOS code signing and entitlements, which + `secret` does not have. The last commit that has them is `ef0ae90`, tagged + `v1.0.0-rc1`. `secret unlocker add` takes only `passphrase` and `pgp` on every + platform, `internal/macse` and those unlockers' files in `internal/secret` are + deleted, and `github.com/keybase/go-keychain` left `go.mod` and `go.sum`. + `secret` still builds and runs on macOS. - 2026-10-07: The part of `github.com/tyler-smith/go-bip39` v1.1.0 that `secret` uses is copied into `internal/bip39`, with upstream's `LICENSE` beside it, because its repository no longer exists diff --git a/go.mod b/go.mod index 6d9ba37..fc3d368 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 // v1.1.24's Open can return another pty's terminal github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.18.0 - github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 github.com/oklog/ulid/v2 v2.1.1 github.com/spf13/afero v1.14.0 github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index 22bbb9b..2ba5bd8 100644 --- a/go.sum +++ b/go.sum @@ -69,8 +69,6 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= -github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 h1:yi1W8qcFJ2plmaGJFN1npm0KQviWPMCtQOYuwDT6Swk= -github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1/go.mod h1:qDHUvIjGZJUtdPtuP4WMu5/U4aVWbFw1MhlkJqCGmCQ= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= diff --git a/internal/cli/unlockers.go b/internal/cli/unlockers.go index 707363c..6792a0f 100644 --- a/internal/cli/unlockers.go +++ b/internal/cli/unlockers.go @@ -10,7 +10,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "slices" "strings" "time" @@ -21,14 +20,10 @@ import ( "sneak.berlin/go/secret/internal/vault" ) -// Unlocker type names and platform identifiers shared across the CLI +// Unlocker type names shared across the CLI const ( - unlockerTypePassphrase = "passphrase" - unlockerTypeKeychain = "keychain" - unlockerTypePGP = "pgp" - unlockerTypeSecureEnclave = "secure-enclave" - - platformDarwin = "darwin" + unlockerTypePassphrase = "passphrase" + unlockerTypePGP = "pgp" cmdUseList = "list" ) @@ -143,9 +138,8 @@ func newUnlockerListCmd() *cobra.Command { } // unlockerAddHelp returns the supported unlocker types list and their -// descriptions for the current platform +// descriptions func unlockerAddHelp() (string, string) { - // Build the supported types list based on platform supportedTypes := "passphrase, pgp" typeDescriptions := "Available unlocker types:\n" + "\n" + @@ -162,43 +156,6 @@ func unlockerAddHelp() (string, string) { " Use --keyid to specify a particular key, " + "otherwise uses your default GPG key." - if runtime.GOOS == platformDarwin { - supportedTypes = "passphrase, keychain, pgp, secure-enclave" - typeDescriptions = "Available unlocker types:\n" + - "\n" + - " passphrase - Traditional password-based encryption\n" + - " Prompts for a passphrase that will be " + - "used to encrypt/decrypt the vault's master key.\n" + - " The passphrase is never stored in " + - "plaintext.\n" + - "\n" + - " keychain - macOS Keychain integration (macOS only)\n" + - " Stores the vault's master key in the " + - "macOS Keychain, protected by your login password.\n" + - " Automatically unlocks when your Keychain " + - "is unlocked (e.g., after login).\n" + - " Provides seamless integration with macOS " + - "security features like Touch ID.\n" + - "\n" + - " pgp - GNU Privacy Guard (GPG) key-based " + - "encryption\n" + - " Uses your existing GPG key to " + - "encrypt/decrypt the vault's master key.\n" + - " Requires gpg to be installed and " + - "configured with at least one secret key.\n" + - " Use --keyid to specify a particular key, " + - "otherwise uses your default GPG key.\n" + - "\n" + - " secure-enclave - Apple Secure Enclave hardware protection " + - "(macOS only)\n" + - " Stores the vault's master key encrypted " + - "by a non-exportable P-256 key\n" + - " held in the Secure Enclave. The key " + - "never leaves the hardware.\n" + - " Uses ECIES encryption; decryption is " + - "performed inside the SE." - } - return supportedTypes, typeDescriptions } @@ -420,18 +377,10 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error switch unlockerType { case unlockerTypePassphrase: return cli.addPassphraseUnlocker(cmd) - case unlockerTypeKeychain: - return cli.addKeychainUnlocker(cmd) - case unlockerTypeSecureEnclave: - return cli.addSecureEnclaveUnlocker(cmd) case unlockerTypePGP: return cli.addPGPUnlocker(cmd) default: - // Build the supported types list based on platform supportedTypes := "passphrase, pgp" - if runtime.GOOS == platformDarwin { - supportedTypes = "passphrase, keychain, pgp, secure-enclave" - } return fmt.Errorf("%w: %s (supported: %s)", errInvalidUnlockerType, unlockerType, supportedTypes) @@ -487,53 +436,6 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error { return nil } -// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault -func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error { - keychainUnlocker, err := secret.CreateKeychainUnlocker( - cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase) - if err != nil { - return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err) - } - - cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID()) - - keyName, err := keychainUnlocker.GetKeychainItemName() - if err == nil { - cmd.Printf("Keychain Item Name: %s\n", keyName) - } - - // Auto-select the newly created unlocker - vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) - if err != nil { - return fmt.Errorf("failed to get current vault: %w", err) - } - - autoSelectUnlocker(cmd, vlt, keychainUnlocker.GetID()) - - return nil -} - -// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the -// current vault -func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error { - seUnlocker, err := secret.CreateSecureEnclaveUnlocker( - cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase) - if err != nil { - return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err) - } - - cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID()) - - vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) - if err != nil { - return fmt.Errorf("failed to get current vault: %w", err) - } - - autoSelectUnlocker(cmd, vlt, seUnlocker.GetID()) - - return nil -} - // addPGPUnlocker creates a PGP unlocker in the current vault func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error { // Get GPG key ID from flag, environment, or default key diff --git a/internal/cli/unlockers_add_test.go b/internal/cli/unlockers_add_test.go index b37e353..09ef065 100644 --- a/internal/cli/unlockers_add_test.go +++ b/internal/cli/unlockers_add_test.go @@ -3,6 +3,7 @@ package cli import ( "path/filepath" + "strings" "testing" "github.com/awnumar/memguard" @@ -104,3 +105,40 @@ func TestAddPGPUnlockerUnknownKey(t *testing.T) { filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName), listTestUnlockerDirOne) } + +// TestUnlockerAddKnowsOnlyPassphraseAndPGP is a regression test for +// https://git.eeqj.de/sneak/secret/issues/132: `secret unlocker add` takes +// only passphrase and pgp, on every platform. keychain and secure-enclave +// fail as unknown types, and neither --help nor shell completion offers +// them. +// +//nolint:paralleltest // executes the CLI in-process and sets the environment +func TestUnlockerAddKnowsOnlyPassphraseAndPGP(t *testing.T) { + env := map[string]string{secret.EnvStateDir: t.TempDir()} + removedTypes := []string{"keychain", "secure-enclave"} + + for _, unlockerType := range removedTypes { + _, err := ExecuteCommandInProcess( + []string{"unlocker", "add", unlockerType}, "", env) + require.ErrorIs(t, err, errInvalidUnlockerType) + + instance, cmd := newTestInstance(newListTestVault(t, 1)) + require.ErrorIs(t, instance.UnlockersAdd(unlockerType, cmd), + errInvalidUnlockerType) + } + + // --help prints the command's Long text, and shell completion offers + // its ValidArgs. + addCmd := newUnlockerAddCmd() + help := addCmd.Long + completions := strings.Join(addCmd.ValidArgs, " ") + + for _, offered := range []string{help, completions} { + assert.Contains(t, offered, unlockerTypePassphrase) + assert.Contains(t, offered, unlockerTypePGP) + + for _, unlockerType := range removedTypes { + assert.NotContains(t, offered, unlockerType) + } + } +} diff --git a/internal/cli/unlockers_ids_test.go b/internal/cli/unlockers_ids_test.go index e68969e..46131a0 100644 --- a/internal/cli/unlockers_ids_test.go +++ b/internal/cli/unlockers_ids_test.go @@ -19,8 +19,7 @@ import ( // copying an unlocker directory leaves them. It asserts that `unlocker // list` and the shell completion of `unlocker select` and `unlocker remove` // give each its own ID, and that each is selected and removed by its ID -// alone. Keychain and Secure Enclave unlockers, which only macOS can add, -// get their IDs the same way. +// alone. func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) { t.Parallel() diff --git a/internal/macse/macse_darwin.go b/internal/macse/macse_darwin.go deleted file mode 100644 index 130d33c..0000000 --- a/internal/macse/macse_darwin.go +++ /dev/null @@ -1,152 +0,0 @@ -//go:build darwin - -// Package macse provides Go bindings for macOS Secure Enclave operations -// using CryptoTokenKit identities created via sc_auth. -// Key creation and deletion shell out to sc_auth (which has SE entitlements). -// Encrypt/decrypt use Security.framework ECIES directly (works unsigned). -package macse - -/* -#cgo CFLAGS: -x objective-c -fobjc-arc -#cgo LDFLAGS: -framework Security -framework Foundation -framework CoreFoundation -#include -#include "secure_enclave.h" -*/ -import "C" - -import ( - "errors" - "fmt" - "unsafe" -) - -const ( - // p256UncompressedKeySize is the size of an uncompressed P-256 public key. - p256UncompressedKeySize = 65 - - // errorBufferSize is the size of the C error message buffer. - errorBufferSize = 512 - - // hashBufferSize is the size of the hash output buffer. - hashBufferSize = 128 - - // maxCiphertextSize is the max buffer for ECIES ciphertext. - // ECIES overhead for P-256: 65 (ephemeral pub) + 16 (GCM tag) + 16 (IV) + plaintext. - maxCiphertextSize = 8192 - - // maxPlaintextSize is the max buffer for decrypted plaintext. - maxPlaintextSize = 8192 -) - -// 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). 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) { - var hashBuf [hashBufferSize]C.char - var errBuf [errorBufferSize]C.char - - cLabel := C.CString(label) - defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern - - result := C.se_create_key(cLabel, - &hashBuf[0], C.int(hashBufferSize), - &errBuf[0], C.int(errorBufferSize)) - - if result != 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 - pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) - - return pk, h, nil -} - -// Encrypt encrypts plaintext using the SE-backed public key via ECIES -// (eciesEncryptionStandardVariableIVX963SHA256AESGCM). -// Encryption uses only the public key; no SE interaction required. -func Encrypt(label string, plaintext []byte) ([]byte, error) { - ciphertextBuf := make([]C.uint8_t, maxCiphertextSize) - ciphertextLen := C.int(maxCiphertextSize) - var errBuf [errorBufferSize]C.char - - cLabel := C.CString(label) - defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern - - result := C.se_encrypt(cLabel, - (*C.uint8_t)(unsafe.Pointer(&plaintext[0])), C.int(len(plaintext)), - &ciphertextBuf[0], &ciphertextLen, - &errBuf[0], C.int(errorBufferSize)) - - if result != 0 { - return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) - } - - //nolint:nlreturn // CGo result extraction - out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) - - return out, nil -} - -// Decrypt decrypts ECIES ciphertext using the SE-backed private key. -// The ECDH portion of decryption is performed inside the Secure Enclave. -func Decrypt(label string, ciphertext []byte) ([]byte, error) { - plaintextBuf := make([]C.uint8_t, maxPlaintextSize) - plaintextLen := C.int(maxPlaintextSize) - var errBuf [errorBufferSize]C.char - - cLabel := C.CString(label) - defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern - - result := C.se_decrypt(cLabel, - (*C.uint8_t)(unsafe.Pointer(&ciphertext[0])), C.int(len(ciphertext)), - &plaintextBuf[0], &plaintextLen, - &errBuf[0], C.int(errorBufferSize)) - - if result != 0 { - return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) - } - - //nolint:nlreturn // CGo result extraction - out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) - - return out, nil -} - -// DeleteKey removes a CTK identity from the Secure Enclave via sc_auth. -func DeleteKey(hash string) error { - var errBuf [errorBufferSize]C.char - - cHash := C.CString(hash) - defer C.free(unsafe.Pointer(cHash)) //nolint:nlreturn // CGo free pattern - - result := C.se_delete_key(cHash, &errBuf[0], C.int(errorBufferSize)) - - if result != 0 { - return fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) - } - - return nil -} diff --git a/internal/macse/macse_stub.go b/internal/macse/macse_stub.go deleted file mode 100644 index f813ee9..0000000 --- a/internal/macse/macse_stub.go +++ /dev/null @@ -1,28 +0,0 @@ -//go:build !darwin || !cgo - -// Package macse provides Go bindings for macOS Secure Enclave operations. -package macse - -import "errors" - -var errNotSupported = errors.New("secure enclave needs a macOS build with cgo") - -// CreateKey fails: the Secure Enclave needs a macOS build with cgo. -func CreateKey(_ string) ([]byte, string, error) { - return nil, "", errNotSupported -} - -// Encrypt fails: the Secure Enclave needs a macOS build with cgo. -func Encrypt(_ string, _ []byte) ([]byte, error) { - return nil, errNotSupported -} - -// Decrypt fails: the Secure Enclave needs a macOS build with cgo. -func Decrypt(_ string, _ []byte) ([]byte, error) { - return nil, errNotSupported -} - -// DeleteKey fails: the Secure Enclave needs a macOS build with cgo. -func DeleteKey(_ string) error { - return errNotSupported -} diff --git a/internal/macse/macse_test.go b/internal/macse/macse_test.go deleted file mode 100644 index 954e296..0000000 --- a/internal/macse/macse_test.go +++ /dev/null @@ -1,164 +0,0 @@ -//go:build darwin && cgo - -package macse - -import ( - "bytes" - "testing" -) - -const testKeyLabel = "berlin.sneak.app.secret.test.se-key" - -// testKeyHash stores the hash of the created test key for cleanup. -var testKeyHash string //nolint:gochecknoglobals - -// skipIfNoSecureEnclave skips the test if SE access is unavailable. -func skipIfNoSecureEnclave(t *testing.T) { - t.Helper() - - probeLabel := "berlin.sneak.app.secret.test.se-probe" - _, hash, err := CreateKey(probeLabel) - if err != nil { - t.Skipf("Secure Enclave unavailable (skipping): %v", err) - } - - if hash != "" { - _ = DeleteKey(hash) - } -} - -func TestCreateAndDeleteKey(t *testing.T) { - skipIfNoSecureEnclave(t) - - if testKeyHash != "" { - _ = DeleteKey(testKeyHash) - } - - pubKey, hash, err := CreateKey(testKeyLabel) - if err != nil { - t.Fatalf("CreateKey failed: %v", err) - } - - testKeyHash = hash - t.Logf("Created key with hash: %s", hash) - - // Verify valid uncompressed P-256 public key - if len(pubKey) != p256UncompressedKeySize { - t.Fatalf("expected public key length %d, got %d", - p256UncompressedKeySize, len(pubKey)) - } - - if pubKey[0] != 0x04 { - t.Fatalf("expected uncompressed point prefix 0x04, got 0x%02x", pubKey[0]) - } - - if hash == "" { - t.Fatal("expected non-empty hash") - } - - // Delete the key - if err := DeleteKey(hash); err != nil { - t.Fatalf("DeleteKey failed: %v", err) - } - - testKeyHash = "" - t.Log("Key created, verified, and deleted successfully") -} - -func TestEncryptDecryptRoundTrip(t *testing.T) { - skipIfNoSecureEnclave(t) - - _, hash, err := CreateKey(testKeyLabel) - if err != nil { - t.Fatalf("CreateKey failed: %v", err) - } - - testKeyHash = hash - - defer func() { - if testKeyHash != "" { - _ = DeleteKey(testKeyHash) - testKeyHash = "" - } - }() - - // Test data simulating an age private key - plaintext := []byte("AGE-SECRET-KEY-1" + - "QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ") - - // Encrypt - ciphertext, err := Encrypt(testKeyLabel, plaintext) - if err != nil { - t.Fatalf("Encrypt failed: %v", err) - } - - t.Logf("Plaintext: %d bytes, Ciphertext: %d bytes", len(plaintext), len(ciphertext)) - - if bytes.Equal(ciphertext, plaintext) { - t.Fatal("ciphertext should differ from plaintext") - } - - // Decrypt - decrypted, err := Decrypt(testKeyLabel, ciphertext) - if err != nil { - t.Fatalf("Decrypt failed: %v", err) - } - - if !bytes.Equal(decrypted, plaintext) { - t.Fatalf("decrypted data does not match original plaintext") - } - - t.Log("ECIES encrypt/decrypt round-trip successful") -} - -func TestEncryptProducesDifferentCiphertexts(t *testing.T) { - skipIfNoSecureEnclave(t) - - _, hash, err := CreateKey(testKeyLabel) - if err != nil { - t.Fatalf("CreateKey failed: %v", err) - } - - testKeyHash = hash - - defer func() { - if testKeyHash != "" { - _ = DeleteKey(testKeyHash) - testKeyHash = "" - } - }() - - plaintext := []byte("test-secret-data") - - ct1, err := Encrypt(testKeyLabel, plaintext) - if err != nil { - t.Fatalf("first Encrypt failed: %v", err) - } - - ct2, err := Encrypt(testKeyLabel, plaintext) - if err != nil { - t.Fatalf("second Encrypt failed: %v", err) - } - - // ECIES uses a random ephemeral key each time, so ciphertexts should differ - if bytes.Equal(ct1, ct2) { - t.Fatal("two encryptions of same plaintext should produce different ciphertexts") - } - - // Both should decrypt to the same plaintext - dec1, err := Decrypt(testKeyLabel, ct1) - if err != nil { - t.Fatalf("first Decrypt failed: %v", err) - } - - dec2, err := Decrypt(testKeyLabel, ct2) - if err != nil { - t.Fatalf("second Decrypt failed: %v", err) - } - - if !bytes.Equal(dec1, plaintext) || !bytes.Equal(dec2, plaintext) { - t.Fatal("both ciphertexts should decrypt to original plaintext") - } - - t.Log("ECIES correctly produces different ciphertexts that decrypt to same plaintext") -} diff --git a/internal/macse/secure_enclave.h b/internal/macse/secure_enclave.h deleted file mode 100644 index c70c7dd..0000000 --- a/internal/macse/secure_enclave.h +++ /dev/null @@ -1,69 +0,0 @@ -//go:build darwin - -#ifndef SECURE_ENCLAVE_H -#define SECURE_ENCLAVE_H - -#include - -// 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) -// 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, - 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 -// plaintext_len: length of plaintext -// ciphertext_out: output buffer for the ECIES ciphertext -// ciphertext_len: on input, size of buffer; 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_encrypt(const char *label, - const uint8_t *plaintext, int plaintext_len, - uint8_t *ciphertext_out, int *ciphertext_len, - char *error_out, int error_out_len); - -// se_decrypt decrypts ECIES ciphertext using the SE-backed private key. -// The ECDH portion of decryption is performed inside the Secure Enclave. -// label: label of the CTK identity whose private key to use -// ciphertext: ECIES ciphertext produced by se_encrypt -// ciphertext_len: length of ciphertext -// plaintext_out: output buffer for decrypted data -// plaintext_len: on input, size of buffer; 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_decrypt(const char *label, - const uint8_t *ciphertext, int ciphertext_len, - uint8_t *plaintext_out, int *plaintext_len, - char *error_out, int error_out_len); - -// se_delete_key removes a CTK identity from the Secure Enclave via sc_auth. -// hash: the identity hash returned by se_create_key -// error_out: output buffer for error message -// error_out_len: size of error_out buffer -// Returns 0 on success, -1 on failure. -int se_delete_key(const char *hash, - char *error_out, int error_out_len); - -#endif // SECURE_ENCLAVE_H diff --git a/internal/macse/secure_enclave.m b/internal/macse/secure_enclave.m deleted file mode 100644 index 50651ed..0000000 --- a/internal/macse/secure_enclave.m +++ /dev/null @@ -1,317 +0,0 @@ -//go:build darwin - -#import -#import -#include "secure_enclave.h" -#include - -// snprintf_error writes an error message string to the output buffer. -static void snprintf_error(char *error_out, int error_out_len, NSString *msg) { - if (error_out && error_out_len > 0) { - snprintf(error_out, error_out_len, "%s", msg.UTF8String); - } -} - -// lookup_ctk_identity finds a CTK identity by label and returns the private key. -static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int error_out_len) { - NSDictionary *query = @{ - (id)kSecClass: (id)kSecClassIdentity, - (id)kSecAttrLabel: [NSString stringWithUTF8String:label], - (id)kSecMatchLimit: (id)kSecMatchLimitOne, - (id)kSecReturnRef: @YES, - }; - - SecIdentityRef identity = NULL; - OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&identity); - - if (status != errSecSuccess || !identity) { - NSString *msg = [NSString stringWithFormat:@"CTK identity '%s' not found: OSStatus %d", - label, (int)status]; - snprintf_error(error_out, error_out_len, msg); - return NULL; - } - - SecKeyRef privateKey = NULL; - status = SecIdentityCopyPrivateKey(identity, &privateKey); - CFRelease(identity); - - if (status != errSecSuccess || !privateKey) { - NSString *msg = [NSString stringWithFormat: - @"failed to get private key from CTK identity '%s': OSStatus %d", - label, (int)status]; - snprintf_error(error_out, error_out_len, msg); - return NULL; - } - - return privateKey; -} - -int se_create_key(const char *label, - char *hash_out, int hash_out_len, - char *error_out, int error_out_len) { - @autoreleasepool { - NSString *labelStr = [NSString stringWithUTF8String:label]; - - // Shell out to sc_auth (which has SE entitlements) to create the key - NSTask *task = [[NSTask alloc] init]; - task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"]; - task.arguments = @[ - @"create-ctk-identity", - @"-k", @"p-256-ne", - @"-t", @"none", - @"-l", labelStr, - ]; - - NSPipe *stderrPipe = [NSPipe pipe]; - task.standardOutput = [NSPipe pipe]; - task.standardError = stderrPipe; - - NSError *nsError = nil; - if (![task launchAndReturnError:&nsError]) { - NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@", - nsError.localizedDescription]; - snprintf_error(error_out, error_out_len, msg); - return -1; - } - - [task waitUntilExit]; - - if (task.terminationStatus != 0) { - NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile]; - NSString *stderrStr = [[NSString alloc] initWithData:stderrData - encoding:NSUTF8StringEncoding]; - NSString *msg = [NSString stringWithFormat:@"sc_auth failed: %@", - stderrStr ?: @"unknown error"]; - snprintf_error(error_out, error_out_len, msg); - return -1; - } - - // 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; - } - - SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey); - CFRelease(privateKey); - - if (!publicKey) { - snprintf_error(error_out, error_out_len, @"failed to get public key"); - return -1; - } - - CFErrorRef cfError = NULL; - CFDataRef pubKeyData = SecKeyCopyExternalRepresentation(publicKey, &cfError); - CFRelease(publicKey); - - if (!pubKeyData) { - NSError *err = (__bridge_transfer NSError *)cfError; - NSString *msg = [NSString stringWithFormat:@"failed to export public key: %@", - err.localizedDescription]; - snprintf_error(error_out, error_out_len, msg); - return -1; - } - - const UInt8 *bytes = CFDataGetBytePtr(pubKeyData); - CFIndex length = CFDataGetLength(pubKeyData); - - if (length > *pub_key_len) { - CFRelease(pubKeyData); - snprintf_error(error_out, error_out_len, @"public key buffer too small"); - return -1; - } - - memcpy(pub_key_out, bytes, length); - *pub_key_len = (int)length; - CFRelease(pubKeyData); - - return 0; - } -} - -int se_encrypt(const char *label, - const uint8_t *plaintext, int plaintext_len, - uint8_t *ciphertext_out, int *ciphertext_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; - } - - SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey); - CFRelease(privateKey); - - if (!publicKey) { - snprintf_error(error_out, error_out_len, @"failed to get public key for encryption"); - return -1; - } - - NSData *plaintextData = [NSData dataWithBytes:plaintext length:plaintext_len]; - - CFErrorRef cfError = NULL; - CFDataRef encrypted = SecKeyCreateEncryptedData( - publicKey, - kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM, - (__bridge CFDataRef)plaintextData, - &cfError - ); - CFRelease(publicKey); - - if (!encrypted) { - NSError *nsError = (__bridge_transfer NSError *)cfError; - NSString *msg = [NSString stringWithFormat:@"ECIES encryption failed: %@", - nsError.localizedDescription]; - snprintf_error(error_out, error_out_len, msg); - return -1; - } - - const UInt8 *encBytes = CFDataGetBytePtr(encrypted); - CFIndex encLength = CFDataGetLength(encrypted); - - if (encLength > *ciphertext_len) { - CFRelease(encrypted); - snprintf_error(error_out, error_out_len, @"ciphertext buffer too small"); - return -1; - } - - memcpy(ciphertext_out, encBytes, encLength); - *ciphertext_len = (int)encLength; - CFRelease(encrypted); - - return 0; - } -} - -int se_decrypt(const char *label, - const uint8_t *ciphertext, int ciphertext_len, - uint8_t *plaintext_out, int *plaintext_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; - } - - NSData *ciphertextData = [NSData dataWithBytes:ciphertext length:ciphertext_len]; - - CFErrorRef cfError = NULL; - CFDataRef decrypted = SecKeyCreateDecryptedData( - privateKey, - kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM, - (__bridge CFDataRef)ciphertextData, - &cfError - ); - CFRelease(privateKey); - - if (!decrypted) { - NSError *nsError = (__bridge_transfer NSError *)cfError; - NSString *msg = [NSString stringWithFormat:@"ECIES decryption failed: %@", - nsError.localizedDescription]; - snprintf_error(error_out, error_out_len, msg); - return -1; - } - - const UInt8 *decBytes = CFDataGetBytePtr(decrypted); - CFIndex decLength = CFDataGetLength(decrypted); - - if (decLength > *plaintext_len) { - CFRelease(decrypted); - snprintf_error(error_out, error_out_len, @"plaintext buffer too small"); - return -1; - } - - memcpy(plaintext_out, decBytes, decLength); - *plaintext_len = (int)decLength; - CFRelease(decrypted); - - return 0; - } -} - -int se_delete_key(const char *hash, - char *error_out, int error_out_len) { - @autoreleasepool { - NSTask *task = [[NSTask alloc] init]; - task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"]; - task.arguments = @[ - @"delete-ctk-identity", - @"-h", [NSString stringWithUTF8String:hash], - ]; - - NSPipe *stderrPipe = [NSPipe pipe]; - task.standardOutput = [NSPipe pipe]; - task.standardError = stderrPipe; - - NSError *nsError = nil; - if (![task launchAndReturnError:&nsError]) { - NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@", - nsError.localizedDescription]; - snprintf_error(error_out, error_out_len, msg); - return -1; - } - - [task waitUntilExit]; - - if (task.terminationStatus != 0) { - NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile]; - NSString *stderrStr = [[NSString alloc] initWithData:stderrData - encoding:NSUTF8StringEncoding]; - NSString *msg = [NSString stringWithFormat:@"sc_auth delete failed: %@", - stderrStr ?: @"unknown error"]; - snprintf_error(error_out, error_out_len, msg); - return -1; - } - - return 0; - } -} diff --git a/internal/secret/atomic_test.go b/internal/secret/atomic_test.go index 83119be..5257ae1 100644 --- a/internal/secret/atomic_test.go +++ b/internal/secret/atomic_test.go @@ -12,7 +12,6 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "sneak.berlin/go/secret/internal/macse" "sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/vault" ) @@ -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, nil) - require.NoError(t, err) - - // The unlocker's directory is named se-