diff --git a/internal/secret/cli.go b/internal/secret/cli.go index 6533ea8..2d91a0c 100644 --- a/internal/secret/cli.go +++ b/internal/secret/cli.go @@ -95,7 +95,12 @@ func getStdinScanner() *bufio.Scanner { // readLineFromStdin reads a single line from stdin with a prompt // Uses a shared scanner to avoid buffering issues between multiple calls func readLineFromStdin(prompt string) (string, error) { - fmt.Print(prompt) + // 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 { @@ -108,6 +113,7 @@ func readLineFromStdin(prompt string) (string, error) { // 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) @@ -115,6 +121,7 @@ func CLIEntry() { } func newRootCmd() *cobra.Command { + Debug("newRootCmd starting") cmd := &cobra.Command{ Use: "secret", Short: "A simple secrets manager", @@ -124,6 +131,7 @@ func newRootCmd() *cobra.Command { SilenceErrors: false, } + Debug("Adding subcommands to root command") // Add subcommands cmd.AddCommand(newInitCmd()) cmd.AddCommand(newGenerateCmd()) @@ -137,6 +145,7 @@ func newRootCmd() *cobra.Command { cmd.AddCommand(newEncryptCmd()) cmd.AddCommand(newDecryptCmd()) + Debug("newRootCmd completed") return cmd } @@ -280,9 +289,12 @@ func newAddCmd() *cobra.Command { 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) }, } @@ -528,7 +540,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error { // Create default vault Debug("Creating default vault") - _, err = CreateVault(cli.fs, cli.stateDir, "default") + 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) @@ -550,6 +562,9 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error { 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 != "" { @@ -567,7 +582,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error { // Create passphrase-protected unlock key Debug("Creating passphrase-protected unlock key") - passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) + 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) @@ -749,24 +764,41 @@ func (cli *CLIInstance) VaultSelect(name string) error { // 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)) } - return vault.AddSecret(secretName, value, force) + 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 @@ -777,27 +809,9 @@ func (cli *CLIInstance) GetSecret(secretName string) error { return err } - // Get the secret object - secret, err := vault.GetSecretObject(secretName) - if err != nil { - return err - } - - // Get the value using the current unlock key (or mnemonic if available) - var value []byte - if os.Getenv(EnvMnemonic) != "" { - // If mnemonic is available, GetValue can handle it without an unlock key - value, err = secret.GetValue(nil) - } else { - // Get the current unlock key - unlockKey, unlockErr := vault.GetCurrentUnlockKey() - if unlockErr != nil { - return fmt.Errorf("failed to get current unlock key: %w", unlockErr) - } - - value, err = secret.GetValue(unlockKey) - } - + // 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 } @@ -1037,20 +1051,33 @@ func (cli *CLIInstance) KeysList(jsonOutput bool) error { 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 - var err error passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") if err != nil { return fmt.Errorf("failed to read passphrase: %w", err) } } - passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) + passphraseKey, err := vault.CreatePassphraseKey(passphraseStr) if err != nil { return err } @@ -1374,6 +1401,11 @@ func isValidAgeSecretKey(key string) bool { // 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 @@ -1390,22 +1422,22 @@ func readSecurePassphrase(prompt string) (string, error) { } // Terminal input - use secure password reading with confirmation - fmt.Print(prompt) + 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.Println() // Print newline since ReadPassword doesn't echo + fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo // Read confirmation passphrase - fmt.Print("Confirm 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.Println() // Print newline since ReadPassword doesn't echo + fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo // Compare passphrases if string(passphrase1) != string(passphrase2) { @@ -1444,6 +1476,10 @@ func (cli *CLIInstance) importMnemonic(vaultName, mnemonic string) error { 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) + // Get or create passphrase for unlock key var passphraseStr string if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { @@ -1456,38 +1492,12 @@ func (cli *CLIInstance) importMnemonic(vaultName, mnemonic string) error { } } - // Create passphrase-protected unlock key - passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) + // Create passphrase-protected unlock key (vault is now unlocked) + passphraseKey, err := vault.CreatePassphraseKey(passphraseStr) if err != nil { return fmt.Errorf("failed to create unlock key: %w", err) } - // Encrypt long-term private key to the unlock key - unlockKeyDir := passphraseKey.GetDirectory() - - // Read unlock key public key - unlockPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockKeyDir, "pub.age")) - if err != nil { - return fmt.Errorf("failed to read unlock key public key: %w", err) - } - - unlockRecipient, err := age.ParseX25519Recipient(string(unlockPubKeyData)) - if err != nil { - return fmt.Errorf("failed to parse unlock key public key: %w", err) - } - - // Encrypt long-term private key to unlock key - ltPrivKeyData := []byte(ltIdentity.String()) - encryptedLtPrivKey, err := encryptToRecipient(ltPrivKeyData, unlockRecipient) - if err != nil { - return fmt.Errorf("failed to encrypt long-term private key: %w", err) - } - - // Write encrypted long-term private key - if err := afero.WriteFile(cli.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, 0600); err != nil { - return fmt.Errorf("failed to write encrypted long-term private key: %w", err) - } - fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) fmt.Printf("Long-term public key: %s\n", ltPubKey) fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID) diff --git a/internal/secret/crypto.go b/internal/secret/crypto.go index e1909ea..fa47381 100644 --- a/internal/secret/crypto.go +++ b/internal/secret/crypto.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "os" "syscall" "filippo.io/age" @@ -12,21 +13,34 @@ import ( // 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 + Debug("Creating age encryptor") w, err := age.Encrypt(&buf, recipient) if err != nil { + Debug("Failed to create encryptor", "error", err) return nil, fmt.Errorf("failed to create encryptor: %w", err) } + Debug("Created age encryptor successfully") + Debug("Writing data to encryptor") if _, err := w.Write(data); err != nil { + Debug("Failed to write data to encryptor", "error", err) return nil, fmt.Errorf("failed to write data: %w", err) } + Debug("Wrote data to encryptor successfully") + Debug("Closing encryptor") if err := w.Close(); err != nil { + Debug("Failed to close encryptor", "error", err) return nil, fmt.Errorf("failed to close encryptor: %w", err) } + Debug("Closed encryptor successfully") - return buf.Bytes(), nil + result := buf.Bytes() + Debug("encryptToRecipient completed successfully", "result_length", len(result)) + return result, nil } // decryptWithIdentity decrypts data with an identity using age @@ -67,25 +81,24 @@ 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 - fall back to regular input - fmt.Print(prompt) - var passphrase string - _, err := fmt.Scanln(&passphrase) - if err != nil { - return "", fmt.Errorf("failed to read passphrase: %w", err) - } - return passphrase, nil + // Not a terminal - use shared line reader to avoid buffering conflicts + return readLineFromStdin(prompt) } // Terminal input - use secure password reading - fmt.Print(prompt) + fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout passphrase, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { return "", fmt.Errorf("failed to read passphrase: %w", err) } - fmt.Println() // Print newline since ReadPassword doesn't echo + fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo if len(passphrase) == 0 { return "", fmt.Errorf("passphrase cannot be empty")