summaryrefslogtreecommitdiff
path: root/lib/crypto/gpg/gpgbin/gpgbin.go
diff options
context:
space:
mode:
authorTim Culverhouse <tim@timculverhouse.com>2022-04-25 08:30:44 -0500
committerRobin Jarry <robin@jarry.cc>2022-04-27 09:46:25 +0200
commit57699b1fa6367a42d5877afcfdb1504e52835ed9 (patch)
treeb5000bfad3d62f01127f5831d64d27aac07872e1 /lib/crypto/gpg/gpgbin/gpgbin.go
parentd09636ee0b9957ed60fc01224ddfbb03c4f4b7fa (diff)
downloadaerc-57699b1fa6367a42d5877afcfdb1504e52835ed9.zip
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>
Diffstat (limited to 'lib/crypto/gpg/gpgbin/gpgbin.go')
-rw-r--r--lib/crypto/gpg/gpgbin/gpgbin.go262
1 files changed, 262 insertions, 0 deletions
diff --git a/lib/crypto/gpg/gpgbin/gpgbin.go b/lib/crypto/gpg/gpgbin/gpgbin.go
new file mode 100644
index 0000000..da046f4
--- /dev/null
+++ b/lib/crypto/gpg/gpgbin/gpgbin.go
@@ -0,0 +1,262 @@
+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)
+}