mirror of
https://github.com/FiloSottile/age.git
synced 2025-12-23 13:35:14 +00:00
417 lines
11 KiB
Go
417 lines
11 KiB
Go
// Copyright 2021 Google LLC
|
|
//
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file or at
|
|
// https://developers.google.com/open-source/licenses/bsd
|
|
|
|
// Package plugin implements the age plugin protocol.
|
|
package plugin
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
exec "golang.org/x/sys/execabs"
|
|
|
|
"filippo.io/age"
|
|
"filippo.io/age/internal/bech32"
|
|
"filippo.io/age/internal/format"
|
|
)
|
|
|
|
type Recipient struct {
|
|
name string
|
|
encoding string
|
|
ui *ClientUI
|
|
|
|
// identity is true when encoding is an identity string.
|
|
identity bool
|
|
}
|
|
|
|
var _ age.Recipient = &Recipient{}
|
|
|
|
func NewRecipient(s string, ui *ClientUI) (*Recipient, error) {
|
|
hrp, _, err := bech32.Decode(s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid recipient encoding %q: %v", s, err)
|
|
}
|
|
if !strings.HasPrefix(hrp, "age1") {
|
|
return nil, fmt.Errorf("not a plugin recipient %q: %v", s, err)
|
|
}
|
|
name := strings.TrimPrefix(hrp, "age1")
|
|
return &Recipient{
|
|
name: name, encoding: s, ui: ui,
|
|
}, nil
|
|
}
|
|
|
|
// Name returns the plugin name, which is used in the recipient ("age1name1...")
|
|
// and identity ("AGE-PLUGIN-NAME-1...") encodings, as well as in the plugin
|
|
// binary name ("age-plugin-name").
|
|
func (r *Recipient) Name() string {
|
|
return r.name
|
|
}
|
|
|
|
func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) {
|
|
defer func() {
|
|
if err != nil {
|
|
err = fmt.Errorf("%s plugin: %w", r.name, err)
|
|
}
|
|
}()
|
|
|
|
conn, err := openClientConnection(r.name, "recipient-v1")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't start plugin: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Phase 1: client sends recipient or identity and file key
|
|
addType := "add-recipient"
|
|
if r.identity {
|
|
addType = "add-identity"
|
|
}
|
|
if err := writeStanza(conn, addType, r.encoding); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := writeStanzaWithBody(conn, "wrap-file-key", fileKey); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := writeStanza(conn, "done"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Phase 2: plugin responds with stanzas
|
|
sr := format.NewStanzaReader(bufio.NewReader(conn))
|
|
ReadLoop:
|
|
for {
|
|
s, err := sr.ReadStanza()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch s.Type {
|
|
case "recipient-stanza":
|
|
if len(s.Args) < 2 {
|
|
return nil, fmt.Errorf("malformed recipient stanza: unexpected argument count")
|
|
}
|
|
n, err := strconv.Atoi(s.Args[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("malformed recipient stanza: invalid index")
|
|
}
|
|
// We only send a single file key, so the index must be 0.
|
|
if n != 0 {
|
|
return nil, fmt.Errorf("malformed recipient stanza: unexpected index")
|
|
}
|
|
|
|
stanzas = append(stanzas, &age.Stanza{
|
|
Type: s.Args[1],
|
|
Args: s.Args[2:],
|
|
Body: s.Body,
|
|
})
|
|
|
|
if err := writeStanza(conn, "ok"); err != nil {
|
|
return nil, err
|
|
}
|
|
case "error":
|
|
if err := writeStanza(conn, "ok"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return nil, fmt.Errorf("%s", s.Body)
|
|
case "done":
|
|
break ReadLoop
|
|
default:
|
|
if ok, err := r.ui.handle(r.name, conn, s); err != nil {
|
|
return nil, err
|
|
} else if !ok {
|
|
if err := writeStanza(conn, "unsupported"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(stanzas) == 0 {
|
|
return nil, fmt.Errorf("received zero recipient stanzas")
|
|
}
|
|
|
|
return stanzas, nil
|
|
}
|
|
|
|
type Identity struct {
|
|
name string
|
|
encoding string
|
|
ui *ClientUI
|
|
}
|
|
|
|
var _ age.Identity = &Identity{}
|
|
|
|
func NewIdentity(s string, ui *ClientUI) (*Identity, error) {
|
|
hrp, _, err := bech32.Decode(s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid identity encoding: %v", err)
|
|
}
|
|
if !strings.HasPrefix(hrp, "AGE-PLUGIN-") || !strings.HasSuffix(hrp, "-") {
|
|
return nil, fmt.Errorf("not a plugin identity: %v", err)
|
|
}
|
|
name := strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-")
|
|
name = strings.ToLower(name)
|
|
return &Identity{
|
|
name: name, encoding: s, ui: ui,
|
|
}, nil
|
|
}
|
|
|
|
func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) {
|
|
s, err := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Identity{
|
|
name: name, encoding: s, ui: ui,
|
|
}, nil
|
|
}
|
|
|
|
// Name returns the plugin name, which is used in the recipient ("age1name1...")
|
|
// and identity ("AGE-PLUGIN-NAME-1...") encodings, as well as in the plugin
|
|
// binary name ("age-plugin-name").
|
|
func (i *Identity) Name() string {
|
|
return i.name
|
|
}
|
|
|
|
// Recipient returns a Recipient wrapping this identity. When that Recipient is
|
|
// used to encrypt a file key, the identity encoding is provided as-is to the
|
|
// plugin, which is expected to support encrypting to identities.
|
|
func (i *Identity) Recipient() *Recipient {
|
|
return &Recipient{
|
|
name: i.name,
|
|
encoding: i.encoding,
|
|
identity: true,
|
|
ui: i.ui,
|
|
}
|
|
}
|
|
|
|
func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
|
|
defer func() {
|
|
if err != nil {
|
|
err = fmt.Errorf("%s plugin: %w", i.name, err)
|
|
}
|
|
}()
|
|
|
|
conn, err := openClientConnection(i.name, "identity-v1")
|
|
if err != nil {
|
|
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
|
|
if err := writeStanza(conn, "add-identity", i.encoding); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, rs := range stanzas {
|
|
s := &format.Stanza{
|
|
Type: "recipient-stanza",
|
|
Args: append([]string{"0", rs.Type}, rs.Args...),
|
|
Body: rs.Body,
|
|
}
|
|
if err := s.Marshal(conn); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := writeStanza(conn, "done"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Phase 2: plugin responds with various commands and a file key
|
|
sr := format.NewStanzaReader(bufio.NewReader(conn))
|
|
ReadLoop:
|
|
for {
|
|
s, err := sr.ReadStanza()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch s.Type {
|
|
case "file-key":
|
|
if len(s.Args) != 1 {
|
|
return nil, fmt.Errorf("malformed file-key stanza: unexpected arguments count")
|
|
}
|
|
n, err := strconv.Atoi(s.Args[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("malformed file-key stanza: invalid index")
|
|
}
|
|
// We only send a single file key, so the index must be 0.
|
|
if n != 0 {
|
|
return nil, fmt.Errorf("malformed file-key stanza: unexpected index")
|
|
}
|
|
if fileKey != nil {
|
|
return nil, fmt.Errorf("received duplicated file-key stanza")
|
|
}
|
|
|
|
fileKey = s.Body
|
|
|
|
if err := writeStanza(conn, "ok"); err != nil {
|
|
return nil, err
|
|
}
|
|
case "error":
|
|
if err := writeStanza(conn, "ok"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return nil, fmt.Errorf("%s", s.Body)
|
|
case "done":
|
|
break ReadLoop
|
|
default:
|
|
if ok, err := i.ui.handle(i.name, conn, s); err != nil {
|
|
return nil, err
|
|
} else if !ok {
|
|
if err := writeStanza(conn, "unsupported"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if fileKey == nil {
|
|
return nil, age.ErrIncorrectIdentity
|
|
}
|
|
return fileKey, nil
|
|
}
|
|
|
|
// ClientUI holds callbacks that will be invoked by (Un)Wrap if the plugin
|
|
// wishes to interact with the user. If any of them is nil or returns an error,
|
|
// failure will be reported to the plugin.
|
|
type ClientUI struct {
|
|
// DisplayMessage displays the message, which is expected to have lowercase
|
|
// initials and no final period.
|
|
DisplayMessage func(name, message string) error
|
|
|
|
// RequestValue requests a secret or public input, with the provided prompt.
|
|
RequestValue func(name, prompt string, secret bool) (string, error)
|
|
|
|
// Confirm requests a confirmation with the provided prompt. The yes and no
|
|
// value are the choices provided to the user. no may be empty. The return
|
|
// value indicates whether the user selected the yes or no option.
|
|
Confirm func(name, prompt, yes, no string) (choseYes bool, err error)
|
|
}
|
|
|
|
func (c *ClientUI) handle(name string, conn *clientConnection, s *format.Stanza) (ok bool, err error) {
|
|
// TODO: surface non-fatal but probably useful errors.
|
|
switch s.Type {
|
|
case "msg":
|
|
if c.DisplayMessage == nil {
|
|
return true, writeStanza(conn, "fail")
|
|
}
|
|
if err := c.DisplayMessage(name, string(s.Body)); err != nil {
|
|
return true, writeStanza(conn, "fail")
|
|
}
|
|
return true, writeStanza(conn, "ok")
|
|
case "request-secret", "request-public":
|
|
if c.RequestValue == nil {
|
|
return true, writeStanza(conn, "fail")
|
|
}
|
|
secret, err := c.RequestValue(name, string(s.Body), s.Type == "request-secret")
|
|
if err != nil {
|
|
return true, writeStanza(conn, "fail")
|
|
}
|
|
return true, writeStanzaWithBody(conn, "ok", []byte(secret))
|
|
case "confirm":
|
|
if len(s.Args) != 1 && len(s.Args) != 2 {
|
|
return true, fmt.Errorf("malformed confirm stanza: unexpected number of arguments")
|
|
}
|
|
if c.Confirm == nil {
|
|
return true, writeStanza(conn, "fail")
|
|
}
|
|
yes, err := format.DecodeString(s.Args[0])
|
|
if err != nil {
|
|
return true, fmt.Errorf("malformed confirm stanza: invalid YES option encoding")
|
|
}
|
|
var no []byte
|
|
if len(s.Args) == 2 {
|
|
no, err = format.DecodeString(s.Args[1])
|
|
if err != nil {
|
|
return true, fmt.Errorf("malformed confirm stanza: invalid NO option encoding")
|
|
}
|
|
}
|
|
choseYes, err := c.Confirm(name, string(s.Body), string(yes), string(no))
|
|
if err != nil {
|
|
return true, writeStanza(conn, "fail")
|
|
}
|
|
result := "yes"
|
|
if !choseYes {
|
|
result = "no"
|
|
}
|
|
return true, writeStanza(conn, "ok", result)
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
type clientConnection struct {
|
|
cmd *exec.Cmd
|
|
io.Reader // stdout
|
|
io.Writer // stdin
|
|
stderr bytes.Buffer
|
|
close func()
|
|
}
|
|
|
|
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,
|
|
Writer: stdin,
|
|
close: func() {
|
|
stdin.Close()
|
|
stdout.Close()
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
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.close()
|
|
cc.cmd.Process.Signal(os.Interrupt)
|
|
return cc.cmd.Wait()
|
|
}
|
|
|
|
func writeStanza(conn io.Writer, t string, args ...string) error {
|
|
s := &format.Stanza{Type: t, Args: args}
|
|
return s.Marshal(conn)
|
|
}
|
|
|
|
func writeStanzaWithBody(conn io.Writer, t string, body []byte) error {
|
|
s := &format.Stanza{Type: t, Body: body}
|
|
return s.Marshal(conn)
|
|
}
|