27 Commits

Author SHA1 Message Date
a3d3fb3b69 secure-enclave-unlocker (#24)
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #24
Reviewed-by: clawbot <clawbot@noreply.example.org>
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-03-14 07:36:28 +01:00
4dc26c9394 Merge pull request 'chore: remove stale .cursorrules and coverage.out' (#22) from chore/remove-stale-files into main
Reviewed-on: #22
2026-02-28 19:29:52 +01:00
user
7546cb094f chore: remove stale .cursorrules and coverage.out
Remove committed editor config (.cursorrules) and test coverage
artifact (coverage.out). Both added to .gitignore.
2026-02-20 02:59:23 -08:00
797d2678c8 Merge pull request 'Add secret.Warn() calls for all silent anomalous conditions' (#20) from clawbot/secret:audit/add-warnings into main
Reviewed-on: #20
2026-02-20 09:22:29 +01:00
user
78015afb35 Add secret.Warn() calls for all silent anomalous conditions
Audit of the codebase found 9 locations where errors or anomalous
conditions were silently swallowed or only logged via Debug(). Users
should be informed when something unexpected happens, even if the
program can continue.

Changes:
- DetermineStateDir: warn on config dir fallback to ~/.config
- info_helper: warn when vault/secret stats cannot be read
- unlockers list: warn on metadata read/parse failures (fixes FIXMEs)
- unlockers list: warn on fallback ID generation
- checkUnlockerExists: warn on errors during duplicate checking
- completions: warn on unlocker metadata read/parse failures
- version list: upgrade metadata load failure from Debug to Warn
- secrets: upgrade file close failure from Debug to Warn
- version naming: warn on malformed version directory names

Closes #19
2026-02-20 00:03:49 -08:00
1c330c697f Merge pull request 'Skip unlocker directories with missing metadata instead of failing (closes #1)' (#17) from clawbot/secret:fix/issue-1 into main
Reviewed-on: #17
2026-02-20 08:59:04 +01:00
d18e286377 Merge branch 'main' into fix/issue-1 2026-02-20 08:58:43 +01:00
f49fde3a06 Merge pull request 'Fix getLongTermPrivateKey derivation index hardcoded to 0 (closes #3)' (#8) from clawbot/secret:fix/issue-3 into main
Reviewed-on: #8
2026-02-20 08:58:21 +01:00
206651f89a Merge branch 'main' into fix/issue-3 2026-02-20 08:58:10 +01:00
user
c0f221b1ca Change missing metadata log from Debug to Warn for visibility without --verbose
Per review feedback: missing unlocker metadata should produce a warning
visible in normal output, not hidden behind debug flags.
2026-02-19 23:57:39 -08:00
09be20a044 Merge pull request 'Allow uppercase letters in secret names (closes #2)' (#16) from clawbot/secret:fix/issue-2 into main
Reviewed-on: #16
2026-02-20 08:57:19 +01:00
2e1ba7d2e0 Merge branch 'main' into fix/issue-2 2026-02-20 08:57:03 +01:00
1a23016df1 Merge pull request 'Validate secret name in GetSecretVersion to prevent path traversal (closes #13)' (#15) from clawbot/secret:fix/issue-13 into main
Reviewed-on: #15
2026-02-20 08:56:51 +01:00
ebe3c17618 Merge branch 'main' into fix/issue-13 2026-02-20 08:56:36 +01:00
clawbot
1a96360f6a Skip unlocker directories with missing metadata instead of failing
When an unlocker directory exists but is missing unlocker-metadata.json,
log a debug warning and skip it instead of returning a hard error that
crashes the entire 'unlocker ls' command.

Closes #1
2026-02-19 23:56:08 -08:00
4f5d2126d6 Merge pull request 'Return error from GetDefaultStateDir when home directory unavailable (closes #14)' (#18) from clawbot/secret:fix/issue-14 into main
Reviewed-on: #18
2026-02-20 08:54:22 +01:00
clawbot
6be4601763 refactor: return errors from NewCLIInstance instead of panicking
Change NewCLIInstance() and NewCLIInstanceWithFs() to return
(*Instance, error) instead of panicking on DetermineStateDir failure.

Callers in RunE contexts propagate the error. Callers in command
construction (for shell completion) use log.Fatalf. Test callers
use t.Fatalf.

Addresses review feedback on PR #18.
2026-02-19 23:53:35 -08:00
clawbot
dc225bd0b1 fix: add blank line before return for nlreturn linter 2026-02-19 23:44:38 -08:00
clawbot
6acd57d0ec fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:43:32 -08:00
clawbot
596027f210 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:43:13 -08:00
clawbot
0aa9a52497 test: add test for getLongTermPrivateKey derivation index
Verifies that getLongTermPrivateKey reads the derivation index from
vault metadata instead of using hardcoded index 0. Test creates a
mock vault with DerivationIndex=5 and confirms the derived key
matches index 5.
2026-02-19 23:43:13 -08:00
clawbot
09ec79c57e fix: use vault derivation index in getLongTermPrivateKey instead of hardcoded 0
Previously, getLongTermPrivateKey() always used derivation index 0 when
deriving the long-term key from a mnemonic. This caused wrong key
derivation for vaults with index > 0 (second+ vault from same mnemonic),
leading to silent data corruption in keychain unlocker creation.

Now reads the vault's actual DerivationIndex from vault-metadata.json.
2026-02-19 23:43:13 -08:00
clawbot
e8339f4d12 fix: update integration test to allow uppercase secret names 2026-02-19 23:42:39 -08:00
clawbot
4f984cd9c6 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:41:43 -08:00
user
8eb25b98fd fix: block .. path components in secret names and validate in GetSecretObject
- isValidSecretName() now rejects names with '..' path components (e.g. foo/../bar)
- GetSecretObject() now calls isValidSecretName() before building paths
- Added test cases for mid-path traversal patterns
2026-02-15 14:17:33 -08:00
user
0307f23024 Allow uppercase letters in secret names (closes #2)
The isValidSecretName() regex only allowed lowercase letters [a-z], rejecting
valid secret names containing uppercase characters (e.g. AWS access key IDs).

Changed regex from ^[a-z0-9\.\-\_\/]+$ to ^[a-zA-Z0-9\.\-\_\/]+$ and added
tests for uppercase secret names in both vault and secret packages.
2026-02-15 14:03:50 -08:00
clawbot
3fd30bb9e6 Validate secret name in GetSecretVersion to prevent path traversal
Add isValidSecretName() check at the top of GetSecretVersion(), matching
the existing validation in AddSecret(). Without this, crafted secret names
containing path traversal sequences (e.g. '../../../etc/passwd') could be
used to read files outside the vault directory.

Add regression tests for both GetSecretVersion and GetSecret.

Closes #13
2026-02-15 14:03:28 -08:00
40 changed files with 1979 additions and 232 deletions

View File

@@ -1,3 +0,0 @@
EXTREMELY IMPORTANT: Read and follow the policies, procedures, and
instructions in the `AGENTS.md` file in the root of the repository. Make
sure you follow *all* of the instructions meticulously.

4
.gitignore vendored
View File

@@ -6,3 +6,7 @@ cli.test
vault.test
*.test
settings.local.json
# Stale files
.cursorrules
coverage.out

View File

@@ -184,6 +184,7 @@ Creates a new unlocker of the specified type:
- `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption
- `keychain`: macOS Keychain integration (macOS only)
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
**Options:**
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
@@ -286,11 +287,11 @@ Unlockers provide different authentication methods to access the long-term keys:
- Automatic unlocking when Keychain is unlocked
- Cross-application integration
4. **Secure Enclave Unlockers** (macOS - planned):
4. **Secure Enclave Unlockers** (macOS):
- Hardware-backed key storage using Apple Secure Enclave
- Currently partially implemented but non-functional
- Requires Apple Developer Program membership and code signing entitlements
- Full implementation blocked by entitlement requirements
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required)
- ECIES encryption: vault long-term key encrypted directly by SE hardware
- Protected by biometric authentication (Touch ID) or system password
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
@@ -330,8 +331,7 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
- Hardware token support via PGP/GPG integration
- macOS Keychain integration for system-level security
- Secure Enclave support planned (requires paid Apple Developer Program for
signed entitlements to access the SEP and doxxing myself to Apple)
- Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit)
## Examples
@@ -385,6 +385,7 @@ secret vault remove personal --force
secret unlocker add passphrase # Password-based
secret unlocker add pgp --keyid ABCD1234 # GPG key
secret unlocker add keychain # macOS Keychain (macOS only)
secret unlocker add secure-enclave # macOS Secure Enclave (macOS only)
# List unlockers
secret unlocker list
@@ -443,7 +444,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### Cross-Platform Support
- **macOS**: Full support including Keychain and planned Secure Enclave integration
- **macOS**: Full support including Keychain and Secure Enclave integration
- **Linux**: Full support (excluding macOS-specific features)
## Security Considerations
@@ -487,7 +488,7 @@ go test -tags=integration -v ./internal/cli # Integration tests
## Features
- **Multiple Authentication Methods**: Supports passphrase, PGP, and macOS Keychain unlockers
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers
- **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases

View File

@@ -1,102 +0,0 @@
mode: set
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:57.41,60.38 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:60.38,61.41 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:65.2,70.3 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:74.50,76.2 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:79.85,81.28 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:81.28,83.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:86.2,87.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:87.16,89.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:92.2,93.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:93.16,95.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:98.2,98.35 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:102.89,105.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:105.16,107.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:110.2,114.21 4 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:118.99,119.46 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:119.46,121.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:124.2,134.39 5 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:134.39,137.15 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:137.15,140.4 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:143.3,145.17 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:145.17,147.4 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.3,150.15 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.15,152.4 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:155.3,156.17 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:156.17,158.4 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:160.3,160.14 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:163.2,163.17 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:167.107,171.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:171.16,173.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:177.2,186.15 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:187.15,188.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:189.15,190.13 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:191.15,192.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:193.15,194.13 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:195.15,196.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:197.10,198.64 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:202.2,204.21 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:208.84,212.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:212.16,214.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:217.2,222.16 4 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:222.16,224.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:226.2,226.26 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:230.99,234.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:234.16,236.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:239.2,251.45 6 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:251.45,253.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:256.2,275.45 12 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:279.39,284.2 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:287.91,288.36 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:288.36,290.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:292.2,295.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:295.16,297.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:300.2,302.41 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:306.100,307.32 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:307.32,309.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:311.2,314.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:314.16,316.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:319.2,325.35 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:325.35,327.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:329.2,329.33 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:333.100,334.32 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:334.32,336.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:338.2,341.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:341.16,343.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:346.2,349.32 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:349.32,351.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:353.2,353.30 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:357.57,375.52 7 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:375.52,381.46 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:381.46,385.4 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:387.3,387.20 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:390.2,390.21 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:394.67,396.2 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:32.22,36.2 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:40.67,41.31 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:41.31,43.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:46.2,55.16 6 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:55.16,57.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:58.2,59.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:59.16,61.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:63.2,63.52 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:68.63,74.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:74.16,76.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:79.2,83.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:83.16,85.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:88.2,91.16 4 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:91.16,93.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:95.2,95.17 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:100.67,103.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:103.16,105.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:108.2,112.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:112.16,114.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:117.2,120.16 4 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:120.16,122.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:124.2,124.17 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:129.77,131.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:131.16,133.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:135.2,135.33 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:140.81,142.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:142.16,144.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:146.2,146.33 1 1

View File

@@ -17,30 +17,30 @@ type Instance struct {
}
// NewCLIInstance creates a new CLI instance with the real filesystem
func NewCLIInstance() *Instance {
func NewCLIInstance() (*Instance, error) {
fs := afero.NewOsFs()
stateDir, err := secret.DetermineStateDir("")
if err != nil {
panic(fmt.Sprintf("cannot determine state directory: %v", err))
return nil, fmt.Errorf("cannot determine state directory: %w", err)
}
return &Instance{
fs: fs,
stateDir: stateDir,
}
}, nil
}
// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing)
func NewCLIInstanceWithFs(fs afero.Fs) *Instance {
func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
stateDir, err := secret.DetermineStateDir("")
if err != nil {
panic(fmt.Sprintf("cannot determine state directory: %v", err))
return nil, fmt.Errorf("cannot determine state directory: %w", err)
}
return &Instance{
fs: fs,
stateDir: stateDir,
}
}, nil
}
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state directory (for testing)

View File

@@ -25,7 +25,10 @@ func TestCLIInstanceStateDir(t *testing.T) {
func TestCLIInstanceWithFs(t *testing.T) {
// Test creating CLI instance with custom filesystem
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithFs(fs)
cli, err := NewCLIInstanceWithFs(fs)
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
// The state directory should be determined automatically
stateDir := cli.GetStateDir()

View File

@@ -71,6 +71,8 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(fs, unlockersDir)
if err != nil {
secret.Warn("Could not read unlockers directory during completion", "error", err)
continue
}
@@ -85,11 +87,15 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
secret.Warn("Could not read unlocker metadata during completion", "path", metadataPath, "error", err)
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
secret.Warn("Could not parse unlocker metadata during completion", "path", metadataPath, "error", err)
continue
}

View File

@@ -22,7 +22,10 @@ func newEncryptCmd() *cobra.Command {
inputFile, _ := cmd.Flags().GetString("input")
outputFile, _ := cmd.Flags().GetString("output")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
cli.cmd = cmd
return cli.Encrypt(args[0], inputFile, outputFile)
@@ -45,7 +48,10 @@ func newDecryptCmd() *cobra.Command {
inputFile, _ := cmd.Flags().GetString("input")
outputFile, _ := cmd.Flags().GetString("output")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
cli.cmd = cmd
return cli.Decrypt(args[0], inputFile, outputFile)

View File

@@ -38,7 +38,10 @@ func newGenerateMnemonicCmd() *cobra.Command {
`mnemonic phrase that can be used with 'secret init' ` +
`or 'secret import'.`,
RunE: func(cmd *cobra.Command, _ []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.GenerateMnemonic(cmd)
},
@@ -56,7 +59,10 @@ func newGenerateSecretCmd() *cobra.Command {
secretType, _ := cmd.Flags().GetString("type")
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
},

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"path/filepath"
"runtime"
"strings"
@@ -40,7 +41,10 @@ type InfoOutput struct {
// newInfoCmd returns the info command
func newInfoCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
var jsonOutput bool

View File

@@ -4,6 +4,7 @@ import (
"path/filepath"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
)
@@ -28,6 +29,8 @@ func gatherVaultStats(
// Count secrets in this vault
secretEntries, err := afero.ReadDir(fs, secretsPath)
if err != nil {
secret.Warn("Could not read secrets directory for vault", "vault", vaultEntry.Name(), "error", err)
continue
}
@@ -43,6 +46,8 @@ func gatherVaultStats(
versionsPath := filepath.Join(secretPath, "versions")
versionEntries, err := afero.ReadDir(fs, versionsPath)
if err != nil {
secret.Warn("Could not read versions directory for secret", "secret", secretEntry.Name(), "error", err)
continue
}

View File

@@ -2,6 +2,7 @@ package cli
import (
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
@@ -27,7 +28,10 @@ func NewInitCmd() *cobra.Command {
// RunInit is the exported function that handles the init command
func RunInit(cmd *cobra.Command, _ []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return cli.Init(cmd)
}

View File

@@ -1047,7 +1047,6 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
// Test invalid secret names
invalidNames := []string{
"", // empty
"UPPERCASE", // uppercase not allowed
"with space", // spaces not allowed
"with@symbol", // special characters not allowed
"with#hash", // special characters not allowed
@@ -1073,7 +1072,7 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
// Some of these might not be invalid after all (e.g., leading/trailing slashes might be stripped, .hidden might be allowed)
// For now, just check the ones we know should definitely fail
definitelyInvalid := []string{"", "UPPERCASE", "with space", "with@symbol", "with#hash", "with$dollar"}
definitelyInvalid := []string{"", "with space", "with@symbol", "with#hash", "with$dollar"}
shouldFail := false
for _, invalid := range definitelyInvalid {
if invalidName == invalid {

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"path/filepath"
"strings"
@@ -44,7 +45,10 @@ func newAddCmd() *cobra.Command {
force, _ := cmd.Flags().GetBool("force")
secret.Debug("Got force flag", "force", force)
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
cli.cmd = cmd // Set the command for stdin access
secret.Debug("Created CLI instance, calling AddSecret")
@@ -58,7 +62,10 @@ func newAddCmd() *cobra.Command {
}
func newGetCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
cmd := &cobra.Command{
Use: "get <secret-name>",
Short: "Retrieve a secret from the vault",
@@ -66,7 +73,10 @@ func newGetCmd() *cobra.Command {
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
version, _ := cmd.Flags().GetString("version")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.GetSecretWithVersion(cmd, args[0], version)
},
@@ -93,7 +103,10 @@ func newListCmd() *cobra.Command {
filter = args[0]
}
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter)
},
@@ -115,7 +128,10 @@ func newImportCmd() *cobra.Command {
sourceFile, _ := cmd.Flags().GetString("source")
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.ImportSecret(cmd, args[0], sourceFile, force)
},
@@ -129,7 +145,10 @@ func newImportCmd() *cobra.Command {
}
func newRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
cmd := &cobra.Command{
Use: "remove <secret-name>",
Aliases: []string{"rm"},
@@ -139,7 +158,10 @@ func newRemoveCmd() *cobra.Command {
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.RemoveSecret(cmd, args[0], false)
},
@@ -149,7 +171,10 @@ func newRemoveCmd() *cobra.Command {
}
func newMoveCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
cmd := &cobra.Command{
Use: "move <source> <destination>",
Aliases: []string{"mv", "rename"},
@@ -172,7 +197,10 @@ The source secret is deleted after successful copy.`,
},
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.MoveSecret(cmd, args[0], args[1], force)
},
@@ -479,7 +507,7 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
}
defer func() {
if err := file.Close(); err != nil {
secret.Debug("Failed to close file", "error", err)
secret.Warn("Failed to close file", "error", err)
}
}()

View File

@@ -113,7 +113,10 @@ func TestAddSecretVariousSizes(t *testing.T) {
cmd.SetIn(stdin)
// Create CLI instance
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cli.fs = fs
cli.stateDir = stateDir
cli.cmd = cmd
@@ -230,7 +233,10 @@ func TestImportSecretVariousSizes(t *testing.T) {
cmd := &cobra.Command{}
// Create CLI instance
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cli.fs = fs
cli.stateDir = stateDir
@@ -318,7 +324,10 @@ func TestAddSecretBufferGrowth(t *testing.T) {
cmd.SetIn(stdin)
// Create CLI instance
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cli.fs = fs
cli.stateDir = stateDir
cli.cmd = cmd
@@ -377,7 +386,10 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
cmd.SetIn(slowReader)
// Create CLI instance
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cli.fs = fs
cli.stateDir = stateDir
cli.cmd = cmd

View File

@@ -3,6 +3,7 @@ package cli
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
@@ -96,7 +97,10 @@ func newUnlockerListCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
cli.cmd = cmd
return cli.UnlockersList(jsonOutput)
@@ -123,22 +127,27 @@ func newUnlockerAddCmd() *cobra.Command {
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
if runtime.GOOS == "darwin" {
supportedTypes = "passphrase, keychain, pgp"
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
typeDescriptions = `Available unlocker types:
passphrase - Traditional password-based encryption
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
The passphrase is never stored in plaintext.
passphrase - Traditional password-based encryption
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
The passphrase is never stored in plaintext.
keychain - macOS Keychain integration (macOS only)
Stores the vault's master key in the macOS Keychain, protected by your login password.
Automatically unlocks when your Keychain is unlocked (e.g., after login).
Provides seamless integration with macOS security features like Touch ID.
keychain - macOS Keychain integration (macOS only)
Stores the vault's master key in the macOS Keychain, protected by your login password.
Automatically unlocks when your Keychain is unlocked (e.g., after login).
Provides seamless integration with macOS security features like Touch ID.
pgp - GNU Privacy Guard (GPG) key-based encryption
Uses your existing GPG key to encrypt/decrypt the vault's master key.
Requires gpg to be installed and configured with at least one secret key.
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
pgp - GNU Privacy Guard (GPG) key-based encryption
Uses your existing GPG key to encrypt/decrypt the vault's master key.
Requires gpg to be installed and configured with at least one secret key.
Use --keyid to specify a particular key, otherwise uses your default GPG key.
secure-enclave - Apple Secure Enclave hardware protection (macOS only)
Stores the vault's master key encrypted by a non-exportable P-256 key
held in the Secure Enclave. The key never leaves the hardware.
Uses ECIES encryption; decryption is performed inside the SE.`
}
cmd := &cobra.Command{
@@ -153,7 +162,10 @@ to access the same vault. This provides flexibility and backup access options.`,
Args: cobra.ExactArgs(1),
ValidArgs: strings.Split(supportedTypes, ", "),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
unlockerType := args[0]
// Validate unlocker type
@@ -186,7 +198,10 @@ to access the same vault. This provides flexibility and backup access options.`,
}
func newUnlockerRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
cmd := &cobra.Command{
Use: "remove <unlocker-id>",
Aliases: []string{"rm"},
@@ -198,7 +213,10 @@ func newUnlockerRemoveCmd() *cobra.Command {
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.UnlockersRemove(args[0], force, cmd)
},
@@ -210,7 +228,10 @@ func newUnlockerRemoveCmd() *cobra.Command {
}
func newUnlockerSelectCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return &cobra.Command{
Use: "select <unlocker-id>",
@@ -218,7 +239,10 @@ func newUnlockerSelectCmd() *cobra.Command {
Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(_ *cobra.Command, args []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.UnlockerSelect(args[0])
},
@@ -252,6 +276,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Create unlocker instance to get the proper ID
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory while listing unlockers", "error", err)
continue
}
@@ -259,6 +285,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil {
secret.Warn("Could not read unlockers directory", "error", err)
continue
}
@@ -274,12 +302,16 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil {
continue // FIXME this error needs to be handled
secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err)
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue // FIXME this error needs to be handled
secret.Warn("Could not parse unlocker metadata file", "path", metadataPath, "error", err)
continue
}
// Match by type and creation time
@@ -292,6 +324,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
case "pgp":
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
case "secure-enclave":
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
}
break
@@ -305,6 +339,7 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
} else {
// Generate ID as fallback
properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
secret.Warn("Could not create unlocker instance, using fallback ID", "fallback_id", properID, "type", metadata.Type)
}
unlockerInfo := UnlockerInfo{
@@ -382,7 +417,7 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
// Build the supported types list based on platform
supportedTypes := "passphrase, pgp"
if runtime.GOOS == "darwin" {
supportedTypes = "passphrase, keychain, pgp"
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
}
switch unlockerType {
@@ -453,6 +488,31 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
return nil
case "secure-enclave":
if runtime.GOOS != "darwin" {
return fmt.Errorf("secure enclave unlockers are only supported on macOS")
}
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
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)
}
if err := vlt.SelectUnlocker(seUnlocker.GetID()); err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil
case "pgp":
// Get GPG key ID from flag, environment, or default key
var gpgKeyID string
@@ -571,12 +631,16 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
// Get the list of unlockers and check if any match the ID
unlockers, err := vlt.ListUnlockers()
if err != nil {
secret.Warn("Could not list unlockers during duplicate check", "error", err)
return nil // If we can't list unlockers, assume it doesn't exist
}
// Get vault directory to construct unlocker instances
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory during duplicate check", "error", err)
return nil
}
@@ -586,6 +650,8 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil {
secret.Warn("Could not read unlockers directory during duplicate check", "error", err)
continue
}
@@ -600,11 +666,15 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
// Check if this matches our metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil {
secret.Warn("Could not read unlocker metadata during duplicate check", "path", metadataPath, "error", err)
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
secret.Warn("Could not parse unlocker metadata during duplicate check", "path", metadataPath, "error", err)
continue
}
@@ -618,6 +688,8 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
case "pgp":
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
case "secure-enclave":
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
}
if unlocker != nil && unlocker.GetID() == unlockerID {

View File

@@ -3,6 +3,7 @@ package cli
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
@@ -41,7 +42,10 @@ func newVaultListCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.ListVaults(cmd, jsonOutput)
},
@@ -58,7 +62,10 @@ func newVaultCreateCmd() *cobra.Command {
Short: "Create a new vault",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.CreateVault(cmd, args[0])
},
@@ -66,7 +73,10 @@ func newVaultCreateCmd() *cobra.Command {
}
func newVaultSelectCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return &cobra.Command{
Use: "select <name>",
@@ -74,7 +84,10 @@ func newVaultSelectCmd() *cobra.Command {
Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.SelectVault(cmd, args[0])
},
@@ -82,7 +95,10 @@ func newVaultSelectCmd() *cobra.Command {
}
func newVaultImportCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return &cobra.Command{
Use: "import <vault-name>",
@@ -96,7 +112,10 @@ func newVaultImportCmd() *cobra.Command {
vaultName = args[0]
}
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.VaultImport(cmd, vaultName)
},
@@ -104,7 +123,10 @@ func newVaultImportCmd() *cobra.Command {
}
func newVaultRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
cmd := &cobra.Command{
Use: "remove <name>",
Aliases: []string{"rm"},
@@ -115,7 +137,10 @@ func newVaultRemoveCmd() *cobra.Command {
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.RemoveVault(cmd, args[0], force)
},

View File

@@ -2,6 +2,7 @@ package cli
import (
"fmt"
"log"
"path/filepath"
"strings"
"text/tabwriter"
@@ -18,7 +19,10 @@ const (
// newVersionCmd returns the version management command
func newVersionCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return VersionCommands(cli)
}
@@ -160,7 +164,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
// Load metadata
if err := sv.LoadMetadata(ltIdentity); err != nil {
secret.Debug("Failed to load version metadata", "version", version, "error", err)
secret.Warn("Failed to load version metadata", "version", version, "error", err)
// Display version with error
status := "error"
if version == currentVersion {

View File

@@ -266,7 +266,10 @@ func TestGetSecretWithVersion(t *testing.T) {
func TestVersionCommandStructure(t *testing.T) {
// Test that version commands are properly structured
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cmd := VersionCommands(cli)
assert.Equal(t, "version", cmd.Use)

View File

@@ -0,0 +1,129 @@
//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 (
"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).
func CreateKey(label string) (publicKey []byte, hash string, err error) {
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
pubKeyLen := C.int(p256UncompressedKeySize)
var hashBuf [hashBufferSize]C.char
var 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,
&pubKeyBuf[0], &pubKeyLen,
&hashBuf[0], C.int(hashBufferSize),
&errBuf[0], C.int(errorBufferSize))
if result != 0 {
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
}
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction
h := C.GoString(&hashBuf[0])
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]))
}
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction
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]))
}
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction
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
}

View File

@@ -0,0 +1,29 @@
//go:build !darwin
// +build !darwin
// Package macse provides Go bindings for macOS Secure Enclave operations.
package macse
import "fmt"
var errNotSupported = fmt.Errorf("secure enclave is only supported on macOS") //nolint:gochecknoglobals
// CreateKey is not supported on non-darwin platforms.
func CreateKey(_ string) ([]byte, string, error) {
return nil, "", errNotSupported
}
// Encrypt is not supported on non-darwin platforms.
func Encrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported
}
// Decrypt is not supported on non-darwin platforms.
func Decrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported
}
// DeleteKey is not supported on non-darwin platforms.
func DeleteKey(_ string) error {
return errNotSupported
}

View File

@@ -0,0 +1,163 @@
//go:build darwin
// +build darwin
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-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
// 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")
}

View File

@@ -0,0 +1,57 @@
#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.
// label: unique identifier for the CTK identity (UTF-8 C string)
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
// pub_key_len: on input, size of pub_key_out; on output, actual size written
// hash_out: output buffer for the identity hash (for deletion)
// hash_out_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,
uint8_t *pub_key_out, int *pub_key_len,
char *hash_out, int hash_out_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

View File

@@ -0,0 +1,300 @@
#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,
uint8_t *pub_key_out, int *pub_key_len,
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;
}
// Retrieve the public key from the created identity
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);
// Get the identity hash by parsing sc_auth list output
hash_out[0] = '\0';
NSTask *listTask = [[NSTask alloc] init];
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
listTask.arguments = @[@"list-ctk-identities"];
NSPipe *listPipe = [NSPipe pipe];
listTask.standardOutput = listPipe;
listTask.standardError = [NSPipe pipe];
if ([listTask launchAndReturnError:&nsError]) {
[listTask waitUntilExit];
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
NSString *listStr = [[NSString alloc] initWithData:listData
encoding:NSUTF8StringEncoding];
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
if ([line containsString:labelStr]) {
NSMutableArray *tokens = [NSMutableArray array];
for (NSString *part in [line componentsSeparatedByCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]]) {
if (part.length > 0) {
[tokens addObject:part];
}
}
if (tokens.count > 1) {
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
}
break;
}
}
}
return 0;
}
}
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;
}
}

View File

@@ -58,6 +58,16 @@ func IsDebugEnabled() bool {
return debugEnabled
}
// Warn logs a warning message to stderr unconditionally (visible without --verbose or debug flags)
func Warn(msg string, args ...any) {
output := fmt.Sprintf("WARNING: %s", msg)
for i := 0; i+1 < len(args); i += 2 {
output += fmt.Sprintf(" %s=%v", args[i], args[i+1])
}
output += "\n"
fmt.Fprint(os.Stderr, output)
}
// Debug logs a debug message with optional attributes
func Debug(msg string, args ...any) {
if !debugEnabled {

View File

@@ -0,0 +1,82 @@
package secret
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// 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 }
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") }
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { 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) {
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)
t.Setenv(EnvMnemonic, testMnemonic)
result, err := getLongTermPrivateKey(fs, vault)
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")
}

View File

@@ -53,7 +53,10 @@ func DetermineStateDir(customConfigDir string) (string, error) {
return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr)
}
return filepath.Join(homeDir, ".config", AppID), nil
fallbackDir := filepath.Join(homeDir, ".config", AppID)
Warn("Could not determine user config directory, falling back to default", "fallback", fallbackDir, "error", err)
return fallbackDir, nil
}
return filepath.Join(configDir, AppID), nil

View File

@@ -251,8 +251,25 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
// Check if mnemonic is available in environment variable
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
// 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
if err := json.Unmarshal(metadataBytes, &metadata); 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(envMnemonic, metadata.DerivationIndex)
if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}

View File

@@ -257,9 +257,10 @@ func isValidSecretName(name string) bool {
if name == "" {
return false
}
// Valid characters for secret names: lowercase letters, numbers, dash, dot, underscore, slash
// Valid characters for secret names: letters, numbers, dash, dot, underscore, slash
for _, char := range name {
if (char < 'a' || char > 'z') && // lowercase letters
(char < 'A' || char > 'Z') && // uppercase letters
(char < '0' || char > '9') && // numbers
char != '-' && // dash
char != '.' && // dot
@@ -283,9 +284,11 @@ func TestSecretNameValidation(t *testing.T) {
{"valid/path/name", true},
{"123valid", true},
{"", false},
{"Invalid-Name", false}, // uppercase not allowed
{"invalid name", false}, // space not allowed
{"invalid@name", false}, // @ not allowed
{"Valid-Upper-Name", true}, // uppercase allowed
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true}, // real-world uppercase key ID
{"MixedCase/Path/Name", true}, // mixed case with path
{"invalid name", false}, // space not allowed
{"invalid@name", false}, // @ not allowed
}
for _, test := range tests {

View File

@@ -0,0 +1,385 @@
//go:build darwin
// +build darwin
package secret
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
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
}
// 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.
func (s *SecureEnclaveUnlocker) GetID() string {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
createdAt := s.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-%s", timestamp, hostname, seUnlockerType)
}
// 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)
if err := macse.DeleteKey(seKeyHash); 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)
if err := s.fs.RemoveAll(s.Directory); 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() (label string, hash string, err 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
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
}
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
}
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// 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)
}
enrollmentDate := time.Now().UTC().Format("2006-01-02")
return fmt.Sprintf(
"%s.%s-%s-%s",
seKeyLabelPrefix,
vaultName,
hostname,
enrollmentDate,
), 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.
func CreateSecureEnclaveUnlocker(
fs afero.Fs,
stateDir string,
) (*SecureEnclaveUnlocker, error) {
if err := checkMacOSAvailable(); err != nil {
return nil, err
}
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: 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)
// Step 2: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
if err != nil {
return nil, fmt.Errorf(
"failed to get long-term private key: %w",
err,
)
}
defer ltPrivKeyData.Destroy()
// Step 3: Encrypt the long-term key directly with the SE (ECIES)
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
if err != nil {
return nil, fmt.Errorf(
"failed to encrypt long-term key with SE: %w",
err,
)
}
// Step 4: Create unlocker directory and write files
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf(
"failed to create unlocker directory: %w",
err,
)
}
// Write SE-encrypted long-term key
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
return nil, fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
// Write metadata
seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
Flags: []string{seUnlockerType, "macos"},
},
SEKeyLabel: seKeyLabel,
SEKeyHash: seKeyHash,
}
metadataBytes, err := json.MarshalIndent(seMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
if err := afero.WriteFile(fs, metadataPath, metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write metadata: %w", err)
}
return &SecureEnclaveUnlocker{
Directory: unlockerDir,
Metadata: seMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key
// either from the mnemonic env var or by unlocking via the current unlocker.
func getLongTermKeyForSE(
fs afero.Fs,
vault VaultInterface,
) (*memguard.LockedBuffer, error) {
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
// 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
if err := json.Unmarshal(metadataBytes, &metadata); 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(
envMnemonic,
metadata.DerivationIndex,
)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w",
err,
)
}
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
}
currentUnlocker, err := vault.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
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
}

View File

@@ -0,0 +1,84 @@
//go:build !darwin
// +build !darwin
package secret
import (
"fmt"
"filippo.io/age"
"github.com/spf13/afero"
)
var errSENotSupported = fmt.Errorf(
"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
}
// 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 "secure-enclave"
}
// 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.
func (s *SecureEnclaveUnlocker) GetID() string {
return fmt.Sprintf(
"%s-secure-enclave",
s.Metadata.CreatedAt.Format("2006-01-02.15.04"),
)
}
// Remove returns an error on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) Remove() error {
return errSENotSupported
}
// 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,
}
}
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
func CreateSecureEnclaveUnlocker(
_ afero.Fs,
_ string,
) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported
}

View File

@@ -0,0 +1,90 @@
//go:build !darwin
// +build !darwin
package secret
import (
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSecureEnclaveUnlocker(t *testing.T) {
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{"secure-enclave", "macos"},
}
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
// Test GetType returns correct type
assert.Equal(t, "secure-enclave", 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 a formatted string with the creation timestamp
expectedID := "2026-01-15.10.30-secure-enclave"
assert.Equal(t, expectedID, unlocker.GetID())
}
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
identity, err := unlocker.GetIdentity()
assert.Nil(t, identity)
assert.Error(t, err)
assert.ErrorIs(t, err, errSENotSupported)
}
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
err := unlocker.Remove()
assert.Error(t, err)
assert.ErrorIs(t, err, errSENotSupported)
}
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
assert.Nil(t, unlocker)
assert.Error(t, err)
assert.ErrorIs(t, err, errSENotSupported)
}
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
// Verify the stub implements the Unlocker interface
var _ Unlocker = unlocker
}

View File

@@ -0,0 +1,101 @@
//go:build darwin
// +build darwin
package secret
import (
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSecureEnclaveUnlocker(t *testing.T) {
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{"secure-enclave", "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) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
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) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
id := unlocker.GetID()
// ID should contain the timestamp and "secure-enclave" type
assert.Contains(t, id, "2026-03-10.14.30")
assert.Contains(t, id, seUnlockerType)
}
func TestGenerateSEKeyLabel(t *testing.T) {
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) {
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: "secure-enclave",
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)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
}

View File

@@ -102,6 +102,8 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
var serial int
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
Warn("Skipping malformed version directory name", "name", entry.Name(), "error", err)
continue
}

View File

@@ -0,0 +1,96 @@
package vault
import (
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
// validates the secret name and rejects path traversal attempts.
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
fs := afero.NewMemMapFs()
stateDir := "/test/state"
vlt, err := CreateVault(fs, stateDir, "test-vault")
require.NoError(t, err)
// Add a legitimate secret so the vault is set up
value := memguard.NewBufferFromBytes([]byte("legitimate-secret"))
err = vlt.AddSecret("legit", value, false)
require.NoError(t, err)
// These names contain path traversal and should be rejected
maliciousNames := []string{
"../../../etc/passwd",
"..%2f..%2fetc/passwd",
".secret",
"../sibling-vault/secrets.d/target",
"foo/../bar",
"a/../../etc/passwd",
}
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
_, err := vlt.GetSecretVersion(name, "")
assert.Error(t, err, "GetSecretVersion should reject malicious name: %s", name)
assert.Contains(t, err.Error(), "invalid secret name",
"error should indicate invalid name for: %s", name)
})
}
}
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls GetSecretVersion)
// also rejects path traversal names.
func TestGetSecretRejectsPathTraversal(t *testing.T) {
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
fs := afero.NewMemMapFs()
stateDir := "/test/state"
vlt, err := CreateVault(fs, stateDir, "test-vault")
require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid secret name")
}
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
// also validates names and rejects path traversal attempts.
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
fs := afero.NewMemMapFs()
stateDir := "/test/state"
vlt, err := CreateVault(fs, stateDir, "test-vault")
require.NoError(t, err)
maliciousNames := []string{
"../../../etc/passwd",
"foo/../bar",
"a/../../etc/passwd",
}
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
_, err := vlt.GetSecretObject(name)
assert.Error(t, err, "GetSecretObject should reject: %s", name)
assert.Contains(t, err.Error(), "invalid secret name")
})
}
}

View File

@@ -67,7 +67,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
return secrets, nil
}
// isValidSecretName validates secret names according to the format [a-z0-9\.\-\_\/]+
// isValidSecretName validates secret names according to the format [a-zA-Z0-9\.\-\_\/]+
// but with additional restrictions:
// - No leading or trailing slashes
// - No double slashes
@@ -92,8 +92,15 @@ func isValidSecretName(name string) bool {
return false
}
// Check for path traversal via ".." components
for _, part := range strings.Split(name, "/") {
if part == ".." {
return false
}
}
// Check the basic pattern
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
matched, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-\_\/]+$`, name)
return matched
}
@@ -319,6 +326,13 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
slog.String("version", version),
)
// Validate secret name to prevent path traversal
if !isValidSecretName(name) {
secret.Debug("Invalid secret name provided", "secret_name", name)
return nil, fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
}
// Get vault directory
vaultDir, err := v.GetDirectory()
if err != nil {
@@ -454,6 +468,10 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
if !isValidSecretName(name) {
return nil, fmt.Errorf("invalid secret name: %s", name)
}
// First check if the secret exists by checking for the metadata file
vaultDir, err := v.GetDirectory()
if err != nil {

View File

@@ -0,0 +1,42 @@
package vault
import "testing"
func TestIsValidSecretNameUppercase(t *testing.T) {
tests := []struct {
name string
valid bool
}{
// Lowercase (existing behavior)
{"valid-name", true},
{"valid.name", true},
{"valid_name", true},
{"valid/path/name", true},
{"123valid", true},
// Uppercase (new behavior - issue #2)
{"Valid-Upper-Name", true},
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true},
{"MixedCase/Path/Name", true},
{"ALLUPPERCASE", true},
{"ABC123", true},
// Still invalid
{"", false},
{"invalid name", false},
{"invalid@name", false},
{".dotstart", false},
{"/leading-slash", false},
{"trailing-slash/", false},
{"double//slash", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidSecretName(tt.name)
if result != tt.valid {
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
}
})
}
}

View File

@@ -83,6 +83,9 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
case "keychain":
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
case "secure-enclave":
secret.Debug("Creating secure enclave unlocker instance", "unlocker_type", metadata.Type)
unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata)
default:
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
@@ -166,6 +169,8 @@ func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlock
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
case "keychain":
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
case "secure-enclave":
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
default:
continue
}
@@ -213,7 +218,9 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
}
if !exists {
return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name())
secret.Warn("Skipping unlocker directory with missing metadata file", "directory", file.Name())
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)

View File

@@ -129,55 +129,12 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
slog.String("unlocker_id", unlocker.GetID()),
)
// Get unlocker identity
unlockerIdentity, err := unlocker.GetIdentity()
// Get the long-term key via the unlocker.
// SE unlockers return the long-term key directly from GetIdentity().
// Other unlockers return their own identity, used to decrypt longterm.age.
ltIdentity, err := v.unlockLongTermKey(unlocker)
if err != nil {
secret.Debug("Failed to get unlocker identity", "error", err, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
// Read encrypted long-term private key from unlocker directory
unlockerDir := unlocker.GetDirectory()
encryptedLtPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
secret.Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
if err != nil {
secret.Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
}
secret.DebugWith("Read encrypted long-term private key",
slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
)
// Decrypt long-term private key using unlocker
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
if err != nil {
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
secret.DebugWith("Successfully decrypted long-term private key",
slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.Int("decrypted_length", ltPrivKeyBuffer.Size()),
)
// Parse long-term private key
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil {
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
return nil, err
}
secret.DebugWith("Successfully obtained long-term identity via unlocker",
@@ -194,6 +151,47 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return ltIdentity, nil
}
// unlockLongTermKey extracts the vault's long-term key using the given unlocker.
// SE unlockers decrypt the long-term key directly; other unlockers use an intermediate identity.
func (v *Vault) unlockLongTermKey(unlocker secret.Unlocker) (*age.X25519Identity, error) {
if unlocker.GetType() == "secure-enclave" {
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()
if err != nil {
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
}
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil {
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
return ltIdentity, nil
}
// GetDirectory returns the vault's directory path
func (v *Vault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil

View File

@@ -243,3 +243,57 @@ func TestVaultOperations(t *testing.T) {
}
})
}
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
// Set test environment variables
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
// Use in-memory filesystem
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create vault
vlt, err := CreateVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Create a passphrase unlocker so we have at least one valid unlocker
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphraseBuffer.Destroy()
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// Create a bogus unlocker directory with no metadata file
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
err = fs.MkdirAll(bogusDir, 0o700)
if err != nil {
t.Fatalf("Failed to create bogus directory: %v", err)
}
// ListUnlockers should succeed, skipping the bogus directory
unlockers, err := vlt.ListUnlockers()
if err != nil {
t.Fatalf("ListUnlockers returned error when it should have skipped bad directory: %v", err)
}
// Should still have the valid passphrase unlocker
if len(unlockers) == 0 {
t.Errorf("Expected at least one unlocker, got none")
}
// Verify we only got the valid unlocker(s), not the bogus one
for _, u := range unlockers {
if u.Type == "" {
t.Errorf("Got unlocker with empty type, likely from bogus directory")
}
}
}