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:
parent
f838c8cb98
commit
bbaf1cbd97
@ -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)
|
||||
|
@ -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")
|
||||
|
Loading…
Reference in New Issue
Block a user