From a08eb28359a46ff521c7e8a4e87632770c1a4cc9 Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 14 Oct 2026 23:35:24 +0200 Subject: [PATCH] End the mnemonic prompt at end of input (closes #78) term.ReadPassword reads the terminal with a raw read that returns 0 bytes at the end of input and takes that as nothing yet, so Ctrl-D at the prompt did nothing. On the Unix systems the prompt now sets the terminal as ReadPassword does and reads the line from standard input, which reports the end of input; elsewhere, such as Windows, it still uses ReadPassword. Ctrl-D with nothing typed gives an empty mnemonic, refused with status 1, and the terminal settings are put back. A test types Ctrl-D at the prompt on a pseudo-terminal. Unverified: the prompt was run only on Linux; other systems were built. Deviation: a Ctrl-H byte is no longer a backspace at the prompt. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 6 ++- go.mod | 3 +- go.sum | 2 + internal/cli/prompt_test.go | 83 +++++++++++++++++++++++++++++ internal/mnemonic/mnemonic.go | 4 +- internal/mnemonic/prompt_notunix.go | 18 +++++++ internal/mnemonic/prompt_unix.go | 48 +++++++++++++++++ internal/mnemonic/termios_bsd.go | 12 +++++ internal/mnemonic/termios_other.go | 12 +++++ 9 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 internal/cli/prompt_test.go create mode 100644 internal/mnemonic/prompt_notunix.go create mode 100644 internal/mnemonic/prompt_unix.go create mode 100644 internal/mnemonic/termios_bsd.go create mode 100644 internal/mnemonic/termios_other.go diff --git a/README.md b/README.md index 6d676bd..381ff1b 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,11 @@ order; the first one found wins: 2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command held in the environment. 3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself. -4. A prompt on the terminal with echo turned off. +4. A prompt on the terminal with echo turned off. The mnemonic is what is typed + up to Enter, and echo is turned back on once it is read. Ctrl-D with nothing + typed ends the prompt at once and leaves the mnemonic empty, so the tool + refuses it and exits with status 1. After text has been typed, Ctrl-D has to + be pressed twice to end the prompt, and the text typed is the mnemonic. If none of these is available and standard input is not a terminal, the tool refuses and exits with status 1. A mnemonic that is empty, does not have 12, 15, diff --git a/go.mod b/go.mod index 8a4b92a..baa503c 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,11 @@ require ( filippo.io/age v1.3.2 github.com/btcsuite/btcd v0.25.0 github.com/btcsuite/btcd/btcutil v1.2.0 + github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 // v1.1.24's Open can return another pty's terminal github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.1 golang.org/x/crypto v0.57.0 + golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 sneak.berlin/go/secret v0.0.0-20261007105610-ef0ae90768c8 ) @@ -22,5 +24,4 @@ require ( github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index a6b1fa6..23f98c9 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+W github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 h1:yMIg99+4aBvqfl/HzJRKfxTX9rGfikoI9uvFzterhc8= github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0/go.mod h1:Y72Ren9gfhlEvnwnT78BGcSNO2UMphTKLn9AorF+5rg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 h1:CY3gjC7naqYGLMiywvj3suPfa1i0p/QEr7o8ujxL/2M= +github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= diff --git a/internal/cli/prompt_test.go b/internal/cli/prompt_test.go new file mode 100644 index 0000000..6090182 --- /dev/null +++ b/internal/cli/prompt_test.go @@ -0,0 +1,83 @@ +package cli_test + +import ( + "bufio" + "io" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/creack/pty" + "github.com/stretchr/testify/require" + "golang.org/x/term" + "sneak.berlin/go/keyfunc/internal/mnemonic" +) + +// TestEndOfInputAtThePromptEndsTheTool runs "age pub" with no mnemonic +// given and a terminal on all three streams, waits for the prompt, and +// types Ctrl-D. The tool has to end with status 1, say that the +// mnemonic is empty, and leave the terminal set as it found it, so with +// echo on again. +// +// pty.Open returns the two ends of a new terminal: tty is the end the +// tool uses as its terminal, and ptmx the end the test reads what the +// terminal shows from and types into. Reading ptmx stops at the +// deadline, so a prompt that never comes fails the test instead of +// hanging it. +func TestEndOfInputAtThePromptEndsTheTool(t *testing.T) { + t.Setenv(mnemonic.Variable, "") + t.Setenv(mnemonic.CommandVariable, "") + + ptmx, tty, err := pty.Open() + require.NoError(t, err) + + defer func() { _ = ptmx.Close() }() + defer func() { _ = tty.Close() }() + + require.NoError(t, ptmx.SetReadDeadline(time.Now().Add(10*time.Second))) + + before, err := term.GetState(int(tty.Fd())) + require.NoError(t, err) + + //nolint:gosec // this test's own binary as the tool + command := exec.CommandContext(t.Context(), os.Args[0], "age", "pub") + + command.Env = append(os.Environ(), runAsTool+"=1") + command.Stdin = tty + command.Stdout = tty + command.Stderr = tty + + require.NoError(t, command.Start()) + + var ( + shown string + char byte + ) + + terminal := bufio.NewReader(ptmx) + for !strings.HasSuffix(shown, "mnemonic: ") { + char, err = terminal.ReadByte() + require.NoError(t, err, "the tool did not prompt: %q", shown) + + shown += string(char) + } + + _, err = ptmx.WriteString("\x04") // Ctrl-D + require.NoError(t, err) + + waitForTool(t, "Ctrl-D at the prompt", command) + require.Equal(t, failedStatus, command.ProcessState.ExitCode()) + + after, err := term.GetState(int(tty.Fd())) + require.NoError(t, err) + require.Equal(t, before, after, "the terminal settings were not restored") + + // With the tool ended and this end closed too, ptmx gives what is + // left on the terminal and then an error. + require.NoError(t, tty.Close()) + + said, _ := io.ReadAll(terminal) + require.Contains(t, string(said), mnemonic.ErrEmpty.Error()) +} diff --git a/internal/mnemonic/mnemonic.go b/internal/mnemonic/mnemonic.go index ca4418e..6d2e44e 100644 --- a/internal/mnemonic/mnemonic.go +++ b/internal/mnemonic/mnemonic.go @@ -136,7 +136,7 @@ func ask() (string, error) { fmt.Fprint(os.Stderr, "mnemonic: ") - typed, err := term.ReadPassword(fd) + typed, err := readWithoutEcho() fmt.Fprintln(os.Stderr) @@ -144,7 +144,7 @@ func ask() (string, error) { return "", fmt.Errorf("reading the mnemonic: %w", err) } - return checked(string(typed)) + return checked(typed) } // checked joins the words with single spaces, whatever whitespace diff --git a/internal/mnemonic/prompt_notunix.go b/internal/mnemonic/prompt_notunix.go new file mode 100644 index 0000000..78ef5ac --- /dev/null +++ b/internal/mnemonic/prompt_notunix.go @@ -0,0 +1,18 @@ +//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !zos + +package mnemonic + +import ( + "os" + + "golang.org/x/term" +) + +// readWithoutEcho reads one line from the terminal on standard input +// with echo turned off, through term.ReadPassword, on the systems +// prompt_unix.go is not built for, such as Windows. +func readWithoutEcho() (string, error) { + typed, err := term.ReadPassword(int(os.Stdin.Fd())) + + return string(typed), err +} diff --git a/internal/mnemonic/prompt_unix.go b/internal/mnemonic/prompt_unix.go new file mode 100644 index 0000000..e7701b2 --- /dev/null +++ b/internal/mnemonic/prompt_unix.go @@ -0,0 +1,48 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos + +package mnemonic + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +// readWithoutEcho reads one line from the terminal on standard input +// with echo turned off. The line ends at a newline or at the end of +// input (Ctrl-D). The terminal is set as term.ReadPassword sets it; +// ReadPassword itself is not used here because it never sees the end +// of input and goes on waiting. +func readWithoutEcho() (string, error) { + fd := int(os.Stdin.Fd()) + + settings, err := unix.IoctlGetTermios(fd, ioctlReadTermios) + if err != nil { + return "", fmt.Errorf("reading the terminal settings: %w", err) + } + + hidden := *settings + hidden.Lflag &^= unix.ECHO + hidden.Lflag |= unix.ICANON | unix.ISIG + hidden.Iflag |= unix.ICRNL + + err = unix.IoctlSetTermios(fd, ioctlWriteTermios, &hidden) + if err != nil { + return "", fmt.Errorf("turning echo off: %w", err) + } + + defer func() { _ = unix.IoctlSetTermios(fd, ioctlWriteTermios, settings) }() + + // With ICANON set, a read from the terminal returns at most one + // line, so the buffer takes nothing past the newline. + typed, err := bufio.NewReader(os.Stdin).ReadString('\n') + if errors.Is(err, io.EOF) { + return typed, nil + } + + return typed, err +} diff --git a/internal/mnemonic/termios_bsd.go b/internal/mnemonic/termios_bsd.go new file mode 100644 index 0000000..619d97f --- /dev/null +++ b/internal/mnemonic/termios_bsd.go @@ -0,0 +1,12 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package mnemonic + +import "golang.org/x/sys/unix" + +// The requests that read and set the terminal settings, as +// golang.org/x/term names them. +const ( + ioctlReadTermios = unix.TIOCGETA + ioctlWriteTermios = unix.TIOCSETA +) diff --git a/internal/mnemonic/termios_other.go b/internal/mnemonic/termios_other.go new file mode 100644 index 0000000..9d1d744 --- /dev/null +++ b/internal/mnemonic/termios_other.go @@ -0,0 +1,12 @@ +//go:build aix || linux || solaris || zos + +package mnemonic + +import "golang.org/x/sys/unix" + +// The requests that read and set the terminal settings, as +// golang.org/x/term names them. +const ( + ioctlReadTermios = unix.TCGETS + ioctlWriteTermios = unix.TCSETS +)