package cli import ( "encoding/json" "errors" "fmt" "log" "path/filepath" "slices" "strings" "github.com/awnumar/memguard" "github.com/spf13/afero" "github.com/spf13/cobra" "sneak.berlin/go/secret/internal/bip39" "sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/vault" ) // Sentinel errors for vault operations var ( errMnemonicEmpty = errors.New("mnemonic cannot be empty") errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase") errCannotRemoveLastVault = errors.New("cannot remove the last vault") ) func newVaultCmd() *cobra.Command { cmd := &cobra.Command{ Use: "vault", Short: "Manage vaults", Long: `Create, list, and select vaults for organizing secrets.`, } cmd.AddCommand(newVaultListCmd()) cmd.AddCommand(newVaultCreateCmd()) cmd.AddCommand(newVaultSelectCmd()) cmd.AddCommand(newVaultRemoveCmd()) return cmd } func newVaultListCmd() *cobra.Command { cmd := &cobra.Command{ Use: cmdUseList, Aliases: []string{"ls"}, Short: "List available vaults", RunE: func(cmd *cobra.Command, _ []string) error { jsonOutput, _ := cmd.Flags().GetBool("json") cli, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } return cli.ListVaults(cmd, jsonOutput) }, } cmd.Flags().Bool("json", false, "Output in JSON format") return cmd } func newVaultCreateCmd() *cobra.Command { return &cobra.Command{ Use: "create ", Short: "Create a new vault", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cli, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } destroySecrets := cli.readSecretEnv() defer destroySecrets() return cli.CreateVault(cmd, args[0]) }, } } func newVaultSelectCmd() *cobra.Command { cli, err := NewCLIInstance() if err != nil { log.Fatalf("failed to initialize CLI: %v", err) } return &cobra.Command{ 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, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } return cli.SelectVault(cmd, args[0]) }, } } func newVaultRemoveCmd() *cobra.Command { cli, err := NewCLIInstance() if err != nil { log.Fatalf("failed to initialize CLI: %v", err) } cmd := &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, Short: "Remove a vault", Long: `Remove a vault and all its secrets. Asks for ` + `confirmation first, naming how many secrets the vault ` + `holds; when stdin is not a terminal, fails unless --force ` + `is given. Will automatically switch to another vault if ` + `removing the currently selected one. The last vault ` + `cannot be removed.`, Args: cobra.ExactArgs(1), ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { force, _ := cmd.Flags().GetBool("force") cli, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } return cli.RemoveVault(cmd, args[0], force) }, } cmd.Flags().BoolP("force", "f", false, "Remove without asking for confirmation, even a vault that contains secrets") return cmd } // ListVaults lists all available vaults func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error { vaults, err := vault.ListVaults(cli.fs, cli.stateDir) if err != nil { return err } out := cmd.OutOrStdout() if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic // Get current vault name for context currentVault := "" currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err == nil { currentVault = currentVlt.GetName() } result := map[string]any{ "vaults": vaults, "currentVault": currentVault, } jsonBytes, err := json.MarshalIndent(result, "", " ") if err != nil { return err } _, _ = fmt.Fprintln(out, string(jsonBytes)) } else { // Text output _, _ = fmt.Fprintln(out, "Available vaults:") if len(vaults) == 0 { _, _ = fmt.Fprintln(out, " (none)") } else { // Try to get current vault for marking currentVault := "" currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err == nil { currentVault = currentVlt.GetName() } for _, vaultName := range vaults { if vaultName == currentVault { _, _ = fmt.Fprintf(out, " %s (current)\n", vaultName) } else { _, _ = fmt.Fprintf(out, " %s\n", vaultName) } } } } return nil } // resolvePassphrase returns the unlock passphrase from the environment, // cli.UnlockPassphrase, or prompts the user for it with confirmation. The // returned cleanup function must be deferred by the caller. func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) { if cli.UnlockPassphrase != nil { secret.Debug("Using unlock passphrase from environment variable") return cli.UnlockPassphrase, func() {}, nil } secret.Debug("Prompting user for unlock passphrase") // Use secure passphrase input with confirmation passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ") if err != nil { return nil, nil, err } return passphraseBuffer, passphraseBuffer.Destroy, nil } // CreateVault creates a new vault func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error { secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir) release, err := vault.LockStateDir(cli.fs, cli.stateDir) if err != nil { return err } defer release() // Refuse an invalid name or an existing vault before asking for anything err = vault.CheckNewVaultName(cli.fs, cli.stateDir, name) if err != nil { return err } mnemonic, cleanupMnemonic, err := cli.promptMnemonic() if err != nil { return err } defer cleanupMnemonic() mnemonicStr := mnemonic.String() if mnemonicStr == "" { return errMnemonicEmpty } // Validate the mnemonic mnemonicWords := strings.Fields(mnemonicStr) secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords)) if !bip39.IsMnemonicValid(mnemonicStr) { return errInvalidMnemonicPhrase } // Ask for the unlocker passphrase before creating the vault, so that // stopping at the prompt leaves no vault without an unlocker behind passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase() if err != nil { return err } defer cleanupPassphrase() // Create the vault with its passphrase unlocker vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic, passphraseBuffer) if err != nil { return err } ltIdentity, err := vlt.GetOrDeriveLongTermKey() if err != nil { return fmt.Errorf("failed to get long-term key: %w", err) } unlocker, err := vlt.GetCurrentUnlocker() if err != nil { return 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", unlocker.GetID()) return nil } // SelectVault selects a vault as the current one func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error { release, err := vault.LockStateDir(cli.fs, cli.stateDir) if err != nil { return err } defer release() err = vault.SelectVault(cli.fs, cli.stateDir, name) if err != nil { return err } cmd.Printf("Selected vault '%s' as current\n", name) return nil } // countVaultSecrets returns the number of secrets in the vault directory func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) { secretsDir := filepath.Join(vaultDir, "secrets.d") exists, err := afero.DirExists(cli.fs, secretsDir) if err != nil { return 0, fmt.Errorf("failed to check secrets directory %s: %w", secretsDir, err) } if !exists { return 0, nil } entries, err := afero.ReadDir(cli.fs, secretsDir) if err != nil { return 0, fmt.Errorf("failed to read secrets directory %s: %w", secretsDir, err) } return len(entries), nil } // switchAwayFromVault selects another vault as current before removal func (cli *Instance) switchAwayFromVault( cmd *cobra.Command, vaults []string, name string, ) error { // Find another vault to switch to var newVault string for _, v := range vaults { if v != name { newVault = v break } } // Switch to the new vault err := vault.SelectVault(cli.fs, cli.stateDir, newVault) if err != nil { return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err) } cmd.Printf("Switched current vault to '%s'\n", newVault) return nil } // RemoveVault removes a vault and all its secrets, after asking the user // to confirm unless force is set. func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error { err := vault.ValidateVaultName(name) if err != nil { return err } var found vaultToRemove release, err := cli.askThenLock(cmd, force, func() (string, error) { var err error found, err = cli.findVaultToRemove(name) return found.question, err }) if err != nil { return err } defer release() // If removing current vault, switch to another vault first if found.isCurrent { err = cli.switchAwayFromVault(cmd, found.vaults, name) if err != nil { return err } } // Remove the vault directory err = secret.RemoveDirAtomic(cli.fs, found.dir) if err != nil { return fmt.Errorf("failed to remove vault directory: %w", err) } cmd.Printf("Removed vault '%s'\n", name) if found.secrets > 0 { cmd.Printf("Warning: Vault contained secrets that have been " + "permanently deleted\n") } return nil } // vaultToRemove is what removing a vault removes, as findVaultToRemove // found it. type vaultToRemove struct { // dir is the vault's directory, which holds all its secrets. dir string secrets int // vaults lists every vault, this one included, and isCurrent is set // when this one is the current vault. vaults []string isCurrent bool // question names what is removed, for the user to confirm. question string } // findVaultToRemove checks that the vault exists and is not the last one, // and counts its secrets. func (cli *Instance) findVaultToRemove(name string) (vaultToRemove, error) { vaults, err := vault.ListVaults(cli.fs, cli.stateDir) if err != nil { return vaultToRemove{}, fmt.Errorf("failed to list vaults: %w", err) } if !slices.Contains(vaults, name) { return vaultToRemove{}, fmt.Errorf("vault '%s' %w", name, vault.ErrVaultNotFound) } if len(vaults) == 1 { return vaultToRemove{}, errCannotRemoveLastVault } currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { return vaultToRemove{}, fmt.Errorf("failed to get current vault: %w", err) } vaultDir, err := vault.NewVault(cli.fs, cli.stateDir, name).GetDirectory() if err != nil { return vaultToRemove{}, fmt.Errorf("failed to get vault directory: %w", err) } secrets, err := cli.countVaultSecrets(vaultDir) if err != nil { return vaultToRemove{}, err } return vaultToRemove{ dir: vaultDir, secrets: secrets, vaults: vaults, isCurrent: currentVault.GetName() == name, question: fmt.Sprintf( "Permanently remove vault '%s' and its %d secret(s)?", name, secrets), }, nil }