From e95609ce696d0bbe31fc5821897aa6eb3e08e67d Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 29 May 2025 11:02:22 -0700 Subject: [PATCH] latest --- cmd/secret/main.go | 4 +- internal/cli/cli.go | 91 ++ internal/{secret => cli}/cli_test.go | 21 +- internal/cli/crypto.go | 243 ++++ internal/cli/generate.go | 162 +++ internal/cli/init.go | 216 ++++ internal/cli/keys.go | 323 ++++++ internal/cli/root.go | 46 + internal/cli/secrets.go | 269 +++++ internal/cli/vault.go | 248 ++++ internal/secret/cli.go | 1565 -------------------------- internal/secret/constants.go | 12 + internal/secret/crypto.go | 25 +- internal/secret/debug.go | 2 +- internal/secret/helpers.go | 54 + 15 files changed, 1693 insertions(+), 1588 deletions(-) create mode 100644 internal/cli/cli.go rename internal/{secret => cli}/cli_test.go (73%) create mode 100644 internal/cli/crypto.go create mode 100644 internal/cli/generate.go create mode 100644 internal/cli/init.go create mode 100644 internal/cli/keys.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/secrets.go create mode 100644 internal/cli/vault.go delete mode 100644 internal/secret/cli.go create mode 100644 internal/secret/constants.go create mode 100644 internal/secret/helpers.go diff --git a/cmd/secret/main.go b/cmd/secret/main.go index ff6f145..7a3074b 100644 --- a/cmd/secret/main.go +++ b/cmd/secret/main.go @@ -1,7 +1,7 @@ package main -import "git.eeqj.de/sneak/secret/internal/secret" +import "git.eeqj.de/sneak/secret/internal/cli" func main() { - secret.CLIEntry() + cli.CLIEntry() } diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..be6ab42 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,91 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "strings" + "syscall" + + "git.eeqj.de/sneak/secret/internal/secret" + "github.com/spf13/afero" + "golang.org/x/term" +) + +// 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 := secret.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 := secret.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) { + // Check if stderr is a terminal - if not, we can't prompt interactively + if !term.IsTerminal(int(syscall.Stderr)) { + return "", fmt.Errorf("cannot prompt for input: stderr is not a terminal (running in non-interactive mode)") + } + + fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout + 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 +} diff --git a/internal/secret/cli_test.go b/internal/cli/cli_test.go similarity index 73% rename from internal/secret/cli_test.go rename to internal/cli/cli_test.go index 11d4204..239585b 100644 --- a/internal/secret/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,10 +1,11 @@ -package secret +package cli import ( "os" "path/filepath" "testing" + "git.eeqj.de/sneak/secret/internal/secret" "github.com/spf13/afero" ) @@ -34,32 +35,32 @@ func TestCLIInstanceWithFs(t *testing.T) { } func TestDetermineStateDir(t *testing.T) { - // Test the determineStateDir function + // Test the determineStateDir function from the secret package // Save original environment and restore it after test - originalStateDir := os.Getenv(EnvStateDir) + originalStateDir := os.Getenv(secret.EnvStateDir) defer func() { if originalStateDir == "" { - os.Unsetenv(EnvStateDir) + os.Unsetenv(secret.EnvStateDir) } else { - os.Setenv(EnvStateDir, originalStateDir) + os.Setenv(secret.EnvStateDir, originalStateDir) } }() // Test with environment variable set testEnvDir := "/test-env-dir" - os.Setenv(EnvStateDir, testEnvDir) + os.Setenv(secret.EnvStateDir, testEnvDir) - stateDir := determineStateDir("") + stateDir := secret.DetermineStateDir("") if stateDir != testEnvDir { t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir) } // Test with custom config dir - os.Unsetenv(EnvStateDir) + os.Unsetenv(secret.EnvStateDir) customConfigDir := "/custom-config" - stateDir = determineStateDir(customConfigDir) - expectedDir := filepath.Join(customConfigDir, AppID) + stateDir = secret.DetermineStateDir(customConfigDir) + expectedDir := filepath.Join(customConfigDir, secret.AppID) if stateDir != expectedDir { t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir) } diff --git a/internal/cli/crypto.go b/internal/cli/crypto.go new file mode 100644 index 0000000..fac166d --- /dev/null +++ b/internal/cli/crypto.go @@ -0,0 +1,243 @@ +package cli + +import ( + "fmt" + "io" + "os" + + "filippo.io/age" + "git.eeqj.de/sneak/secret/internal/secret" + "github.com/spf13/cobra" +) + +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 +} + +// 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 := secret.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + var ageSecretKey string + + // Check if secret exists + secretObj := secret.NewSecret(vault, secretName) + exists, err := secretObj.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(secret.EnvMnemonic) != "" { + secretValue, err = secretObj.GetValue(nil) + } else { + unlockKey, unlockErr := vault.GetCurrentUnlockKey() + if unlockErr != nil { + return fmt.Errorf("failed to get current unlock key: %w", unlockErr) + } + secretValue, err = secretObj.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 := secret.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + // Check if secret exists + secretObj := secret.NewSecret(vault, secretName) + exists, err := secretObj.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(secret.EnvMnemonic) != "" { + secretValue, err = secretObj.GetValue(nil) + } else { + unlockKey, unlockErr := vault.GetCurrentUnlockKey() + if unlockErr != nil { + return fmt.Errorf("failed to get current unlock key: %w", unlockErr) + } + secretValue, err = secretObj.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 +} + +// 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 +} diff --git a/internal/cli/generate.go b/internal/cli/generate.go new file mode 100644 index 0000000..d83db65 --- /dev/null +++ b/internal/cli/generate.go @@ -0,0 +1,162 @@ +package cli + +import ( + "crypto/rand" + "fmt" + "math/big" + "os" + + "git.eeqj.de/sneak/secret/internal/secret" + "github.com/spf13/cobra" + "github.com/tyler-smith/go-bip39" +) + +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 +} + +// 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 secretValue string + var err error + + switch secretType { + case "base58": + secretValue, err = generateRandomBase58(length) + case "alnum": + secretValue, 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 := secret.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + if err := vault.AddSecret(secretName, []byte(secretValue), force); err != nil { + return err + } + + fmt.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName) + return nil +} + +// 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 +} diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..7fad966 --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,216 @@ +package cli + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "syscall" + + "filippo.io/age" + "git.eeqj.de/sneak/secret/internal/secret" + "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" +) + +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) + }, + } +} + +// Init initializes the secrets manager +func (cli *CLIInstance) Init(cmd *cobra.Command) error { + secret.Debug("Starting secret manager initialization") + + // Create state directory + stateDir := cli.GetStateDir() + secret.DebugWith("Creating state directory", slog.String("path", stateDir)) + + if err := cli.fs.MkdirAll(stateDir, 0700); err != nil { + secret.Debug("Failed to create state directory", "error", err) + 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(secret.EnvMnemonic); envMnemonic != "" { + secret.Debug("Using mnemonic from environment variable") + mnemonicStr = envMnemonic + } else { + secret.Debug("Prompting user for mnemonic phrase") + // Read mnemonic from stdin using shared line reader + var err error + mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ") + if err != nil { + secret.Debug("Failed to read mnemonic from stdin", "error", err) + return fmt.Errorf("failed to read mnemonic: %w", err) + } + } + + if mnemonicStr == "" { + secret.Debug("Empty mnemonic provided") + return fmt.Errorf("mnemonic cannot be empty") + } + + // Validate the mnemonic using BIP39 + secret.DebugWith("Validating BIP39 mnemonic", slog.Int("word_count", len(strings.Fields(mnemonicStr)))) + if !bip39.IsMnemonicValid(mnemonicStr) { + secret.Debug("Invalid BIP39 mnemonic provided") + return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic") + } + + // Derive long-term keypair from mnemonic + secret.DebugWith("Deriving long-term key from mnemonic", slog.Int("index", 0)) + ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, 0) + if err != nil { + secret.Debug("Failed to derive long-term key", "error", err) + return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) + } + + // Create default vault + secret.Debug("Creating default vault") + vault, err := secret.CreateVault(cli.fs, cli.stateDir, "default") + if err != nil { + secret.Debug("Failed to create default vault", "error", err) + return fmt.Errorf("failed to create default vault: %w", err) + } + + // Set default vault as current + secret.Debug("Setting default vault as current") + if err := secret.SelectVault(cli.fs, cli.stateDir, "default"); err != nil { + secret.Debug("Failed to select default vault", "error", err) + 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() + secret.DebugWith("Storing long-term public key", slog.String("pubkey", ltPubKey), slog.String("vault_dir", vaultDir)) + if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), 0600); err != nil { + secret.Debug("Failed to write long-term public key", "error", err) + return fmt.Errorf("failed to write long-term public key: %w", err) + } + + // Unlock the vault with the derived long-term key + vault.Unlock(ltIdentity) + + // Prompt for passphrase for unlock key + var passphraseStr string + if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" { + secret.Debug("Using unlock passphrase from environment variable") + passphraseStr = envPassphrase + } else { + secret.Debug("Prompting user for unlock passphrase") + // Use secure passphrase input with confirmation + passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") + if err != nil { + secret.Debug("Failed to read unlock passphrase", "error", err) + return fmt.Errorf("failed to read passphrase: %w", err) + } + } + + // Create passphrase-protected unlock key + secret.Debug("Creating passphrase-protected unlock key") + passphraseKey, err := vault.CreatePassphraseKey(passphraseStr) + if err != nil { + secret.Debug("Failed to create unlock key", "error", err) + 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 := secret.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 +} + +// 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 - never read passphrases from piped input for security reasons + return "", fmt.Errorf("cannot read passphrase from non-terminal stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE environment variable or run interactively") + } + + // Check if stderr is a terminal - if not, we can't prompt interactively + if !term.IsTerminal(int(syscall.Stderr)) { + return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE environment variable") + } + + // Terminal input - use secure password reading with confirmation + fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout + + // Read first passphrase + passphrase1, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", fmt.Errorf("failed to read passphrase: %w", err) + } + fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo + + // Read confirmation passphrase + fmt.Fprint(os.Stderr, "Confirm passphrase: ") // Write prompt to stderr, not stdout + passphrase2, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", fmt.Errorf("failed to read passphrase confirmation: %w", err) + } + fmt.Fprintln(os.Stderr) // Print newline to stderr 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 +} diff --git a/internal/cli/keys.go b/internal/cli/keys.go new file mode 100644 index 0000000..b6188ec --- /dev/null +++ b/internal/cli/keys.go @@ -0,0 +1,323 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "git.eeqj.de/sneak/secret/internal/secret" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +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, keychain, 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]) + }, + } +} + +// KeysList lists unlock keys in the current vault +func (cli *CLIInstance) KeysList(jsonOutput bool) error { + // Get current vault + vault, err := secret.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 secret.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 secret.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 = secret.NewPassphraseUnlockKey(cli.fs, keyDir, metadata) + case "keychain": + unlockKey = secret.NewKeychainUnlockKey(cli.fs, keyDir, metadata) + case "pgp": + unlockKey = secret.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": + // Get current vault + vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return fmt.Errorf("failed to get current vault: %w", err) + } + + // Try to unlock the vault if not already unlocked + if vault.Locked() { + _, err := vault.UnlockVault() + if err != nil { + return fmt.Errorf("failed to unlock vault: %w", err) + } + } + + // Check if passphrase is set in environment variable + var passphraseStr string + if envPassphrase := os.Getenv(secret.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) + } + } + + passphraseKey, err := vault.CreatePassphraseKey(passphraseStr) + if err != nil { + return err + } + + cmd.Printf("Created passphrase unlock key: %s\n", passphraseKey.GetMetadata().ID) + return nil + + case "keychain": + keychainKey, err := secret.CreateKeychainUnlockKey(cli.fs, cli.stateDir) + if err != nil { + return fmt.Errorf("failed to create macOS Keychain unlock key: %w", err) + } + + cmd.Printf("Created macOS Keychain unlock key: %s\n", keychainKey.GetMetadata().ID) + if keyName, err := keychainKey.GetKeychainItemName(); err == nil { + cmd.Printf("Keychain Item Name: %s\n", keyName) + } + return nil + + 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(secret.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 := secret.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, keychain, pgp)", keyType) + } +} + +// KeysRemove removes an unlock key +func (cli *CLIInstance) KeysRemove(keyID string) error { + // Get current vault + vault, err := secret.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 := secret.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + return vault.SelectUnlockKey(keyID) +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..5b17038 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,46 @@ +package cli + +import ( + "os" + + "git.eeqj.de/sneak/secret/internal/secret" + "github.com/spf13/cobra" +) + +// CLIEntry is the entry point for the secret CLI application +func CLIEntry() { + secret.Debug("CLIEntry starting - debug output is working") + cmd := newRootCmd() + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + secret.Debug("newRootCmd starting") + 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, + } + + secret.Debug("Adding subcommands to root command") + // 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(newEncryptCmd()) + cmd.AddCommand(newDecryptCmd()) + + secret.Debug("newRootCmd completed") + return cmd +} diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go new file mode 100644 index 0000000..b8c14f9 --- /dev/null +++ b/internal/cli/secrets.go @@ -0,0 +1,269 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "git.eeqj.de/sneak/secret/internal/secret" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +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 { + secret.Debug("Add command RunE starting", "secret_name", args[0]) + force, _ := cmd.Flags().GetBool("force") + secret.Debug("Got force flag", "force", force) + + cli := NewCLIInstance() + secret.Debug("Created CLI instance, calling AddSecret") + 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 newImportCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "import ", + Short: "Import a secret from a file", + Long: `Import a secret from a file and store it in the current vault under the given name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sourceFile, _ := cmd.Flags().GetString("source") + force, _ := cmd.Flags().GetBool("force") + + cli := NewCLIInstance() + return cli.ImportSecret(args[0], sourceFile, force) + }, + } + + cmd.Flags().StringP("source", "s", "", "Source file to import from (required)") + cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret") + _ = cmd.MarkFlagRequired("source") + return cmd +} + +// AddSecret adds a secret to the vault +func (cli *CLIInstance) AddSecret(secretName string, force bool) error { + secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force) + + // Get current vault + secret.Debug("Getting current vault") + vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + secret.Debug("Failed to get current vault", "error", err) + return err + } + secret.Debug("Got current vault", "vault_name", vault.Name) + + // Read secret value from stdin + secret.Debug("Reading secret value from stdin") + value, err := io.ReadAll(os.Stdin) + if err != nil { + secret.Debug("Failed to read secret from stdin", "error", err) + return fmt.Errorf("failed to read secret from stdin: %w", err) + } + secret.Debug("Read secret value from stdin", "value_length", len(value)) + + // Remove trailing newline if present + if len(value) > 0 && value[len(value)-1] == '\n' { + value = value[:len(value)-1] + secret.Debug("Removed trailing newline", "new_length", len(value)) + } + + secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", len(value), "force", force) + err = vault.AddSecret(secretName, value, force) + if err != nil { + secret.Debug("vault.AddSecret failed", "error", err) + return err + } + secret.Debug("vault.AddSecret completed successfully") + + return nil +} + +// GetSecret retrieves a secret from the vault +func (cli *CLIInstance) GetSecret(secretName string) error { + // Get current vault + vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + // Get the secret value using the vault's GetSecret method + // This handles the per-secret key architecture internally + value, err := vault.GetSecret(secretName) + 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 := secret.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 +} + +// ImportSecret imports a secret from a file +func (cli *CLIInstance) ImportSecret(secretName, sourceFile string, force bool) error { + // Get current vault + vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir) + if err != nil { + return err + } + + // Read secret value from the source file + value, err := afero.ReadFile(cli.fs, sourceFile) + if err != nil { + return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err) + } + + // Store the secret in the vault + if err := vault.AddSecret(secretName, value, force); err != nil { + return err + } + + fmt.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile) + return nil +} diff --git a/internal/cli/vault.go b/internal/cli/vault.go new file mode 100644 index 0000000..c8dd154 --- /dev/null +++ b/internal/cli/vault.go @@ -0,0 +1,248 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "syscall" + + "git.eeqj.de/sneak/secret/internal/secret" + "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" +) + +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()) + + 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 newVaultImportCmd() *cobra.Command { + 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), + RunE: func(cmd *cobra.Command, args []string) error { + vaultName := "default" + if len(args) > 0 { + vaultName = args[0] + } + + cli := NewCLIInstance() + return cli.Import(vaultName) + }, + } +} + +// VaultList lists available vaults +func (cli *CLIInstance) VaultList(jsonOutput bool) error { + vaults, err := secret.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 := secret.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 := secret.CreateVault(cli.fs, cli.stateDir, name) + return err +} + +// VaultSelect selects a vault as current +func (cli *CLIInstance) VaultSelect(name string) error { + return secret.SelectVault(cli.fs, cli.stateDir, name) +} + +// 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(secret.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) +} + +// 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 the vault instance and unlock it + vault := secret.NewVault(cli.fs, vaultName, cli.stateDir) + vault.Unlock(ltIdentity) + + fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) + fmt.Printf("Long-term public key: %s\n", ltPubKey) + + // Try to create unlock key only if running interactively + if term.IsTerminal(int(syscall.Stderr)) { + // Get or create passphrase for unlock key + var passphraseStr string + if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" { + passphraseStr = envPassphrase + } else { + // Use secure passphrase input with confirmation + passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") + if err != nil { + fmt.Printf("Warning: Failed to create unlock key: %v\n", err) + fmt.Printf("You can create unlock keys later with 'secret keys add passphrase'\n") + return nil + } + } + + // Create passphrase-protected unlock key (vault is now unlocked) + passphraseKey, err := vault.CreatePassphraseKey(passphraseStr) + if err != nil { + fmt.Printf("Warning: Failed to create unlock key: %v\n", err) + fmt.Printf("You can create unlock keys later with 'secret keys add passphrase'\n") + return nil + } + + fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID) + } else { + fmt.Printf("Running in non-interactive mode - unlock key not created\n") + fmt.Printf("You can create unlock keys later with 'secret keys add passphrase'\n") + } + + return nil +} diff --git a/internal/secret/cli.go b/internal/secret/cli.go deleted file mode 100644 index df40e0f..0000000 --- a/internal/secret/cli.go +++ /dev/null @@ -1,1565 +0,0 @@ -package secret - -import ( - "bufio" - "crypto/rand" - "encoding/json" - "fmt" - "io" - "log/slog" - "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) { - // Check if stderr is a terminal - if not, we can't prompt interactively - if !term.IsTerminal(int(syscall.Stderr)) { - return "", fmt.Errorf("cannot prompt for input: stderr is not a terminal (running in non-interactive mode)") - } - - fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout - 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() { - Debug("CLIEntry starting - debug output is working") - cmd := newRootCmd() - if err := cmd.Execute(); err != nil { - os.Exit(1) - } -} - -func newRootCmd() *cobra.Command { - Debug("newRootCmd starting") - 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, - } - - Debug("Adding subcommands to root command") - // 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(newEncryptCmd()) - cmd.AddCommand(newDecryptCmd()) - - Debug("newRootCmd completed") - 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()) - cmd.AddCommand(newVaultImportCmd()) - - 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 newVaultImportCmd() *cobra.Command { - 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), - RunE: func(cmd *cobra.Command, args []string) error { - vaultName := "default" - if len(args) > 0 { - vaultName = args[0] - } - - cli := NewCLIInstance() - return cli.Import(vaultName) - }, - } -} - -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 { - Debug("Add command RunE starting", "secret_name", args[0]) - force, _ := cmd.Flags().GetBool("force") - Debug("Got force flag", "force", force) - - cli := NewCLIInstance() - Debug("Created CLI instance, calling AddSecret") - 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, keychain, 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 { - cmd := &cobra.Command{ - Use: "import ", - Short: "Import a secret from a file", - Long: `Import a secret from a file and store it in the current vault under the given name.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - sourceFile, _ := cmd.Flags().GetString("source") - force, _ := cmd.Flags().GetBool("force") - - cli := NewCLIInstance() - return cli.ImportSecret(args[0], sourceFile, force) - }, - } - - cmd.Flags().StringP("source", "s", "", "Source file to import from (required)") - cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret") - _ = cmd.MarkFlagRequired("source") - return cmd -} - -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 { - Debug("Starting secret manager initialization") - - // Create state directory - stateDir := cli.GetStateDir() - DebugWith("Creating state directory", slog.String("path", stateDir)) - - if err := cli.fs.MkdirAll(stateDir, 0700); err != nil { - Debug("Failed to create state directory", "error", err) - 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 != "" { - Debug("Using mnemonic from environment variable") - mnemonicStr = envMnemonic - } else { - Debug("Prompting user for mnemonic phrase") - // Read mnemonic from stdin using shared line reader - var err error - mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ") - if err != nil { - Debug("Failed to read mnemonic from stdin", "error", err) - return fmt.Errorf("failed to read mnemonic: %w", err) - } - } - - if mnemonicStr == "" { - Debug("Empty mnemonic provided") - return fmt.Errorf("mnemonic cannot be empty") - } - - // Validate the mnemonic using BIP39 - DebugWith("Validating BIP39 mnemonic", slog.Int("word_count", len(strings.Fields(mnemonicStr)))) - if !bip39.IsMnemonicValid(mnemonicStr) { - Debug("Invalid BIP39 mnemonic provided") - return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic") - } - - // Derive long-term keypair from mnemonic - DebugWith("Deriving long-term key from mnemonic", slog.Int("index", 0)) - ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, 0) - if err != nil { - Debug("Failed to derive long-term key", "error", err) - return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) - } - - // Create default vault - Debug("Creating default vault") - vault, err := CreateVault(cli.fs, cli.stateDir, "default") - if err != nil { - Debug("Failed to create default vault", "error", err) - return fmt.Errorf("failed to create default vault: %w", err) - } - - // Set default vault as current - Debug("Setting default vault as current") - if err := SelectVault(cli.fs, cli.stateDir, "default"); err != nil { - Debug("Failed to select default vault", "error", err) - 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() - DebugWith("Storing long-term public key", slog.String("pubkey", ltPubKey), slog.String("vault_dir", vaultDir)) - if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), 0600); err != nil { - Debug("Failed to write long-term public key", "error", err) - return fmt.Errorf("failed to write long-term public key: %w", err) - } - - // Unlock the vault with the derived long-term key - vault.Unlock(ltIdentity) - - // Prompt for passphrase for unlock key - var passphraseStr string - if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { - Debug("Using unlock passphrase from environment variable") - passphraseStr = envPassphrase - } else { - Debug("Prompting user for unlock passphrase") - // Use secure passphrase input with confirmation - passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") - if err != nil { - Debug("Failed to read unlock passphrase", "error", err) - return fmt.Errorf("failed to read passphrase: %w", err) - } - } - - // Create passphrase-protected unlock key - Debug("Creating passphrase-protected unlock key") - passphraseKey, err := vault.CreatePassphraseKey(passphraseStr) - if err != nil { - Debug("Failed to create unlock key", "error", err) - 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 { - Debug("CLI AddSecret starting", "secret_name", secretName, "force", force) - - // Get current vault - Debug("Getting current vault") - vault, err := GetCurrentVault(cli.fs, cli.stateDir) - if err != nil { - Debug("Failed to get current vault", "error", err) - return err - } - Debug("Got current vault", "vault_name", vault.Name) - - // Read secret value from stdin - Debug("Reading secret value from stdin") - value, err := io.ReadAll(os.Stdin) - if err != nil { - Debug("Failed to read secret from stdin", "error", err) - return fmt.Errorf("failed to read secret from stdin: %w", err) - } - Debug("Read secret value from stdin", "value_length", len(value)) - - // Remove trailing newline if present - if len(value) > 0 && value[len(value)-1] == '\n' { - value = value[:len(value)-1] - Debug("Removed trailing newline", "new_length", len(value)) - } - - Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", len(value), "force", force) - err = vault.AddSecret(secretName, value, force) - if err != nil { - Debug("vault.AddSecret failed", "error", err) - return err - } - Debug("vault.AddSecret completed successfully") - - return nil -} - -// 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 value using the vault's GetSecret method - // This handles the per-secret key architecture internally - value, err := vault.GetSecret(secretName) - 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 "keychain": - unlockKey = NewKeychainUnlockKey(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": - // Get current vault - vault, err := GetCurrentVault(cli.fs, cli.stateDir) - if err != nil { - return fmt.Errorf("failed to get current vault: %w", err) - } - - // Try to unlock the vault if not already unlocked - if vault.Locked() { - _, err := vault.UnlockVault() - if err != nil { - return fmt.Errorf("failed to unlock vault: %w", err) - } - } - - // 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 - passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") - if err != nil { - return fmt.Errorf("failed to read passphrase: %w", err) - } - } - - passphraseKey, err := vault.CreatePassphraseKey(passphraseStr) - if err != nil { - return err - } - - cmd.Printf("Created passphrase unlock key: %s\n", passphraseKey.GetMetadata().ID) - return nil - - case "keychain": - keychainKey, err := CreateKeychainUnlockKey(cli.fs, cli.stateDir) - if err != nil { - return fmt.Errorf("failed to create macOS Keychain unlock key: %w", err) - } - - cmd.Printf("Created macOS Keychain unlock key: %s\n", keychainKey.GetMetadata().ID) - if keyName, err := keychainKey.GetKeychainItemName(); err == nil { - cmd.Printf("Keychain Item Name: %s\n", keyName) - } - return nil - - 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, keychain, 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) -} - -// 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 stderr is a terminal - if not, we can't prompt interactively - if !term.IsTerminal(int(syscall.Stderr)) { - return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode)") - } - - // 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.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout - - // Read first passphrase - passphrase1, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return "", fmt.Errorf("failed to read passphrase: %w", err) - } - fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo - - // Read confirmation passphrase - fmt.Fprint(os.Stderr, "Confirm passphrase: ") // Write prompt to stderr, not stdout - passphrase2, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return "", fmt.Errorf("failed to read passphrase confirmation: %w", err) - } - fmt.Fprintln(os.Stderr) // Print newline to stderr 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 the vault instance and unlock it - vault := NewVault(cli.fs, vaultName, cli.stateDir) - vault.Unlock(ltIdentity) - - fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) - fmt.Printf("Long-term public key: %s\n", ltPubKey) - - // Try to create unlock key only if running interactively - if term.IsTerminal(int(syscall.Stderr)) { - // 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 { - fmt.Printf("Warning: Failed to create unlock key: %v\n", err) - fmt.Printf("You can create unlock keys later with 'secret keys add passphrase'\n") - return nil - } - } - - // Create passphrase-protected unlock key (vault is now unlocked) - passphraseKey, err := vault.CreatePassphraseKey(passphraseStr) - if err != nil { - fmt.Printf("Warning: Failed to create unlock key: %v\n", err) - fmt.Printf("You can create unlock keys later with 'secret keys add passphrase'\n") - return nil - } - - fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID) - } else { - fmt.Printf("Running in non-interactive mode - unlock key not created\n") - fmt.Printf("You can create unlock keys later with 'secret keys add passphrase'\n") - } - - 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) -} - -// ImportSecret imports a secret from a file -func (cli *CLIInstance) ImportSecret(secretName, sourceFile string, force bool) error { - // Get current vault - vault, err := GetCurrentVault(cli.fs, cli.stateDir) - if err != nil { - return err - } - - // Read secret value from the source file - value, err := afero.ReadFile(cli.fs, sourceFile) - if err != nil { - return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err) - } - - // Store the secret in the vault - if err := vault.AddSecret(secretName, value, force); err != nil { - return err - } - - fmt.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile) - return nil -} diff --git a/internal/secret/constants.go b/internal/secret/constants.go new file mode 100644 index 0000000..adb22d3 --- /dev/null +++ b/internal/secret/constants.go @@ -0,0 +1,12 @@ +package secret + +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" +) diff --git a/internal/secret/crypto.go b/internal/secret/crypto.go index fa47381..19f2423 100644 --- a/internal/secret/crypto.go +++ b/internal/secret/crypto.go @@ -11,8 +11,8 @@ import ( "golang.org/x/term" ) -// encryptToRecipient encrypts data to a recipient using age -func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) { +// EncryptToRecipient encrypts data to a recipient using age +func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) { Debug("encryptToRecipient starting", "data_length", len(data)) var buf bytes.Buffer @@ -43,6 +43,11 @@ func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) { return result, nil } +// encryptToRecipient encrypts data to a recipient using age (internal version) +func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) { + return EncryptToRecipient(data, recipient) +} + // decryptWithIdentity decrypts data with an identity using age func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) { r, err := age.Decrypt(bytes.NewReader(data), identity) @@ -81,18 +86,18 @@ func decryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, err // readPassphrase reads a passphrase securely from the terminal without echoing // This version is for unlocking and doesn't require confirmation func readPassphrase(prompt string) (string, error) { - // Check if stderr is a terminal - if not, we can't prompt interactively - if !term.IsTerminal(int(syscall.Stderr)) { - return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode)") - } - // Check if stdin is a terminal if !term.IsTerminal(int(syscall.Stdin)) { - // Not a terminal - use shared line reader to avoid buffering conflicts - return readLineFromStdin(prompt) + // Not a terminal - never read passphrases from piped input for security reasons + return "", fmt.Errorf("cannot read passphrase from non-terminal stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE environment variable or run interactively") } - // Terminal input - use secure password reading + // stdin is a terminal, check if stderr is also a terminal for interactive prompting + if !term.IsTerminal(int(syscall.Stderr)) { + return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE environment variable") + } + + // Both stdin and stderr are terminals - use secure password reading fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout passphrase, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { diff --git a/internal/secret/debug.go b/internal/secret/debug.go index 8912924..04a08f8 100644 --- a/internal/secret/debug.go +++ b/internal/secret/debug.go @@ -33,7 +33,7 @@ func initDebugLogging() { } // Disable stderr buffering for immediate debug output when debugging is enabled - syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC) + _, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC) // Check if STDERR is a TTY isTTY := term.IsTerminal(int(syscall.Stderr)) diff --git a/internal/secret/helpers.go b/internal/secret/helpers.go new file mode 100644 index 0000000..4f5572e --- /dev/null +++ b/internal/secret/helpers.go @@ -0,0 +1,54 @@ +package secret + +import ( + "crypto/rand" + "fmt" + "math/big" + "os" + "path/filepath" +) + +// 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 +} + +// 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) +}