mirror of
https://github.com/FiloSottile/age.git
synced 2025-12-23 05:25:14 +00:00
cmd/age: clean up the terminal UI
This commit is contained in:
@@ -10,7 +10,6 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
@@ -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]
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
cmd/age/tui.go
Normal file
113
cmd/age/tui.go
Normal file
@@ -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
go.mod
2
go.mod
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user