Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 3 additions & 23 deletions cmd/auth.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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{
Expand All @@ -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")
Expand All @@ -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)
}
Expand Down
12 changes: 3 additions & 9 deletions cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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,
Expand All @@ -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
},
}))
Expand All @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions cmd/prompt.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
48 changes: 48 additions & 0 deletions cmd/prompt_test.go
Original file line number Diff line number Diff line change
@@ -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"),
)
})
Loading