package cli import ( "encoding/json" "errors" "fmt" "log" "path/filepath" "slices" "strings" "time" "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" ) // Sentinel errors for vault operations var ( errMnemonicEmpty = errors.New("mnemonic cannot be empty") errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase") errInvalidMnemonic = errors.New("invalid BIP39 mnemonic") errVaultHasLongTermKey = errors.New( "already has a long-term key configured") errMnemonicEnvNotSet = errors.New( "SB_SECRET_MNEMONIC environment variable not set") errPassphraseEnvNotSet = errors.New( "SB_UNLOCK_PASSPHRASE environment variable not set") 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(newVaultImportCmd()) 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 newVaultImportCmd() *cobra.Command { cli, err := NewCLIInstance() if err != nil { log.Fatalf("failed to initialize CLI: %v", err) } 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), ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { vaultName := "default" if len(args) > 0 { vaultName = args[0] } cli, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } destroySecrets := cli.readSecretEnv() defer destroySecrets() return cli.VaultImport(cmd, vaultName) }, } } 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 } 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 } cmd.Println(string(jsonBytes)) } else { // Text output cmd.Println("Available vaults:") if len(vaults) == 0 { cmd.Println(" (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 { cmd.Printf(" %s (current)\n", vaultName) } else { cmd.Printf(" %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, fmt.Errorf("failed to read passphrase: %w", 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() 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 - it will handle key derivation internally vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic) 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) // 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 } // 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 } // vaultImportPreflight verifies the vault exists without a long-term key // and returns the vault directory, public key path, and validated mnemonic func (cli *Instance) vaultImportPreflight( vlt *vault.Vault, vaultName string, ) (string, string, string, error) { // Check if vault exists vaultDir, err := vlt.GetDirectory() if err != nil { return "", "", "", err } exists, err := afero.DirExists(cli.fs, vaultDir) if err != nil { return "", "", "", fmt.Errorf("failed to check if vault exists: %w", err) } if !exists { return "", "", "", fmt.Errorf("vault '%s' %w", vaultName, errVaultDoesNotExist) } // Check if vault already has a public key pubKeyPath := vaultDir + "/pub.age" exists, err = afero.Exists(cli.fs, pubKeyPath) if err != nil { return "", "", "", fmt.Errorf("failed to check %s: %w", pubKeyPath, err) } if exists { return "", "", "", fmt.Errorf("vault '%s' %w", vaultName, errVaultHasLongTermKey) } // Get mnemonic from environment if cli.Mnemonic == nil { return "", "", "", errMnemonicEnvNotSet } mnemonic := cli.Mnemonic.String() // Validate the mnemonic mnemonicWords := strings.Fields(mnemonic) secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords)) if !bip39.IsMnemonicValid(mnemonic) { return "", "", "", errInvalidMnemonic } return vaultDir, pubKeyPath, mnemonic, nil } // updateVaultImportMetadata stores the derivation info in vault metadata func updateVaultImportMetadata( fs afero.Fs, vaultDir string, derivationIndex uint32, publicKeyHash, familyHash string, ) error { // Load existing metadata existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir) if err != nil { // If metadata doesn't exist, create new existingMetadata = &vault.Metadata{ CreatedAt: time.Now(), } } // Update metadata with new derivation info existingMetadata.DerivationIndex = derivationIndex existingMetadata.PublicKeyHash = publicKeyHash existingMetadata.MnemonicFamilyHash = familyHash err = vault.SaveVaultMetadata(fs, vaultDir, existingMetadata) if err != nil { secret.Debug("Failed to save vault metadata", "error", err) return fmt.Errorf("failed to save vault metadata: %w", err) } secret.Debug("Saved vault metadata with derivation index and public key hash") return nil } // VaultImport imports a mnemonic into a specific vault, holding the state // directory lock while importMnemonic runs func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error { err := vault.ValidateVaultName(vaultName) if err != nil { return err } release, err := vault.LockStateDir(cli.fs, cli.stateDir) if err != nil { return err } defer release() return cli.importMnemonic(cmd, vaultName) } // importMnemonic gives the vault a long-term key derived from the mnemonic // and a passphrase unlocker func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error { secret.Debug("Importing mnemonic into vault", "vault_name", vaultName, "state_dir", cli.stateDir) // Get the specific vault by name vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName) vaultDir, pubKeyPath, mnemonic, err := cli.vaultImportPreflight(vlt, vaultName) if err != nil { return err } // Get the next available derivation index for this mnemonic derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic) if err != nil { secret.Debug("Failed to get next derivation index", "error", err) return fmt.Errorf("failed to get next derivation index: %w", err) } secret.Debug("Using derivation index", "index", derivationIndex) // Derive long-term key from mnemonic with the appropriate index secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex) ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex) if err != nil { return fmt.Errorf("failed to derive long-term key: %w", err) } // Store long-term public key in vault ltPublicKey := ltIdentity.Recipient().String() secret.Debug("Storing long-term public key", "pubkey", ltPublicKey, "vault_dir", vaultDir) err = secret.WriteFileAtomic(cli.fs, pubKeyPath, []byte(ltPublicKey)) if err != nil { return fmt.Errorf("failed to store long-term public key: %w", err) } // Calculate public key hash from the actual derivation index being used // This is used to verify that the derived key matches what was stored publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String())) // Calculate family hash from index 0 (same for all vaults with this // mnemonic). This is used to identify which vaults belong to the same // mnemonic family. identity0, err := agehd.DeriveIdentity(mnemonic, 0) if err != nil { return fmt.Errorf("failed to derive identity for index 0: %w", err) } familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String())) err = updateVaultImportMetadata( cli.fs, vaultDir, derivationIndex, publicKeyHash, familyHash) if err != nil { return err } // Get passphrase from environment variable passphraseBuffer := cli.UnlockPassphrase if passphraseBuffer == nil { return errPassphraseEnvNotSet } secret.Debug("Using unlock passphrase from environment variable") // Unlock the vault with the derived long-term key vlt.Unlock(ltIdentity) // Create passphrase-protected unlocker secret.Debug("Creating passphrase-protected unlocker") passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer) if err != nil { secret.Debug("Failed to create unlocker", "error", err) return fmt.Errorf("failed to create unlocker: %w", err) } cmd.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) cmd.Printf("Long-term public key: %s\n", ltPublicKey) cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID()) 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, errVaultDoesNotExist) } 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 }