diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index ea5bbed..0000000 --- a/.cursorrules +++ /dev/null @@ -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. diff --git a/.gitignore b/.gitignore index 79c2f3d..fcdf079 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ cli.test vault.test *.test settings.local.json + +# Stale files +.cursorrules +coverage.out diff --git a/AGENTS.md b/AGENTS.md index 3892686..8d95b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/coverage.out b/coverage.out deleted file mode 100644 index c86d0d2..0000000 --- a/coverage.out +++ /dev/null @@ -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 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8b79612..5141c83 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 8e5b3eb..32fd44b 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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) diff --git a/internal/cli/completions.go b/internal/cli/completions.go index 6b1602d..371f632 100644 --- a/internal/cli/completions.go +++ b/internal/cli/completions.go @@ -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 } diff --git a/internal/cli/crypto.go b/internal/cli/crypto.go index f58c8c5..263a9e0 100644 --- a/internal/cli/crypto.go +++ b/internal/cli/crypto.go @@ -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) diff --git a/internal/cli/generate.go b/internal/cli/generate.go index d2bc79b..623ccbd 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -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) }, diff --git a/internal/cli/info.go b/internal/cli/info.go index 1a838e1..f62805e 100644 --- a/internal/cli/info.go +++ b/internal/cli/info.go @@ -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 diff --git a/internal/cli/info_helper.go b/internal/cli/info_helper.go index d4a57aa..4ed174c 100644 --- a/internal/cli/info_helper.go +++ b/internal/cli/info_helper.go @@ -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 } diff --git a/internal/cli/init.go b/internal/cli/init.go index 1167f7c..1390506 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -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") diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 66aea95..d995e58 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -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 { diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index 7962980..f6f6e74 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -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 ", 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 ", 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 ", 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) } }() diff --git a/internal/cli/secrets_size_test.go b/internal/cli/secrets_size_test.go index 8e1dac8..dd882f8 100644 --- a/internal/cli/secrets_size_test.go +++ b/internal/cli/secrets_size_test.go @@ -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 diff --git a/internal/cli/unlockers.go b/internal/cli/unlockers.go index c03a1e3..8615586 100644 --- a/internal/cli/unlockers.go +++ b/internal/cli/unlockers.go @@ -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) @@ -158,7 +162,10 @@ to access the same vault. This provides flexibility and backup access options.`, Args: cobra.ExactArgs(1), ValidArgs: strings.Split(supportedTypes, ", "), RunE: func(cmd *cobra.Command, args []string) error { - cli := NewCLIInstance() + cli, err := NewCLIInstance() + if err != nil { + return fmt.Errorf("failed to initialize CLI: %w", err) + } unlockerType := args[0] // Validate unlocker type @@ -191,7 +198,10 @@ to access the same vault. This provides flexibility and backup access options.`, } func newUnlockerRemoveCmd() *cobra.Command { - cli := NewCLIInstance() + cli, err := NewCLIInstance() + if err != nil { + log.Fatalf("failed to initialize CLI: %v", err) + } cmd := &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, @@ -203,7 +213,10 @@ func newUnlockerRemoveCmd() *cobra.Command { ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { force, _ := cmd.Flags().GetBool("force") - cli := NewCLIInstance() + cli, err := NewCLIInstance() + if err != nil { + return fmt.Errorf("failed to initialize CLI: %w", err) + } return cli.UnlockersRemove(args[0], force, cmd) }, @@ -215,7 +228,10 @@ func newUnlockerRemoveCmd() *cobra.Command { } func newUnlockerSelectCmd() *cobra.Command { - cli := NewCLIInstance() + cli, err := NewCLIInstance() + if err != nil { + log.Fatalf("failed to initialize CLI: %v", err) + } return &cobra.Command{ Use: "select ", @@ -223,7 +239,10 @@ func newUnlockerSelectCmd() *cobra.Command { Args: cobra.ExactArgs(1), ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir), RunE: func(_ *cobra.Command, args []string) error { - cli := NewCLIInstance() + cli, err := NewCLIInstance() + if err != nil { + return fmt.Errorf("failed to initialize CLI: %w", err) + } return cli.UnlockerSelect(args[0]) }, @@ -257,6 +276,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error { // Create unlocker instance to get the proper ID vaultDir, err := vlt.GetDirectory() if err != nil { + secret.Warn("Could not get vault directory while listing unlockers", "error", err) + continue } @@ -264,6 +285,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error { unlockersDir := filepath.Join(vaultDir, "unlockers.d") files, err := afero.ReadDir(cli.fs, unlockersDir) if err != nil { + secret.Warn("Could not read unlockers directory", "error", err) + continue } @@ -279,12 +302,16 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error { // Check if this is the right unlocker by comparing metadata metadataBytes, err := afero.ReadFile(cli.fs, metadataPath) if err != nil { - continue // FIXME this error needs to be handled + secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err) + + continue } var diskMetadata secret.UnlockerMetadata if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil { - continue // FIXME this error needs to be handled + secret.Warn("Could not parse unlocker metadata file", "path", metadataPath, "error", err) + + continue } // Match by type and creation time @@ -312,6 +339,7 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error { } else { // Generate ID as fallback properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type) + secret.Warn("Could not create unlocker instance, using fallback ID", "fallback_id", properID, "type", metadata.Type) } unlockerInfo := UnlockerInfo{ @@ -603,12 +631,16 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er // Get the list of unlockers and check if any match the ID unlockers, err := vlt.ListUnlockers() if err != nil { + secret.Warn("Could not list unlockers during duplicate check", "error", err) + return nil // If we can't list unlockers, assume it doesn't exist } // Get vault directory to construct unlocker instances vaultDir, err := vlt.GetDirectory() if err != nil { + secret.Warn("Could not get vault directory during duplicate check", "error", err) + return nil } @@ -618,6 +650,8 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er unlockersDir := filepath.Join(vaultDir, "unlockers.d") files, err := afero.ReadDir(cli.fs, unlockersDir) if err != nil { + secret.Warn("Could not read unlockers directory during duplicate check", "error", err) + continue } @@ -632,11 +666,15 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er // Check if this matches our metadata metadataBytes, err := afero.ReadFile(cli.fs, metadataPath) if err != nil { + secret.Warn("Could not read unlocker metadata during duplicate check", "path", metadataPath, "error", err) + continue } var diskMetadata secret.UnlockerMetadata if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil { + secret.Warn("Could not parse unlocker metadata during duplicate check", "path", metadataPath, "error", err) + continue } diff --git a/internal/cli/vault.go b/internal/cli/vault.go index f070d9c..0ae5f07 100644 --- a/internal/cli/vault.go +++ b/internal/cli/vault.go @@ -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 ", @@ -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 ", @@ -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 ", 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) }, diff --git a/internal/cli/version.go b/internal/cli/version.go index 77f750e..a29fbfa 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -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 { diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go index 2bf687d..2bebc6b 100644 --- a/internal/cli/version_test.go +++ b/internal/cli/version_test.go @@ -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) diff --git a/internal/secret/crypto.go b/internal/secret/crypto.go index cd60b88..1bf20a3 100644 --- a/internal/secret/crypto.go +++ b/internal/secret/crypto.go @@ -68,6 +68,11 @@ func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBu // Create a secure buffer for the decrypted data resultBuffer := memguard.NewBufferFromBytes(result) + // Zero out the original slice to prevent plaintext from lingering in unprotected memory + for i := range result { + result[i] = 0 + } + return resultBuffer, nil } diff --git a/internal/secret/debug.go b/internal/secret/debug.go index e85b8dc..c38d4ad 100644 --- a/internal/secret/debug.go +++ b/internal/secret/debug.go @@ -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 { diff --git a/internal/secret/derivation_index_test.go b/internal/secret/derivation_index_test.go new file mode 100644 index 0000000..ad86553 --- /dev/null +++ b/internal/secret/derivation_index_test.go @@ -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") +} diff --git a/internal/secret/helpers.go b/internal/secret/helpers.go index 26bd7e0..580aba8 100644 --- a/internal/secret/helpers.go +++ b/internal/secret/helpers.go @@ -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 } diff --git a/internal/secret/helpers_test.go b/internal/secret/helpers_test.go new file mode 100644 index 0000000..b989b2d --- /dev/null +++ b/internal/secret/helpers_test.go @@ -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) + } +} diff --git a/internal/secret/keychainunlocker.go b/internal/secret/keychainunlocker.go index c19705a..c544214 100644 --- a/internal/secret/keychainunlocker.go +++ b/internal/secret/keychainunlocker.go @@ -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) } diff --git a/internal/secret/keychainunlocker_stub.go b/internal/secret/keychainunlocker_stub.go index e6c6fb7..6e79370 100644 --- a/internal/secret/keychainunlocker_stub.go +++ b/internal/secret/keychainunlocker_stub.go @@ -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 } diff --git a/internal/secret/pgpunlocker.go b/internal/secret/pgpunlocker.go index 1fedb17..bca1546 100644 --- a/internal/secret/pgpunlocker.go +++ b/internal/secret/pgpunlocker.go @@ -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() diff --git a/internal/secret/secret_test.go b/internal/secret/secret_test.go index c307d80..3639dd2 100644 --- a/internal/secret/secret_test.go +++ b/internal/secret/secret_test.go @@ -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 } diff --git a/internal/secret/version.go b/internal/secret/version.go index 483b993..0525021 100644 --- a/internal/secret/version.go +++ b/internal/secret/version.go @@ -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 } diff --git a/internal/vault/path_traversal_test.go b/internal/vault/path_traversal_test.go new file mode 100644 index 0000000..f244fe1 --- /dev/null +++ b/internal/vault/path_traversal_test.go @@ -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") + }) + } +} diff --git a/internal/vault/secrets.go b/internal/vault/secrets.go index 3452e0d..89184ea 100644 --- a/internal/vault/secrets.go +++ b/internal/vault/secrets.go @@ -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 { diff --git a/internal/vault/secrets_name_test.go b/internal/vault/secrets_name_test.go new file mode 100644 index 0000000..205f8f3 --- /dev/null +++ b/internal/vault/secrets_name_test.go @@ -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) + } + }) + } +} diff --git a/internal/vault/unlockers.go b/internal/vault/unlockers.go index a396879..0faf7eb 100644 --- a/internal/vault/unlockers.go +++ b/internal/vault/unlockers.go @@ -218,7 +218,9 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) { return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err) } if !exists { - return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name()) + secret.Warn("Skipping unlocker directory with missing metadata file", "directory", file.Name()) + + continue } metadataBytes, err := afero.ReadFile(v.fs, metadataPath) diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 150a55e..597c85f 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -225,27 +225,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++ } } diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index bed6752..15ac662 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -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") + } + } +}