package gpgbin
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
"git.sr.ht/~rjarry/aerc/log"
"git.sr.ht/~rjarry/aerc/models"
)
// 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(strings.Join(lines, ", "))
}
// 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
err := cmd.Run()
if err != nil {
log.Errorf("gpg: failed to get identity: %v", err)
return ""
}
out := strings.Split(outbuf.String(), "\n")
for _, line := range out {
if strings.HasPrefix(line, "uid") {
flds := strings.Split(line, ":")
return flds[9]
}
}
return ""
}
// getKeyId returns the 16 digit key id, if key exists
func getKeyId(s string, private bool) string {
cmd := exec.Command("gpg", "--with-colons", "--batch")
listArg := "--list-keys"
if private {
listArg = "--list-secret-keys"
}
cmd.Args = append(cmd.Args, listArg, s)
var outbuf strings.Builder
cmd.Stdout = &outbuf
err := cmd.Run()
if err != nil {
log.Errorf("gpg: failed to get key ID: %v", err)
return ""
}
out := strings.Split(outbuf.String(), "\n")
for _, line := range out {
if strings.HasPrefix(line, "fpr") {
flds := strings.Split(line, ":")
id := flds[9]
return id[len(id)-16:]
}
}
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 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
log.Tracef(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"
case "FAILURE":
return fmt.Errorf(strings.TrimPrefix(line, "[GNUPG:] "))
}
}
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",
}