From 721aaeaaec68326348c439bbe39e0dd566b502dd Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 23:20:40 +0200 Subject: [PATCH] fix: keep authentication input inline and mask passwords --- cmd/auth.go | 26 ++---------- cmd/common.go | 12 ++---- cmd/prompt.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/prompt_test.go | 48 +++++++++++++++++++++++ 4 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 cmd/prompt.go create mode 100644 cmd/prompt_test.go diff --git a/cmd/auth.go b/cmd/auth.go index 5128c5f0..0824a36c 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -1,17 +1,13 @@ package cmd import ( - "bufio" "errors" "fmt" - "os" - "strings" "time" "github.com/avast/retry-go" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/spf13/cobra" - "golang.org/x/term" ) func authCmd() *cobra.Command { @@ -28,18 +24,6 @@ func authCmd() *cobra.Command { } func loginCmd() *cobra.Command { - promptForAuthCode := func() (string, error) { - authCode, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - return "", fmt.Errorf("failed to read string: %w", err) - } - - authCode = strings.Trim(authCode, "\n") - authCode = strings.Trim(authCode, "\r") - - return authCode, nil - } - var email, password, authCode string cmd := &cobra.Command{ @@ -53,13 +37,11 @@ func loginCmd() *cobra.Command { } if password == "" && interactive { - dependencies.Logger.Log().Msg("enter password:") - - bytes, err := term.ReadPassword(int(os.Stdin.Fd())) + value, err := readPrompt("enter password: ", true) if err != nil { return fmt.Errorf("failed to read password: %w", err) } - password = string(bytes) + password = value } dependencies.Logger.Log().Msg("preparing authentication; the first login may take a few minutes") @@ -69,10 +51,8 @@ func loginCmd() *cobra.Command { // nolint:wrapcheck return retry.Do(func() error { if errors.Is(lastErr, appstore.ErrAuthCodeRequired) && interactive { - dependencies.Logger.Log().Msg("enter 2FA code:") - var err error - authCode, err = promptForAuthCode() + authCode, err = readPrompt("enter 2FA code: ", false) if err != nil { return fmt.Errorf("failed to read auth code: %w", err) } diff --git a/cmd/common.go b/cmd/common.go index da3c4186..8a9258f9 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -19,7 +19,6 @@ import ( "github.com/majd/ipatool/v2/pkg/util/operatingsystem" "github.com/rs/zerolog" "github.com/spf13/cobra" - "golang.org/x/term" ) var dependencies = Dependencies{} @@ -60,7 +59,7 @@ func newCookieJar(stateDirectory string) http.CookieJar { } // newKeychain returns a new keychain instance. -func newKeychain(stateDirectory string, logger log.Logger, interactive bool) keychain.Keychain { +func newKeychain(stateDirectory string, interactive bool) keychain.Keychain { ring := util.Must(openKeyring(keyring.Config{ AllowedBackends: []keyring.BackendType{ keyring.KeychainBackend, @@ -80,16 +79,11 @@ func newKeychain(stateDirectory string, logger log.Logger, interactive bool) key } path := strings.Split(s, " unlock ")[1] - logger.Log().Msgf("enter passphrase to unlock %s (this is separate from your Apple ID password): ", path) - bytes, err := term.ReadPassword(int(os.Stdin.Fd())) + password, err := readPrompt(fmt.Sprintf("enter passphrase to unlock %s (this is separate from your Apple ID password): ", path), true) if err != nil { return "", fmt.Errorf("failed to read password: %w", err) } - password := string(bytes) - password = strings.Trim(password, "\n") - password = strings.Trim(password, "\r") - return password, nil }, })) @@ -111,7 +105,7 @@ func initWithCommand(cmd *cobra.Command) { dependencies.Machine = machine.New(machine.Args{OS: dependencies.OS}) stateDirectory := util.Must(prepareStateDirectory(dependencies.OS, dependencies.Machine.HomeDirectory())) dependencies.CookieJar = newCookieJar(stateDirectory) - dependencies.Keychain = newKeychain(stateDirectory, dependencies.Logger, interactive) + dependencies.Keychain = newKeychain(stateDirectory, interactive) dependencies.AppStore = appstore.NewAppStore(appstore.Args{ CookieJar: dependencies.CookieJar, OperatingSystem: dependencies.OS, diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 00000000..322d8c83 --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + "unicode" + + "golang.org/x/term" +) + +func readPrompt(prompt string, masked bool) (string, error) { + if masked { + state, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return "", fmt.Errorf("failed to configure terminal: %w", err) + } + defer term.Restore(int(os.Stdin.Fd()), state) //nolint:errcheck + } + + // Prompts belong on stderr so structured output on stdout stays valid. + if _, err := io.WriteString(os.Stderr, prompt); err != nil { + return "", fmt.Errorf("failed to write prompt: %w", err) + } + + if masked { + defer fmt.Fprint(os.Stderr, "\r\n") + + return readMaskedInput(os.Stdin, os.Stderr) + } + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil +} + +func readMaskedInput(input io.Reader, output io.Writer) (string, error) { + reader := bufio.NewReader(input) + + var ( + password []rune + escape int + ) + + for { + key, _, err := reader.ReadRune() + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + + // Ignore terminal escape sequences (for example, arrow keys). + if escape != 0 { + if escape == 1 && (key == '[' || key == 'O') { + escape = 2 + } else if escape == 1 || (key >= 0x40 && key <= 0x7e) { + escape = 0 + } + + continue + } + + var echo string + + switch key { + case '\r', '\n': + return string(password), nil + case 3: + return "", errors.New("input interrupted") + case 4: + return "", io.EOF + case 27: + escape = 1 + case '\b', 127: + if len(password) > 0 { + password = password[:len(password)-1] + echo = "\b \b" + } + case 21: + echo = strings.Repeat("\b \b", len(password)) + password = password[:0] + default: + if unicode.IsPrint(key) { + password = append(password, key) + echo = "*" + } + } + + if _, err := io.WriteString(output, echo); err != nil { + return "", fmt.Errorf("failed to echo input: %w", err) + } + } +} diff --git a/cmd/prompt_test.go b/cmd/prompt_test.go new file mode 100644 index 00000000..bf2a208a --- /dev/null +++ b/cmd/prompt_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "bytes" + "io" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Masked input", func() { + DescribeTable("echoes masks while preserving the entered password", func(input, password, echo string) { + var output bytes.Buffer + value, err := readMaskedInput(strings.NewReader(input), &output) + + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(Equal(password)) + Expect(output.String()).To(Equal(echo)) + }, + Entry("enter", "secret\r", "secret", "******"), + Entry("newline", "secret\n", "secret", "******"), + Entry("unicode", "pä密🔑\r", "pä密🔑", "****"), + Entry("delete", "ab\x7fc\r", "ac", "**\b \b*"), + Entry("unicode backspace", "a密\bc\r", "ac", "**\b \b*"), + Entry("backspace on empty input", "\b\x7fa\r", "a", "*"), + Entry("clear line", "ab\x15c\r", "c", "**\b \b\b \b*"), + Entry("arrow keys", "ab\x1b[D\x1bOCc\r", "abc", "***"), + Entry("empty input", "\r", "", ""), + ) + + It("cancels without returning the password on Ctrl-C", func() { + value, err := readMaskedInput(strings.NewReader("secret\x03"), io.Discard) + + Expect(err).To(MatchError("input interrupted")) + Expect(value).To(BeEmpty()) + }) + + DescribeTable("discards incomplete input", func(input string) { + value, err := readMaskedInput(strings.NewReader(input), io.Discard) + + Expect(err).To(MatchError(ContainSubstring("EOF"))) + Expect(value).To(BeEmpty()) + }, + Entry("end of stream", "secret"), + Entry("Ctrl-D", "secret\x04"), + ) +})