diff --git a/cmd/age-keygen/keygen.go b/cmd/age-keygen/keygen.go index 549d3cf8..6556dcb6 100644 --- a/cmd/age-keygen/keygen.go +++ b/cmd/age-keygen/keygen.go @@ -14,7 +14,7 @@ import ( "time" "filippo.io/age" - "golang.org/x/term" + "filippo.io/age/internal/term" ) const usage = `Usage: @@ -126,7 +126,7 @@ func generate(out *os.File) { errorf("internal error: %v", err) } - if !term.IsTerminal(int(out.Fd())) { + if !term.IsTerminal(out) { fmt.Fprintf(os.Stderr, "Public key: %s\n", k.Recipient()) } diff --git a/cmd/age/age.go b/cmd/age/age.go index 4406d76b..64b9df8d 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -20,8 +20,8 @@ import ( "filippo.io/age/agessh" "filippo.io/age/armor" "filippo.io/age/internal/logger" + "filippo.io/age/internal/term" "filippo.io/age/plugin" - "golang.org/x/term" ) const usage = `Usage: @@ -248,7 +248,7 @@ func main() { in = f } else { stdinInUse = true - if decryptFlag && term.IsTerminal(int(os.Stdin.Fd())) { + if decryptFlag && term.IsTerminal(os.Stdin) { // If the input comes from a TTY, assume it's armored, and buffer up // to the END line (or EOF/EOT) so that a password prompt or the // output don't get in the way of typing the input. See Issue 364. @@ -272,7 +272,7 @@ func main() { } }() out = f - } else if term.IsTerminal(int(os.Stdout.Fd())) { + } else if term.IsTerminal(os.Stdout) { if name != "-" { if decryptFlag { // TODO: buffer the output and check it's printable. @@ -284,7 +284,7 @@ func main() { `force anyway with "-o -"`) } } - if in == os.Stdin && term.IsTerminal(int(os.Stdin.Fd())) { + if in == os.Stdin && term.IsTerminal(os.Stdin) { // If the input comes from a TTY and output will go to a TTY, // buffer it up so it doesn't get in the way of typing the input. buf := &bytes.Buffer{} @@ -306,7 +306,7 @@ func main() { } func passphrasePromptForEncryption() (string, error) { - pass, err := readSecret("Enter passphrase (leave empty to autogenerate a secure one):") + pass, err := term.ReadSecret("Enter passphrase (leave empty to autogenerate a secure one):") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -322,7 +322,7 @@ func passphrasePromptForEncryption() (string, error) { return "", fmt.Errorf("could not print passphrase: %v", err) } } else { - confirm, err := readSecret("Confirm passphrase:") + confirm, err := term.ReadSecret("Confirm passphrase:") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -491,7 +491,7 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) { } func passphrasePromptForDecryption() (string, error) { - pass, err := readSecret("Enter passphrase:") + pass, err := term.ReadSecret("Enter passphrase:") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 56c29b03..3f9896f9 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -16,6 +16,7 @@ import ( "filippo.io/age/agessh" "filippo.io/age/armor" "filippo.io/age/internal/logger" + "filippo.io/age/internal/term" "filippo.io/age/plugin" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/ssh" @@ -163,7 +164,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { return []age.Identity{&EncryptedIdentity{ Contents: contents, Passphrase: func() (string, error) { - pass, err := readSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name)) + pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name)) if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -247,7 +248,7 @@ func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) { } } passphrasePrompt := func() ([]byte, error) { - pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", name)) + pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for %q:", name)) if err != nil { return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err) } diff --git a/cmd/age/tui.go b/cmd/age/tui.go index f6b6af67..1110a40d 100644 --- a/cmd/age/tui.go +++ b/cmd/age/tui.go @@ -19,101 +19,20 @@ import ( "fmt" "io" "os" - "runtime" "filippo.io/age/armor" "filippo.io/age/internal/logger" + "filippo.io/age/internal/term" "filippo.io/age/plugin" - "golang.org/x/term" ) -// clearLine clears the current line on the terminal, or opens a new line if -// terminal escape codes don't work. -func clearLine(out io.Writer) { - const ( - CUI = "\033[" // Control Sequence Introducer - CPL = CUI + "F" // Cursor Previous Line - EL = CUI + "K" // Erase in Line - ) - - // First, open a new line, which is guaranteed to work everywhere. Then, try - // to erase the line above with escape codes. - // - // (We use CRLF instead of LF to work around an apparent bug in WSL2's - // handling of CONOUT$. Only when running a Windows binary from WSL2, the - // cursor would not go back to the start of the line with a simple LF. - // Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.) - fmt.Fprintf(out, "\r\n"+CPL+EL) -} - -// withTerminal runs f with the terminal input and output files, if available. -// withTerminal does not open a non-terminal stdin, so the caller does not need -// to check stdinInUse. -func withTerminal(f func(in, out *os.File) error) error { - if runtime.GOOS == "windows" { - in, err := os.OpenFile("CONIN$", os.O_RDWR, 0) - if err != nil { - return err - } - defer in.Close() - out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) - if err != nil { - return err - } - defer out.Close() - return f(in, out) - } else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { - defer tty.Close() - return f(tty, tty) - } else if term.IsTerminal(int(os.Stdin.Fd())) { - return f(os.Stdin, os.Stdin) - } else { - return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) - } -} - func printfToTerminal(format string, v ...interface{}) error { - return withTerminal(func(_, out *os.File) error { + return term.WithTerminal(func(_, out *os.File) error { _, err := fmt.Fprintf(out, "age: "+format+"\n", v...) return err }) } -// readSecret reads a value from the terminal with no echo. The prompt is ephemeral. -func readSecret(prompt string) (s []byte, err error) { - err = withTerminal(func(in, out *os.File) error { - fmt.Fprintf(out, "%s ", prompt) - defer clearLine(out) - s, err = term.ReadPassword(int(in.Fd())) - return err - }) - return -} - -// readCharacter reads a single character from the terminal with no echo. The -// prompt is ephemeral. -func readCharacter(prompt string) (c byte, err error) { - err = withTerminal(func(in, out *os.File) error { - fmt.Fprintf(out, "%s ", prompt) - defer clearLine(out) - - oldState, err := term.MakeRaw(int(in.Fd())) - if err != nil { - return err - } - defer term.Restore(int(in.Fd()), oldState) - - b := make([]byte, 1) - if _, err := in.Read(b); err != nil { - return err - } - - c = b[0] - return nil - }) - return -} - var pluginTerminalUI = &plugin.ClientUI{ DisplayMessage: func(name, message string) error { logger.Global.Printf("%s plugin: %s", name, message) @@ -125,7 +44,7 @@ var pluginTerminalUI = &plugin.ClientUI{ logger.Global.Warningf("could not read value for age-plugin-%s: %v", name, err) } }() - secret, err := readSecret(message) + secret, err := term.ReadSecret(message) if err != nil { return "", err } @@ -139,7 +58,7 @@ var pluginTerminalUI = &plugin.ClientUI{ }() if no == "" { message += fmt.Sprintf(" (press enter for %q)", yes) - _, err := readSecret(message) + _, err := term.ReadSecret(message) if err != nil { return false, err } @@ -147,7 +66,7 @@ var pluginTerminalUI = &plugin.ClientUI{ } message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no) for { - selection, err := readCharacter(message) + selection, err := term.ReadCharacter(message) if err != nil { return false, err } diff --git a/internal/term/term.go b/internal/term/term.go new file mode 100644 index 00000000..597c0b16 --- /dev/null +++ b/internal/term/term.go @@ -0,0 +1,98 @@ +// Copyright 2021 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package term + +import ( + "fmt" + "io" + "os" + "runtime" + + "golang.org/x/term" +) + +// clearLine clears the current line on the terminal, or opens a new line if +// terminal escape codes don't work. +func clearLine(out io.Writer) { + const ( + CUI = "\033[" // Control Sequence Introducer + CPL = CUI + "F" // Cursor Previous Line + EL = CUI + "K" // Erase in Line + ) + + // First, open a new line, which is guaranteed to work everywhere. Then, try + // to erase the line above with escape codes. + // + // (We use CRLF instead of LF to work around an apparent bug in WSL2's + // handling of CONOUT$. Only when running a Windows binary from WSL2, the + // cursor would not go back to the start of the line with a simple LF. + // Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.) + fmt.Fprintf(out, "\r\n"+CPL+EL) +} + +// WithTerminal runs f with the terminal input and output files, if available. +// WithTerminal does not open a non-terminal stdin, so the caller does not need +// to check stdinInUse. +func WithTerminal(f func(in, out *os.File) error) error { + if runtime.GOOS == "windows" { + in, err := os.OpenFile("CONIN$", os.O_RDWR, 0) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) + if err != nil { + return err + } + defer out.Close() + return f(in, out) + } else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { + defer tty.Close() + return f(tty, tty) + } else if IsTerminal(os.Stdin) { + return f(os.Stdin, os.Stdin) + } else { + return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) + } +} + +// term.ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral. +func ReadSecret(prompt string) (s []byte, err error) { + err = WithTerminal(func(in, out *os.File) error { + fmt.Fprintf(out, "%s ", prompt) + defer clearLine(out) + s, err = term.ReadPassword(int(in.Fd())) + return err + }) + return +} + +// ReadCharacter reads a single character from the terminal with no echo. The +// prompt is ephemeral. +func ReadCharacter(prompt string) (c byte, err error) { + err = WithTerminal(func(in, out *os.File) error { + fmt.Fprintf(out, "%s ", prompt) + defer clearLine(out) + + oldState, err := term.MakeRaw(int(in.Fd())) + if err != nil { + return err + } + defer term.Restore(int(in.Fd()), oldState) + + b := make([]byte, 1) + if _, err := in.Read(b); err != nil { + return err + } + + c = b[0] + return nil + }) + return +} + +func IsTerminal(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) +}