From bb82d10f9112780f9df08876ab3d06d970a40431 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 29 May 2025 05:59:29 -0700 Subject: [PATCH] fix: enable cobra usage printing after errors - Set SilenceUsage and SilenceErrors to false in root command - Addresses critical TODO item for better error handling - Users will now see command usage when commands fail --- internal/secret/cli.go | 1499 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1499 insertions(+) create mode 100644 internal/secret/cli.go diff --git a/internal/secret/cli.go b/internal/secret/cli.go new file mode 100644 index 0000000..8b00ceb --- /dev/null +++ b/internal/secret/cli.go @@ -0,0 +1,1499 @@ +package secret + +import ( + "bufio" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "filippo.io/age" + "git.eeqj.de/sneak/secret/pkg/agehd" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/tyler-smith/go-bip39" + "golang.org/x/term" +) + +const ( + // AppID is the unique identifier for this application + AppID = "berlin.sneak.pkg.secret" + + // Environment variable names + EnvStateDir = "SB_SECRET_STATE_DIR" + EnvMnemonic = "SB_SECRET_MNEMONIC" + EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" + EnvGPGKeyID = "SB_GPG_KEY_ID" +) + +// Global scanner for consistent stdin reading +var stdinScanner *bufio.Scanner + +// CLIInstance encapsulates all CLI functionality and state +type CLIInstance struct { + fs afero.Fs + stateDir string +} + +// NewCLIInstance creates a new CLI instance with the real filesystem +func NewCLIInstance() *CLIInstance { + fs := afero.NewOsFs() + stateDir := determineStateDir("") + return &CLIInstance{ + fs: fs, + stateDir: stateDir, + } +} + +// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing) +func NewCLIInstanceWithFs(fs afero.Fs) *CLIInstance { + stateDir := determineStateDir("") + return &CLIInstance{ + fs: fs, + stateDir: stateDir, + } +} + +// NewCLIInstanceWithStateDir creates a new CLI instance with custom state directory (for testing) +func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *CLIInstance { + return &CLIInstance{ + fs: fs, + stateDir: stateDir, + } +} + +// SetFilesystem sets the filesystem for this CLI instance (for testing) +func (cli *CLIInstance) SetFilesystem(fs afero.Fs) { + cli.fs = fs +} + +// SetStateDir sets the state directory for this CLI instance (for testing) +func (cli *CLIInstance) SetStateDir(stateDir string) { + cli.stateDir = stateDir +} + +// GetStateDir returns the state directory for this CLI instance +func (cli *CLIInstance) GetStateDir() string { + return cli.stateDir +} + +// getStdinScanner returns a shared scanner for stdin to avoid buffering issues +func getStdinScanner() *bufio.Scanner { + if stdinScanner == nil { + stdinScanner = bufio.NewScanner(os.Stdin) + } + return stdinScanner +} + +// readLineFromStdin reads a single line from stdin with a prompt +// Uses a shared scanner to avoid buffering issues between multiple calls +func readLineFromStdin(prompt string) (string, error) { + fmt.Print(prompt) + scanner := getStdinScanner() + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("failed to read from stdin: %w", err) + } + return "", fmt.Errorf("failed to read from stdin: EOF") + } + return strings.TrimSpace(scanner.Text()), nil +} + +// CLIEntry is the entry point for the secret CLI application +func CLIEntry() { + cmd := newRootCmd() + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "secret", + Short: "A simple secrets manager", + Long: `A simple secrets manager to store and retrieve sensitive information securely.`, + // Ensure usage is shown after errors + SilenceUsage: false, + SilenceErrors: false, + } + + // Add subcommands + cmd.AddCommand(newInitCmd()) + cmd.AddCommand(newGenerateCmd()) + cmd.AddCommand(newVaultCmd()) + cmd.AddCommand(newAddCmd()) + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newKeysCmd()) + cmd.AddCommand(newKeyCmd()) + cmd.AddCommand(newImportCmd()) + cmd.AddCommand(newEnrollCmd()) + cmd.AddCommand(newEncryptCmd()) + cmd.AddCommand(newDecryptCmd()) + + return cmd +} + +func newInitCmd() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Initialize the secrets manager", + Long: `Create the necessary directory structure for storing secrets and generate encryption keys.`, + RunE: func(cmd *cobra.Command, args []string) error { + cli := NewCLIInstance() + return cli.Init(cmd) + }, + } +} + +func newGenerateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate", + Short: "Generate random data", + Long: `Generate various types of random data including mnemonics and secrets.`, + } + + cmd.AddCommand(newGenerateMnemonicCmd()) + cmd.AddCommand(newGenerateSecretCmd()) + + return cmd +} + +func newGenerateMnemonicCmd() *cobra.Command { + return &cobra.Command{ + Use: "mnemonic", + Short: "Generate a random BIP39 mnemonic phrase", + Long: `Generate a cryptographically secure random BIP39 mnemonic phrase that can be used with 'secret init' or 'secret import'.`, + RunE: func(cmd *cobra.Command, args []string) error { + cli := NewCLIInstance() + return cli.GenerateMnemonic() + }, + } +} + +func newGenerateSecretCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "secret ", + Short: "Generate a random secret and store it in the vault", + Long: `Generate a cryptographically secure random secret and store it in the current vault under the given name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + length, _ := cmd.Flags().GetInt("length") + secretType, _ := cmd.Flags().GetString("type") + force, _ := cmd.Flags().GetBool("force") + + cli := NewCLIInstance() + return cli.GenerateSecret(args[0], length, secretType, force) + }, + } + + cmd.Flags().IntP("length", "l", 16, "Length of the generated secret (default 16)") + cmd.Flags().StringP("type", "t", "base58", "Type of secret to generate (base58, alnum)") + cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret") + + return cmd +} + +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()) + + return cmd +} + +func newVaultListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List available vaults", + RunE: func(cmd *cobra.Command, args []string) error { + jsonOutput, _ := cmd.Flags().GetBool("json") + + cli := NewCLIInstance() + return cli.VaultList(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 := NewCLIInstance() + return cli.VaultCreate(args[0]) + }, + } +} + +func newVaultSelectCmd() *cobra.Command { + return &cobra.Command{ + Use: "select ", + Short: "Select a vault as current", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli := NewCLIInstance() + return cli.VaultSelect(args[0]) + }, + } +} + +func newAddCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a secret to the vault", + Long: `Add a secret to the current vault. The secret value is read from stdin.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + force, _ := cmd.Flags().GetBool("force") + + cli := NewCLIInstance() + return cli.AddSecret(args[0], force) + }, + } + + cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret") + return cmd +} + +func newGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Retrieve a secret from the vault", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli := NewCLIInstance() + return cli.GetSecret(args[0]) + }, + } +} + +func newListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list [filter]", + Aliases: []string{"ls"}, + Short: "List all secrets in the current vault", + Long: `List all secrets in the current vault. Optionally filter by substring match in secret name.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + jsonOutput, _ := cmd.Flags().GetBool("json") + + var filter string + if len(args) > 0 { + filter = args[0] + } + + cli := NewCLIInstance() + return cli.ListSecrets(jsonOutput, filter) + }, + } + + cmd.Flags().Bool("json", false, "Output in JSON format") + return cmd +} + +func newKeysCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "keys", + Short: "Manage unlock keys", + Long: `Create, list, and remove unlock keys for the current vault.`, + } + + cmd.AddCommand(newKeysListCmd()) + cmd.AddCommand(newKeysAddCmd()) + cmd.AddCommand(newKeysRmCmd()) + + return cmd +} + +func newKeysListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List unlock keys in the current vault", + RunE: func(cmd *cobra.Command, args []string) error { + jsonOutput, _ := cmd.Flags().GetBool("json") + + cli := NewCLIInstance() + return cli.KeysList(jsonOutput) + }, + } + + cmd.Flags().Bool("json", false, "Output in JSON format") + return cmd +} + +func newKeysAddCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a new unlock key", + Long: `Add a new unlock key of the specified type (passphrase, macos-sep, pgp).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli := NewCLIInstance() + return cli.KeysAdd(args[0], cmd) + }, + } + + cmd.Flags().String("keyid", "", "GPG key ID for PGP unlock keys") + return cmd +} + +func newKeysRmCmd() *cobra.Command { + return &cobra.Command{ + Use: "rm ", + Short: "Remove an unlock key", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli := NewCLIInstance() + return cli.KeysRemove(args[0]) + }, + } +} + +func newKeyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "key", + Short: "Manage current unlock key", + Long: `Select the current unlock key for operations.`, + } + + cmd.AddCommand(newKeySelectSubCmd()) + + return cmd +} + +func newKeySelectSubCmd() *cobra.Command { + return &cobra.Command{ + Use: "select ", + Short: "Select an unlock key as current", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli := NewCLIInstance() + return cli.KeySelect(args[0]) + }, + } +} + +func newImportCmd() *cobra.Command { + return &cobra.Command{ + Use: "import [vault-name]", + 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), + RunE: func(cmd *cobra.Command, args []string) error { + vaultName := "default" + if len(args) > 0 { + vaultName = args[0] + } + + cli := NewCLIInstance() + return cli.Import(vaultName) + }, + } +} + +func newEnrollCmd() *cobra.Command { + return &cobra.Command{ + Use: "enroll", + Short: "Enroll a macOS Secure Enclave unlock key", + Long: `Enroll a macOS Secure Enclave unlock key that uses Touch ID/Face ID for biometric authentication.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cli := NewCLIInstance() + return cli.Enroll() + }, + } +} + +func newEncryptCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "encrypt ", + Short: "Encrypt data using an age secret key stored in a secret", + Long: `Encrypt data using an age secret key. If the secret doesn't exist, a new age key is generated and stored.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inputFile, _ := cmd.Flags().GetString("input") + outputFile, _ := cmd.Flags().GetString("output") + + cli := NewCLIInstance() + return cli.Encrypt(args[0], inputFile, outputFile) + }, + } + + cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)") + cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)") + return cmd +} + +func newDecryptCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "decrypt ", + Short: "Decrypt data using an age secret key stored in a secret", + Long: `Decrypt data using an age secret key stored in the specified secret.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inputFile, _ := cmd.Flags().GetString("input") + outputFile, _ := cmd.Flags().GetString("output") + + cli := NewCLIInstance() + return cli.Decrypt(args[0], inputFile, outputFile) + }, + } + + cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)") + cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)") + return cmd +} + +// CLI Method Implementations + +// Init initializes the secrets manager +func (cli *CLIInstance) Init(cmd *cobra.Command) error { + // Create state directory + stateDir := cli.GetStateDir() + + if err := cli.fs.MkdirAll(stateDir, 0700); err != nil { + return fmt.Errorf("failed to create state directory: %w", err) + } + + if cmd != nil { + cmd.Printf("Initialized secrets manager at: %s\n", stateDir) + } + + // Prompt for mnemonic + var mnemonicStr string + + if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" { + mnemonicStr = envMnemonic + } else { + // Read mnemonic from stdin using shared line reader + var err error + mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ") + if err != nil { + return fmt.Errorf("failed to read mnemonic: %w", err) + } + } + + if mnemonicStr == "" { + return fmt.Errorf("mnemonic cannot be empty") + } + + // Validate the mnemonic using BIP39 + if !bip39.IsMnemonicValid(mnemonicStr) { + return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic") + } + + // Derive long-term keypair from mnemonic + ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, 0) + if err != nil { + return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) + } + + // Create default vault + _, err = CreateVault(cli.fs, cli.stateDir, "default") + if err != nil { + return fmt.Errorf("failed to create default vault: %w", err) + } + + // Set default vault as current + if err := SelectVault(cli.fs, cli.stateDir, "default"); err != nil { + return fmt.Errorf("failed to select default vault: %w", err) + } + + // Store long-term public key in vault + vaultDir := filepath.Join(stateDir, "vaults.d", "default") + ltPubKey := ltIdentity.Recipient().String() + if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), 0600); err != nil { + return fmt.Errorf("failed to write long-term public key: %w", err) + } + + // Prompt for passphrase for unlock key + var passphraseStr string + if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { + passphraseStr = envPassphrase + } else { + // Use secure passphrase input with confirmation + passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") + if err != nil { + return fmt.Errorf("failed to read passphrase: %w", err) + } + } + + // Create passphrase-protected unlock key + passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) + if err != nil { + return fmt.Errorf("failed to create unlock key: %w", err) + } + + // Encrypt long-term private key to the unlock key + unlockKeyDir := passphraseKey.GetDirectory() + + // Read unlock key public key + unlockPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockKeyDir, "pub.age")) + if err != nil { + return fmt.Errorf("failed to read unlock key public key: %w", err) + } + + unlockRecipient, err := age.ParseX25519Recipient(string(unlockPubKeyData)) + if err != nil { + return fmt.Errorf("failed to parse unlock key public key: %w", err) + } + + // Encrypt long-term private key to unlock key + ltPrivKeyData := []byte(ltIdentity.String()) + encryptedLtPrivKey, err := encryptToRecipient(ltPrivKeyData, unlockRecipient) + if err != nil { + return fmt.Errorf("failed to encrypt long-term private key: %w", err) + } + + // Write encrypted long-term private key + if err := afero.WriteFile(cli.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, 0600); err != nil { + return fmt.Errorf("failed to write encrypted long-term private key: %w", err) + } + + if cmd != nil { + cmd.Printf("\nDefault vault created and configured\n") + cmd.Printf("Long-term public key: %s\n", ltPubKey) + cmd.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID) + cmd.Println("\nYour secret manager is ready to use!") + cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,") + cmd.Println("unlock keys are not required for secret operations.") + } + + return nil +} + +// GenerateMnemonic generates a random BIP39 mnemonic phrase +func (cli *CLIInstance) GenerateMnemonic() error { + // Generate 128 bits of entropy for a 12-word mnemonic + entropy, err := bip39.NewEntropy(128) + if err != nil { + return fmt.Errorf("failed to generate entropy: %w", err) + } + + // Create mnemonic from entropy + mnemonic, err := bip39.NewMnemonic(entropy) + if err != nil { + return fmt.Errorf("failed to generate mnemonic: %w", err) + } + + // Output mnemonic to stdout + fmt.Println(mnemonic) + + // Output helpful information to stderr + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "⚠️ IMPORTANT: Save this mnemonic phrase securely!") + fmt.Fprintln(os.Stderr, " • Write it down on paper and store it safely") + fmt.Fprintln(os.Stderr, " • Do not store it digitally or share it with anyone") + fmt.Fprintln(os.Stderr, " • You will need this phrase to recover your secrets") + fmt.Fprintln(os.Stderr, " • If you lose this phrase, your secrets cannot be recovered") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Use this mnemonic with:") + fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)") + fmt.Fprintln(os.Stderr, " secret import (to import into an existing vault)") + + return nil +} + +// GenerateSecret generates a random secret and stores it in the vault +func (cli *CLIInstance) GenerateSecret(secretName string, length int, secretType string, force bool) error { + if length < 1 { + return fmt.Errorf("length must be at least 1") + } + + var secret string + var err error + + switch secretType { + case "base58": + secret, err = generateRandomBase58(length) + case "alnum": + secret, err = generateRandomAlnum(length) + case "mnemonic": + return fmt.Errorf("mnemonic type not supported for secret generation, use 'secret generate mnemonic' instead") + default: + return fmt.Errorf("unsupported type: %s (supported: base58, alnum)", secretType) + } + + if err != nil { + return fmt.Errorf("failed to generate random secret: %w", err) + } + + // Store the secret in the vault + vault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + if err := vault.AddSecret(secretName, []byte(secret), force); err != nil { + return err + } + + fmt.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName) + return nil +} + +// VaultList lists available vaults +func (cli *CLIInstance) VaultList(jsonOutput bool) error { + vaults, err := ListVaults(cli.fs, cli.stateDir) + if err != nil { + return err + } + + if jsonOutput { + // JSON output + output := map[string]interface{}{ + "vaults": vaults, + } + + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + fmt.Println(string(jsonBytes)) + } else { + // Pretty table output + if len(vaults) == 0 { + fmt.Println("No vaults found.") + fmt.Println("Run 'secret init' to create the default vault.") + return nil + } + + // Get current vault for highlighting + currentVault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + fmt.Printf("%-20s %s\n", "VAULT", "STATUS") + fmt.Printf("%-20s %s\n", "-----", "------") + + for _, vault := range vaults { + fmt.Printf("%-20s %s\n", vault, "") + } + } else { + fmt.Printf("%-20s %s\n", "VAULT", "STATUS") + fmt.Printf("%-20s %s\n", "-----", "------") + + for _, vault := range vaults { + status := "" + if vault == currentVault.Name { + status = "(current)" + } + fmt.Printf("%-20s %s\n", vault, status) + } + } + + fmt.Printf("\nTotal: %d vault(s)\n", len(vaults)) + } + + return nil +} + +// VaultCreate creates a new vault +func (cli *CLIInstance) VaultCreate(name string) error { + _, err := CreateVault(cli.fs, cli.stateDir, name) + return err +} + +// VaultSelect selects a vault as current +func (cli *CLIInstance) VaultSelect(name string) error { + return SelectVault(cli.fs, cli.stateDir, name) +} + +// AddSecret adds a secret to the vault +func (cli *CLIInstance) AddSecret(secretName string, force bool) error { + // Get current vault + vault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + // Read secret value from stdin + value, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read secret from stdin: %w", err) + } + + // Remove trailing newline if present + if len(value) > 0 && value[len(value)-1] == '\n' { + value = value[:len(value)-1] + } + + return vault.AddSecret(secretName, value, force) +} + +// GetSecret retrieves a secret from the vault +func (cli *CLIInstance) GetSecret(secretName string) error { + // Get current vault + vault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + // Get the secret object + secret, err := vault.GetSecretObject(secretName) + if err != nil { + return err + } + + // Get the value using the current unlock key (or mnemonic if available) + var value []byte + if os.Getenv(EnvMnemonic) != "" { + // If mnemonic is available, GetValue can handle it without an unlock key + value, err = secret.GetValue(nil) + } else { + // Get the current unlock key + unlockKey, unlockErr := vault.GetCurrentUnlockKey() + if unlockErr != nil { + return fmt.Errorf("failed to get current unlock key: %w", unlockErr) + } + + value, err = secret.GetValue(unlockKey) + } + + if err != nil { + return err + } + + fmt.Print(string(value)) + return nil +} + +// ListSecrets lists all secrets in the current vault +func (cli *CLIInstance) ListSecrets(jsonOutput bool, filter string) error { + // Get current vault + vault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + secrets, err := vault.ListSecrets() + if err != nil { + return err + } + + // Filter secrets if filter is provided + var filteredSecrets []string + if filter != "" { + for _, secretName := range secrets { + if strings.Contains(secretName, filter) { + filteredSecrets = append(filteredSecrets, secretName) + } + } + } else { + filteredSecrets = secrets + } + + if jsonOutput { + // For JSON output, get metadata for each secret + secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets)) + + for _, secretName := range filteredSecrets { + secretInfo := map[string]interface{}{ + "name": secretName, + } + + // Try to get metadata using GetSecretObject + if secretObj, err := vault.GetSecretObject(secretName); err == nil { + metadata := secretObj.GetMetadata() + secretInfo["created_at"] = metadata.CreatedAt + secretInfo["updated_at"] = metadata.UpdatedAt + } + + secretsWithMetadata = append(secretsWithMetadata, secretInfo) + } + + output := map[string]interface{}{ + "secrets": secretsWithMetadata, + } + if filter != "" { + output["filter"] = filter + } + + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + fmt.Println(string(jsonBytes)) + } else { + // Pretty table output + if len(filteredSecrets) == 0 { + if filter != "" { + fmt.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vault.Name, filter) + } else { + fmt.Println("No secrets found in current vault.") + fmt.Println("Run 'secret add ' to create one.") + } + return nil + } + + // Get current vault name for display + if filter != "" { + fmt.Printf("Secrets in vault '%s' matching '%s':\n\n", vault.Name, filter) + } else { + fmt.Printf("Secrets in vault '%s':\n\n", vault.Name) + } + fmt.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED") + fmt.Printf("%-40s %-20s\n", "----", "------------") + + for _, secretName := range filteredSecrets { + lastUpdated := "unknown" + if secretObj, err := vault.GetSecretObject(secretName); err == nil { + metadata := secretObj.GetMetadata() + lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04") + } + fmt.Printf("%-40s %-20s\n", secretName, lastUpdated) + } + + fmt.Printf("\nTotal: %d secret(s)", len(filteredSecrets)) + if filter != "" { + fmt.Printf(" (filtered from %d)", len(secrets)) + } + fmt.Println() + } + + return nil +} + +// KeysList lists unlock keys in the current vault +func (cli *CLIInstance) KeysList(jsonOutput bool) error { + // Get current vault + vault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + // Get the metadata first + keyMetadataList, err := vault.ListUnlockKeys() + if err != nil { + return err + } + + // Load actual unlock key objects to get the proper IDs + type KeyInfo struct { + ID string `json:"id"` + Type string `json:"type"` + CreatedAt time.Time `json:"created_at"` + Flags []string `json:"flags,omitempty"` + } + + var keys []KeyInfo + for _, metadata := range keyMetadataList { + // Create unlock key instance to get the proper ID + vaultDir, err := vault.GetDirectory() + if err != nil { + continue + } + + // Find the key directory by type and created time + unlockKeysDir := filepath.Join(vaultDir, "unlock.d") + files, err := afero.ReadDir(cli.fs, unlockKeysDir) + if err != nil { + continue + } + + var unlockKey UnlockKey + for _, file := range files { + if !file.IsDir() { + continue + } + + keyDir := filepath.Join(unlockKeysDir, file.Name()) + metadataPath := filepath.Join(keyDir, "unlock-metadata.json") + + // Check if this is the right key by comparing metadata + metadataBytes, err := afero.ReadFile(cli.fs, metadataPath) + if err != nil { + continue + } + + var diskMetadata UnlockKeyMetadata + 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 unlock key instance + switch metadata.Type { + case "passphrase": + unlockKey = NewPassphraseUnlockKey(cli.fs, keyDir, metadata) + case "macos-sep": + unlockKey = NewSEPUnlockKey(cli.fs, keyDir, metadata) + case "pgp": + unlockKey = NewPGPUnlockKey(cli.fs, keyDir, metadata) + } + break + } + } + + // Get the proper ID using the unlock key's ID() method + var properID string + if unlockKey != nil { + properID = unlockKey.ID() + } else { + properID = metadata.ID // fallback to metadata ID + } + + keyInfo := KeyInfo{ + ID: properID, + Type: metadata.Type, + CreatedAt: metadata.CreatedAt, + Flags: metadata.Flags, + } + keys = append(keys, keyInfo) + } + + if jsonOutput { + // JSON output + output := map[string]interface{}{ + "keys": keys, + } + + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + fmt.Println(string(jsonBytes)) + } else { + // Pretty table output + if len(keys) == 0 { + fmt.Println("No unlock keys found in current vault.") + fmt.Println("Run 'secret keys add passphrase' to create one.") + return nil + } + + fmt.Printf("%-18s %-12s %-20s %s\n", "KEY ID", "TYPE", "CREATED", "FLAGS") + fmt.Printf("%-18s %-12s %-20s %s\n", "------", "----", "-------", "-----") + + for _, key := range keys { + flags := "" + if len(key.Flags) > 0 { + flags = strings.Join(key.Flags, ",") + } + fmt.Printf("%-18s %-12s %-20s %s\n", + key.ID, + key.Type, + key.CreatedAt.Format("2006-01-02 15:04:05"), + flags) + } + + fmt.Printf("\nTotal: %d unlock key(s)\n", len(keys)) + } + + return nil +} + +// KeysAdd adds a new unlock key +func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error { + switch keyType { + case "passphrase": + // Check if passphrase is set in environment variable + var passphraseStr string + if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { + passphraseStr = envPassphrase + } else { + // Use secure passphrase input with confirmation + var err error + passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") + if err != nil { + return fmt.Errorf("failed to read passphrase: %w", err) + } + } + + passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) + if err != nil { + return err + } + + cmd.Printf("Created passphrase unlock key: %s\n", passphraseKey.GetMetadata().ID) + return nil + + case "macos-sep": + return fmt.Errorf("macOS Secure Enclave unlock keys should be created using 'secret enroll' command") + + case "pgp": + // Get GPG key ID from flag or environment variable + var gpgKeyID string + if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" { + gpgKeyID = flagKeyID + } else if envKeyID := os.Getenv(EnvGPGKeyID); envKeyID != "" { + gpgKeyID = envKeyID + } else { + return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable") + } + + pgpKey, err := CreatePGPUnlockKey(cli.fs, cli.stateDir, gpgKeyID) + if err != nil { + return err + } + + cmd.Printf("Created PGP unlock key: %s\n", pgpKey.GetMetadata().ID) + cmd.Printf("GPG Key ID: %s\n", gpgKeyID) + return nil + + default: + return fmt.Errorf("unsupported key type: %s (supported: passphrase, macos-sep, pgp)", keyType) + } +} + +// KeysRemove removes an unlock key +func (cli *CLIInstance) KeysRemove(keyID string) error { + // Get current vault + vault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + return vault.RemoveUnlockKey(keyID) +} + +// KeySelect selects an unlock key as current +func (cli *CLIInstance) KeySelect(keyID string) error { + // Get current vault + vault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + return vault.SelectUnlockKey(keyID) +} + +// Import imports a mnemonic into a vault +func (cli *CLIInstance) Import(vaultName string) error { + var mnemonicStr string + + // Check if mnemonic is set in environment variable + if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" { + mnemonicStr = envMnemonic + } else { + // Read mnemonic from stdin using shared line reader + var err error + mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ") + if err != nil { + return fmt.Errorf("failed to read mnemonic: %w", err) + } + } + + if mnemonicStr == "" { + return fmt.Errorf("mnemonic cannot be empty") + } + + // Validate the mnemonic using BIP39 + if !bip39.IsMnemonicValid(mnemonicStr) { + return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic") + } + + return cli.importMnemonic(vaultName, mnemonicStr) +} + +// Enroll enrolls a hardware security module +func (cli *CLIInstance) Enroll() error { + sepKey, err := CreateSEPUnlockKey(cli.fs, cli.stateDir) + if err != nil { + return fmt.Errorf("failed to enroll macOS SEP unlock key: %w", err) + } + + fmt.Printf("macOS SEP unlock key enrolled successfully!\n") + fmt.Printf("Key ID: %s\n", sepKey.GetMetadata().ID) + fmt.Printf("Directory: %s\n", sepKey.GetDirectory()) + + // Load the key name to show the keychain key name + if keyName, err := sepKey.GetKeyName(); err == nil { + fmt.Printf("Keychain Key Name: %s\n", keyName) + } + + return nil +} + +// Encrypt encrypts data using an age secret key stored in a secret +func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error { + // Get current vault + vault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + var ageSecretKey string + + // Check if secret exists + secret := NewSecret(vault, secretName) + exists, err := secret.Exists() + if err != nil { + return fmt.Errorf("failed to check if secret exists: %w", err) + } + + if exists { + // Secret exists, get the age secret key from it + var secretValue []byte + if os.Getenv(EnvMnemonic) != "" { + secretValue, err = secret.GetValue(nil) + } else { + unlockKey, unlockErr := vault.GetCurrentUnlockKey() + if unlockErr != nil { + return fmt.Errorf("failed to get current unlock key: %w", unlockErr) + } + secretValue, err = secret.GetValue(unlockKey) + } + if err != nil { + return fmt.Errorf("failed to get secret value: %w", err) + } + + ageSecretKey = string(secretValue) + + // Validate that it's a valid age secret key + if !isValidAgeSecretKey(ageSecretKey) { + return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName) + } + } else { + // Secret doesn't exist, generate a new age secret key + identity, err := age.GenerateX25519Identity() + if err != nil { + return fmt.Errorf("failed to generate age secret key: %w", err) + } + + ageSecretKey = identity.String() + + // Store the new secret + if err := vault.AddSecret(secretName, []byte(ageSecretKey), false); err != nil { + return fmt.Errorf("failed to store age secret key: %w", err) + } + + fmt.Fprintf(os.Stderr, "Generated new age secret key and stored in secret '%s'\n", secretName) + } + + // Parse the age secret key to get the identity + identity, err := age.ParseX25519Identity(ageSecretKey) + if err != nil { + return fmt.Errorf("failed to parse age secret key: %w", err) + } + + // Get the recipient (public key) for encryption + recipient := identity.Recipient() + + // Set up input reader + var input io.Reader = os.Stdin + if inputFile != "" { + file, err := cli.fs.Open(inputFile) + if err != nil { + return fmt.Errorf("failed to open input file: %w", err) + } + defer file.Close() + input = file + } + + // Set up output writer + var output io.Writer = os.Stdout + if outputFile != "" { + file, err := cli.fs.Create(outputFile) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer file.Close() + output = file + } + + // Encrypt the data + encryptor, err := age.Encrypt(output, recipient) + if err != nil { + return fmt.Errorf("failed to create age encryptor: %w", err) + } + + if _, err := io.Copy(encryptor, input); err != nil { + return fmt.Errorf("failed to encrypt data: %w", err) + } + + if err := encryptor.Close(); err != nil { + return fmt.Errorf("failed to finalize encryption: %w", err) + } + + return nil +} + +// Decrypt decrypts data using an age secret key stored in a secret +func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error { + // Get current vault + vault, err := GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + // Check if secret exists + secret := NewSecret(vault, secretName) + exists, err := secret.Exists() + if err != nil { + return fmt.Errorf("failed to check if secret exists: %w", err) + } + + if !exists { + return fmt.Errorf("secret '%s' does not exist", secretName) + } + + // Get the age secret key from the secret + var secretValue []byte + if os.Getenv(EnvMnemonic) != "" { + secretValue, err = secret.GetValue(nil) + } else { + unlockKey, unlockErr := vault.GetCurrentUnlockKey() + if unlockErr != nil { + return fmt.Errorf("failed to get current unlock key: %w", unlockErr) + } + secretValue, err = secret.GetValue(unlockKey) + } + if err != nil { + return fmt.Errorf("failed to get secret value: %w", err) + } + + ageSecretKey := string(secretValue) + + // Validate that it's a valid age secret key + if !isValidAgeSecretKey(ageSecretKey) { + return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName) + } + + // Parse the age secret key to get the identity + identity, err := age.ParseX25519Identity(ageSecretKey) + if err != nil { + return fmt.Errorf("failed to parse age secret key: %w", err) + } + + // Set up input reader + var input io.Reader = os.Stdin + if inputFile != "" { + file, err := cli.fs.Open(inputFile) + if err != nil { + return fmt.Errorf("failed to open input file: %w", err) + } + defer file.Close() + input = file + } + + // Set up output writer + var output io.Writer = os.Stdout + if outputFile != "" { + file, err := cli.fs.Create(outputFile) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer file.Close() + output = file + } + + // Decrypt the data + decryptor, err := age.Decrypt(input, identity) + if err != nil { + return fmt.Errorf("failed to create age decryptor: %w", err) + } + + if _, err := io.Copy(output, decryptor); err != nil { + return fmt.Errorf("failed to decrypt data: %w", err) + } + + return nil +} + +// Helper methods + +// generateRandomBase58 generates a random base58 string of the specified length +func generateRandomBase58(length int) (string, error) { + const base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + return generateRandomString(length, base58Chars) +} + +// generateRandomAlnum generates a random alphanumeric string of the specified length +func generateRandomAlnum(length int) (string, error) { + const alnumChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + return generateRandomString(length, alnumChars) +} + +// generateRandomString generates a random string of the specified length using the given character set +func generateRandomString(length int, charset string) (string, error) { + if length <= 0 { + return "", fmt.Errorf("length must be positive") + } + + result := make([]byte, length) + charsetLen := big.NewInt(int64(len(charset))) + + for i := 0; i < length; i++ { + randomIndex, err := rand.Int(rand.Reader, charsetLen) + if err != nil { + return "", fmt.Errorf("failed to generate random number: %w", err) + } + result[i] = charset[randomIndex.Int64()] + } + + return string(result), nil +} + +// isValidAgeSecretKey checks if a string is a valid age secret key by attempting to parse it +func isValidAgeSecretKey(key string) bool { + _, err := age.ParseX25519Identity(key) + return err == nil +} + +// readSecurePassphrase reads a passphrase securely from the terminal without echoing +// and prompts for confirmation. Falls back to regular input when not on a terminal. +func readSecurePassphrase(prompt string) (string, error) { + // Check if stdin is a terminal + if !term.IsTerminal(int(syscall.Stdin)) { + // Not a terminal (piped input, testing, etc.) - use shared line reader + passphrase, err := readLineFromStdin(prompt) + if err != nil { + return "", fmt.Errorf("failed to read passphrase: %w", err) + } + + if passphrase == "" { + return "", fmt.Errorf("passphrase cannot be empty") + } + + return passphrase, nil + } + + // Terminal input - use secure password reading with confirmation + fmt.Print(prompt) + + // Read first passphrase + passphrase1, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", fmt.Errorf("failed to read passphrase: %w", err) + } + fmt.Println() // Print newline since ReadPassword doesn't echo + + // Read confirmation passphrase + fmt.Print("Confirm passphrase: ") + passphrase2, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", fmt.Errorf("failed to read passphrase confirmation: %w", err) + } + fmt.Println() // Print newline since ReadPassword doesn't echo + + // Compare passphrases + if string(passphrase1) != string(passphrase2) { + return "", fmt.Errorf("passphrases do not match") + } + + if len(passphrase1) == 0 { + return "", fmt.Errorf("passphrase cannot be empty") + } + + return string(passphrase1), nil +} + +// importMnemonic imports a BIP39 mnemonic into the specified vault +func (cli *CLIInstance) importMnemonic(vaultName, mnemonic string) error { + // Derive long-term keypair from mnemonic + ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0) + if err != nil { + return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) + } + + // Check if vault exists + stateDir := cli.GetStateDir() + vaultDir := filepath.Join(stateDir, "vaults.d", vaultName) + 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 does not exist", vaultName) + } + + // Store long-term public key in vault + ltPubKey := ltIdentity.Recipient().String() + if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), 0600); err != nil { + return fmt.Errorf("failed to write long-term public key: %w", err) + } + + // Get or create passphrase for unlock key + var passphraseStr string + if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { + passphraseStr = envPassphrase + } else { + // Use secure passphrase input with confirmation + passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") + if err != nil { + return fmt.Errorf("failed to read passphrase: %w", err) + } + } + + // Create passphrase-protected unlock key + passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) + if err != nil { + return fmt.Errorf("failed to create unlock key: %w", err) + } + + // Encrypt long-term private key to the unlock key + unlockKeyDir := passphraseKey.GetDirectory() + + // Read unlock key public key + unlockPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockKeyDir, "pub.age")) + if err != nil { + return fmt.Errorf("failed to read unlock key public key: %w", err) + } + + unlockRecipient, err := age.ParseX25519Recipient(string(unlockPubKeyData)) + if err != nil { + return fmt.Errorf("failed to parse unlock key public key: %w", err) + } + + // Encrypt long-term private key to unlock key + ltPrivKeyData := []byte(ltIdentity.String()) + encryptedLtPrivKey, err := encryptToRecipient(ltPrivKeyData, unlockRecipient) + if err != nil { + return fmt.Errorf("failed to encrypt long-term private key: %w", err) + } + + // Write encrypted long-term private key + if err := afero.WriteFile(cli.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, 0600); err != nil { + return fmt.Errorf("failed to write encrypted long-term private key: %w", err) + } + + fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) + fmt.Printf("Long-term public key: %s\n", ltPubKey) + fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID) + + return nil +} + +// determineStateDir determines the state directory based on environment variables and OS +func determineStateDir(customConfigDir string) string { + // Check for environment variable first + if envStateDir := os.Getenv(EnvStateDir); envStateDir != "" { + return envStateDir + } + + // Use custom config dir if provided + if customConfigDir != "" { + return filepath.Join(customConfigDir, AppID) + } + + // Use os.UserConfigDir() which handles platform-specific directories: + // - On Unix systems, it returns $XDG_CONFIG_HOME or $HOME/.config + // - On Darwin, it returns $HOME/Library/Application Support + // - On Windows, it returns %AppData% + configDir, err := os.UserConfigDir() + if err != nil { + // Fallback to a reasonable default if we can't determine user config dir + homeDir, _ := os.UserHomeDir() + return filepath.Join(homeDir, ".config", AppID) + } + return filepath.Join(configDir, AppID) +}