check / check (push) Waiting to run
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. What they checked before asking is checked again under the lock: unlocker add fails if the vault was removed meanwhile and refuses a GPG key added meanwhile. A forced add of a secret that another command created meanwhile lets go of the lock, gets the key, and takes the lock again. secret.CreatePGPUnlocker now takes the vault, already unlocked; SetMnemonic and SetUnlockPassphrase, used only by it, are gone. Not done: secret move between vaults still unlocks both under the lock; the issue does not name it. Model: opus-5-5
466 lines
12 KiB
Go
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
|
|
}
|