diff --git a/cmd/age/age.go b/cmd/age/age.go index f6661c1..41b41b0 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -22,8 +22,8 @@ import ( "filippo.io/age" "filippo.io/age/agessh" "filippo.io/age/armor" + "filippo.io/age/internal/term" "filippo.io/age/plugin" - "golang.org/x/term" ) const usage = `Usage: @@ -251,7 +251,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. @@ -275,7 +275,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. @@ -287,7 +287,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{} @@ -309,7 +309,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) } @@ -325,7 +325,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) } @@ -370,7 +370,7 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader } recipients = append(recipients, r...) case "j": - id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) + id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf)) if err != nil { errorf("initializing %q: %v", f.Value, err) } @@ -450,7 +450,7 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) { } identities = append(identities, ids...) case "j": - id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) + id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf)) if err != nil { errorf("initializing %q: %v", f.Value, err) } @@ -509,7 +509,7 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) { var lazyScryptIdentity = &LazyScryptIdentity{passphrasePromptForDecryption} 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 71d1c5b..d62757c 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -16,6 +16,7 @@ import ( "filippo.io/age" "filippo.io/age/agessh" "filippo.io/age/armor" + "filippo.io/age/internal/term" "filippo.io/age/plugin" "filippo.io/age/tag" "golang.org/x/crypto/cryptobyte" @@ -37,7 +38,7 @@ func parseRecipient(arg string) (age.Recipient, error) { case strings.HasPrefix(arg, "age1pq1"): return age.ParseHybridRecipient(arg) case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: - return plugin.NewRecipient(arg, pluginTerminalUI) + return plugin.NewRecipient(arg, plugin.NewTerminalUI(printf, warningf)) case strings.HasPrefix(arg, "age1"): return age.ParseX25519Recipient(arg) case strings.HasPrefix(arg, "ssh-"): @@ -175,7 +176,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) } @@ -211,7 +212,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { func parseIdentity(s string) (age.Identity, error) { switch { case strings.HasPrefix(s, "AGE-PLUGIN-"): - return plugin.NewIdentity(s, pluginTerminalUI) + return plugin.NewIdentity(s, plugin.NewTerminalUI(printf, warningf)) case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): return age.ParseX25519Identity(s) case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"): @@ -265,7 +266,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 3247d6f..4876367 100644 --- a/cmd/age/tui.go +++ b/cmd/age/tui.go @@ -12,19 +12,19 @@ package main // // - Everything else goes to standard error with an "age:" prefix. // No capitalized initials and no periods at the end. +// +// The one exception is the autogenerated passphrase, which goes to +// the terminal, since we really want it to reach the user only. import ( "bytes" - "errors" "fmt" "io" "log" "os" - "runtime" "filippo.io/age/armor" - "filippo.io/age/plugin" - "golang.org/x/term" + "filippo.io/age/internal/term" ) // l is a logger with no prefixes. @@ -53,183 +53,13 @@ func errorWithHint(error string, hints ...string) { os.Exit(1) } -// avoidTerminalEscapeSequences is set if we need to avoid using escape -// sequences to prevent weird characters being printed to the console. This will -// happen on Windows when virtual terminal processing cannot be enabled. -var avoidTerminalEscapeSequences bool - -// 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, if possible. - // - // (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") - if !avoidTerminalEscapeSequences { - fmt.Fprintf(out, 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 -} - -// readPublic reads a value from the terminal. The prompt is ephemeral. -func readPublic(prompt string) (s []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) - - t := term.NewTerminal(in, "") - line, err := t.ReadLine() - s = []byte(line) - 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 { - printf("%s plugin: %s", name, message) - return nil - }, - RequestValue: func(name, message string, isSecret bool) (s string, err error) { - defer func() { - if err != nil { - warningf("could not read value for age-plugin-%s: %v", name, err) - } - }() - if isSecret { - secret, err := readSecret(message) - if err != nil { - return "", err - } - return string(secret), nil - } else { - public, err := readPublic(message) - if err != nil { - return "", err - } - return string(public), nil - } - }, - Confirm: func(name, message, yes, no string) (choseYes bool, err error) { - defer func() { - if err != nil { - warningf("could not read value for age-plugin-%s: %v", name, err) - } - }() - if no == "" { - message += fmt.Sprintf(" (press enter for %q)", yes) - _, err := readSecret(message) - if err != nil { - return false, err - } - return true, nil - } - message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no) - for { - selection, err := readCharacter(message) - if err != nil { - return false, err - } - switch selection { - case '1': - return true, nil - case '2': - return false, nil - case '\x03': // CTRL-C - return false, errors.New("user cancelled prompt") - default: - warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) - } - } - }, - WaitTimer: func(name string) { - printf("waiting on %s plugin...", name) - }, -} - func bufferTerminalInput(in io.Reader) (io.Reader, error) { buf := &bytes.Buffer{} if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) { diff --git a/cmd/age/tui_windows.go b/cmd/age/tui_windows.go deleted file mode 100644 index 2f4f8eb..0000000 --- a/cmd/age/tui_windows.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2022 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 main - -import ( - "errors" - "os" - "syscall" - - "golang.org/x/sys/windows" -) - -// Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell) -// do not have the virtual terminal processing enabled, which is necessary to -// make terminal escape sequences work. For this reason the clearLine function -// may not properly work. Here we enable the virtual terminal processing, if -// possible. -// -// See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences. -func init() { - const ( - ENABLE_PROCESSED_OUTPUT uint32 = 0x1 - ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4 - ) - - kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll") - setConsoleMode := kernel32DLL.NewProc("SetConsoleMode") - - if err := withTerminal(func(in, out *os.File) error { - var mode uint32 - if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil { - return err - } - - mode |= ENABLE_PROCESSED_OUTPUT - mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING - - // If the SetConsoleMode function fails, the return value is zero. - // See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value. - if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 { - return errors.New("SetConsoleMode failed") - } - - return nil - }); err != nil { - avoidTerminalEscapeSequences = true - } -} diff --git a/internal/term/term.go b/internal/term/term.go new file mode 100644 index 0000000..42d7f31 --- /dev/null +++ b/internal/term/term.go @@ -0,0 +1,122 @@ +package term + +import ( + "fmt" + "os" + "runtime" + + "golang.org/x/term" +) + +// enableVirtualTerminalProcessing tries to enable virtual terminal processing +// on Windows. If it fails, avoid using escape sequences to prevent weird +// characters being printed to the console. +var enableVirtualTerminalProcessing func(out *os.File) error + +// clearLine clears the current line on the terminal, or opens a new line if +// terminal escape codes don't work. +func clearLine(out *os.File) { + 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, if possible. + // + // (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") + if enableVirtualTerminalProcessing == nil || enableVirtualTerminalProcessing(out) == nil { + fmt.Fprintf(out, 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 if stdin is in use. +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) + } +} + +// 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 +} + +// ReadPublic reads a value from the terminal. The prompt is ephemeral. +func ReadPublic(prompt string) (s []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) + + t := term.NewTerminal(in, "") + line, err := t.ReadLine() + s = []byte(line) + 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 +} + +// IsTerminal returns whether the given file is a terminal. +func IsTerminal(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) +} diff --git a/internal/term/term_windows.go b/internal/term/term_windows.go new file mode 100644 index 0000000..4e8a690 --- /dev/null +++ b/internal/term/term_windows.go @@ -0,0 +1,48 @@ +// Copyright 2022 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 ( + "errors" + "os" + "syscall" + + "golang.org/x/sys/windows" +) + +func init() { + enableVirtualTerminalProcessing = func(out *os.File) error { + // Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell) + // do not have the virtual terminal processing enabled, which is necessary to + // make terminal escape sequences work. For this reason the clearLine function + // may not properly work. Here we enable the virtual terminal processing, if + // possible. + // + // See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences. + + const ( + ENABLE_PROCESSED_OUTPUT uint32 = 0x1 + ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4 + ) + + kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll") + setConsoleMode := kernel32DLL.NewProc("SetConsoleMode") + + var mode uint32 + if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil { + return err + } + + mode |= ENABLE_PROCESSED_OUTPUT + mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING + + // If the SetConsoleMode function fails, the return value is zero. + // See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value. + if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 { + return errors.New("SetConsoleMode failed") + } + return nil + } +} diff --git a/plugin/tui.go b/plugin/tui.go new file mode 100644 index 0000000..2266385 --- /dev/null +++ b/plugin/tui.go @@ -0,0 +1,78 @@ +package plugin + +import ( + "errors" + "fmt" + + "filippo.io/age/internal/term" +) + +// NewTerminalUI returns a [ClientUI] that uses the terminal to request inputs, +// and the provided functions to display messages and errors. +// +// The terminal is reached directly through /dev/tty or CONIN$/CONOUT$, +// bypassing standard input and output, so this UI can be used even when +// standard input or output are redirected. +func NewTerminalUI(printf, warningf func(format string, v ...interface{})) *ClientUI { + return &ClientUI{ + DisplayMessage: func(name, message string) error { + printf("%s plugin: %s", name, message) + return nil + }, + RequestValue: func(name, message string, isSecret bool) (s string, err error) { + defer func() { + if err != nil { + warningf("could not read value for age-plugin-%s: %v", name, err) + } + }() + if isSecret { + secret, err := term.ReadSecret(message) + if err != nil { + return "", err + } + return string(secret), nil + } else { + public, err := term.ReadPublic(message) + if err != nil { + return "", err + } + return string(public), nil + } + }, + Confirm: func(name, message, yes, no string) (choseYes bool, err error) { + defer func() { + if err != nil { + warningf("could not read value for age-plugin-%s: %v", name, err) + } + }() + if no == "" { + message += fmt.Sprintf(" (press enter for %q)", yes) + _, err := term.ReadSecret(message) + if err != nil { + return false, err + } + return true, nil + } + message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no) + for { + selection, err := term.ReadCharacter(message) + if err != nil { + return false, err + } + switch selection { + case '1': + return true, nil + case '2': + return false, nil + case '\x03': // CTRL-C + return false, errors.New("user cancelled prompt") + default: + warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) + } + } + }, + WaitTimer: func(name string) { + printf("waiting on %s plugin...", name) + }, + } +}