mirror of
https://github.com/FiloSottile/age.git
synced 2026-01-03 19:03:57 +00:00
cmd/age: clean up the terminal UI
This commit is contained in:
@@ -10,7 +10,6 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
@@ -23,15 +22,6 @@ import (
|
|||||||
"golang.org/x/term"
|
"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:
|
const usage = `Usage:
|
||||||
age [--encrypt] (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
|
age [--encrypt] (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
|
||||||
age [--encrypt] --passphrase [--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.
|
// golang.org/issue/29814 and golang.org/issue/29228.
|
||||||
var Version string
|
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() {
|
func main() {
|
||||||
log.SetFlags(0)
|
|
||||||
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
|
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
|
||||||
|
|
||||||
if len(os.Args) == 1 {
|
if len(os.Args) == 1 {
|
||||||
@@ -119,6 +121,8 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if buildInfo, ok := debug.ReadBuildInfo(); ok {
|
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)
|
fmt.Println(buildInfo.Main.Version)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -255,7 +259,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func passphrasePromptForEncryption() (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||||
}
|
}
|
||||||
@@ -266,10 +270,14 @@ func passphrasePromptForEncryption() (string, error) {
|
|||||||
words = append(words, randomWord())
|
words = append(words, randomWord())
|
||||||
}
|
}
|
||||||
p = strings.Join(words, "-")
|
p = strings.Join(words, "-")
|
||||||
// TODO: consider printing this to the terminal, instead of stderr.
|
// It's somewhat unfortunate that the prompt comes through the terminal,
|
||||||
fmt.Fprintf(os.Stderr, "Using the autogenerated passphrase %q.\n", p)
|
// 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 {
|
} else {
|
||||||
confirm, err := readPassphrase("Confirm passphrase:")
|
confirm, err := readSecret("Confirm passphrase:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
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) {
|
func passphrasePrompt() (string, error) {
|
||||||
pass, err := readPassphrase("Enter passphrase:")
|
pass, err := readSecret("Enter passphrase:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||||
}
|
}
|
||||||
@@ -450,20 +458,3 @@ func (l *lazyOpener) Close() error {
|
|||||||
}
|
}
|
||||||
return nil
|
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"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
"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 {
|
type LazyScryptIdentity struct {
|
||||||
Passphrase func() (string, error)
|
Passphrase func() (string, error)
|
||||||
}
|
}
|
||||||
@@ -102,37 +102,3 @@ func (i *EncryptedIdentity) decrypt() error {
|
|||||||
i.identities, err = parseIdentities(d)
|
i.identities, err = parseIdentities(d)
|
||||||
return err
|
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"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stdinInUse is set in main. It's a singleton like os.Stdin.
|
|
||||||
var stdinInUse bool
|
|
||||||
|
|
||||||
type gitHubRecipientError struct {
|
type gitHubRecipientError struct {
|
||||||
username string
|
username string
|
||||||
}
|
}
|
||||||
@@ -171,7 +168,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
|
|||||||
return []age.Identity{&EncryptedIdentity{
|
return []age.Identity{&EncryptedIdentity{
|
||||||
Contents: contents,
|
Contents: contents,
|
||||||
Passphrase: func() (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err)
|
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
|
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 (
|
require (
|
||||||
filippo.io/edwards25519 v1.0.0-rc.1
|
filippo.io/edwards25519 v1.0.0-rc.1
|
||||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
|
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/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) {
|
func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
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) {
|
func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
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" {
|
if os.Getenv("AGEDEBUG") == "plugin" {
|
||||||
cc.Reader = io.TeeReader(cc.Reader, os.Stderr)
|
cc.Reader = io.TeeReader(cc.Reader, os.Stderr)
|
||||||
cc.Writer = io.MultiWriter(cc.Writer, 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()
|
cmd.Dir = os.TempDir()
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user