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 // readLineFromStdin reads a single line from stdin with a prompt
// Uses a shared scanner to avoid buffering issues between multiple calls // Uses a shared scanner to avoid buffering issues between multiple calls
func readLineFromStdin(prompt string) (string, error) { 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() scanner := getStdinScanner()
if !scanner.Scan() { if !scanner.Scan() {
if err := scanner.Err(); err != nil { 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 // CLIEntry is the entry point for the secret CLI application
func CLIEntry() { func CLIEntry() {
Debug("CLIEntry starting - debug output is working")
cmd := newRootCmd() cmd := newRootCmd()
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
os.Exit(1) os.Exit(1)
@ -115,6 +121,7 @@ func CLIEntry() {
} }
func newRootCmd() *cobra.Command { func newRootCmd() *cobra.Command {
Debug("newRootCmd starting")
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "secret", Use: "secret",
Short: "A simple secrets manager", Short: "A simple secrets manager",
@ -124,6 +131,7 @@ func newRootCmd() *cobra.Command {
SilenceErrors: false, SilenceErrors: false,
} }
Debug("Adding subcommands to root command")
// Add subcommands // Add subcommands
cmd.AddCommand(newInitCmd()) cmd.AddCommand(newInitCmd())
cmd.AddCommand(newGenerateCmd()) cmd.AddCommand(newGenerateCmd())
@ -137,6 +145,7 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newEncryptCmd()) cmd.AddCommand(newEncryptCmd())
cmd.AddCommand(newDecryptCmd()) cmd.AddCommand(newDecryptCmd())
Debug("newRootCmd completed")
return cmd 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.`, Long: `Add a secret to the current vault. The secret value is read from stdin.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
Debug("Add command RunE starting", "secret_name", args[0])
force, _ := cmd.Flags().GetBool("force") force, _ := cmd.Flags().GetBool("force")
Debug("Got force flag", "force", force)
cli := NewCLIInstance() cli := NewCLIInstance()
Debug("Created CLI instance, calling AddSecret")
return cli.AddSecret(args[0], force) return cli.AddSecret(args[0], force)
}, },
} }
@ -528,7 +540,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
// Create default vault // Create default vault
Debug("Creating default vault") Debug("Creating default vault")
_, err = CreateVault(cli.fs, cli.stateDir, "default") vault, err := CreateVault(cli.fs, cli.stateDir, "default")
if err != nil { if err != nil {
Debug("Failed to create default vault", "error", err) Debug("Failed to create default vault", "error", err)
return fmt.Errorf("failed to create default vault: %w", 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) 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 // Prompt for passphrase for unlock key
var passphraseStr string var passphraseStr string
if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" {
@ -567,7 +582,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
// Create passphrase-protected unlock key // Create passphrase-protected unlock key
Debug("Creating 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 { if err != nil {
Debug("Failed to create unlock key", "error", err) Debug("Failed to create unlock key", "error", err)
return fmt.Errorf("failed to create unlock key: %w", 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 // AddSecret adds a secret to the vault
func (cli *CLIInstance) AddSecret(secretName string, force bool) error { func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
// Get current vault // Get current vault
Debug("Getting current vault")
vault, err := GetCurrentVault(cli.fs, cli.stateDir) vault, err := GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
Debug("Failed to get current vault", "error", err)
return err return err
} }
Debug("Got current vault", "vault_name", vault.Name)
// Read secret value from stdin // Read secret value from stdin
Debug("Reading secret value from stdin")
value, err := io.ReadAll(os.Stdin) value, err := io.ReadAll(os.Stdin)
if err != nil { if err != nil {
Debug("Failed to read secret from stdin", "error", err)
return fmt.Errorf("failed to read secret from stdin: %w", 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 // Remove trailing newline if present
if len(value) > 0 && value[len(value)-1] == '\n' { if len(value) > 0 && value[len(value)-1] == '\n' {
value = value[:len(value)-1] 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 // GetSecret retrieves a secret from the vault
@ -777,27 +809,9 @@ func (cli *CLIInstance) GetSecret(secretName string) error {
return err return err
} }
// Get the secret object // Get the secret value using the vault's GetSecret method
secret, err := vault.GetSecretObject(secretName) // This handles the per-secret key architecture internally
if err != nil { value, err := vault.GetSecret(secretName)
return err
}
// Get the value using the current unlock key (or mnemonic if available)
var value []byte
if os.Getenv(EnvMnemonic) != "" {
// If mnemonic is available, GetValue can handle it without an unlock key
value, err = secret.GetValue(nil)
} else {
// Get the current unlock key
unlockKey, unlockErr := vault.GetCurrentUnlockKey()
if unlockErr != nil {
return fmt.Errorf("failed to get current unlock key: %w", unlockErr)
}
value, err = secret.GetValue(unlockKey)
}
if err != nil { if err != nil {
return err return err
} }
@ -1037,20 +1051,33 @@ func (cli *CLIInstance) KeysList(jsonOutput bool) error {
func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error { func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error {
switch keyType { switch keyType {
case "passphrase": 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 // Check if passphrase is set in environment variable
var passphraseStr string var passphraseStr string
if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" {
passphraseStr = envPassphrase passphraseStr = envPassphrase
} else { } else {
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
var err error
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ")
if err != nil { if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err) 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 { if err != nil {
return err return err
} }
@ -1374,6 +1401,11 @@ func isValidAgeSecretKey(key string) bool {
// readSecurePassphrase reads a passphrase securely from the terminal without echoing // readSecurePassphrase reads a passphrase securely from the terminal without echoing
// and prompts for confirmation. Falls back to regular input when not on a terminal. // and prompts for confirmation. Falls back to regular input when not on a terminal.
func readSecurePassphrase(prompt string) (string, error) { 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 // Check if stdin is a terminal
if !term.IsTerminal(int(syscall.Stdin)) { if !term.IsTerminal(int(syscall.Stdin)) {
// Not a terminal (piped input, testing, etc.) - use shared line reader // 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 // 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 // Read first passphrase
passphrase1, err := term.ReadPassword(int(syscall.Stdin)) passphrase1, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read passphrase: %w", err) 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 // 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)) passphrase2, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read passphrase confirmation: %w", err) 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 // Compare passphrases
if string(passphrase1) != string(passphrase2) { 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) 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 // Get or create passphrase for unlock key
var passphraseStr string var passphraseStr string
if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" {
@ -1456,38 +1492,12 @@ func (cli *CLIInstance) importMnemonic(vaultName, mnemonic string) error {
} }
} }
// Create passphrase-protected unlock key // Create passphrase-protected unlock key (vault is now unlocked)
passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) passphraseKey, err := vault.CreatePassphraseKey(passphraseStr)
if err != nil { if err != nil {
return fmt.Errorf("failed to create unlock key: %w", 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)
}
fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
fmt.Printf("Long-term public key: %s\n", ltPubKey) fmt.Printf("Long-term public key: %s\n", ltPubKey)
fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID) fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID)

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"os"
"syscall" "syscall"
"filippo.io/age" "filippo.io/age"
@ -12,21 +13,34 @@ import (
// encryptToRecipient encrypts data to a recipient using age // encryptToRecipient encrypts data to a recipient using age
func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) { func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
Debug("encryptToRecipient starting", "data_length", len(data))
var buf bytes.Buffer var buf bytes.Buffer
Debug("Creating age encryptor")
w, err := age.Encrypt(&buf, recipient) w, err := age.Encrypt(&buf, recipient)
if err != nil { if err != nil {
Debug("Failed to create encryptor", "error", err)
return nil, fmt.Errorf("failed to create encryptor: %w", 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 { 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) 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 { if err := w.Close(); err != nil {
Debug("Failed to close encryptor", "error", err)
return nil, fmt.Errorf("failed to close encryptor: %w", 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 // 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 // readPassphrase reads a passphrase securely from the terminal without echoing
// This version is for unlocking and doesn't require confirmation // This version is for unlocking and doesn't require confirmation
func readPassphrase(prompt string) (string, error) { 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 // Check if stdin is a terminal
if !term.IsTerminal(int(syscall.Stdin)) { if !term.IsTerminal(int(syscall.Stdin)) {
// Not a terminal - fall back to regular input // Not a terminal - use shared line reader to avoid buffering conflicts
fmt.Print(prompt) return readLineFromStdin(prompt)
var passphrase string
_, err := fmt.Scanln(&passphrase)
if err != nil {
return "", fmt.Errorf("failed to read passphrase: %w", err)
}
return passphrase, nil
} }
// Terminal input - use secure password reading // 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)) passphrase, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read passphrase: %w", err) 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 { if len(passphrase) == 0 {
return "", fmt.Errorf("passphrase cannot be empty") return "", fmt.Errorf("passphrase cannot be empty")