diff --git a/cmd/age/age.go b/cmd/age/age.go index 1717c0e..aa68f5f 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -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") -} diff --git a/cmd/age/encrypted_keys.go b/cmd/age/encrypted_keys.go index 7cbf696..ef66085 100644 --- a/cmd/age/encrypted_keys.go +++ b/cmd/age/encrypted_keys.go @@ -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())) -} diff --git a/cmd/age/parse.go b/cmd/age/parse.go index ed3ae6b..16bb15e 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -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 - } -} diff --git a/cmd/age/tui.go b/cmd/age/tui.go new file mode 100644 index 0000000..956f4f8 --- /dev/null +++ b/cmd/age/tui.go @@ -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 + } +} diff --git a/go.mod b/go.mod index f898b77..0120f90 100644 --- a/go.mod +++ b/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 ) diff --git a/internal/plugin/client.go b/internal/plugin/client.go index 21e2973..15834fe 100644 --- a/internal/plugin/client.go +++ b/internal/plugin/client.go @@ -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 {