diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..5178631 --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,64 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newCompletionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate completion script", + Long: `To load completions: + +Bash: + $ source <(secret completion bash) + # To load completions for each session, execute once: + # Linux: + $ secret completion bash > /etc/bash_completion.d/secret + # macOS: + $ secret completion bash > $(brew --prefix)/etc/bash_completion.d/secret + +Zsh: + # If shell completion is not already enabled in your environment, + # you will need to enable it. You can execute the following once: + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + + # To load completions for each session, execute once: + $ secret completion zsh > "${fpath[1]}/_secret" + # You will need to start a new shell for this setup to take effect. + +Fish: + $ secret completion fish | source + # To load completions for each session, execute once: + $ secret completion fish > ~/.config/fish/completions/secret.fish + +PowerShell: + PS> secret completion powershell | Out-String | Invoke-Expression + # To load completions for every new session, run: + PS> secret completion powershell > secret.ps1 + # and source this file from your PowerShell profile. +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + default: + return fmt.Errorf("unsupported shell type: %s", args[0]) + } + }, + } + + return cmd +} diff --git a/internal/cli/completions.go b/internal/cli/completions.go new file mode 100644 index 0000000..a10bf3f --- /dev/null +++ b/internal/cli/completions.go @@ -0,0 +1,144 @@ +package cli + +import ( + "encoding/json" + "path/filepath" + "strings" + + "git.eeqj.de/sneak/secret/internal/secret" + "git.eeqj.de/sneak/secret/internal/vault" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +// getSecretNamesCompletionFunc returns a completion function that provides secret names +func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func( + cmd *cobra.Command, args []string, toComplete string, +) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Get current vault + vlt, err := vault.GetCurrentVault(fs, stateDir) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Get list of secrets + secrets, err := vlt.ListSecrets() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Filter secrets based on what user has typed + var completions []string + for _, secret := range secrets { + if strings.HasPrefix(secret, toComplete) { + completions = append(completions, secret) + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +// getUnlockerIDsCompletionFunc returns a completion function that provides unlocker IDs +func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func( + cmd *cobra.Command, args []string, toComplete string, +) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Get current vault + vlt, err := vault.GetCurrentVault(fs, stateDir) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Get unlocker metadata list + unlockerMetadataList, err := vlt.ListUnlockers() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Get vault directory + vaultDir, err := vlt.GetDirectory() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Collect unlocker IDs + var completions []string + + for _, metadata := range unlockerMetadataList { + // Get the actual unlocker ID by creating the unlocker instance + unlockersDir := filepath.Join(vaultDir, "unlockers.d") + files, err := afero.ReadDir(fs, unlockersDir) + if err != nil { + continue + } + + for _, file := range files { + if !file.IsDir() { + continue + } + + unlockerDir := filepath.Join(unlockersDir, file.Name()) + metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json") + + // Check if this is the right unlocker by comparing metadata + metadataBytes, err := afero.ReadFile(fs, metadataPath) + if err != nil { + continue + } + + var diskMetadata secret.UnlockerMetadata + if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil { + continue + } + + // Match by type and creation time + if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) { + // Create the appropriate unlocker instance + var unlocker secret.Unlocker + switch metadata.Type { + case "passphrase": + unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, diskMetadata) + case "keychain": + unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, diskMetadata) + case "pgp": + unlocker = secret.NewPGPUnlocker(fs, unlockerDir, diskMetadata) + } + + if unlocker != nil { + id := unlocker.GetID() + if strings.HasPrefix(id, toComplete) { + completions = append(completions, id) + } + } + + break + } + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +// getVaultNamesCompletionFunc returns a completion function that provides vault names +func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func( + cmd *cobra.Command, args []string, toComplete string, +) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + vaults, err := vault.ListVaults(fs, stateDir) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var completions []string + for _, v := range vaults { + if strings.HasPrefix(v, toComplete) { + completions = append(completions, v) + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 96b56dc..e8856b4 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -18,6 +18,11 @@ import ( "github.com/stretchr/testify/require" ) +const ( + // testMnemonic is a standard BIP39 mnemonic used for testing + testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +) + // TestMain runs before all tests and ensures the binary is built func TestMain(m *testing.M) { // Get the current working directory @@ -60,7 +65,6 @@ func TestSecretManagerIntegration(t *testing.T) { } // Test configuration - testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" testPassphrase := "test-passphrase-123" // Create a temporary directory for our vault @@ -125,7 +129,8 @@ func TestSecretManagerIntegration(t *testing.T) { // - work vault has pub.age file // - work vault has unlockers.d/passphrase directory // - Unlocker metadata and encrypted keys present - test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv) + // NOTE: Skipped because vault creation now includes mnemonic import + // test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv) // Test 5: Add secrets with versioning // Command: echo "password123" | secret add database/password @@ -452,6 +457,12 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) { } func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) { + // Set environment variables for vault creation + os.Setenv("SB_SECRET_MNEMONIC", testMnemonic) + os.Setenv("SB_UNLOCK_PASSPHRASE", "test-passphrase") + defer os.Unsetenv("SB_SECRET_MNEMONIC") + defer os.Unsetenv("SB_UNLOCK_PASSPHRASE") + // Create work vault output, err := runSecret("vault", "create", "work") require.NoError(t, err, "vault create should succeed") @@ -480,9 +491,9 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) ( secretsDir := filepath.Join(workVaultDir, "secrets.d") verifyFileExists(t, secretsDir) - // Verify that work vault does NOT have a long-term key yet (no mnemonic imported) + // Verify that work vault has a long-term key (mnemonic was provided) pubKeyFile := filepath.Join(workVaultDir, "pub.age") - verifyFileNotExists(t, pubKeyFile) + verifyFileExists(t, pubKeyFile) // List vaults to verify both exist output, err = runSecret("vault", "list") diff --git a/internal/cli/root.go b/internal/cli/root.go index 4ae59ae..a0aad85 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -42,6 +42,7 @@ func newRootCmd() *cobra.Command { cmd.AddCommand(newDecryptCmd()) cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newInfoCmd()) + cmd.AddCommand(newCompletionCmd()) secret.Debug("newRootCmd completed") diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index c3c1042..6af444f 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -39,10 +39,12 @@ func newAddCmd() *cobra.Command { } func newGetCmd() *cobra.Command { + cli := NewCLIInstance() cmd := &cobra.Command{ - Use: "get ", - Short: "Retrieve a secret from the vault", - Args: cobra.ExactArgs(1), + Use: "get ", + Short: "Retrieve a secret from the vault", + Args: cobra.ExactArgs(1), + ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { version, _ := cmd.Flags().GetString("version") cli := NewCLIInstance() @@ -108,13 +110,15 @@ func newImportCmd() *cobra.Command { } func newRemoveCmd() *cobra.Command { + cli := NewCLIInstance() cmd := &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, Short: "Remove a secret from the vault", Long: `Remove a secret and all its versions from the current vault. This action is permanent and ` + `cannot be undone.`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), + ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { cli := NewCLIInstance() @@ -126,6 +130,7 @@ func newRemoveCmd() *cobra.Command { } func newMoveCmd() *cobra.Command { + cli := NewCLIInstance() cmd := &cobra.Command{ Use: "move ", Aliases: []string{"mv", "rename"}, @@ -133,6 +138,14 @@ func newMoveCmd() *cobra.Command { Long: `Move or rename a secret within the current vault. ` + `If the destination already exists, the operation will fail.`, Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Only complete the first argument (source) + if len(args) == 0 { + return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + }, RunE: func(cmd *cobra.Command, args []string) error { cli := NewCLIInstance() @@ -360,20 +373,21 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu return fmt.Errorf("failed to marshal JSON: %w", err) } - cmd.Println(string(jsonBytes)) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes)) } else if quietOutput { // Quiet output - just secret names for _, secretName := range filteredSecrets { - cmd.Println(secretName) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName) } } else { // Pretty table output + out := cmd.OutOrStdout() if len(filteredSecrets) == 0 { if filter != "" { - cmd.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter) + _, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter) } else { - cmd.Println("No secrets found in current vault.") - cmd.Println("Run 'secret add ' to create one.") + _, _ = fmt.Fprintln(out, "No secrets found in current vault.") + _, _ = fmt.Fprintln(out, "Run 'secret add ' to create one.") } return nil @@ -381,12 +395,25 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu // Get current vault name for display if filter != "" { - cmd.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter) + _, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter) } else { - cmd.Printf("Secrets in vault '%s':\n\n", vlt.GetName()) + _, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName()) } - cmd.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED") - cmd.Printf("%-40s %-20s\n", "----", "------------") + + // Calculate the maximum name length for proper column alignment + maxNameLen := len("NAME") // Start with header length + for _, secretName := range filteredSecrets { + if len(secretName) > maxNameLen { + maxNameLen = len(secretName) + } + } + // Add some padding + maxNameLen += 2 + + // Print headers with dynamic width + nameFormat := fmt.Sprintf("%%-%ds", maxNameLen) + _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED") + _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", strings.Repeat("-", len("NAME")), "------------") for _, secretName := range filteredSecrets { lastUpdated := "unknown" @@ -394,14 +421,14 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu metadata := secretObj.GetMetadata() lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04") } - cmd.Printf("%-40s %-20s\n", secretName, lastUpdated) + _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated) } - cmd.Printf("\nTotal: %d secret(s)", len(filteredSecrets)) + _, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets)) if filter != "" { - cmd.Printf(" (filtered from %d)", len(secrets)) + _, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets)) } - cmd.Println() + _, _ = fmt.Fprintln(out) } return nil diff --git a/internal/cli/unlockers.go b/internal/cli/unlockers.go index 144fdd5..c3c784e 100644 --- a/internal/cli/unlockers.go +++ b/internal/cli/unlockers.go @@ -17,6 +17,23 @@ import ( "github.com/spf13/cobra" ) +// UnlockerInfo represents unlocker information for display +type UnlockerInfo struct { + ID string `json:"id"` + Type string `json:"type"` + CreatedAt time.Time `json:"createdAt"` + Flags []string `json:"flags,omitempty"` + IsCurrent bool `json:"isCurrent"` +} + +// Table formatting constants +const ( + unlockerIDWidth = 40 + unlockerTypeWidth = 12 + unlockerDateWidth = 20 + unlockerFlagsWidth = 20 +) + // getDefaultGPGKey returns the default GPG key ID if available func getDefaultGPGKey() (string, error) { // First try to get the configured default key using gpgconf @@ -94,22 +111,66 @@ func newUnlockerListCmd() *cobra.Command { func newUnlockerAddCmd() *cobra.Command { // Build the supported types list based on platform supportedTypes := "passphrase, pgp" + typeDescriptions := `Available unlocker types: + + passphrase - Traditional password-based encryption + Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key. + The passphrase is never stored in plaintext. + + pgp - GNU Privacy Guard (GPG) key-based encryption + Uses your existing GPG key to encrypt/decrypt the vault's master key. + Requires gpg to be installed and configured with at least one secret key. + Use --keyid to specify a particular key, otherwise uses your default GPG key.` + if runtime.GOOS == "darwin" { supportedTypes = "passphrase, keychain, pgp" + typeDescriptions = `Available unlocker types: + + passphrase - Traditional password-based encryption + Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key. + The passphrase is never stored in plaintext. + + keychain - macOS Keychain integration (macOS only) + Stores the vault's master key in the macOS Keychain, protected by your login password. + Automatically unlocks when your Keychain is unlocked (e.g., after login). + Provides seamless integration with macOS security features like Touch ID. + + pgp - GNU Privacy Guard (GPG) key-based encryption + Uses your existing GPG key to encrypt/decrypt the vault's master key. + Requires gpg to be installed and configured with at least one secret key. + Use --keyid to specify a particular key, otherwise uses your default GPG key.` } cmd := &cobra.Command{ Use: "add ", Short: "Add a new unlocker", - Long: fmt.Sprintf(`Add a new unlocker of the specified type (%s). + Long: fmt.Sprintf(`Add a new unlocker to the current vault. -For PGP unlockers, you can optionally specify a GPG key ID with --keyid. -If not specified, the default GPG key will be used.`, supportedTypes), - Args: cobra.ExactArgs(1), +%s + +Each vault can have multiple unlockers, allowing different authentication methods +to access the same vault. This provides flexibility and backup access options.`, typeDescriptions), + Args: cobra.ExactArgs(1), + ValidArgs: strings.Split(supportedTypes, ", "), RunE: func(cmd *cobra.Command, args []string) error { cli := NewCLIInstance() unlockerType := args[0] + // Validate unlocker type + validTypes := strings.Split(supportedTypes, ", ") + valid := false + for _, t := range validTypes { + if unlockerType == t { + valid = true + + break + } + } + if !valid { + return fmt.Errorf("invalid unlocker type '%s'\n\nSupported types: %s\n\n"+ + "Run 'secret unlocker add --help' for detailed descriptions", unlockerType, supportedTypes) + } + // Check if --keyid was used with non-PGP type if unlockerType != "pgp" && cmd.Flags().Changed("keyid") { return fmt.Errorf("--keyid flag is only valid for PGP unlockers") @@ -125,6 +186,7 @@ If not specified, the default GPG key will be used.`, supportedTypes), } func newUnlockerRemoveCmd() *cobra.Command { + cli := NewCLIInstance() cmd := &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, @@ -132,7 +194,8 @@ func newUnlockerRemoveCmd() *cobra.Command { Long: `Remove an unlocker from the current vault. Cannot remove the last unlocker if the vault has ` + `secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` + `will be permanently inaccessible.`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), + ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { force, _ := cmd.Flags().GetBool("force") cli := NewCLIInstance() @@ -147,10 +210,13 @@ func newUnlockerRemoveCmd() *cobra.Command { } func newUnlockerSelectCmd() *cobra.Command { + cli := NewCLIInstance() + return &cobra.Command{ - Use: "select ", - Short: "Select an unlocker as current", - Args: cobra.ExactArgs(1), + Use: "select ", + Short: "Select an unlocker as current", + Args: cobra.ExactArgs(1), + ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir), RunE: func(_ *cobra.Command, args []string) error { cli := NewCLIInstance() @@ -167,6 +233,13 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error { return err } + // Get the current unlocker ID + var currentUnlockerID string + currentUnlocker, err := vlt.GetCurrentUnlocker() + if err == nil { + currentUnlockerID = currentUnlocker.GetID() + } + // Get the metadata first unlockerMetadataList, err := vlt.ListUnlockers() if err != nil { @@ -174,13 +247,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error { } // Load actual unlocker objects to get the proper IDs - type UnlockerInfo struct { - ID string `json:"id"` - Type string `json:"type"` - CreatedAt time.Time `json:"createdAt"` - Flags []string `json:"flags,omitempty"` - } - var unlockers []UnlockerInfo for _, metadata := range unlockerMetadataList { // Create unlocker instance to get the proper ID @@ -246,49 +312,68 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error { Type: metadata.Type, CreatedAt: metadata.CreatedAt, Flags: metadata.Flags, + IsCurrent: properID == currentUnlockerID, } unlockers = append(unlockers, unlockerInfo) } if jsonOutput { - // JSON output - output := map[string]interface{}{ - "unlockers": unlockers, - } - - jsonBytes, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - cli.cmd.Println(string(jsonBytes)) - } else { - // Pretty table output - if len(unlockers) == 0 { - cli.cmd.Println("No unlockers found in current vault.") - cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.") - - return nil - } - - cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS") - cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----") - - for _, unlocker := range unlockers { - flags := "" - if len(unlocker.Flags) > 0 { - flags = strings.Join(unlocker.Flags, ",") - } - cli.cmd.Printf("%-18s %-12s %-20s %s\n", - unlocker.ID, - unlocker.Type, - unlocker.CreatedAt.Format("2006-01-02 15:04:05"), - flags) - } - - cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers)) + return cli.printUnlockersJSON(unlockers, currentUnlockerID) } + return cli.printUnlockersTable(unlockers) +} + +// printUnlockersJSON prints unlockers in JSON format +func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error { + output := map[string]interface{}{ + "unlockers": unlockers, + "currentUnlockerID": currentUnlockerID, + } + + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + cli.cmd.Println(string(jsonBytes)) + + return nil +} + +// printUnlockersTable prints unlockers in a formatted table +func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error { + if len(unlockers) == 0 { + cli.cmd.Println("No unlockers found in current vault.") + cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.") + + return nil + } + + cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS") + cli.cmd.Printf(" %-40s %-12s %-20s %s\n", + strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth), + strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth)) + + for _, unlocker := range unlockers { + flags := "" + if len(unlocker.Flags) > 0 { + flags = strings.Join(unlocker.Flags, ",") + } + prefix := " " + if unlocker.IsCurrent { + prefix = "* " + } + cli.cmd.Printf("%s%-40s %-12s %-20s %s\n", + prefix, + unlocker.ID, + unlocker.Type, + unlocker.CreatedAt.Format("2006-01-02 15:04:05"), + flags) + } + + cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers)) + return nil } @@ -331,6 +416,13 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID()) + // Auto-select the newly created unlocker + if err := vlt.SelectUnlocker(passphraseUnlocker.GetID()); err != nil { + cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err) + } else { + cmd.Printf("Automatically selected as current unlocker\n") + } + return nil case "keychain": @@ -348,6 +440,17 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error cmd.Printf("Keychain Item Name: %s\n", keyName) } + // Auto-select the newly created unlocker + vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return fmt.Errorf("failed to get current vault: %w", err) + } + if err := vlt.SelectUnlocker(keychainUnlocker.GetID()); err != nil { + cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err) + } else { + cmd.Printf("Automatically selected as current unlocker\n") + } + return nil case "pgp": @@ -393,6 +496,13 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID()) cmd.Printf("GPG Key ID: %s\n", gpgKeyID) + // Auto-select the newly created unlocker + if err := vlt.SelectUnlocker(pgpUnlocker.GetID()); err != nil { + cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err) + } else { + cmd.Printf("Automatically selected as current unlocker\n") + } + return nil default: diff --git a/internal/cli/vault.go b/internal/cli/vault.go index 9e60758..f070d9c 100644 --- a/internal/cli/vault.go +++ b/internal/cli/vault.go @@ -66,10 +66,13 @@ func newVaultCreateCmd() *cobra.Command { } func newVaultSelectCmd() *cobra.Command { + cli := NewCLIInstance() + return &cobra.Command{ - Use: "select ", - Short: "Select a vault as current", - Args: cobra.ExactArgs(1), + Use: "select ", + Short: "Select a vault as current", + Args: cobra.ExactArgs(1), + ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { cli := NewCLIInstance() @@ -79,11 +82,14 @@ func newVaultSelectCmd() *cobra.Command { } func newVaultImportCmd() *cobra.Command { + cli := NewCLIInstance() + return &cobra.Command{ - Use: "import ", - Short: "Import a mnemonic into a vault", - Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`, - Args: cobra.MaximumNArgs(1), + Use: "import ", + Short: "Import a mnemonic into a vault", + Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { vaultName := "default" if len(args) > 0 { @@ -98,13 +104,15 @@ func newVaultImportCmd() *cobra.Command { } func newVaultRemoveCmd() *cobra.Command { + cli := NewCLIInstance() cmd := &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, Short: "Remove a vault", Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` + `switch to another vault if removing the currently selected one.`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), + ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { force, _ := cmd.Flags().GetBool("force") cli := NewCLIInstance() @@ -171,12 +179,95 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error { func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error { secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir) + // Get or prompt for mnemonic + var mnemonicStr string + if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" { + secret.Debug("Using mnemonic from environment variable") + mnemonicStr = envMnemonic + } else { + secret.Debug("Prompting user for mnemonic phrase") + // Read mnemonic securely without echo + mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ") + if err != nil { + secret.Debug("Failed to read mnemonic from stdin", "error", err) + + return fmt.Errorf("failed to read mnemonic: %w", err) + } + defer mnemonicBuffer.Destroy() + + mnemonicStr = mnemonicBuffer.String() + fmt.Fprintln(os.Stderr) // Add newline after hidden input + } + + if mnemonicStr == "" { + return fmt.Errorf("mnemonic cannot be empty") + } + + // Validate the mnemonic + mnemonicWords := strings.Fields(mnemonicStr) + secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords)) + if !bip39.IsMnemonicValid(mnemonicStr) { + return fmt.Errorf("invalid BIP39 mnemonic phrase") + } + + // Set mnemonic in environment for CreateVault to use + originalMnemonic := os.Getenv(secret.EnvMnemonic) + _ = os.Setenv(secret.EnvMnemonic, mnemonicStr) + defer func() { + if originalMnemonic != "" { + _ = os.Setenv(secret.EnvMnemonic, originalMnemonic) + } else { + _ = os.Unsetenv(secret.EnvMnemonic) + } + }() + + // Create the vault - it will handle key derivation internally vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name) if err != nil { return err } + // Get the vault metadata to retrieve the derivation index + vaultDir := filepath.Join(cli.stateDir, "vaults.d", name) + metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir) + if err != nil { + return fmt.Errorf("failed to load vault metadata: %w", err) + } + + // Derive the long-term key using the same index that CreateVault used + ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex) + if err != nil { + return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) + } + + // Unlock the vault with the derived long-term key + vlt.Unlock(ltIdentity) + + // Get or prompt for passphrase + var passphraseBuffer *memguard.LockedBuffer + if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" { + secret.Debug("Using unlock passphrase from environment variable") + passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase)) + } else { + secret.Debug("Prompting user for unlock passphrase") + // Use secure passphrase input with confirmation + passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ") + if err != nil { + return fmt.Errorf("failed to read passphrase: %w", err) + } + } + defer passphraseBuffer.Destroy() + + // Create passphrase-protected unlocker + secret.Debug("Creating passphrase-protected unlocker") + passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer) + if err != nil { + return fmt.Errorf("failed to create unlocker: %w", err) + } + cmd.Printf("Created vault '%s'\n", vlt.GetName()) + cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String()) + cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID()) return nil } diff --git a/internal/cli/version.go b/internal/cli/version.go index c2f83ec..77f750e 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -33,10 +33,11 @@ func VersionCommands(cli *Instance) *cobra.Command { // List versions command listCmd := &cobra.Command{ - Use: "list ", - Aliases: []string{"ls"}, - Short: "List all versions of a secret", - Args: cobra.ExactArgs(1), + Use: "list ", + Aliases: []string{"ls"}, + Short: "List all versions of a secret", + Args: cobra.ExactArgs(1), + ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { return cli.ListVersions(cmd, args[0]) }, @@ -48,6 +49,14 @@ func VersionCommands(cli *Instance) *cobra.Command { Short: "Promote a specific version to current", Long: "Updates the current symlink to point to the specified version without modifying timestamps", Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Complete secret name for first arg + if len(args) == 0 { + return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete) + } + // TODO: Complete version numbers for second arg + return nil, cobra.ShellCompDirectiveNoFileComp + }, RunE: func(cmd *cobra.Command, args []string) error { return cli.PromoteVersion(cmd, args[0], args[1]) }, @@ -60,6 +69,14 @@ func VersionCommands(cli *Instance) *cobra.Command { Short: "Remove a specific version of a secret", Long: "Remove a specific version of a secret. Cannot remove the current version.", Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Complete secret name for first arg + if len(args) == 0 { + return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete) + } + // TODO: Complete version numbers for second arg + return nil, cobra.ShellCompDirectiveNoFileComp + }, RunE: func(cmd *cobra.Command, args []string) error { return cli.RemoveVersion(cmd, args[0], args[1]) }, diff --git a/internal/secret/keychainunlocker.go b/internal/secret/keychainunlocker.go index 74accab..8de30de 100644 --- a/internal/secret/keychainunlocker.go +++ b/internal/secret/keychainunlocker.go @@ -161,15 +161,18 @@ func (k *KeychainUnlocker) GetDirectory() string { // GetID implements Unlocker interface - generates ID from keychain item name func (k *KeychainUnlocker) GetID() string { - // Generate ID using keychain item name - keychainItemName, err := k.GetKeychainItemName() + // Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain + // This matches the passphrase unlocker format + hostname, err := os.Hostname() if err != nil { - // The vault metadata is corrupt - this is a fatal error - // We cannot continue with a fallback ID as that would mask data corruption - panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err)) + hostname = "unknown" } - return fmt.Sprintf("%s-keychain", keychainItemName) + // Use the creation timestamp from metadata + createdAt := k.Metadata.CreatedAt + timestamp := createdAt.Format("2006-01-02.15.04") + + return fmt.Sprintf("%s-%s-keychain", timestamp, hostname) } // Remove implements Unlocker interface - removes the keychain unlocker