1
0
forked from sneak/secret

Compare commits

...

39 Commits

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

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

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

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

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

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

Now reads the vault's actual DerivationIndex from vault-metadata.json.
2026-02-19 23:43:13 -08:00
clawbot
e8339f4d12 fix: update integration test to allow uppercase secret names 2026-02-19 23:42:39 -08:00
clawbot
4f984cd9c6 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:41:43 -08:00
clawbot
d1caf0a208 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:40:21 -08:00
user
8eb25b98fd fix: block .. path components in secret names and validate in GetSecretObject
- isValidSecretName() now rejects names with '..' path components (e.g. foo/../bar)
- GetSecretObject() now calls isValidSecretName() before building paths
- Added test cases for mid-path traversal patterns
2026-02-15 14:17:33 -08:00
clawbot
6211b8e768 Return error from GetDefaultStateDir when home directory unavailable
When os.UserConfigDir() fails, DetermineStateDir falls back to
os.UserHomeDir(). Previously the error from UserHomeDir was discarded,
which could result in a dangerous root-relative path (/.config/...) if
both calls fail.

Now DetermineStateDir returns (string, error) and propagates failures
from both UserConfigDir and UserHomeDir.

Closes #14
2026-02-15 14:05:15 -08:00
user
0307f23024 Allow uppercase letters in secret names (closes #2)
The isValidSecretName() regex only allowed lowercase letters [a-z], rejecting
valid secret names containing uppercase characters (e.g. AWS access key IDs).

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

Add regression tests for both GetSecretVersion and GetSecret.

Closes #13
2026-02-15 14:03:28 -08:00
6ff00c696a Merge pull request 'Remove redundant longterm.age encryption in Init command (closes #6)' (#11) from clawbot/secret:fix/issue-6 into main
Reviewed-on: sneak/secret#11
2026-02-09 02:39:55 +01:00
c6551e4901 Merge branch 'main' into fix/issue-6 2026-02-09 02:39:41 +01:00
b06d7fa3f4 Merge pull request 'Fix NumSecrets() always returning 0 (closes #4)' (#9) from clawbot/secret:fix/issue-4 into main
Reviewed-on: sneak/secret#9
2026-02-09 02:39:30 +01:00
16d5b237d2 Merge branch 'main' into fix/issue-4 2026-02-09 02:26:20 +01:00
660de5716a Merge pull request 'Non-darwin KeychainUnlocker stub returns errors instead of panicking (closes #7)' (#12) from clawbot/secret:fix/issue-7 into main
Reviewed-on: sneak/secret#12
2026-02-09 02:20:14 +01:00
51fb2805fd Merge branch 'main' into fix/issue-7 2026-02-09 02:19:56 +01:00
6ffb24b544 Merge pull request 'Zero plaintext after copying to memguard in DecryptWithIdentity (closes #5)' (#10) from clawbot/secret:fix/issue-5 into main
Reviewed-on: sneak/secret#10
2026-02-09 02:18:06 +01:00
clawbot
4419ef7730 fix: non-darwin KeychainUnlocker stub returns errors instead of panicking
The stub previously panicked on all methods including NewKeychainUnlocker,
which is called from vault code when processing keychain-type unlocker
metadata. This caused crashes on Linux/Windows when a vault synced from
macOS contained keychain unlockers.

Now returns proper error values, allowing graceful degradation and
cross-platform vault portability.
2026-02-08 12:05:38 -08:00
clawbot
991b1a5a0b fix: remove redundant longterm.age encryption in Init command
CreatePassphraseUnlocker already encrypts and writes the long-term
private key to longterm.age. The Init command was doing this a second
time, overwriting the file with a functionally equivalent but
separately encrypted blob. This was wasteful and a maintenance hazard.
2026-02-08 12:05:09 -08:00
clawbot
341428d9ca fix: NumSecrets() now correctly counts secrets by checking for current file
NumSecrets() previously looked for non-directory, non-'current' files
directly under each secret directory, but the only children are
'current' (file, excluded) and 'versions' (directory, excluded),
so it always returned 0.

Now checks for the existence of the 'current' file, which is the
canonical indicator that a secret exists and has an active version.

This fixes the safety check in UnlockersRemove that was always
allowing removal of the last unlocker.
2026-02-08 12:04:15 -08:00
34 changed files with 682 additions and 233 deletions

View File

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

4
.gitignore vendored
View File

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

View File

@ -141,3 +141,17 @@ Version: 2025-06-08
- 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

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

View File

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

View File

@ -25,7 +25,10 @@ func TestCLIInstanceStateDir(t *testing.T) {
func TestCLIInstanceWithFs(t *testing.T) {
// Test creating CLI instance with custom filesystem
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithFs(fs)
cli, err := NewCLIInstanceWithFs(fs)
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
// The state directory should be determined automatically
stateDir := cli.GetStateDir()
@ -41,7 +44,10 @@ func TestDetermineStateDir(t *testing.T) {
testEnvDir := "/test-env-dir"
t.Setenv(secret.EnvStateDir, testEnvDir)
stateDir := secret.DetermineStateDir("")
stateDir, err := secret.DetermineStateDir("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateDir != testEnvDir {
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
}
@ -49,7 +55,10 @@ func TestDetermineStateDir(t *testing.T) {
// Test with custom config dir
_ = os.Unsetenv(secret.EnvStateDir)
customConfigDir := "/custom-config"
stateDir = secret.DetermineStateDir(customConfigDir)
stateDir, err = secret.DetermineStateDir(customConfigDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedDir := filepath.Join(customConfigDir, secret.AppID)
if stateDir != expectedDir {
t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,17 @@
package cli
import (
"log"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39"
)
@ -29,7 +28,10 @@ func NewInitCmd() *cobra.Command {
// RunInit is the exported function that handles the init command
func RunInit(cmd *cobra.Command, _ []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return cli.Init(cmd)
}
@ -154,35 +156,8 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
return fmt.Errorf("failed to create unlocker: %w", err)
}
// Encrypt long-term private key to the unlocker
unlockerDir := passphraseUnlocker.GetDirectory()
// Read unlocker public key
unlockerPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockerDir, "pub.age"))
if err != nil {
return fmt.Errorf("failed to read unlocker public key: %w", err)
}
unlockerRecipient, err := age.ParseX25519Recipient(string(unlockerPubKeyData))
if err != nil {
return fmt.Errorf("failed to parse unlocker public key: %w", err)
}
// Encrypt long-term private key to unlocker
// Use memguard to protect the private key in memory
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyBuffer.Destroy()
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, unlockerRecipient)
if err != nil {
return fmt.Errorf("failed to encrypt long-term private key: %w", err)
}
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(cli.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
// Note: CreatePassphraseUnlocker already encrypts and writes the long-term
// private key to longterm.age, so no need to do it again here.
if cmd != nil {
cmd.Printf("\nDefault vault created and configured\n")

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package cli
import (
"log"
"encoding/json"
"fmt"
"os"
@ -96,7 +97,10 @@ func newUnlockerListCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
cli.cmd = cmd
return cli.UnlockersList(jsonOutput)
@ -153,7 +157,10 @@ to access the same vault. This provides flexibility and backup access options.`,
Args: cobra.ExactArgs(1),
ValidArgs: strings.Split(supportedTypes, ", "),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
unlockerType := args[0]
// Validate unlocker type
@ -186,7 +193,10 @@ to access the same vault. This provides flexibility and backup access options.`,
}
func newUnlockerRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
cmd := &cobra.Command{
Use: "remove <unlocker-id>",
Aliases: []string{"rm"},
@ -198,7 +208,10 @@ func newUnlockerRemoveCmd() *cobra.Command {
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.UnlockersRemove(args[0], force, cmd)
},
@ -210,7 +223,10 @@ func newUnlockerRemoveCmd() *cobra.Command {
}
func newUnlockerSelectCmd() *cobra.Command {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return &cobra.Command{
Use: "select <unlocker-id>",
@ -218,7 +234,10 @@ func newUnlockerSelectCmd() *cobra.Command {
Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(_ *cobra.Command, args []string) error {
cli := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.UnlockerSelect(args[0])
},
@ -252,6 +271,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Create unlocker instance to get the proper ID
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory while listing unlockers", "error", err)
continue
}
@ -259,6 +280,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil {
secret.Warn("Could not read unlockers directory", "error", err)
continue
}
@ -274,12 +297,16 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil {
continue // FIXME this error needs to be handled
secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err)
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue // FIXME this error needs to be handled
secret.Warn("Could not parse unlocker metadata file", "path", metadataPath, "error", err)
continue
}
// Match by type and creation time
@ -305,6 +332,7 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
} else {
// Generate ID as fallback
properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
secret.Warn("Could not create unlocker instance, using fallback ID", "fallback_id", properID, "type", metadata.Type)
}
unlockerInfo := UnlockerInfo{
@ -571,12 +599,16 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
// Get the list of unlockers and check if any match the ID
unlockers, err := vlt.ListUnlockers()
if err != nil {
secret.Warn("Could not list unlockers during duplicate check", "error", err)
return nil // If we can't list unlockers, assume it doesn't exist
}
// Get vault directory to construct unlocker instances
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory during duplicate check", "error", err)
return nil
}
@ -586,6 +618,8 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil {
secret.Warn("Could not read unlockers directory during duplicate check", "error", err)
continue
}
@ -600,11 +634,15 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
// Check if this matches our metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil {
secret.Warn("Could not read unlocker metadata during duplicate check", "path", metadataPath, "error", err)
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
secret.Warn("Could not parse unlocker metadata during duplicate check", "path", metadataPath, "error", err)
continue
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,82 @@
package secret
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// realVault is a minimal VaultInterface backed by a real afero filesystem,
// using the same directory layout as vault.Vault.
type realVault struct {
name string
stateDir string
fs afero.Fs
}
func (v *realVault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
}
func (v *realVault) GetName() string { return v.name }
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
panic("not used")
}
// createRealVault sets up a complete vault directory structure on an in-memory
// filesystem, identical to what vault.CreateVault produces.
func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault {
t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", name)
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms))
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "unlockers.d"), DirPerms))
metadata := VaultMetadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
}
metaBytes, err := json.Marshal(metadata)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
return &realVault{name: name, stateDir: stateDir, fs: fs}
}
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive expected keys at two different indices to prove they differ.
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
require.NoError(t, err)
key5, err := agehd.DeriveIdentity(testMnemonic, 5)
require.NoError(t, err)
require.NotEqual(t, key0.String(), key5.String(),
"sanity check: different derivation indices must produce different keys")
// Build a real vault with DerivationIndex=5 on an in-memory filesystem.
fs := afero.NewMemMapFs()
vault := createRealVault(t, fs, "/state", "test-vault", 5)
t.Setenv(EnvMnemonic, testMnemonic)
result, err := getLongTermPrivateKey(fs, vault)
require.NoError(t, err)
defer result.Destroy()
assert.Equal(t, key5.String(), string(result.Bytes()),
"getLongTermPrivateKey should derive at vault's DerivationIndex (5)")
assert.NotEqual(t, key0.String(), string(result.Bytes()),
"getLongTermPrivateKey must not use hardcoded index 0")
}

View File

@ -28,16 +28,17 @@ func generateRandomString(length int, charset string) (string, error) {
return string(result), nil
}
// DetermineStateDir determines the state directory based on environment variables and OS
func DetermineStateDir(customConfigDir string) string {
// DetermineStateDir determines the state directory based on environment variables and OS.
// It returns an error if no usable directory can be determined.
func DetermineStateDir(customConfigDir string) (string, error) {
// Check for environment variable first
if envStateDir := os.Getenv(EnvStateDir); envStateDir != "" {
return envStateDir
return envStateDir, nil
}
// Use custom config dir if provided
if customConfigDir != "" {
return filepath.Join(customConfigDir, AppID)
return filepath.Join(customConfigDir, AppID), nil
}
// Use os.UserConfigDir() which handles platform-specific directories:
@ -47,10 +48,16 @@ func DetermineStateDir(customConfigDir string) string {
configDir, err := os.UserConfigDir()
if err != nil {
// Fallback to a reasonable default if we can't determine user config dir
homeDir, _ := os.UserHomeDir()
homeDir, homeErr := os.UserHomeDir()
if homeErr != nil {
return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr)
}
return filepath.Join(homeDir, ".config", AppID)
fallbackDir := filepath.Join(homeDir, ".config", AppID)
Warn("Could not determine user config directory, falling back to default", "fallback", fallbackDir, "error", err)
return fallbackDir, nil
}
return filepath.Join(configDir, AppID)
return filepath.Join(configDir, AppID), nil
}

View File

@ -0,0 +1,50 @@
package secret
import (
"testing"
)
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
// Clear all env vars that could provide a home/config directory.
// On Darwin, os.UserHomeDir may still succeed via the password
// database, so we also test via an explicit empty-customConfigDir
// path to exercise the fallback branch.
t.Setenv(EnvStateDir, "")
t.Setenv("HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
result, err := DetermineStateDir("")
// On systems where both lookups fail, we must get an error.
// On systems where the OS provides a fallback (e.g. macOS pw db),
// result should still be valid (non-empty, not root-relative).
if err != nil {
// Good — the error case is handled.
return
}
if result == "/.config/"+AppID || result == "" {
t.Errorf("DetermineStateDir returned dangerous/empty path %q without error", result)
}
}
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
t.Setenv(EnvStateDir, "/custom/state")
result, err := DetermineStateDir("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "/custom/state" {
t.Errorf("expected /custom/state, got %q", result)
}
}
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
t.Setenv(EnvStateDir, "")
result, err := DetermineStateDir("/my/config")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := "/my/config/" + AppID
if result != expected {
t.Errorf("expected %q, got %q", expected, result)
}
}

View File

@ -251,8 +251,25 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
// Check if mnemonic is available in environment variable
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}

View File

@ -4,6 +4,8 @@
package secret
import (
"fmt"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
@ -22,52 +24,59 @@ type KeychainUnlocker struct {
fs afero.Fs
}
// GetIdentity panics on non-Darwin platforms
var errKeychainNotSupported = fmt.Errorf("keychain unlockers are only supported on macOS")
// GetIdentity returns an error on non-Darwin platforms
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
panic("keychain unlockers are only supported on macOS")
return nil, errKeychainNotSupported
}
// GetType panics on non-Darwin platforms
// GetType returns the unlocker type
func (k *KeychainUnlocker) GetType() string {
panic("keychain unlockers are only supported on macOS")
return "keychain"
}
// GetMetadata panics on non-Darwin platforms
// GetMetadata returns the unlocker metadata
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
panic("keychain unlockers are only supported on macOS")
return k.Metadata
}
// GetDirectory panics on non-Darwin platforms
// GetDirectory returns the unlocker directory
func (k *KeychainUnlocker) GetDirectory() string {
panic("keychain unlockers are only supported on macOS")
return k.Directory
}
// GetID returns the unlocker ID
func (k *KeychainUnlocker) GetID() string {
panic("keychain unlockers are only supported on macOS")
return fmt.Sprintf("%s-keychain", k.Metadata.CreatedAt.Format("2006-01-02.15.04"))
}
// GetKeychainItemName panics on non-Darwin platforms
// GetKeychainItemName returns an error on non-Darwin platforms
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
panic("keychain unlockers are only supported on macOS")
return "", errKeychainNotSupported
}
// Remove panics on non-Darwin platforms
// Remove returns an error on non-Darwin platforms
func (k *KeychainUnlocker) Remove() error {
panic("keychain unlockers are only supported on macOS")
return errKeychainNotSupported
}
// NewKeychainUnlocker panics on non-Darwin platforms
// NewKeychainUnlocker creates a stub KeychainUnlocker on non-Darwin platforms.
// The returned instance's methods that require macOS functionality will return errors.
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
panic("keychain unlockers are only supported on macOS")
return &KeychainUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// CreateKeychainUnlocker panics on non-Darwin platforms
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
panic("keychain unlockers are only supported on macOS")
// CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
return nil, errKeychainNotSupported
}
// getLongTermPrivateKey panics on non-Darwin platforms
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
panic("keychain unlockers are only supported on macOS")
// getLongTermPrivateKey returns an error on non-Darwin platforms
func getLongTermPrivateKey(_ afero.Fs, _ VaultInterface) (*memguard.LockedBuffer, error) {
return nil, errKeychainNotSupported
}

View File

@ -320,7 +320,9 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) {
}
// Use GPG to get the full fingerprint for the key
cmd := exec.Command("gpg", "--list-keys", "--with-colons", "--fingerprint", keyID)
cmd := exec.Command( // #nosec G204 -- keyID validated
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
)
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
@ -359,7 +361,9 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
}
cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
cmd := exec.Command( // #nosec G204 -- keyID validated
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
)
cmd.Stdin = strings.NewReader(data.String())
output, err := cmd.Output()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -213,7 +213,9 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
}
if !exists {
return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name())
secret.Warn("Skipping unlocker directory with missing metadata file", "directory", file.Name())
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)

View File

@ -227,27 +227,23 @@ func (v *Vault) NumSecrets() (int, error) {
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
}
// Count only directories that contain at least one version file
// Count only directories that have a "current" version pointer file
count := 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// Check if this secret directory contains any version files
// A valid secret has a "current" file pointing to the active version
secretDir := filepath.Join(secretsDir, entry.Name())
versionFiles, err := afero.ReadDir(v.fs, secretDir)
currentFile := filepath.Join(secretDir, "current")
exists, err := afero.Exists(v.fs, currentFile)
if err != nil {
continue // Skip directories we can't read
}
// Look for at least one version file (excluding "current" symlink)
for _, vFile := range versionFiles {
if !vFile.IsDir() && vFile.Name() != "current" {
count++
break // Found at least one version, count this secret
}
if exists {
count++
}
}

View File

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