aerc/lib/crypto/gpg/gpgbin/gpgbin.go
Tim Culverhouse 57699b1fa6 feat: add gpg integration
This commit adds gpg system integration. This is done through two new
packages: gpgbin, which handles the system calls and parsing; and gpg
which is mostly a copy of emersion/go-pgpmail with modifications to
interface with package gpgbin. gpg includes tests for many cases, and
by it's nature also tests package gpgbin. I separated these in case an
external dependency is ever used for the gpg sys-calls/parsing (IE we
mirror how go-pgpmail+openpgp currently are dependencies)

Two new config options are introduced:
* pgp-provider. If it is not explicitly set to "gpg", aerc will default to
it's internal pgp provider
* pgp-key-id: (Optionally) specify a key by short or long keyId

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
Acked-by: Koni Marti <koni.marti@gmail.com>
Acked-by: Robin Jarry <robin@jarry.cc>
2022-04-27 09:46:25 +02:00

262 lines
6.1 KiB
Go

package gpgbin
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"os/exec"
"strconv"
"strings"
"git.sr.ht/~rjarry/aerc/models"
"github.com/mattn/go-isatty"
)
// gpg represents a gpg command with buffers attached to stdout and stderr
type gpg struct {
cmd *exec.Cmd
stdout bytes.Buffer
stderr bytes.Buffer
}
// newGpg creates a new gpg command with buffers attached
func newGpg(stdin io.Reader, args []string) *gpg {
g := new(gpg)
g.cmd = exec.Command("gpg", "--status-fd", "1", "--batch")
g.cmd.Args = append(g.cmd.Args, args...)
g.cmd.Stdin = stdin
g.cmd.Stdout = &g.stdout
g.cmd.Stderr = &g.stderr
return g
}
// parseError parses errors returned by gpg that don't show up with a [GNUPG:]
// prefix
func parseError(s string) error {
lines := strings.Split(s, "\n")
for _, line := range lines {
line = strings.ToLower(line)
if GPGErrors[line] > 0 {
return errors.New(line)
}
}
return errors.New("unknown gpg error")
}
// fields returns the field name from --status-fd output. See:
// https://github.com/gpg/gnupg/blob/master/doc/DETAILS
func field(s string) string {
tokens := strings.SplitN(s, " ", 3)
if tokens[0] == "[GNUPG:]" {
return tokens[1]
}
return ""
}
// getIdentity returns the identity of the given key
func getIdentity(key uint64) string {
fpr := fmt.Sprintf("%X", key)
cmd := exec.Command("gpg", "--with-colons", "--batch", "--list-keys", fpr)
var outbuf strings.Builder
cmd.Stdout = &outbuf
cmd.Run()
out := strings.Split(outbuf.String(), "\n")
for _, line := range out {
if strings.HasPrefix(line, "uid") {
flds := strings.Split(line, ":")
return flds[9]
}
}
return ""
}
// longKeyToUint64 returns a uint64 version of the given key
func longKeyToUint64(key string) (uint64, error) {
fpr := string(key[len(key)-16:])
fprUint64, err := strconv.ParseUint(fpr, 16, 64)
if err != nil {
return 0, err
}
return fprUint64, nil
}
// parse parses the output of gpg --status-fd
func parse(r io.Reader, md *models.MessageDetails) error {
var (
logOut io.Writer
logger *log.Logger
)
if !isatty.IsTerminal(os.Stdout.Fd()) {
logOut = os.Stdout
} else {
logOut = ioutil.Discard
os.Stdout, _ = os.Open(os.DevNull)
}
logger = log.New(logOut, "", log.LstdFlags)
var err error
var msgContent []byte
var msgCollecting bool
newLine := []byte("\r\n")
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
if field(line) == "PLAINTEXT_LENGTH" {
continue
}
if strings.HasPrefix(line, "[GNUPG:]") {
msgCollecting = false
logger.Println(line)
}
if msgCollecting {
msgContent = append(msgContent, scanner.Bytes()...)
msgContent = append(msgContent, newLine...)
}
switch field(line) {
case "ENC_TO":
md.IsEncrypted = true
case "DECRYPTION_KEY":
md.DecryptedWithKeyId, err = parseDecryptionKey(line)
md.DecryptedWith = getIdentity(md.DecryptedWithKeyId)
if err != nil {
return err
}
case "DECRYPTION_FAILED":
return fmt.Errorf("gpg: decryption failed")
case "PLAINTEXT":
msgCollecting = true
case "NEWSIG":
md.IsSigned = true
case "GOODSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignedBy = t[3]
case "BADSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignatureError = "gpg: invalid signature"
md.SignedBy = t[3]
case "EXPSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignatureError = "gpg: expired signature"
md.SignedBy = t[3]
case "EXPKEYSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignatureError = "gpg: signature made with expired key"
md.SignedBy = t[3]
case "REVKEYSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignatureError = "gpg: signature made with revoked key"
md.SignedBy = t[3]
case "ERRSIG":
t := strings.SplitN(line, " ", 9)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
if t[7] == "9" {
md.SignatureError = "gpg: missing public key"
}
if t[7] == "4" {
md.SignatureError = "gpg: unsupported algorithm"
}
md.SignedBy = "(unknown signer)"
case "BEGIN_ENCRYPTION":
msgCollecting = true
case "SIG_CREATED":
fields := strings.Split(line, " ")
micalg, err := strconv.Atoi(fields[4])
if err != nil {
return fmt.Errorf("gpg: micalg not found")
}
md.Micalg = micalgs[micalg]
msgCollecting = true
case "VALIDSIG":
fields := strings.Split(line, " ")
micalg, err := strconv.Atoi(fields[9])
if err != nil {
return fmt.Errorf("gpg: micalg not found")
}
md.Micalg = micalgs[micalg]
case "NODATA":
md.SignatureError = "gpg: no signature packet found"
}
}
md.Body = bytes.NewReader(msgContent)
return nil
}
// parseDecryptionKey returns primary key from DECRYPTION_KEY line
func parseDecryptionKey(l string) (uint64, error) {
key := strings.Split(l, " ")[3]
fpr := string(key[len(key)-16:])
fprUint64, err := longKeyToUint64(fpr)
if err != nil {
return 0, err
}
getIdentity(fprUint64)
return fprUint64, nil
}
type GPGError int32
const (
ERROR_NO_PGP_DATA_FOUND GPGError = iota + 1
)
var GPGErrors = map[string]GPGError{
"gpg: no valid openpgp data found.": ERROR_NO_PGP_DATA_FOUND,
}
// micalgs represent hash algorithms for signatures. These are ignored by many
// email clients, but can be used as an additional verification so are sent.
// Both gpgmail and pgpmail implementations in aerc check for matching micalgs
var micalgs = map[int]string{
1: "pgp-md5",
2: "pgp-sha1",
3: "pgp-ripemd160",
8: "pgp-sha256",
9: "pgp-sha384",
10: "pgp-sha512",
11: "pgp-sha224",
}
func logger(s string) {
var (
logOut io.Writer
logger *log.Logger
)
if !isatty.IsTerminal(os.Stdout.Fd()) {
logOut = os.Stdout
} else {
logOut = ioutil.Discard
os.Stdout, _ = os.Open(os.DevNull)
}
logger = log.New(logOut, "", log.LstdFlags)
logger.Println(s)
}