diff --git a/cmd/age/parse.go b/cmd/age/parse.go index e12534a..28c3fd9 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -201,11 +201,18 @@ 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) + i, err := plugin.NewIdentity(s) + if err != nil { + return nil, err + } + i.DisplayMessage = pluginDisplayMessage(i.Name()) + i.RequestSecret = pluginRequestSecret(i.Name()) + return i, nil case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): return age.ParseX25519Identity(s) + default: + return nil, fmt.Errorf("unknown identity type") } - return nil, fmt.Errorf("unknown identity type") } // parseIdentities is like age.ParseIdentities, but supports plugin identities. @@ -290,3 +297,23 @@ 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) (string, error) { + return func(message string) (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/go.mod b/go.mod index 2858c16..f898b77 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,5 @@ 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 ) - -require golang.org/x/sys v0.0.0-20210903071746-97244b99971b // indirect diff --git a/internal/plugin/client.go b/internal/plugin/client.go index c70c322..4a8a952 100644 --- a/internal/plugin/client.go +++ b/internal/plugin/client.go @@ -11,11 +11,13 @@ import ( "bufio" "bytes" "fmt" - "os/exec" - "path/filepath" + "io" + "os" "strconv" "strings" + exec "golang.org/x/sys/execabs" + "filippo.io/age" "filippo.io/age/internal/bech32" "filippo.io/age/internal/format" @@ -49,29 +51,25 @@ func (r *Recipient) Name() string { return r.name } -func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { - cmd := exec.Command("age-plugin-"+r.name, "--age-plugin=recipient-v1") - stderr := &bytes.Buffer{} - cmd.Stderr = stderr - stdout, err := cmd.StdoutPipe() +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) + } + }() + + conn, err := openClientConnection(r.name, "recipient-v1") if err != nil { - return nil, err - } - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - cmd.Dir = filepath.Clean("/") // TODO: does this work on Windows - if err := cmd.Start(); err != nil { - return nil, err + return nil, fmt.Errorf("couldn't start plugin: %v", err) } + defer conn.Close() // Phase 1: client sends recipient and file key s := &format.Stanza{ Type: "add-recipient", Args: []string{r.encoding}, } - if err := s.Marshal(stdin); err != nil { + if err := s.Marshal(conn); err != nil { return nil, err } @@ -79,20 +77,19 @@ func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { Type: "wrap-file-key", Body: fileKey, } - if err := s.Marshal(stdin); err != nil { + if err := s.Marshal(conn); err != nil { return nil, err } s = &format.Stanza{ Type: "done", } - if err := s.Marshal(stdin); err != nil { + if err := s.Marshal(conn); err != nil { return nil, err } // Phase 2: plugin responds with stanzas - var out []*age.Stanza - sr := format.NewStanzaReader(bufio.NewReader(stdout)) + sr := format.NewStanzaReader(bufio.NewReader(conn)) ReadLoop: for { s, err := sr.ReadStanza() @@ -103,24 +100,24 @@ ReadLoop: switch s.Type { case "recipient-stanza": if len(s.Args) < 2 { - return nil, fmt.Errorf("plugin error: received malformed recipient stanza") + return nil, fmt.Errorf("received malformed recipient stanza") } n, err := strconv.Atoi(s.Args[0]) if err != nil { - return nil, fmt.Errorf("plugin error: received malformed recipient stanza") + return nil, fmt.Errorf("received malformed recipient stanza") } - // Currently, we only send a single file key, so the index must be 0. + // We only send a single file key, so the index must be 0. if n != 0 { - return nil, fmt.Errorf("plugin error: received malformed recipient stanza") + return nil, fmt.Errorf("received malformed recipient stanza") } - out = append(out, &age.Stanza{ + stanzas = append(stanzas, &age.Stanza{ Type: s.Args[1], Args: s.Args[2:], Body: s.Body, }) case "error": - return nil, fmt.Errorf("plugin error: %q", s.Body) + return nil, fmt.Errorf("%q", s.Body) case "done": break ReadLoop default: @@ -128,23 +125,25 @@ ReadLoop: } } - if len(out) == 0 { - return nil, fmt.Errorf("plugin error: received zero recipient stanzas") + if len(stanzas) == 0 { + return nil, fmt.Errorf("received zero recipient stanzas") } - if err := stdin.Close(); err != nil { - return nil, err - } - if err := cmd.Wait(); err != nil { - return nil, err - } - - return out, nil + return stanzas, nil } type Identity struct { name string encoding string + + // DisplayMessage is a callback that will be invoked by Unwrap if the plugin + // wishes to display a message to the user. If DisplayMessage is nil or + // returns an error, failure will be reported to the plugin. + DisplayMessage func(message string) error + // RequestSecret is a callback that will be invoked by Unwrap if the plugin + // wishes to request a secret from the user. If RequestSecret is nil or + // returns an error, failure will be reported to the plugin. + RequestSecret func(message string) (string, error) } var _ age.Identity = &Identity{} @@ -172,30 +171,24 @@ func (i *Identity) Name() string { } func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { - // TODO: DRY up connection management into a connection type, and defer - // closing the connection. - cmd := exec.Command("age-plugin-"+i.name, "--age-plugin=identity-v1") - stderr := &bytes.Buffer{} - cmd.Stderr = stderr - stdout, err := cmd.StdoutPipe() + defer func() { + if err != nil { + err = fmt.Errorf("age-plugin-%s: %w", i.name, err) + } + }() + + conn, err := openClientConnection(i.name, "identity-v1") if err != nil { - return nil, err - } - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - cmd.Dir = filepath.Clean("/") // TODO: does this work on Windows - if err := cmd.Start(); err != nil { - return nil, err + return nil, fmt.Errorf("couldn't start plugin: %v", err) } + defer conn.Close() // Phase 1: client sends the plugin the identity string and the stanzas s := &format.Stanza{ Type: "add-identity", Args: []string{i.encoding}, } - if err := s.Marshal(stdin); err != nil { + if err := s.Marshal(conn); err != nil { return nil, err } @@ -205,7 +198,7 @@ func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { Args: append([]string{"0", rs.Type}, rs.Args...), Body: rs.Body, } - if err := s.Marshal(stdin); err != nil { + if err := s.Marshal(conn); err != nil { return nil, err } } @@ -213,13 +206,12 @@ func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { s = &format.Stanza{ Type: "done", } - if err := s.Marshal(stdin); err != nil { + if err := s.Marshal(conn); err != nil { return nil, err } // Phase 2: plugin responds with various commands and a file key - var out []byte - sr := format.NewStanzaReader(bufio.NewReader(stdout)) + sr := format.NewStanzaReader(bufio.NewReader(conn)) ReadLoop: for { s, err := sr.ReadStanza() @@ -229,43 +221,72 @@ ReadLoop: switch s.Type { case "msg": - // TODO: unimplemented. - ss := &format.Stanza{Type: "ok"} - if err := ss.Marshal(stdin); err != nil { - return nil, err + if i.DisplayMessage == nil { + ss := &format.Stanza{Type: "fail"} + if err := ss.Marshal(conn); err != nil { + return nil, err + } + break + } + if err := i.DisplayMessage(string(s.Body)); err != nil { + ss := &format.Stanza{Type: "fail"} + if err := ss.Marshal(conn); err != nil { + return nil, err + } + } else { + ss := &format.Stanza{Type: "ok"} + if err := ss.Marshal(conn); err != nil { + return nil, err + } } case "request-secret": - // TODO: unimplemented. - ss := &format.Stanza{Type: "fail"} - if err := ss.Marshal(stdin); err != nil { - return nil, err + if i.RequestSecret == nil { + ss := &format.Stanza{Type: "fail"} + if err := ss.Marshal(conn); err != nil { + return nil, err + } + break + } + if secret, err := i.RequestSecret(string(s.Body)); err != nil { + ss := &format.Stanza{Type: "fail"} + if err := ss.Marshal(conn); err != nil { + return nil, err + } + } else { + ss := &format.Stanza{Type: "ok", Body: []byte(secret)} + if err := ss.Marshal(conn); err != nil { + return nil, err + } } case "file-key": if len(s.Args) != 1 { - return nil, fmt.Errorf("plugin error: received malformed file-key stanza") + return nil, fmt.Errorf("received malformed file-key stanza") } n, err := strconv.Atoi(s.Args[0]) if err != nil { - return nil, fmt.Errorf("plugin error: received malformed file-key stanza") + return nil, fmt.Errorf("received malformed file-key stanza") } - // Currently, we only send a single file key, so the index must be 0. + // We only send a single file key, so the index must be 0. if n != 0 { - return nil, fmt.Errorf("plugin error: received malformed file-key stanza") + return nil, fmt.Errorf("received malformed file-key stanza") + } + if fileKey != nil { + return nil, fmt.Errorf("received duplicated file-key stanza") } - out = s.Body + fileKey = s.Body ss := &format.Stanza{Type: "ok"} - if err := ss.Marshal(stdin); err != nil { + if err := ss.Marshal(conn); err != nil { return nil, err } case "error": ss := &format.Stanza{Type: "ok"} - if err := ss.Marshal(stdin); err != nil { + if err := ss.Marshal(conn); err != nil { return nil, err } - return nil, fmt.Errorf("plugin error: %q", s.Body) + return nil, fmt.Errorf("%q", s.Body) case "done": break ReadLoop default: @@ -273,8 +294,57 @@ ReadLoop: } } - if out == nil { + if fileKey == nil { return nil, age.ErrIncorrectIdentity } - return out, nil + return fileKey, nil +} + +type clientConnection struct { + cmd *exec.Cmd + stderr bytes.Buffer + stdin io.Closer + stdout io.Closer + io.Reader + io.Writer +} + +func openClientConnection(name, protocol string) (*clientConnection, error) { + cmd := exec.Command("age-plugin-"+name, "--age-plugin="+protocol) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + + cc := &clientConnection{ + cmd: cmd, + Reader: stdout, + stdout: stdout, + Writer: stdin, + stdin: stdin, + } + + cmd.Stderr = &cc.stderr + + cmd.Dir = os.TempDir() + + if err := cmd.Start(); err != nil { + return nil, err + } + + return cc, nil +} + +func (cc *clientConnection) Close() error { + // Close stdin and stdout and send SIGINT (if supported) to the plugin, + // then wait for it to cleanup and exit. + cc.stdin.Close() + cc.stdout.Close() + cc.cmd.Process.Signal(os.Interrupt) + return cc.cmd.Wait() }