fix: Prevent hanging in non-interactive environments - Add terminal detection to readPassphrase, readSecurePassphrase, and readLineFromStdin - Return clear error messages when stderr is not a terminal instead of hanging - Improves automation and CI/CD reliability

This commit is contained in:
Jeffrey Paul 2025-05-29 09:52:26 -07:00
parent f838c8cb98
commit bbaf1cbd97
2 changed files with 93 additions and 70 deletions

View File

@ -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)

View File

@ -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")