2 Commits
Author SHA1 Message Date
clawbot fd6c5df1a8 Derive the vault key from the mnemonic's words however they are spaced (closes #135)
check / check (push) Failing after 4s
The long-term key was derived from the mnemonic text exactly as typed,
so a double space, a tab or a trailing newline gave a different key,
and a vault made that way did not open with the same words typed
normally. agehd.DeriveEntropy, which init, vault create, vault import,
unlocker add and every unlock go through, now joins the words with
single spaces and puts them in Unicode NFKD, as BIP39 defines the
seed's input, before hashing them. golang.org/x/text, already an
indirect dependency, becomes a direct one for the NFKD step.

Judgement call: internal/bip39's NewSeed keeps hashing its input as
given, as upstream does; the normalising is in agehd.

Model: opus-5-5
2026-10-13 01:09:36 +02:00
clawbot 2d6675a452 Remove the keychain and Secure Enclave unlockers (closes #132)
check / check (push) Failing after 5s
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
2026-10-13 00:39:35 +02:00
36 changed files with 109 additions and 3090 deletions
+1 -4
View File
@@ -10,10 +10,7 @@ RUN go vet ./...
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# The same checks on the code as a macOS build compiles it, which a Linux # 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 # 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 # needs Apple's SDK headers.
# 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.
RUN GOOS=darwin CGO_ENABLED=0 go vet ./... RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./... RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
+5 -36
View File
@@ -223,8 +223,6 @@ Creates a new unlocker of the specified type:
- `passphrase`: Traditional passphrase-protected unlocker - `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption - `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:** **Options:**
@@ -345,21 +343,6 @@ Unlockers provide different authentication methods to access the long-term keys:
- Leverages existing key management workflows - Leverages existing key management workflows
- Strong authentication through GPG - 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 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 long-term key is encrypted to each unlocker, allowing any authorized unlocker to
access vault secrets. access vault secrets.
@@ -412,9 +395,6 @@ variables.
### Hardware Integration ### Hardware Integration
- Hardware token support via PGP/GPG 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 ## Examples
@@ -468,8 +448,6 @@ secret vault remove personal --force
# Add multiple unlock methods # Add multiple unlock methods
secret unlocker add passphrase # Password-based secret unlocker add passphrase # Password-based
secret unlocker add pgp --keyid ABCD1234 # GPG key 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 # List unlockers
secret unlocker list secret unlocker list
@@ -538,12 +516,8 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### Cross-Platform Support ### Cross-Platform Support
- **macOS**: Full support including Keychain and Secure Enclave integration - **macOS**: Full support
- **Linux**: Full support (excluding macOS-specific features) - **Linux**: Full support
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.
## Security Considerations ## Security Considerations
@@ -556,7 +530,7 @@ to add or use them.
### Best Practices ### Best Practices
1. Use strong, unique passphrases for unlockers 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 3. Regularly audit unlockers and remove unused ones
4. Keep mnemonic phrases securely backed up offline 4. Keep mnemonic phrases securely backed up offline
5. Use separate vaults for different security contexts 5. Use separate vaults for different security contexts
@@ -565,7 +539,6 @@ to add or use them.
- Requires access to unlockers for secret retrieval - Requires access to unlockers for secret retrieval
- Mnemonic phrases must be securely stored and backed up - Mnemonic phrases must be securely stored and backed up
- Hardware features limited to supported platforms
## Development ## Development
@@ -610,10 +583,7 @@ provide:
rerun passes rerun passes
- `script/lint` — build the `lint` phase of the `Dockerfile`, which runs - `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 `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 compiles it (`GOOS=darwin`, with cgo off), which a Linux build never compiles
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
- `script/fmt` — format all Go code with `go fmt` and every markdown file with - `script/fmt` — format all Go code with `go fmt` and every markdown file with
prettier (4-space tabs, `proseWrap: always`) (writes) prettier (4-space tabs, `proseWrap: always`) (writes)
- `script/fmt-check` — check the same formatting without writing, on the host - `script/fmt-check` — check the same formatting without writing, on the host
@@ -629,8 +599,7 @@ provide:
## Features ## Features
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, - **Multiple Authentication Methods**: Supports passphrase and PGP unlockers
and Secure Enclave unlockers
- **Vault Isolation**: Complete separation between different vaults - **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key - **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
+15
View File
@@ -18,6 +18,21 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps # Completed Steps
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
same long-term key as its words separated by single spaces
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
text exactly as typed, so a vault made with a double or trailing space did not
open with the same words typed normally. `agehd.DeriveEntropy`, which every
use of the mnemonic goes through, now joins the words with single spaces and
puts them in Unicode NFKD, as BIP39 defines, before making the seed.
- 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` - 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, uses is copied into `internal/bip39`, with upstream's `LICENSE` beside it,
because its repository no longer exists because its repository no longer exists
+1 -2
View File
@@ -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/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/dustin/go-humanize v1.0.1
github.com/fatih/color v1.18.0 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/oklog/ulid/v2 v2.1.1
github.com/spf13/afero v1.14.0 github.com/spf13/afero v1.14.0
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
@@ -20,6 +19,7 @@ require (
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
golang.org/x/sys v0.33.0 golang.org/x/sys v0.33.0
golang.org/x/term v0.32.0 golang.org/x/term v0.32.0
golang.org/x/text v0.25.0
) )
require ( require (
@@ -32,6 +32,5 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
-2
View File
@@ -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 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/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/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/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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+2 -100
View File
@@ -10,7 +10,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"slices" "slices"
"strings" "strings"
"time" "time"
@@ -21,14 +20,10 @@ import (
"sneak.berlin/go/secret/internal/vault" "sneak.berlin/go/secret/internal/vault"
) )
// Unlocker type names and platform identifiers shared across the CLI // Unlocker type names shared across the CLI
const ( const (
unlockerTypePassphrase = "passphrase" unlockerTypePassphrase = "passphrase"
unlockerTypeKeychain = "keychain"
unlockerTypePGP = "pgp" unlockerTypePGP = "pgp"
unlockerTypeSecureEnclave = "secure-enclave"
platformDarwin = "darwin"
cmdUseList = "list" cmdUseList = "list"
) )
@@ -143,9 +138,8 @@ func newUnlockerListCmd() *cobra.Command {
} }
// unlockerAddHelp returns the supported unlocker types list and their // unlockerAddHelp returns the supported unlocker types list and their
// descriptions for the current platform // descriptions
func unlockerAddHelp() (string, string) { func unlockerAddHelp() (string, string) {
// Build the supported types list based on platform
supportedTypes := "passphrase, pgp" supportedTypes := "passphrase, pgp"
typeDescriptions := "Available unlocker types:\n" + typeDescriptions := "Available unlocker types:\n" +
"\n" + "\n" +
@@ -162,43 +156,6 @@ func unlockerAddHelp() (string, string) {
" Use --keyid to specify a particular key, " + " Use --keyid to specify a particular key, " +
"otherwise uses your default GPG 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 return supportedTypes, typeDescriptions
} }
@@ -420,18 +377,10 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
switch unlockerType { switch unlockerType {
case unlockerTypePassphrase: case unlockerTypePassphrase:
return cli.addPassphraseUnlocker(cmd) return cli.addPassphraseUnlocker(cmd)
case unlockerTypeKeychain:
return cli.addKeychainUnlocker(cmd)
case unlockerTypeSecureEnclave:
return cli.addSecureEnclaveUnlocker(cmd)
case unlockerTypePGP: case unlockerTypePGP:
return cli.addPGPUnlocker(cmd) return cli.addPGPUnlocker(cmd)
default: default:
// Build the supported types list based on platform
supportedTypes := "passphrase, pgp" supportedTypes := "passphrase, pgp"
if runtime.GOOS == platformDarwin {
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
}
return fmt.Errorf("%w: %s (supported: %s)", return fmt.Errorf("%w: %s (supported: %s)",
errInvalidUnlockerType, unlockerType, supportedTypes) errInvalidUnlockerType, unlockerType, supportedTypes)
@@ -487,53 +436,6 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
return nil 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 // addPGPUnlocker creates a PGP unlocker in the current vault
func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error { func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
// Get GPG key ID from flag, environment, or default key // Get GPG key ID from flag, environment, or default key
+38
View File
@@ -3,6 +3,7 @@ package cli
import ( import (
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -104,3 +105,40 @@ func TestAddPGPUnlockerUnknownKey(t *testing.T) {
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName), filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
listTestUnlockerDirOne) 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)
}
}
}
+1 -2
View File
@@ -19,8 +19,7 @@ import (
// copying an unlocker directory leaves them. It asserts that `unlocker // copying an unlocker directory leaves them. It asserts that `unlocker
// list` and the shell completion of `unlocker select` and `unlocker remove` // 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 // 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, // alone.
// get their IDs the same way.
func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) { func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) {
t.Parallel() t.Parallel()
-152
View File
@@ -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 <stdlib.h>
#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
}
-28
View File
@@ -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
}
-164
View File
@@ -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")
}
-69
View File
@@ -1,69 +0,0 @@
//go:build darwin
#ifndef SECURE_ENCLAVE_H
#define SECURE_ENCLAVE_H
#include <stdint.h>
// 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
-317
View File
@@ -1,317 +0,0 @@
//go:build darwin
#import <Foundation/Foundation.h>
#import <Security/Security.h>
#include "secure_enclave.h"
#include <string.h>
// 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;
}
}
-40
View File
@@ -12,7 +12,6 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/macse"
"sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault" "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-<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")
}
+3 -3
View File
@@ -18,9 +18,9 @@ const (
EnvGPGKeyID = "SB_GPG_KEY_ID" EnvGPGKeyID = "SB_GPG_KEY_ID"
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a // UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
// new unlocker's directory, keychain item and Secure Enclave key. It runs // new unlocker's directory. It runs to the nanosecond, so that every new
// to the nanosecond, so that every new unlocker, even one added right // unlocker, even one added right after another, gets a directory of its
// after another, gets a directory of its own. // own.
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000" UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
) )
-1
View File
@@ -21,7 +21,6 @@ var (
errStderrNotTerminal = errors.New( errStderrNotTerminal = errors.New(
"stderr is not a terminal (running in non-interactive mode)") "stderr is not a terminal (running in non-interactive mode)")
errNothingEntered = errors.New("nothing was entered") errNothingEntered = errors.New("nothing was entered")
errEmptyPassphrase = errors.New("passphrase cannot be empty")
) )
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no // ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
-118
View File
@@ -1,118 +0,0 @@
//go:build darwin
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
package secret
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/pkg/agehd"
)
// realVault is a minimal VaultInterface backed by a real afero filesystem,
// using the same directory layout as vault.Vault.
type realVault struct {
name string
stateDir string
fs afero.Fs
}
func (v *realVault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
}
func (v *realVault) GetName() string { return v.name }
//nolint:ireturn // implements VaultInterface
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
panic("not used")
}
//nolint:ireturn // implements VaultInterface
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) {
panic("not used")
}
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
panic("not used")
}
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) {
panic("not used")
}
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) {
panic("not used")
}
func (v *realVault) CreatePassphraseUnlocker(
*memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
panic("not used")
}
// createRealVault sets up a complete vault directory structure on an in-memory
// filesystem, identical to what vault.CreateVault produces.
func createRealVault(
t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32,
) *realVault {
t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", name)
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms))
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "unlockers.d"), DirPerms))
metadata := VaultMetadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
}
metaBytes, err := json.Marshal(metadata)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs,
filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
return &realVault{name: name, stateDir: stateDir, fs: fs}
}
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
//nolint:dupword // BIP39 test mnemonic repeats words by design
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
// Derive expected keys at two different indices to prove they differ.
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
require.NoError(t, err)
key5, err := agehd.DeriveIdentity(testMnemonic, 5)
require.NoError(t, err)
require.NotEqual(t, key0.String(), key5.String(),
"sanity check: different derivation indices must produce different keys")
// Build a real vault with DerivationIndex=5 on an in-memory filesystem.
fs := afero.NewMemMapFs()
vault := createRealVault(t, fs, "/state", "test-vault", 5)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
require.NoError(t, err)
defer result.Destroy()
assert.Equal(t, key5.String(), string(result.Bytes()),
"getLongTermPrivateKey should derive at vault's DerivationIndex (5)")
assert.NotEqual(t, key0.String(), string(result.Bytes()),
"getLongTermPrivateKey must not use hardcoded index 0")
}
-142
View File
@@ -1,142 +0,0 @@
package secret
import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/awnumar/memguard"
)
var (
errPassphraseLength = errors.New(
"passphrase length must be a positive even number")
errPassphraseNotHex = errors.New(
"keychain passphrase must be lowercase hex")
errNoKeychainPassphrase = errors.New(
"keychain data has no agePrivKeyPassphrase string")
)
// KeychainData is what a keychain unlocker stores in the macOS keychain.
// It is stored as JSON, but encode and decodeKeychainData keep the
// passphrase out of encoding/json, which would leave copies of it in
// ordinary memory.
type KeychainData struct {
AgePublicKey string
AgePrivKeyPassphrase *memguard.LockedBuffer
EncryptedLongtermKey string
}
// generateRandomPassphrase returns length random lowercase hex characters
// in a locked buffer. The caller must destroy it.
func generateRandomPassphrase(length int) (*memguard.LockedBuffer, error) {
// Each random byte becomes two hex characters.
randomBytes := hex.DecodedLen(length)
if length <= 0 || hex.EncodedLen(randomBytes) != length {
return nil, errPassphraseLength
}
random := memguard.NewBufferRandom(randomBytes)
defer random.Destroy()
passphrase := memguard.NewBuffer(length)
hex.Encode(passphrase.Bytes(), random.Bytes())
passphrase.Freeze()
return passphrase, nil
}
// encode returns d as JSON in a locked buffer:
// {"agePublicKey":"...","agePrivKeyPassphrase":"...","encryptedLongtermKey":"..."}.
// The passphrase is copied straight into the buffer, so it must be hex,
// which JSON does not escape. The caller must destroy the returned buffer.
func (d *KeychainData) encode() (*memguard.LockedBuffer, error) {
if d.AgePrivKeyPassphrase == nil {
return nil, errNilPassphraseBuffer
}
if d.AgePrivKeyPassphrase.Size() == 0 {
return nil, errEmptyPassphrase
}
for _, c := range d.AgePrivKeyPassphrase.Bytes() {
if strings.IndexByte("0123456789abcdef", c) < 0 {
return nil, errPassphraseNotHex
}
}
publicKey, err := json.Marshal(d.AgePublicKey)
if err != nil {
return nil, fmt.Errorf("failed to encode age public key: %w", err)
}
longtermKey, err := json.Marshal(d.EncryptedLongtermKey)
if err != nil {
return nil, fmt.Errorf("failed to encode long-term key: %w", err)
}
parts := [][]byte{
[]byte(`{"agePublicKey":`), publicKey,
[]byte(`,"agePrivKeyPassphrase":"`), d.AgePrivKeyPassphrase.Bytes(),
[]byte(`","encryptedLongtermKey":`), longtermKey,
[]byte(`}`),
}
size := 0
for _, part := range parts {
size += len(part)
}
encoded := memguard.NewBuffer(size)
written := 0
for _, part := range parts {
written += copy(encoded.Bytes()[written:], part)
}
encoded.Freeze()
return encoded, nil
}
// decodeKeychainData parses keychain data written by encode. The caller
// must destroy the returned AgePrivKeyPassphrase.
func decodeKeychainData(data *memguard.LockedBuffer) (*KeychainData, error) {
if data == nil {
return nil, errNilDataBuffer
}
// json.Unmarshal gives a json.RawMessage field the field's JSON text
// unchanged, in the one copy RawMessage makes; it is wiped on return.
var fields struct {
AgePublicKey string `json:"agePublicKey"`
AgePrivKeyPassphrase json.RawMessage `json:"agePrivKeyPassphrase"`
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
}
defer func() { memguard.WipeBytes(fields.AgePrivKeyPassphrase) }()
err := json.Unmarshal(data.Bytes(), &fields)
if err != nil {
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
}
// json.Unmarshal accepted the JSON, so text that starts with a quote is
// a whole string. The passphrase is hex, so it is the text between the
// quotes.
quoted := fields.AgePrivKeyPassphrase
if !bytes.HasPrefix(quoted, []byte(`"`)) {
return nil, errNoKeychainPassphrase
}
return &KeychainData{
AgePublicKey: fields.AgePublicKey,
// NewBufferFromBytes wipes the bytes it copies.
AgePrivKeyPassphrase: memguard.NewBufferFromBytes(
quoted[1 : len(quoted)-1]),
EncryptedLongtermKey: fields.EncryptedLongtermKey,
}, nil
}
-118
View File
@@ -1,118 +0,0 @@
//nolint:testpackage // white-box test of unexported internals
package secret
import (
"encoding/json"
"testing"
"github.com/awnumar/memguard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateRandomPassphrase(t *testing.T) {
t.Parallel()
first, err := generateRandomPassphrase(64)
require.NoError(t, err)
defer first.Destroy()
second, err := generateRandomPassphrase(64)
require.NoError(t, err)
defer second.Destroy()
assert.Regexp(t, `^[0-9a-f]{64}$`, first.String())
assert.NotEqual(t, first.String(), second.String())
assert.False(t, first.IsMutable())
for _, length := range []int{0, -2, 63} {
_, err := generateRandomPassphrase(length)
require.ErrorIs(t, err, errPassphraseLength, "length %d", length)
}
}
func TestKeychainDataEncodeDecode(t *testing.T) {
t.Parallel()
passphrase := memguard.NewBufferFromBytes([]byte("0a1b2c3d"))
defer passphrase.Destroy()
data := KeychainData{
AgePublicKey: "age1example",
AgePrivKeyPassphrase: passphrase,
EncryptedLongtermKey: "beef",
}
encoded, err := data.encode()
require.NoError(t, err)
defer encoded.Destroy()
assert.JSONEq(t,
`{"agePublicKey":"age1example",`+
`"agePrivKeyPassphrase":"0a1b2c3d",`+
`"encryptedLongtermKey":"beef"}`,
encoded.String())
assert.False(t, encoded.IsMutable())
decoded, err := decodeKeychainData(encoded)
require.NoError(t, err)
defer decoded.AgePrivKeyPassphrase.Destroy()
assert.Equal(t, "age1example", decoded.AgePublicKey)
assert.Equal(t, "0a1b2c3d", decoded.AgePrivKeyPassphrase.String())
assert.Equal(t, "beef", decoded.EncryptedLongtermKey)
}
func TestKeychainDataEncodeRejectsBadPassphrase(t *testing.T) {
t.Parallel()
tests := []struct {
name string
passphrase *memguard.LockedBuffer
wantErr error
}{
{"nil", nil, errNilPassphraseBuffer},
{"empty", memguard.NewBuffer(0), errEmptyPassphrase},
{
"not hex",
memguard.NewBufferFromBytes([]byte(`abc"def`)),
errPassphraseNotHex,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
data := KeychainData{AgePrivKeyPassphrase: tt.passphrase}
_, err := data.encode()
require.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestDecodeKeychainDataRejectsBadData(t *testing.T) {
t.Parallel()
for _, text := range []string{
`{"agePublicKey":"age1example"}`,
`{"agePrivKeyPassphrase":42}`,
} {
data := memguard.NewBufferFromBytes([]byte(text))
_, err := decodeKeychainData(data)
data.Destroy()
require.ErrorIs(t, err, errNoKeychainPassphrase, text)
}
notJSON := memguard.NewBufferFromBytes([]byte(`{"agePrivKeyPassphrase":`))
defer notJSON.Destroy()
_, err := decodeKeychainData(notJSON)
var syntaxError *json.SyntaxError
require.ErrorAs(t, err, &syntaxError)
}
-561
View File
@@ -1,561 +0,0 @@
//go:build darwin
package secret
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"sneak.berlin/go/secret/pkg/agehd"
)
const (
agePrivKeyPassphraseLength = 64
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
//
//nolint:revive // ALL_CAPS is intentional for this constant
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
// keychainUnlockerType is the metadata type string for keychain unlockers.
keychainUnlockerType = "keychain"
// macOSFlag is the unlocker metadata flag of the macOS-only unlockers.
macOSFlag = "macos"
)
// keychainItemNameRegex validates keychain item names
// Allows alphanumeric characters, dots, hyphens, and underscores only
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var (
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
errInvalidKeychainItemName = errors.New("invalid keychain item name format")
errUnsupportedCurrentUnlocker = errors.New(
"unsupported current unlocker type for keychain unlocker creation")
)
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
type KeychainUnlockerMetadata struct {
UnlockerMetadata
// Keychain item name
KeychainItemName string `json:"keychainItemName"`
}
// KeychainUnlocker represents a macOS Keychain-protected unlocker
type KeychainUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
// NewKeychainUnlocker creates a new KeychainUnlocker instance
func NewKeychainUnlocker(
fs afero.Fs, directory string, metadata UnlockerMetadata,
) *KeychainUnlocker {
return &KeychainUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for Keychain-based unlockers
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting keychain unlocker identity",
slog.String("unlocker_id", k.GetID()),
slog.String("unlocker_type", k.GetType()),
)
keychainData, err := k.readKeychainData()
if err != nil {
return nil, err
}
defer keychainData.AgePrivKeyPassphrase.Destroy()
// Step 4: Read the encrypted age private key from filesystem
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
Debug("Reading encrypted age private key", "path", agePrivKeyPath)
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
if err != nil {
Debug("Failed to read encrypted age private key",
"error", err, "path", agePrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
}
DebugWith("Read encrypted age private key",
slog.String("unlocker_id", k.GetID()),
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
)
// Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase",
"unlocker_id", k.GetID())
agePrivKeyBuffer, err := DecryptWithPassphrase(
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase",
"error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf(
"failed to decrypt age private key with keychain passphrase: %w", err)
}
defer agePrivKeyBuffer.Destroy()
DebugWith("Successfully decrypted age private key with keychain passphrase",
slog.String("unlocker_id", k.GetID()),
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
)
// Step 6: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to parse age private key: %w", err)
}
DebugWith("Successfully parsed keychain age identity",
slog.String("unlocker_id", k.GetID()),
slog.String("public_key", ageIdentity.Recipient().String()),
)
return ageIdentity, nil
}
// GetType implements Unlocker interface
func (k *KeychainUnlocker) GetType() string {
return keychainUnlockerType
}
// GetMetadata implements Unlocker interface
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
return k.Metadata
}
// GetDirectory implements Unlocker interface
func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory
}
// GetID implements Unlocker interface: the name of the unlocker's directory
func (k *KeychainUnlocker) GetID() string {
return filepath.Base(k.Directory)
}
// Remove implements Unlocker interface - removes the keychain unlocker
func (k *KeychainUnlocker) Remove() error {
// Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
Debug("Failed to get keychain item name during removal",
"error", err, "unlocker_id", k.GetID())
return fmt.Errorf("failed to get keychain item name: %w", err)
}
// Step 2: Remove from keychain
Debug("Removing keychain item", "keychain_item", keychainItemName)
err = deleteFromKeychain(keychainItemName)
if err != nil {
Debug("Failed to remove keychain item",
"error", err, "keychain_item", keychainItemName)
return fmt.Errorf("failed to remove keychain item: %w", err)
}
// Step 3: Remove directory
Debug("Removing keychain unlocker directory", "directory", k.Directory)
err = RemoveDirAtomic(k.fs, k.Directory)
if err != nil {
Debug("Failed to remove keychain unlocker directory",
"error", err, "directory", k.Directory)
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
}
Debug("Successfully removed keychain unlocker",
"unlocker_id", k.GetID(), "keychain_item", keychainItemName)
return nil
}
// GetKeychainItemName returns the keychain item name from metadata
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
// Load the metadata
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(k.fs, metadataPath)
if err != nil {
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
}
var keychainMetadata KeychainUnlockerMetadata
err = json.Unmarshal(metadataData, &keychainMetadata)
if err != nil {
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
}
return keychainMetadata.KeychainItemName, nil
}
// readKeychainData reads and parses the data this unlocker keeps in the
// keychain (steps 1 to 3 of GetIdentity). The caller must destroy the
// returned AgePrivKeyPassphrase.
func (k *KeychainUnlocker) readKeychainData() (*KeychainData, error) {
// Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
}
// Step 2: Retrieve data from keychain
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
if err != nil {
Debug("Failed to retrieve data from keychain",
"error", err, "keychain_item", keychainItemName)
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
}
DebugWith("Retrieved data from keychain",
slog.String("unlocker_id", k.GetID()),
slog.Int("data_length", len(keychainDataBytes)),
)
// Move the keychain data into locked memory; this wipes keychainDataBytes
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
defer keychainDataBuffer.Destroy()
// Step 3: Parse keychain data
keychainData, err := decodeKeychainData(keychainDataBuffer)
if err != nil {
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
}
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
return keychainData, nil
}
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
func generateKeychainUnlockerName(vaultName string) (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
// Format: secret-<vault>-<hostname>-<time>
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
}
// getLongTermPrivateKey derives the long-term private key from mnemonic when
// it is not nil, else gets it through the current unlocker, which is given
// passphrase when it is a passphrase unlocker.
// Returns a LockedBuffer to ensure the private key is protected in memory
func getLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
if mnemonic != nil {
return deriveLongTermPrivateKey(fs, vault, mnemonic)
}
// Get the vault to access current unlocker
currentUnlocker, err := vault.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
// Get the current unlocker identity
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
}
// Get encrypted long-term key from current unlocker, handling different types
var encryptedLtPrivKey []byte
switch currentUnlocker := currentUnlocker.(type) {
case *PassphraseUnlocker:
// Read the encrypted long-term private key from passphrase unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key "+
"from current passphrase unlocker: %w", err)
}
case *PGPUnlocker:
// Read the encrypted long-term private key from PGP unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key "+
"from current PGP unlocker: %w", err)
}
case *KeychainUnlocker:
// Read the encrypted long-term private key from another keychain
// unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key "+
"from current keychain unlocker: %w", err)
}
default:
return nil, errUnsupportedCurrentUnlocker
}
// Decrypt long-term private key using current unlocker
ltPrivKeyBuffer, err := DecryptWithIdentity(
encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
// Return the decrypted key buffer
return ltPrivKeyBuffer, nil
}
// deriveLongTermPrivateKey derives the long-term private key from mnemonic at
// the vault's derivation index, for getLongTermPrivateKey and
// getLongTermKeyForSE.
func deriveLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w", err)
}
return IdentityToLockedBuffer(ltIdentity), nil
}
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
// vault. The long-term key comes from mnemonic when it is not nil, else from
// the current unlocker, as getLongTermPrivateKey describes.
func CreateKeychainUnlocker(
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
// Get current vault using the GetCurrentVault function from the same package
vault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
// Generate the keychain item name
keychainItemName, err := generateKeychainUnlockerName(vault.GetName())
if err != nil {
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
}
// The unlocker directory is named after the keychain item
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
// Step 1: Generate a new age keypair for the keychain unlocker
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
}
ageRecipient := ageIdentity.Recipient().String()
// Step 2: Generate a random passphrase for encrypting the age private key
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
if err != nil {
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
}
defer agePrivKeyPassphrase.Destroy()
// Step 3: Encrypt age private key with the generated passphrase
agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity)
defer agePrivKeyBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase(
agePrivKeyBuffer, agePrivKeyPassphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
}
// Step 4: Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, err
}
defer ltPrivKeyData.Destroy()
// Step 5: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
ltPrivKeyData, ageIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf(
"failed to encrypt long-term private key to age unlocker: %w", err)
}
// Step 6: Prepare keychain data
keychainData := KeychainData{
AgePublicKey: ageRecipient,
AgePrivKeyPassphrase: agePrivKeyPassphrase,
EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge),
}
keychainDataBuffer, err := keychainData.encode()
if err != nil {
return nil, fmt.Errorf("failed to encode keychain data: %w", err)
}
defer keychainDataBuffer.Destroy()
return writeKeychainUnlocker(fs, unlockerDir, keychainItemName, ageRecipient,
encryptedAgePrivKey, encryptedLtPrivKeyToAge, keychainDataBuffer)
}
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
// The data is stored after the unlocker's files are written, and the keychain
// item is deleted again if moving the unlocker into place then fails.
func writeKeychainUnlocker(
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
encryptedAgePrivKey, encryptedLtPrivKey []byte,
keychainDataBuffer *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
// Step 7: Prepare enhanced metadata
keychainMetadata := KeychainUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: keychainUnlockerType,
CreatedAt: time.Now(),
Flags: []string{keychainUnlockerType, macOSFlag},
},
KeychainItemName: keychainItemName,
}
metadataBytes, err := json.MarshalIndent(keychainMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
}
// Step 8: Write the unlocker's files, the metadata last, then store the
// data in the keychain
stored := false
err = WriteDir(fs, unlockerDir, func(dir string) error {
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
if err != nil {
return fmt.Errorf("failed to write age recipient: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted age private key: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
}
err = storeInKeychain(keychainItemName, keychainDataBuffer)
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err)
}
stored = true
return nil
})
if err != nil && stored {
deleteErr := deleteFromKeychain(keychainItemName)
if deleteErr != nil {
err = errors.Join(err, fmt.Errorf(
"failed to delete keychain item %s: %w", keychainItemName, deleteErr))
}
}
if err != nil {
return nil, err
}
return &KeychainUnlocker{
Directory: unlockerDir,
Metadata: keychainMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// validateKeychainItemName validates that a keychain item name is safe for
// command execution
func validateKeychainItemName(itemName string) error {
if itemName == "" {
return errKeychainItemNameEmpty
}
if !keychainItemNameRegex.MatchString(itemName) {
return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
}
return nil
}
-104
View File
@@ -1,104 +0,0 @@
//go:build darwin && cgo
package secret
import (
"fmt"
"github.com/awnumar/memguard"
keychain "github.com/keybase/go-keychain"
)
// The keychain unlocker's only calls into go-keychain, which is cgo on macOS.
// A macOS build without cgo gets keychainunlocker_nocgo.go instead.
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil {
return errNilDataBuffer
}
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
item.SetDescription("Secret vault keychain data")
item.SetData(data.Bytes())
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
// First try to delete any existing item
deleteItem := keychain.NewItem()
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
deleteItem.SetAccount(itemName)
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
// Add the new item
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("failed to store item in keychain: %w", err)
}
return nil
}
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
func retrieveFromKeychain(itemName string) ([]byte, error) {
if err := validateKeychainItemName(itemName); err != nil {
return nil, fmt.Errorf("invalid keychain item name: %w", err)
}
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(KEYCHAIN_APP_IDENTIFIER)
query.SetAccount(itemName)
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
results, err := keychain.QueryItem(query)
if err != nil {
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("keychain item not found: %s", itemName)
}
return results[0].Data, nil
}
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
// If the item doesn't exist, this function returns nil (not an error) since the goal
// is to ensure the item is gone, and it already being gone satisfies that goal.
func deleteFromKeychain(itemName string) error {
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
if err := keychain.DeleteItem(item); err != nil {
// If the item doesn't exist, that's not an error - the goal is to ensure
// the item is gone, and it already being gone satisfies that goal.
// This is important for cleaning up unlocker directories when the keychain
// item has already been removed (e.g., manually by user, or synced vault
// from a different machine).
if err == keychain.ErrorItemNotFound {
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
return nil
}
return fmt.Errorf("failed to delete item from keychain: %w", err)
}
return nil
}
-30
View File
@@ -1,30 +0,0 @@
//go:build darwin && !cgo
package secret
import (
"errors"
"github.com/awnumar/memguard"
)
// In a macOS build without cgo, these take the place of the functions in
// keychainunlocker_cgo.go: go-keychain is cgo on macOS, so they can only fail.
var errKeychainNotSupported = errors.New(
"keychain unlockers need a macOS build with cgo")
// storeInKeychain fails: the keychain needs a macOS build with cgo.
func storeInKeychain(_ string, _ *memguard.LockedBuffer) error {
return errKeychainNotSupported
}
// retrieveFromKeychain fails: the keychain needs a macOS build with cgo.
func retrieveFromKeychain(_ string) ([]byte, error) {
return nil, errKeychainNotSupported
}
// deleteFromKeychain fails: the keychain needs a macOS build with cgo.
func deleteFromKeychain(_ string) error {
return errKeychainNotSupported
}
-84
View File
@@ -1,84 +0,0 @@
//go:build !darwin
package secret
import (
"errors"
"path/filepath"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
type KeychainUnlockerMetadata struct {
UnlockerMetadata
KeychainItemName string `json:"keychainItemName"`
}
// KeychainUnlocker is a stub for non-Darwin platforms
type KeychainUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
var errKeychainNotSupported = errors.New(
"keychain unlockers are only supported on macOS")
// NewKeychainUnlocker creates a stub KeychainUnlocker on non-Darwin
// platforms. The returned instance's methods that require macOS
// functionality will return errors.
func NewKeychainUnlocker(
fs afero.Fs, directory string, metadata UnlockerMetadata,
) *KeychainUnlocker {
return &KeychainUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity returns an error on non-Darwin platforms
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
return nil, errKeychainNotSupported
}
// GetType returns the unlocker type
func (k *KeychainUnlocker) GetType() string {
return "keychain"
}
// GetMetadata returns the unlocker metadata
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
return k.Metadata
}
// GetDirectory returns the unlocker directory
func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory
}
// GetID returns the unlocker ID, the name of the unlocker's directory
func (k *KeychainUnlocker) GetID() string {
return filepath.Base(k.Directory)
}
// GetKeychainItemName returns an error on non-Darwin platforms
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
return "", errKeychainNotSupported
}
// Remove returns an error on non-Darwin platforms
func (k *KeychainUnlocker) Remove() error {
return errKeychainNotSupported
}
// CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker(
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
return nil, errKeychainNotSupported
}
-215
View File
@@ -1,215 +0,0 @@
//go:build darwin && cgo
package secret
import (
"encoding/hex"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestKeychainStoreRetrieveDelete(t *testing.T) {
// Skip test if not on macOS
if runtime.GOOS != "darwin" {
t.Skip("Keychain tests only run on macOS")
}
// Test data
testItemName := "test-secret-keychain-item"
testData := "test-secret-data-12345"
testBuffer := memguard.NewBufferFromBytes([]byte(testData))
defer testBuffer.Destroy()
// Clean up any existing item first
_ = deleteFromKeychain(testItemName)
// Test 1: Store data in keychain
err := storeInKeychain(testItemName, testBuffer)
require.NoError(t, err, "Failed to store data in keychain")
// Test 2: Retrieve data from keychain
retrievedData, err := retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve data from keychain")
assert.Equal(t, testData, string(retrievedData),
"Retrieved data doesn't match stored data")
// Test 3: Update existing item (store again with different data)
newTestData := "updated-test-data-67890"
newTestBuffer := memguard.NewBufferFromBytes([]byte(newTestData))
defer newTestBuffer.Destroy()
err = storeInKeychain(testItemName, newTestBuffer)
require.NoError(t, err, "Failed to update data in keychain")
// Verify updated data
retrievedData, err = retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve updated data from keychain")
assert.Equal(t, newTestData, string(retrievedData),
"Retrieved data doesn't match updated data")
// Test 4: Delete from keychain
err = deleteFromKeychain(testItemName)
require.NoError(t, err, "Failed to delete data from keychain")
// Test 5: Verify item is deleted (should fail to retrieve)
_, err = retrieveFromKeychain(testItemName)
assert.Error(t, err, "Expected error when retrieving deleted item")
}
func TestKeychainInvalidItemName(t *testing.T) {
// Skip test if not on macOS
if runtime.GOOS != "darwin" {
t.Skip("Keychain tests only run on macOS")
}
testData := memguard.NewBufferFromBytes([]byte("test"))
defer testData.Destroy()
// Test an empty item name
err := storeInKeychain("", testData)
require.ErrorIs(t, err, errKeychainItemNameEmpty)
// Test invalid item names
invalidNames := []string{
"test space", // Contains space
"test/slash", // Contains slash
"test\\backslash", // Contains backslash
"test:colon", // Contains colon
"test;semicolon", // Contains semicolon
"test|pipe", // Contains pipe
"test@at", // Contains @
"test#hash", // Contains #
"test$dollar", // Contains $
"test&ampersand", // Contains &
"test*asterisk", // Contains *
"test?question", // Contains ?
"test!exclamation", // Contains !
"test'quote", // Contains single quote
"test\"doublequote", // Contains double quote
"test(paren", // Contains parenthesis
"test[bracket", // Contains bracket
}
for _, name := range invalidNames {
err := storeInKeychain(name, testData)
require.ErrorIs(t, err, errInvalidKeychainItemName,
"Expected error for invalid name: %s", name)
}
// Test valid names (should not error on validation)
validNames := []string{
"test-name",
"test_name",
"test.name",
"TestName123",
"TEST_NAME_123",
"com.example.test",
"secret-vault-hostname-2024-01-01",
}
for _, name := range validNames {
err := validateKeychainItemName(name)
assert.NoError(t, err, "Expected no error for valid name: %s", name)
// Clean up
_ = deleteFromKeychain(name)
}
}
func TestKeychainNilData(t *testing.T) {
// Skip test if not on macOS
if runtime.GOOS != "darwin" {
t.Skip("Keychain tests only run on macOS")
}
// Test storing nil data
err := storeInKeychain("test-item", nil)
require.ErrorIs(t, err, errNilDataBuffer)
}
func TestKeychainLargeData(t *testing.T) {
// Skip test if not on macOS
if runtime.GOOS != "darwin" {
t.Skip("Keychain tests only run on macOS")
}
// Test with larger hex-encoded data (512 bytes of binary data = 1KB hex)
largeData := make([]byte, 512)
for i := range largeData {
largeData[i] = byte(i % 256)
}
// Convert to hex string for storage
hexData := hex.EncodeToString(largeData)
testItemName := "test-large-data"
testBuffer := memguard.NewBufferFromBytes([]byte(hexData))
defer testBuffer.Destroy()
// Clean up first
_ = deleteFromKeychain(testItemName)
// Store hex data
err := storeInKeychain(testItemName, testBuffer)
require.NoError(t, err, "Failed to store large data")
// Retrieve and verify
retrievedData, err := retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve large data")
// Decode hex and compare
decodedData, err := hex.DecodeString(string(retrievedData))
require.NoError(t, err, "Failed to decode hex data")
assert.Equal(t, largeData, decodedData, "Large data mismatch")
// Clean up
_ = deleteFromKeychain(testItemName)
}
func TestDeleteNonExistentKeychainItem(t *testing.T) {
// Skip test if not on macOS
if runtime.GOOS != "darwin" {
t.Skip("Keychain tests only run on macOS")
}
// Ensure item doesn't exist
testItemName := "test-nonexistent-keychain-item-12345"
_ = deleteFromKeychain(testItemName)
// Deleting a non-existent item should NOT return an error
// This is important for cleaning up unlocker directories when the keychain item
// has already been removed (e.g., manually by user, or on a different machine)
err := deleteFromKeychain(testItemName)
assert.NoError(t, err,
"Deleting non-existent keychain item should not return an error")
}
// TestWriteKeychainUnlockerFailureDeletesItem makes moving a new keychain
// unlocker into place fail after its data is stored in the keychain: the
// keychain item must be deleted again.
func TestWriteKeychainUnlockerFailureDeletesItem(t *testing.T) {
testItemName := "test-secret-keychain-unlocker-cleanup"
_ = deleteFromKeychain(testItemName)
// Moving the unlocker into a read-only directory fails
unlockersDir := filepath.Join(t.TempDir(), "unlockers.d")
require.NoError(t, os.Mkdir(unlockersDir, 0o500))
testBuffer := memguard.NewBufferFromBytes([]byte("test-keychain-data"))
defer testBuffer.Destroy()
_, err := writeKeychainUnlocker(afero.NewOsFs(),
filepath.Join(unlockersDir, testItemName), testItemName, "age1test",
[]byte("test-priv"), []byte("test-longterm"), testBuffer)
require.ErrorIs(t, err, os.ErrPermission,
"moving the unlocker into place should fail")
_, err = retrieveFromKeychain(testItemName)
assert.Error(t, err, "keychain item left behind")
}
+1 -1
View File
@@ -17,7 +17,7 @@ type VaultMetadata struct {
// UnlockerMetadata contains information about an unlocker // UnlockerMetadata contains information about an unlocker
type UnlockerMetadata struct { type UnlockerMetadata struct {
Type string `json:"type"` // passphrase, pgp, keychain Type string `json:"type"` // passphrase or pgp
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags,omitempty"` Flags []string `json:"flags,omitempty"`
} }
-1
View File
@@ -128,7 +128,6 @@ func (p *PassphraseUnlocker) Remove() error {
} }
// For passphrase unlockers, we just need to remove the directory // For passphrase unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up
err := RemoveDirAtomic(p.fs, p.Directory) err := RemoveDirAtomic(p.fs, p.Directory)
if err != nil { if err != nil {
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err) return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
-1
View File
@@ -171,7 +171,6 @@ func (p *PGPUnlocker) GetID() string {
// Remove implements Unlocker interface - removes the PGP unlocker // Remove implements Unlocker interface - removes the PGP unlocker
func (p *PGPUnlocker) Remove() error { func (p *PGPUnlocker) Remove() error {
// For PGP unlockers, we just need to remove the directory // For PGP unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up
err := RemoveDirAtomic(p.fs, p.Directory) err := RemoveDirAtomic(p.fs, p.Directory)
if err != nil { if err != nil {
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err) return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
-397
View File
@@ -1,397 +0,0 @@
//go:build darwin
package secret
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/macse"
)
const (
// seKeyLabelPrefix is the prefix for Secure Enclave CTK identity labels.
seKeyLabelPrefix = "berlin.sneak.app.secret.se"
// seUnlockerType is the metadata type string for Secure Enclave unlockers.
seUnlockerType = "secure-enclave"
// seLongtermFilename is the filename for the SE-encrypted vault long-term private key.
seLongtermFilename = "longterm.age.se"
)
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata
SEKeyLabel string `json:"seKeyLabel"`
SEKeyHash string `json:"seKeyHash"`
}
// SecureEnclaveUnlocker represents a Secure Enclave-protected unlocker.
type SecureEnclaveUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for SE-based unlockers.
// Decrypts the vault's long-term private key directly using the Secure Enclave.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting SE unlocker identity",
slog.String("unlocker_id", s.GetID()),
)
// Get SE key label from metadata
seKeyLabel, _, err := s.getSEKeyInfo()
if err != nil {
return nil, fmt.Errorf("failed to get SE key info: %w", err)
}
// Read ECIES-encrypted long-term private key from disk
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
if err != nil {
return nil, fmt.Errorf(
"failed to read SE-encrypted long-term key: %w",
err,
)
}
DebugWith("Read SE-encrypted long-term key",
slog.Int("encrypted_length", len(encryptedData)),
)
// Decrypt using the Secure Enclave (ECDH happens inside SE hardware)
decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData)
if err != nil {
return nil, fmt.Errorf(
"failed to decrypt long-term key with SE: %w",
err,
)
}
// Parse the decrypted long-term private key
ltIdentity, err := age.ParseX25519Identity(string(decryptedData))
// Clear sensitive data immediately
for i := range decryptedData {
decryptedData[i] = 0
}
if err != nil {
return nil, fmt.Errorf(
"failed to parse long-term private key: %w",
err,
)
}
DebugWith("Successfully decrypted long-term key via SE",
slog.String("unlocker_id", s.GetID()),
)
return ltIdentity, nil
}
// GetType implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetType() string {
return seUnlockerType
}
// GetMetadata implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
return s.Metadata
}
// GetDirectory implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory
}
// GetID implements Unlocker interface: the name of the unlocker's directory.
func (s *SecureEnclaveUnlocker) GetID() string {
return filepath.Base(s.Directory)
}
// Remove implements Unlocker interface.
func (s *SecureEnclaveUnlocker) Remove() error {
_, seKeyHash, err := s.getSEKeyInfo()
if err != nil {
Debug("Failed to get SE key info during removal", "error", err)
return fmt.Errorf("failed to get SE key info: %w", err)
}
if seKeyHash != "" {
Debug("Deleting SE key", "hash", seKeyHash)
err = macse.DeleteKey(seKeyHash)
if err != nil {
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
return fmt.Errorf("failed to delete SE key: %w", err)
}
}
Debug("Removing SE unlocker directory", "directory", s.Directory)
err = RemoveDirAtomic(s.fs, s.Directory)
if err != nil {
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
}
Debug("Successfully removed SE unlocker", "unlocker_id", s.GetID())
return nil
}
// getSEKeyInfo reads the SE key label and hash from metadata.
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(s.fs, metadataPath)
if err != nil {
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
}
var seMetadata SecureEnclaveUnlockerMetadata
err = json.Unmarshal(metadataData, &seMetadata)
if err != nil {
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
}
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
}
// generateSEKeyLabel generates a unique label for the SE CTK identity.
func generateSEKeyLabel(vaultName string) (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf(
"%s.%s-%s-%s",
seKeyLabelPrefix,
vaultName,
hostname,
enrollmentTime,
), nil
}
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
// The vault's long-term private key is encrypted directly by the Secure Enclave
// using ECIES. No intermediate age keypair is used.
// The long-term key comes from mnemonic when it is not nil, else from the
// current unlocker, as getLongTermKeyForSE describes.
// The SE key is created once the long-term key is in hand and the unlocker's
// path is known, and is deleted again if a later step fails.
func CreateSecureEnclaveUnlocker(
fs afero.Fs,
stateDir string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
vault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
// Generate SE key label
seKeyLabel, err := generateSEKeyLabel(vault.GetName())
if err != nil {
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
}
// Step 1: Get the vault's long-term private key
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)
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err)
}
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
ltPrivKeyData)
if err != nil {
deleteErr := macse.DeleteKey(seKeyHash)
if deleteErr != nil {
err = errors.Join(err, fmt.Errorf(
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
}
return nil, err
}
return unlocker, nil
}
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
// new unlocker into unlockerDir (steps 4 and 5 of
// CreateSecureEnclaveUnlocker).
func writeSEUnlocker(
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
ltPrivKeyData *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
// prepare the metadata
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
if err != nil {
return nil, fmt.Errorf(
"failed to encrypt long-term key with SE: %w",
err,
)
}
seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
Flags: []string{seUnlockerType, macOSFlag},
},
SEKeyLabel: seKeyLabel,
SEKeyHash: seKeyHash,
}
metadataBytes, err := json.MarshalIndent(seMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
// Step 5: Write the SE-encrypted long-term key, then the metadata
err = WriteDir(fs, unlockerDir, func(dir string) error {
return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
})
if err != nil {
return nil, err
}
return &SecureEnclaveUnlocker{
Directory: unlockerDir,
Metadata: seMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// writeSEUnlockerFiles writes the files of a new SE unlocker into dir: the
// SE-encrypted long-term key, then the metadata.
func writeSEUnlockerFiles(
fs afero.Fs, dir string, encryptedLtKey, metadataBytes []byte,
) error {
err := WriteFileAtomic(fs, filepath.Join(dir, seLongtermFilename),
encryptedLtKey)
if err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
err = WriteFileAtomic(fs,
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
// from mnemonic when it is not nil, else through the current unlocker, which
// is given passphrase when it is a passphrase unlocker.
func getLongTermKeyForSE(
fs afero.Fs,
vault VaultInterface,
mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
if mnemonic != nil {
return deriveLongTermPrivateKey(fs, vault, mnemonic)
}
currentUnlocker, err := vault.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf(
"failed to get current unlocker identity: %w",
err,
)
}
// All unlocker types store longterm.age in their directory
longtermPath := filepath.Join(
currentUnlocker.GetDirectory(),
"longterm.age",
)
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
if err != nil {
return nil, fmt.Errorf(
"failed to read encrypted long-term key: %w",
err,
)
}
ltPrivKeyBuffer, err := DecryptWithIdentity(
encryptedLtKey,
currentIdentity,
)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
}
return ltPrivKeyBuffer, nil
}
-88
View File
@@ -1,88 +0,0 @@
//go:build !darwin
package secret
import (
"errors"
"path/filepath"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
// seUnlockerType is the type string for Secure Enclave unlockers.
const seUnlockerType = "secure-enclave"
var errSENotSupported = errors.New(
"secure enclave unlockers are only supported on macOS",
)
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata
SEKeyLabel string `json:"seKeyLabel"`
SEKeyHash string `json:"seKeyHash"`
}
// SecureEnclaveUnlocker is a stub for non-Darwin platforms.
type SecureEnclaveUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on
// non-Darwin platforms. The returned instance's methods that require
// macOS functionality will return errors.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity returns an error on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
return nil, errSENotSupported
}
// GetType returns the unlocker type.
func (s *SecureEnclaveUnlocker) GetType() string {
return seUnlockerType
}
// GetMetadata returns the unlocker metadata.
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
return s.Metadata
}
// GetDirectory returns the unlocker directory.
func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory
}
// GetID returns the unlocker ID, the name of the unlocker's directory.
func (s *SecureEnclaveUnlocker) GetID() string {
return filepath.Base(s.Directory)
}
// Remove returns an error on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) Remove() error {
return errSENotSupported
}
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
func CreateSecureEnclaveUnlocker(
_ afero.Fs,
_ string,
_, _ *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported
}
-99
View File
@@ -1,99 +0,0 @@
//go:build !darwin
//nolint:testpackage // white-box test asserting unexported sentinel errors
package secret
import (
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSecureEnclaveUnlocker(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{seUnlockerType, "macos"},
}
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
// Test GetType returns correct type
assert.Equal(t, seUnlockerType, unlocker.GetType())
// Test GetMetadata returns the metadata we passed in
assert.Equal(t, metadata, unlocker.GetMetadata())
// Test GetDirectory returns the directory we passed in
assert.Equal(t, dir, unlocker.GetDirectory())
// Test GetID returns the name of the unlocker's directory
assert.Equal(t, "test-se-unlocker", unlocker.GetID())
}
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
identity, err := unlocker.GetIdentity()
assert.Nil(t, identity)
require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported)
}
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
err := unlocker.Remove()
require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported)
}
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
assert.Nil(t, unlocker)
require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported)
}
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
// Verify the stub implements the Unlocker interface
var _ Unlocker = unlocker
}
-117
View File
@@ -1,117 +0,0 @@
//go:build darwin
//nolint:testpackage // white-box test of unexported Secure Enclave helpers
package secret
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSecureEnclaveUnlocker(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{seUnlockerType, "macos"},
}
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
// Test GetType returns correct type
assert.Equal(t, seUnlockerType, unlocker.GetType())
// Test GetMetadata returns the metadata we passed in
assert.Equal(t, metadata, unlocker.GetMetadata())
// Test GetDirectory returns the directory we passed in
assert.Equal(t, dir, unlocker.GetDirectory())
}
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
// Verify the darwin implementation implements the Unlocker interface
var _ Unlocker = unlocker
}
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
// The ID is the name of the unlocker's directory
assert.Equal(t, "test", unlocker.GetID())
}
func TestGenerateSEKeyLabel(t *testing.T) {
t.Parallel()
label, err := generateSEKeyLabel("test-vault")
require.NoError(t, err)
// Label should contain the prefix and vault name
assert.Contains(t, label, seKeyLabelPrefix)
assert.Contains(t, label, "test-vault")
}
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker-missing"
// Create unlocker directory with metadata but no encrypted key file
require.NoError(t, fs.MkdirAll(dir, DirPerms))
metadataJSON := `{
"type": "secure-enclave",
"createdAt": "2026-01-15T10:30:00Z",
"seKeyLabel": "berlin.sneak.app.secret.se.test",
"seKeyHash": "abc123"
}`
require.NoError(t, afero.WriteFile(
fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms,
))
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
}
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
// GetIdentity should fail because the encrypted longterm key file is missing
identity, err := unlocker.GetIdentity()
assert.Nil(t, identity)
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, filepath.Join(dir, seLongtermFilename), cause.Path)
}
-57
View File
@@ -1,57 +0,0 @@
//go:build darwin
//nolint:testpackage // white-box test of unexported validateKeychainItemName
package secret
import (
"testing"
)
func TestValidateKeychainItemName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
itemName string
wantErr bool
}{
// Valid cases
{name: "valid simple name", itemName: "my-secret-key", wantErr: false},
{name: "valid name with dots", itemName: "com.example.app.key", wantErr: false},
{name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false},
{name: "valid alphanumeric", itemName: "Secret123Key", wantErr: false},
{name: "valid with hyphen at start", itemName: "-my-key", wantErr: false},
{name: "valid with dot at start", itemName: ".hidden-key", wantErr: false},
// Invalid cases
{name: "empty item name", itemName: "", wantErr: true},
{name: "item name with spaces", itemName: "my secret key", wantErr: true},
{name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true},
{name: "item name with pipe", itemName: "key|cat /etc/passwd", wantErr: true},
{name: "item name with backticks", itemName: "key`whoami`", wantErr: true},
{name: "item name with dollar sign", itemName: "key$(whoami)", wantErr: true},
{name: "item name with quotes", itemName: "key\"name", wantErr: true},
{name: "item name with single quotes", itemName: "key'name", wantErr: true},
{name: "item name with backslash", itemName: "key\\name", wantErr: true},
{name: "item name with newline", itemName: "key\nname", wantErr: true},
{name: "item name with carriage return", itemName: "key\rname", wantErr: true},
{name: "item name with ampersand", itemName: "key&echo test", wantErr: true},
{name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true},
{name: "item name with null byte", itemName: "key\x00name", wantErr: true},
{name: "item name with parentheses", itemName: "key(test)", wantErr: true},
{name: "item name with brackets", itemName: "key[test]", wantErr: true},
{name: "item name with asterisk", itemName: "key*", wantErr: true},
{name: "item name with question mark", itemName: "key?", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateKeychainItemName(tt.itemName)
if (err != nil) != tt.wantErr {
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
-14
View File
@@ -19,7 +19,6 @@ import (
// Unlocker metadata type strings. // Unlocker metadata type strings.
const ( const (
unlockerTypePassphrase = "passphrase" unlockerTypePassphrase = "passphrase"
unlockerTypeSecureEnclave = "secure-enclave"
) )
// GetCurrentUnlocker returns the current unlocker for this vault // GetCurrentUnlocker returns the current unlocker for this vault
@@ -79,15 +78,6 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type) secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata) unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
case "keychain":
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
case unlockerTypeSecureEnclave:
secret.Debug("Creating secure enclave unlocker instance",
"unlocker_type", metadata.Type)
unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata)
default: default:
secret.Debug("Unsupported unlocker type", "type", metadata.Type) secret.Debug("Unsupported unlocker type", "type", metadata.Type)
@@ -171,10 +161,6 @@ func (v *Vault) findUnlockerByID(
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata) tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
case "pgp": case "pgp":
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata) tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
case "keychain":
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
case unlockerTypeSecureEnclave:
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
default: default:
continue continue
} }
+3 -17
View File
@@ -109,9 +109,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
slog.String("unlocker_id", unlocker.GetID()), slog.String("unlocker_id", unlocker.GetID()),
) )
// Get the long-term key via the unlocker. // Get the long-term key via the unlocker: its identity decrypts
// SE unlockers return the long-term key directly from GetIdentity(). // longterm.age.
// Other unlockers return their own identity, used to decrypt longterm.age.
ltIdentity, err := v.unlockLongTermKey(unlocker) ltIdentity, err := v.unlockLongTermKey(unlocker)
if err != nil { if err != nil {
return nil, v.withMnemonicAdvice(err) return nil, v.withMnemonicAdvice(err)
@@ -254,23 +253,10 @@ func (v *Vault) deriveLongTermKeyFromMnemonic(
} }
// unlockLongTermKey extracts the vault's long-term key using the given // unlockLongTermKey extracts the vault's long-term key using the given
// unlocker. SE unlockers decrypt the long-term key directly; other unlockers // unlocker's identity.
// use an intermediate identity.
func (v *Vault) unlockLongTermKey( func (v *Vault) unlockLongTermKey(
unlocker secret.Unlocker, unlocker secret.Unlocker,
) (*age.X25519Identity, error) { ) (*age.X25519Identity, error) {
if unlocker.GetType() == unlockerTypeSecureEnclave {
secret.Debug("SE unlocker: decrypting long-term key directly via Secure Enclave")
ltIdentity, err := unlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term key via SE: %w", err)
}
return ltIdentity, nil
}
// Standard unlockers: get unlocker identity, then decrypt longterm.age
unlockerIdentity, err := unlocker.GetIdentity() unlockerIdentity, err := unlocker.GetIdentity()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get unlocker identity: %w", err) return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
+7 -2
View File
@@ -17,6 +17,7 @@ import (
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcutil/bech32" "github.com/btcsuite/btcutil/bech32"
"golang.org/x/text/unicode/norm"
"sneak.berlin/go/secret/internal/bip39" "sneak.berlin/go/secret/internal/bip39"
"sneak.berlin/go/secret/pkg/bip85" "sneak.berlin/go/secret/pkg/bip85"
) )
@@ -73,8 +74,12 @@ func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
// DeriveEntropy derives 32 bytes of application-scoped entropy from the // DeriveEntropy derives 32 bytes of application-scoped entropy from the
// supplied BIP-39 mnemonic and index n using BIP85. // supplied BIP-39 mnemonic and index n using BIP85.
func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) { func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
// Convert mnemonic to seed // BIP-39 makes the seed from the words joined by single spaces, in
seed := bip39.NewSeed(mnemonic, "") // Unicode NFKD. NewSeed hashes its input as given, so the mnemonic is
// brought to that form first: the same words, however they were
// typed, then give the same seed.
sentence := norm.NFKD.String(strings.Join(strings.Fields(mnemonic), " "))
seed := bip39.NewSeed(sentence, "")
// Create master key from seed // Create master key from seed
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
+28
View File
@@ -175,6 +175,34 @@ func TestDeterministicDerivation(t *testing.T) {
t.Logf("Index 1: %s", id3.String()) t.Logf("Index 1: %s", id3.String())
} }
// TestDeriveIdentityIgnoresExtraWhitespace checks that the same words give
// the same identity however they are spaced, as when a mnemonic is typed or
// pasted with a double space or a trailing newline.
func TestDeriveIdentityIgnoresExtraWhitespace(t *testing.T) {
t.Parallel()
want, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive: %v", err)
}
typedMnemonics := []string{
strings.Replace(mnemonic, " ", " ", 1) + " ",
"\t" + strings.ReplaceAll(mnemonic, " ", "\n") + "\n",
}
for _, typed := range typedMnemonics {
got, err := DeriveIdentity(typed, 0)
if err != nil {
t.Fatalf("derive from %q: %v", typed, err)
}
if got.String() != want.String() {
t.Errorf("%q derives a different identity from %q", typed, mnemonic)
}
}
}
func TestDeterministicXPRVDerivation(t *testing.T) { func TestDeterministicXPRVDerivation(t *testing.T) {
t.Parallel() t.Parallel()