Skip to content

Commit

Permalink
cmd/age: clean up the terminal UI
Browse files Browse the repository at this point in the history
  • Loading branch information
FiloSottile committed May 24, 2022
1 parent 384d039 commit 349ed5e
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 100 deletions.
57 changes: 24 additions & 33 deletions cmd/age/age.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"flag"
"fmt"
"io"
"log"
"os"
"regexp"
"runtime/debug"
Expand All @@ -23,15 +22,6 @@ import (
"golang.org/x/term"
)

type multiFlag []string

func (f *multiFlag) String() string { return fmt.Sprint(*f) }

func (f *multiFlag) Set(value string) error {
*f = append(*f, value)
return nil
}

const usage = `Usage:
age [--encrypt] (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
age [--encrypt] --passphrase [--armor] [-o OUTPUT] [INPUT]
Expand Down Expand Up @@ -77,8 +67,20 @@ Example:
// golang.org/issue/29814 and golang.org/issue/29228.
var Version string

// stdinInUse is used to ensure only one of input, recipients, or identities
// file is read from stdin. It's a singleton like os.Stdin.
var stdinInUse bool

type multiFlag []string

func (f *multiFlag) String() string { return fmt.Sprint(*f) }

func (f *multiFlag) Set(value string) error {
*f = append(*f, value)
return nil
}

func main() {
log.SetFlags(0)
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }

if len(os.Args) == 1 {
Expand Down Expand Up @@ -119,6 +121,8 @@ func main() {
return
}
if buildInfo, ok := debug.ReadBuildInfo(); ok {
// TODO: use buildInfo.Settings to prepare a pseudoversion such as
// v0.0.0-20210817164053-32db794688a5+dirty on Go 1.18+.
fmt.Println(buildInfo.Main.Version)
return
}
Expand Down Expand Up @@ -255,7 +259,7 @@ func main() {
}

func passphrasePromptForEncryption() (string, error) {
pass, err := readPassphrase("Enter passphrase (leave empty to autogenerate a secure one):")
pass, err := readSecret("Enter passphrase (leave empty to autogenerate a secure one):")
if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err)
}
Expand All @@ -266,10 +270,14 @@ func passphrasePromptForEncryption() (string, error) {
words = append(words, randomWord())
}
p = strings.Join(words, "-")
// TODO: consider printing this to the terminal, instead of stderr.
fmt.Fprintf(os.Stderr, "Using the autogenerated passphrase %q.\n", p)
// It's somewhat unfortunate that the prompt comes through the terminal,
// while the autogenerated passphrase is printed to stderr. However,
// thinking about the terminal as a pinentry UI, it's better for the
// passphrase to stick around and be copy-pastable, than to show up in
// ephemeral UI.
printf("using autogenerated passphrase %q", p)
} else {
confirm, err := readPassphrase("Confirm passphrase:")
confirm, err := readSecret("Confirm passphrase:")
if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err)
}
Expand Down Expand Up @@ -390,7 +398,7 @@ func decrypt(keys []string, in io.Reader, out io.Writer) {
}

func passphrasePrompt() (string, error) {
pass, err := readPassphrase("Enter passphrase:")
pass, err := readSecret("Enter passphrase:")
if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err)
}
Expand Down Expand Up @@ -450,20 +458,3 @@ func (l *lazyOpener) Close() error {
}
return nil
}

func errorf(format string, v ...interface{}) {
log.Printf("age: error: "+format, v...)
log.Fatalf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
}

func warningf(format string, v ...interface{}) {
log.Printf("age: warning: "+format, v...)
}

func errorWithHint(error string, hints ...string) {
log.Printf("age: error: %s", error)
for _, hint := range hints {
log.Printf("age: hint: %s", hint)
}
log.Fatalf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
}
40 changes: 3 additions & 37 deletions cmd/age/encrypted_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import (
"bytes"
"errors"
"fmt"
"os"
"runtime"

"filippo.io/age"
"golang.org/x/term"
)

// LazyScryptIdentity is an age.Identity that requests a passphrase only if it
// encounters an scrypt stanza. After obtaining a passphrase, it delegates to
// ScryptIdentity.
type LazyScryptIdentity struct {
Passphrase func() (string, error)
}
Expand Down Expand Up @@ -102,37 +102,3 @@ func (i *EncryptedIdentity) decrypt() error {
i.identities, err = parseIdentities(d)
return err
}

// readPassphrase reads a passphrase from the terminal. It does not read from a
// non-terminal stdin, so it does not check stdinInUse.
func readPassphrase(prompt string) ([]byte, error) {
var in, out *os.File
if runtime.GOOS == "windows" {
var err error
in, err = os.OpenFile("CONIN$", os.O_RDWR, 0)
if err != nil {
return nil, err
}
defer in.Close()
out, err = os.OpenFile("CONOUT$", os.O_WRONLY, 0)
if err != nil {
return nil, err
}
defer out.Close()
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
defer tty.Close()
in, out = tty, tty
} else {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil, fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
}
in, out = os.Stdin, os.Stderr
}
fmt.Fprintf(out, "%s ", prompt)
// Use CRLF 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$ even work at all inside WSL2.
defer fmt.Fprintf(out, "\r\n")
return term.ReadPassword(int(in.Fd()))
}
27 changes: 2 additions & 25 deletions cmd/age/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ import (
"golang.org/x/crypto/ssh"
)

// stdinInUse is set in main. It's a singleton like os.Stdin.
var stdinInUse bool

type gitHubRecipientError struct {
username string
}
Expand Down Expand Up @@ -171,7 +168,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
return []age.Identity{&EncryptedIdentity{
Contents: contents,
Passphrase: func() (string, error) {
pass, err := readPassphrase(fmt.Sprintf("Enter passphrase for identity file %q:", name))
pass, err := readSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name))
if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err)
}
Expand Down Expand Up @@ -261,7 +258,7 @@ func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {
}
}
passphrasePrompt := func() ([]byte, error) {
pass, err := readPassphrase(fmt.Sprintf("Enter passphrase for %q:", name))
pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", name))
if err != nil {
return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err)
}
Expand Down Expand Up @@ -303,23 +300,3 @@ Ensure %q exists, or convert the private key %q to a modern format with "ssh-key
}
return pubKey, nil
}

func pluginDisplayMessage(name string) func(string) error {
return func(message string) error {
fmt.Fprintf(os.Stderr, "[age-plugin-%s] %v\n", name, message)
return nil
}
}

func pluginRequestSecret(name string) func(string, bool) (string, error) {
return func(message string, _ bool) (string, error) {
fmt.Fprintf(os.Stderr, "[age-plugin-%s] %v\n", name, message)
prompt := fmt.Sprintf("[age-plugin-%s] Enter value:", name)
secret, err := readPassphrase(prompt)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not read value for age-plugin-%s: %v", name, err)
return "", err
}
return string(secret), nil
}
}
113 changes: 113 additions & 0 deletions cmd/age/tui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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 main

// This file implements the terminal UI of cmd/age. The rules are:
//
// - Anything that requires user interaction goes to the terminal,
// and is erased afterwards if possible. This UI would be possible
// to replace with a pinentry with no output or UX changes.
//
// - Everything else goes to standard error with an "age:" prefix.
// No capitalized initials and no periods at the end.

import (
"fmt"
"log"
"os"
"runtime"

"golang.org/x/term"
)

// l is a logger with no prefixes.
var l = log.New(os.Stderr, "", 0)

func printf(format string, v ...interface{}) {
l.Printf("age: "+format, v...)
}

func errorf(format string, v ...interface{}) {
l.Printf("age: error: "+format, v...)
l.Fatalf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
}

func warningf(format string, v ...interface{}) {
l.Printf("age: warning: "+format, v...)
}

func errorWithHint(error string, hints ...string) {
l.Printf("age: error: %s", error)
for _, hint := range hints {
l.Printf("age: hint: %s", hint)
}
l.Fatalf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
}

// Terminal escape codes to erase the previous line.
const (
CUI = "\033[" // Control Sequence Introducer
CPL = CUI + "F" // Cursor Previous Line
EL = CUI + "K" // Erase in Line
CHA = CUI + "G" // Cursor Horizontal Absolute
)

// readSecret reads a value from the terminal with no echo. The prompt is
// ephemeral. readSecret does not read from a non-terminal stdin, so it does not
// check stdinInUse.
func readSecret(prompt string) ([]byte, error) {
var in, out *os.File
if runtime.GOOS == "windows" {
var err error
in, err = os.OpenFile("CONIN$", os.O_RDWR, 0)
if err != nil {
return nil, err
}
defer in.Close()
out, err = os.OpenFile("CONOUT$", os.O_WRONLY, 0)
if err != nil {
return nil, err
}
defer out.Close()
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
defer tty.Close()
in, out = tty, tty
} else {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil, fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
}
in, out = os.Stdin, os.Stderr
}

fmt.Fprintf(out, "%s ", prompt)

// First, open a new line (since the return character is not echoed, like
// the password), 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$
// even work at all inside WSL2.)
defer fmt.Fprintf(out, "\r\n"+CPL+EL)

return term.ReadPassword(int(in.Fd()))
}

func pluginDisplayMessage(name string) func(string) error {
return func(message string) error {
printf("%s plugin: %s", name, message)
return nil
}
}

func pluginRequestSecret(name string) func(string, bool) (string, error) {
return func(message string, _ bool) (string, error) {
secret, err := readSecret(message)
if err != nil {
return "", fmt.Errorf("could not read value for age-plugin-%s: %v", name, err)
}
return string(secret), nil
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ go 1.17
require (
filippo.io/edwards25519 v1.0.0-rc.1
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
golang.org/x/sys v0.0.0-20210903071746-97244b99971b
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
)
10 changes: 6 additions & 4 deletions internal/plugin/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func (r *Recipient) Name() string {
func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("age-plugin-%s: %w", r.name, err)
err = fmt.Errorf("%s plugin: %w", r.name, err)
}
}()

Expand Down Expand Up @@ -254,7 +254,7 @@ func (i *Identity) Recipient() *Recipient {
func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("age-plugin-%s: %w", i.name, err)
err = fmt.Errorf("%s plugin: %w", i.name, err)
}
}()

Expand Down Expand Up @@ -418,10 +418,12 @@ func openClientConnection(name, protocol string) (*clientConnection, error) {
if os.Getenv("AGEDEBUG") == "plugin" {
cc.Reader = io.TeeReader(cc.Reader, os.Stderr)
cc.Writer = io.MultiWriter(cc.Writer, os.Stderr)
cmd.Stderr = os.Stderr
}

cmd.Stderr = &cc.stderr

// We don't want the plugins to rely on the working directory for anything
// as different clients might treat it differently, so we set it to an empty
// temporary directory.
cmd.Dir = os.TempDir()

if err := cmd.Start(); err != nil {
Expand Down

0 comments on commit 349ed5e

Please sign in to comment.