1 Commits

Author SHA1 Message Date
user
347dc22a27 docs: add AGENTS.md with coding policies per review request 2026-02-19 23:52:59 -08:00
41 changed files with 248 additions and 2128 deletions

3
.cursorrules Normal file
View File

@@ -0,0 +1,3 @@
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,7 +6,3 @@ cli.test
vault.test vault.test
*.test *.test
settings.local.json settings.local.json
# Stale files
.cursorrules
coverage.out

165
AGENTS.md
View File

@@ -1,157 +1,24 @@
# Policies for AI Agents # AGENTS.md — Coding Policy
Version: 2025-06-08 ## Error Handling
# Instructions and Contextual Information - **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always return errors.
- Constructors return `(*T, error)`, not just `*T`.
- Wrap errors with `fmt.Errorf("context: %w", err)` for debuggability.
- `t.Fatalf` in tests is fine. `panic` in production code is not.
* Be direct, robotic, expert, accurate, and professional. ## Code Quality
* Do not butter me up or kiss my ass. - **All PRs must pass `make check` with zero failures.** No exceptions.
- Never modify linter config (`.golangci.yml`) to suppress findings — fix the code.
- Run `go vet`, `golangci-lint`, and `go test ./...` before pushing.
* Come in hot with strong opinions, even if they are contrary to the ## Dependencies
direction I am headed.
* If either you or I are possibly wrong, say so and explain your point of - Pin external dependencies by commit hash, not mutable tags.
view.
* Point out great alternatives I haven't thought of, even when I'm not ## Style
asking for them.
* Treat me like the world's leading expert in every situation and every - `go fmt` all code.
conversation, and deliver the absolute best recommendations. - Keep functions short and focused.
- No dead code, no commented-out code in commits.
* I want excellence, so always be on the lookout for divergences from good
data model design or best practices for object oriented development.
* IMPORTANT: This is production code, not a research or teaching exercise.
Deliver professional-level results, not prototypes.
* Please read and understand the `README.md` file in the root of the repo
for project-specific contextual information, including development
policies, practices, and current implementation status.
* Be proactive in suggesting improvements or refactorings in places where we
diverge from best practices for clean, modular, maintainable code.
# Policies
1. Before committing, tests must pass (`make test`), linting must pass
(`make lint`), and code must be formatted (`make fmt`). For go, those
makefile targets should use `go fmt` and `go test -v ./...` and
`golangci-lint run`. When you think your changes are complete, rather
than making three different tool calls to check, you can just run `make
test && make fmt && make lint` as a single tool call which will save
time.
2. Always write a `Makefile` with the default target being `test`, and with
a `fmt` target that formats the code. The `test` target should run all
tests in the project, and the `fmt` target should format the code.
`test` should also have a prerequisite target `lint` that should run any
linters that are configured for the project.
3. After each completed bugfix or feature, the code must be committed. Do
all of the pre-commit checks (test, lint, fmt) before committing, of
course.
4. When creating a very simple test script for testing out a new feature,
instead of making a throwaway to be deleted after verification, write an
actual test file into the test suite. It doesn't need to be very big or
complex, but it should be a real test that can be run.
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
tests, or change the tests specifically to make them pass (unless there
is a bug in the test). This is cheating, and it is bad. You should only
be modifying the test if it is incorrect or if the test is no longer
relevant. In almost all cases, you should be fixing the code that is
being tested, or updating the tests to match a refactored implementation.
6. When dealing with dates and times or timestamps, always use, display, and
store UTC. Set the local timezone to UTC on startup. If the user needs
to see the time in a different timezone, store the user's timezone in a
separate field and convert the UTC time to the user's timezone when
displaying it. For internal use and internal applications and
administrative purposes, always display UTC.
7. Always write tests, even if they are extremely simple and just check for
correct syntax (ability to compile/import). If you are writing a new
feature, write a test for it. You don't need to target complete
coverage, but you should at least test any new functionality you add. If
you are fixing a bug, write a test first that reproduces the bug, and
then fix the bug in the code.
8. When implementing new features, be aware of potential side-effects (such
as state files on disk, data in the database, etc.) and ensure that it is
possible to mock or stub these side-effects in tests.
9. Always use structured logging. Log any relevant state/context with the
messages (but do not log secrets). If stdout is not a terminal, output
the structured logs in jsonl format.
10. Avoid using bare strings or numbers in code, especially if they appear
anywhere more than once. Always define a constant (usually at the top
of the file) and give it a descriptive name, then use that constant in
the code instead of the bare string or number.
11. You do not need to summarize your changes in the chat after making them.
Making the changes and committing them is sufficient. If anything out
of the ordinary happened, please explain it, but in the normal case
where you found and fixed the bug, or implemented the feature, there is
no need for the end-of-change summary.
12. Do not create additional files in the root directory of the project
without asking permission first. Configuration files, documentation, and
build files are acceptable in the root, but source code and other files
should be organized in appropriate subdirectories.
## Python-Specific Guidelines
1. **Type Annotations (UP006)**: Use built-in collection types directly for type annotations instead of importing from `typing`. This avoids the UP006 linter error.
**Good (modern Python 3.9+):**
```python
def process_items(items: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
return counts
```
**Avoid (triggers UP006):**
```python
from typing import List, Dict
def process_items(items: List[str]) -> Dict[str, int]:
counts: Dict[str, int] = {}
return counts
```
For optional types, use the `|` operator instead of `Union`:
```python
# Good
def get_value(key: str) -> str | None:
return None
# Avoid
from typing import Optional, Union
def get_value(key: str) -> Optional[str]:
return None
```
2. **Import Organization**: Follow the standard Python import order:
- Standard library imports
- Third-party imports
- Local application imports
Each group should be separated by a blank line.
## Go-Specific Guidelines
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values.
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle errors, not crash.
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix the code.
5. **All PRs must pass `make check` with zero failures.** No exceptions, no "pre-existing issue" excuses.
6. **Pin external dependencies by commit hash**, not mutable tags.

View File

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

102
coverage.out Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1047,6 +1047,7 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
// Test invalid secret names // Test invalid secret names
invalidNames := []string{ invalidNames := []string{
"", // empty "", // empty
"UPPERCASE", // uppercase not allowed
"with space", // spaces not allowed "with space", // spaces not allowed
"with@symbol", // special characters not allowed "with@symbol", // special characters not allowed
"with#hash", // special characters not allowed "with#hash", // special characters not allowed
@@ -1072,7 +1073,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) // 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 // For now, just check the ones we know should definitely fail
definitelyInvalid := []string{"", "with space", "with@symbol", "with#hash", "with$dollar"} definitelyInvalid := []string{"", "UPPERCASE", "with space", "with@symbol", "with#hash", "with$dollar"}
shouldFail := false shouldFail := false
for _, invalid := range definitelyInvalid { for _, invalid := range definitelyInvalid {
if invalidName == invalid { if invalidName == invalid {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,129 +0,0 @@
//go:build darwin
// Package macse provides Go bindings for macOS Secure Enclave operations
// using CryptoTokenKit identities created via sc_auth.
// Key creation and deletion shell out to sc_auth (which has SE entitlements).
// Encrypt/decrypt use Security.framework ECIES directly (works unsigned).
package macse
/*
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework Security -framework Foundation -framework CoreFoundation
#include <stdlib.h>
#include "secure_enclave.h"
*/
import "C"
import (
"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

@@ -1,29 +0,0 @@
//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

@@ -1,163 +0,0 @@
//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

@@ -1,57 +0,0 @@
#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

@@ -1,300 +0,0 @@
#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,16 +58,6 @@ func IsDebugEnabled() bool {
return debugEnabled 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 // Debug logs a debug message with optional attributes
func Debug(msg string, args ...any) { func Debug(msg string, args ...any) {
if !debugEnabled { if !debugEnabled {

View File

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

View File

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

View File

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

View File

@@ -1,385 +0,0 @@
//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

@@ -1,84 +0,0 @@
//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

@@ -1,90 +0,0 @@
//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

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

View File

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

View File

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

View File

@@ -129,12 +129,55 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
slog.String("unlocker_id", unlocker.GetID()), slog.String("unlocker_id", unlocker.GetID()),
) )
// Get the long-term key via the unlocker. // Get unlocker identity
// SE unlockers return the long-term key directly from GetIdentity(). unlockerIdentity, err := unlocker.GetIdentity()
// Other unlockers return their own identity, used to decrypt longterm.age.
ltIdentity, err := v.unlockLongTermKey(unlocker)
if err != nil { if err != nil {
return nil, err 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)
} }
secret.DebugWith("Successfully obtained long-term identity via unlocker", secret.DebugWith("Successfully obtained long-term identity via unlocker",
@@ -151,47 +194,6 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return ltIdentity, nil 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 // GetDirectory returns the vault's directory path
func (v *Vault) GetDirectory() (string, error) { func (v *Vault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil return filepath.Join(v.stateDir, "vaults.d", v.Name), nil

View File

@@ -243,57 +243,3 @@ 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")
}
}
}