Files
secret/internal/cli/vault.go
T
clawbot fac2753819
check / check (push) Waiting to run
Ask for the mnemonic and passphrase before taking the state directory lock (closes #152)
init, vault create, unlocker add, and add, import and generate secret
with --force asked for the mnemonic or passphrase, or unlocked the
vault, while holding the state directory lock, so every other command
that changes anything waited at the prompt. They now ask and unlock
first and lock after. Under the lock, unlocker add fails if the vault
was removed, or created again with another key, and refuses a GPG key
added meanwhile; a forced add of a secret created meanwhile lets go of
the lock, gets the key and locks again. secret.CreatePGPUnlocker takes
the unlocked vault; SetMnemonic and SetUnlockPassphrase are gone.

Not done: secret move between vaults still unlocks both under the lock
(#189).

Model: opus-5-5
2026-10-15 09:02:13 +02:00

466 lines
12 KiB
Go

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.`,
// Cobra checks Args only on a command that has a RunE; without
// one, an unknown subcommand prints this help and exits 0.
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
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 <name>",
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 <name>",
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 <name>",
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. It asks for the mnemonic and the
// passphrase before it takes the state directory lock, so that no other
// command waits while the user answers.
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
// 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()
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Create the vault with its passphrase unlocker. Another command may
// have created it since the check above: vault.CreateVault checks again.
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
}