aboutsummaryrefslogtreecommitdiffstats
path: root/lib/crypto
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
parentd09636ee0b9957ed60fc01224ddfbb03c4f4b7fa (diff)
downloadaerc-57699b1fa6367a42d5877afcfdb1504e52835ed9.tar.gz
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')
-rw-r--r--lib/crypto/crypto.go3
-rw-r--r--lib/crypto/gpg/gpg.go62
-rw-r--r--lib/crypto/gpg/gpg_test.go167
-rw-r--r--lib/crypto/gpg/gpgbin/decrypt.go34
-rw-r--r--lib/crypto/gpg/gpgbin/encrypt.go35
-rw-r--r--lib/crypto/gpg/gpgbin/gpgbin.go262
-rw-r--r--lib/crypto/gpg/gpgbin/import.go16
-rw-r--r--lib/crypto/gpg/gpgbin/sign.go27
-rw-r--r--lib/crypto/gpg/gpgbin/verify.go41
-rw-r--r--lib/crypto/gpg/reader.go165
-rw-r--r--lib/crypto/gpg/reader_test.go308
-rw-r--r--lib/crypto/gpg/writer.go179
-rw-r--r--lib/crypto/gpg/writer_test.go122
-rw-r--r--lib/crypto/pgp/pgp.go61
14 files changed, 1461 insertions, 21 deletions
diff --git a/lib/crypto/crypto.go b/lib/crypto/crypto.go
index 47cb9544..47eca99d 100644
--- a/lib/crypto/crypto.go
+++ b/lib/crypto/crypto.go
@@ -5,6 +5,7 @@ import (
"io"
"log"
+ "git.sr.ht/~rjarry/aerc/lib/crypto/gpg"
"git.sr.ht/~rjarry/aerc/lib/crypto/pgp"
"git.sr.ht/~rjarry/aerc/models"
"github.com/ProtonMail/go-crypto/openpgp"
@@ -22,6 +23,8 @@ type Provider interface {
func New(s string) Provider {
switch s {
+ case "gpg":
+ return &gpg.Mail{}
default:
return &pgp.Mail{}
}
diff --git a/lib/crypto/gpg/gpg.go b/lib/crypto/gpg/gpg.go
new file mode 100644
index 00000000..66cd3725
--- /dev/null
+++ b/lib/crypto/gpg/gpg.go
@@ -0,0 +1,62 @@
+package gpg
+
+import (
+ "bytes"
+ "io"
+ "log"
+ "os/exec"
+
+ "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
+ "git.sr.ht/~rjarry/aerc/models"
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/emersion/go-message/mail"
+)
+
+// Mail satisfies the PGPProvider interface in aerc
+type Mail struct {
+ logger *log.Logger
+}
+
+func (m *Mail) Init(l *log.Logger) error {
+ m.logger = l
+ _, err := exec.LookPath("gpg")
+ return err
+}
+
+func (m *Mail) Decrypt(r io.Reader, decryptKeys openpgp.PromptFunction) (*models.MessageDetails, error) {
+ gpgReader, err := Read(r)
+ if err != nil {
+ return nil, err
+ }
+ md := gpgReader.MessageDetails
+ md.SignatureValidity = models.Valid
+ if md.SignatureError != "" {
+ md.SignatureValidity = handleSignatureError(md.SignatureError)
+ }
+ return md, nil
+}
+
+func (m *Mail) ImportKeys(r io.Reader) error {
+ return gpgbin.Import(r)
+}
+
+func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
+
+ return Encrypt(buf, header.Header.Header, rcpts, signer)
+}
+
+func (m *Mail) Sign(buf *bytes.Buffer, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
+ return Sign(buf, header.Header.Header, signer)
+}
+
+func (m *Mail) Close() {}
+
+func handleSignatureError(e string) models.SignatureValidity {
+ if e == "gpg: missing public key" {
+ return models.UnknownEntity
+ }
+ if e == "gpg: header hash does not match actual sig hash" {
+ return models.MicalgMismatch
+ }
+ return models.UnknownValidity
+}
diff --git a/lib/crypto/gpg/gpg_test.go b/lib/crypto/gpg/gpg_test.go
new file mode 100644
index 00000000..61c2ec64
--- /dev/null
+++ b/lib/crypto/gpg/gpg_test.go
@@ -0,0 +1,167 @@
+package gpg
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "log"
+ "os/exec"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+func TestHasGpg(t *testing.T) {
+ gpgmail := new(Mail)
+ hasGpg := gpgmail.Init(new(log.Logger))
+
+ if hasGpg != nil {
+ t.Errorf("System does not have GPG")
+ }
+}
+
+func CleanUp() {
+ cmd := exec.Command("gpg", "--batch", "--yes", "--delete-secret-and-public-keys", testKeyId)
+ err := cmd.Run()
+ if err != nil {
+ fmt.Println("Test cleanup failed: you may need to delete the test keys from your GPG keyring")
+ return
+ }
+}
+
+func toCRLF(s string) string {
+ return strings.ReplaceAll(s, "\n", "\r\n")
+}
+
+func deepEqual(t *testing.T, r *models.MessageDetails, expect *models.MessageDetails) {
+ var resBuf bytes.Buffer
+ if _, err := io.Copy(&resBuf, r.Body); err != nil {
+ t.Fatalf("io.Copy() = %v", err)
+ }
+
+ var expBuf bytes.Buffer
+ if _, err := io.Copy(&expBuf, expect.Body); err != nil {
+ t.Fatalf("io.Copy() = %v", err)
+ }
+
+ if resBuf.String() != expBuf.String() {
+ t.Errorf("MessagesDetails.Body = \n%v\n but want \n%v", resBuf.String(), expBuf.String())
+ }
+
+ if r.IsEncrypted != expect.IsEncrypted {
+ t.Errorf("IsEncrypted = \n%v\n but want \n%v", r.IsEncrypted, expect.IsEncrypted)
+ }
+ if r.IsSigned != expect.IsSigned {
+ t.Errorf("IsSigned = \n%v\n but want \n%v", r.IsSigned, expect.IsSigned)
+ }
+ if r.SignedBy != expect.SignedBy {
+ t.Errorf("SignedBy = \n%v\n but want \n%v", r.SignedBy, expect.SignedBy)
+ }
+ if r.SignedByKeyId != expect.SignedByKeyId {
+ t.Errorf("SignedByKeyId = \n%v\n but want \n%v", r.SignedByKeyId, expect.SignedByKeyId)
+ }
+ if r.SignatureError != expect.SignatureError {
+ t.Errorf("SignatureError = \n%v\n but want \n%v", r.SignatureError, expect.SignatureError)
+ }
+ if r.DecryptedWith != expect.DecryptedWith {
+ t.Errorf("DecryptedWith = \n%v\n but want \n%v", r.DecryptedWith, expect.DecryptedWith)
+ }
+ if r.DecryptedWithKeyId != expect.DecryptedWithKeyId {
+ t.Errorf("DecryptedWithKeyId = \n%v\n but want \n%v", r.DecryptedWithKeyId, expect.DecryptedWithKeyId)
+ }
+}
+
+const testKeyId = `B1A8669354153B799F2217BF307215C13DF7A964`
+
+const testPrivateKeyArmored = `-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+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
++5anTnUn
+=gemU
+-----END PGP PRIVATE KEY BLOCK-----
+`
+
+const testPublicKeyArmored = `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBF5FJf8BCACvlKhSSsv4P8C3Wbv391SrNUBtFquoMuWKtuCr/Ks6KHuofGLn
+bM55uBSQp908aITBDPkaOPsQ3OvwgF7SM8bNIDVpO7FHzCEg2Ysp99iPET/+LsbY
+ugc8oYSuvA5aFFIOMYbAbI+HmbIBuCs+xp0AcU1cemAPzPBDCZs4xl5Y+/ce2yQz
+ZGK9O/tQQIKoBUOWLo/0byAWyD6Gwn/Le3fVxxK6RPeeizDV6VfzHLxhxBNkMgmd
+QUkBkvqF154wYxhzsHn72ushbJpspKz1LQN7d5u6QOq3h2sLwcLbT457qbMfZsZs
+HLhoOibOd+yJ7C6TRbbyC4sQRr+K1CNGcvhJABEBAAG0NEpvaG4gRG9lIChUaGlz
+IGlzIGEgdGVzdCBrZXkpIDxqb2huLmRvZUBleGFtcGxlLm9yZz6JAU4EEwEIADgW
+IQSxqGaTVBU7eZ8iF78wchXBPfepZAUCXkUl/wIbAwULCQgHAgYVCgkICwIEFgID
+AQIeAQIXgAAKCRAwchXBPfepZF4iB/49B7q4AfO3xHEa8LK2H+f7Mnm4dRfS2YPo
+v2p6TRe1h2DxwpTevNQUhXw2U0nfRIEKBAZqgb7NVktkoh0DWtKatms2yHMAS+ah
+lQoHb2gRgXa9M9Tq0x5u9sl0NYnx7Wu5uu6Ybw9luPKoAfO91T0vei0p3eMn3fIV
+0O012ITvmgKJPppQDKFJHGZJMbVDO4TNxP89HgyhB41RO7AZadvu73S00x2K6x+O
+R4s/++4Y98vScCPm3DUOXeoHXKGqFcNYTxJL9bsE2I0uYgvJSxNoK1dVnmvxp3zz
+hcxAdzizgMz0ufY6YLMCjy5MDOzPARkmYPXdkJ6jceOIqGLUw1kquQENBF5FJf8B
+CACpsh5cyHB7eEwQvLzJVsXpTW0Rh/Fe36AwC2Vz13WeE6GFrOvw1qATvtYB1919
+M4B44YH9J7I5SrFZad86Aw4n5Gi0BwLlGNa/oCMvYzlNHaTXURA271ghJqdZizqV
+UETj3WNoaYm4mYMfb0dcayDJvVPWP7InzOsdIRU9WXBUSyVMxNMXccr2UvIuhdPg
+lmVT8NtsWR+q8xBoL2Dp0ojYLVD3MlwKe1pE5mEwasYCkWePLWyGdfDW1MhUDsPH
+3K1IjpPLWU9FBk8KM4z8WooY9/kyMIyRw39MvOHGfgcFBpiZwlELNZGSFhbRun03
+PMk2Qd3k+0FGV1IhFAYsr7QRABEBAAGJATYEGAEIACAWIQSxqGaTVBU7eZ8iF78w
+chXBPfepZAUCXkUl/wIbDAAKCRAwchXBPfepZOtqB/9xsGEgQgm70KYID39H91k4
+ef/RlpRDY1ndC0MoPfqE03IEXTC/MjtU+ksPKEoZeQsxVaUJ2WBueI5WGJ3Y73pO
+HAd7N0SyGHT5s6gK1FSx29be1qiPwUu5KR2jpm3RjgpbymnOWe4C6iiYCFQ85IX+
+LzpE+p9bB02PUrmzOb4MBV6E5mg30UjXIX01+bwZq5XSB4/FaUrQOAxLuRvVRjK0
+CEcFbPGIlkPSW6s4M9xCC2sQi7caFKVK6Zqf78KbOwAHqfS0x9u2jtTIhsgCjGTI
+AOQ5lNwpLEMjwLias6e5sM6hcK9Wo+A9Sw23f8lMau5clOZTJeyAUAff+5anTnUn
+=ZjQT
+-----END PGP PUBLIC KEY BLOCK-----
+`
diff --git a/lib/crypto/gpg/gpgbin/decrypt.go b/lib/crypto/gpg/gpgbin/decrypt.go
new file mode 100644
index 00000000..4b8d8f2b
--- /dev/null
+++ b/lib/crypto/gpg/gpgbin/decrypt.go
@@ -0,0 +1,34 @@
+package gpgbin
+
+import (
+ "bytes"
+ "io"
+ "io/ioutil"
+
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+// Decrypt runs gpg --decrypt on the contents of r. If the packet is signed,
+// the signature is also verified
+func Decrypt(r io.Reader) (*models.MessageDetails, error) {
+ md := new(models.MessageDetails)
+ orig, err := ioutil.ReadAll(r)
+ if err != nil {
+ return md, err
+ }
+ args := []string{"--decrypt"}
+ g := newGpg(bytes.NewReader(orig), args)
+ err = g.cmd.Run()
+ if err != nil {
+ err = parseError(g.stderr.String())
+ switch GPGErrors[err.Error()] {
+ case ERROR_NO_PGP_DATA_FOUND:
+ md.Body = bytes.NewReader(orig)
+ return md, nil
+ default:
+ }
+ }
+ outRdr := bytes.NewReader(g.stdout.Bytes())
+ parse(outRdr, md)
+ return md, nil
+}
diff --git a/lib/crypto/gpg/gpgbin/encrypt.go b/lib/crypto/gpg/gpgbin/encrypt.go
new file mode 100644
index 00000000..4cbac37b
--- /dev/null
+++ b/lib/crypto/gpg/gpgbin/encrypt.go
@@ -0,0 +1,35 @@
+package gpgbin
+
+import (
+ "bytes"
+ "io"
+
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+// Encrypt runs gpg --encrypt [--sign] -r [recipient]. The default is to have
+// --trust-model always set
+func Encrypt(r io.Reader, to []string, from string) ([]byte, error) {
+ //TODO probably shouldn't have --trust-model always a default
+ args := []string{
+ "--armor",
+ "--trust-model", "always",
+ }
+ if from != "" {
+ args = append(args, "--sign", "--default-key", from)
+ }
+ for _, rcpt := range to {
+ args = append(args, "--recipient", rcpt)
+ }
+ args = append(args, "--encrypt", "-")
+
+ g := newGpg(r, args)
+ g.cmd.Run()
+ outRdr := bytes.NewReader(g.stdout.Bytes())
+ var md models.MessageDetails
+ parse(outRdr, &md)
+ var buf bytes.Buffer
+ io.Copy(&buf, md.Body)
+
+ return buf.Bytes(), nil
+}
diff --git a/lib/crypto/gpg/gpgbin/gpgbin.go b/lib/crypto/gpg/gpgbin/gpgbin.go
new file mode 100644
index 00000000..da046f46
--- /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)
+}
diff --git a/lib/crypto/gpg/gpgbin/import.go b/lib/crypto/gpg/gpgbin/import.go
new file mode 100644
index 00000000..49e178b5
--- /dev/null
+++ b/lib/crypto/gpg/gpgbin/import.go
@@ -0,0 +1,16 @@
+package gpgbin
+
+import (
+ "io"
+)
+
+// Import runs gpg --import and thus imports both private and public keys
+func Import(r io.Reader) error {
+ args := []string{"--import"}
+ g := newGpg(r, args)
+ err := g.cmd.Run()
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/lib/crypto/gpg/gpgbin/sign.go b/lib/crypto/gpg/gpgbin/sign.go
new file mode 100644
index 00000000..35ab7e7f
--- /dev/null
+++ b/lib/crypto/gpg/gpgbin/sign.go
@@ -0,0 +1,27 @@
+package gpgbin
+
+import (
+ "bytes"
+ "io"
+
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+// Sign creates a detached signature based on the contents of r
+func Sign(r io.Reader, from string) ([]byte, string, error) {
+ args := []string{
+ "--armor",
+ "--detach-sign",
+ "--default-key", from,
+ }
+
+ g := newGpg(r, args)
+ g.cmd.Run()
+
+ outRdr := bytes.NewReader(g.stdout.Bytes())
+ var md models.MessageDetails
+ parse(outRdr, &md)
+ var buf bytes.Buffer
+ io.Copy(&buf, md.Body)
+ return buf.Bytes(), md.Micalg, nil
+}
diff --git a/lib/crypto/gpg/gpgbin/verify.go b/lib/crypto/gpg/gpgbin/verify.go
new file mode 100644
index 00000000..be9f26f7
--- /dev/null
+++ b/lib/crypto/gpg/gpgbin/verify.go
@@ -0,0 +1,41 @@
+package gpgbin
+
+import (
+ "bytes"
+ "io"
+ "io/ioutil"
+ "os"
+
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+// Verify runs gpg --verify. If s is not nil, then gpg interprets the
+// arguments as a detached signature
+func Verify(m io.Reader, s io.Reader) (*models.MessageDetails, error) {
+ args := []string{"--verify"}
+ if s != nil {
+ // Detached sig, save the sig to a tmp file and send msg over stdin
+ sig, err := ioutil.TempFile("", "sig")
+ if err != nil {
+ return nil, err
+ }
+ io.Copy(sig, s)
+ sig.Close()
+ defer os.Remove(sig.Name())
+ args = append(args, sig.Name(), "-")
+ }
+ orig, err := ioutil.ReadAll(m)
+ if err != nil {
+ return nil, err
+ }
+ g := newGpg(bytes.NewReader(orig), args)
+ g.cmd.Run()
+
+ out := bytes.NewReader(g.stdout.Bytes())
+ md := new(models.MessageDetails)
+ parse(out, md)
+
+ md.Body = bytes.NewReader(orig)
+
+ return md, nil
+}
diff --git a/lib/crypto/gpg/reader.go b/lib/crypto/gpg/reader.go
new file mode 100644
index 00000000..bf977ed4
--- /dev/null
+++ b/lib/crypto/gpg/reader.go
@@ -0,0 +1,165 @@
+// reader.go largerly mimics github.com/emersion/go-gpgmail, with changes made
+// to interface with the gpg package in aerc
+
+package gpg
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "mime"
+ "strings"
+
+ "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
+ "git.sr.ht/~rjarry/aerc/models"
+ "github.com/emersion/go-message/textproto"
+)
+
+type Reader struct {
+ Header textproto.Header
+ MessageDetails *models.MessageDetails
+}
+
+func NewReader(h textproto.Header, body io.Reader) (*Reader, error) {
+ t, params, err := mime.ParseMediaType(h.Get("Content-Type"))
+ if err != nil {
+ return nil, err
+ }
+
+ if strings.EqualFold(t, "multipart/encrypted") && strings.EqualFold(params["protocol"], "application/pgp-encrypted") {
+ mr := textproto.NewMultipartReader(body, params["boundary"])
+ return newEncryptedReader(h, mr)
+ }
+ if strings.EqualFold(t, "multipart/signed") && strings.EqualFold(params["protocol"], "application/pgp-signature") {
+ micalg := params["micalg"]
+ mr := textproto.NewMultipartReader(body, params["boundary"])
+ return newSignedReader(h, mr, micalg)
+ }
+
+ var headerBuf bytes.Buffer
+ textproto.WriteHeader(&headerBuf, h)
+
+ return &Reader{
+ Header: h,
+ MessageDetails: &models.MessageDetails{
+ Body: io.MultiReader(&headerBuf, body),
+ },
+ }, nil
+}
+
+func Read(r io.Reader) (*Reader, error) {
+ br := bufio.NewReader(r)
+
+ h, err := textproto.ReadHeader(br)
+ if err != nil {
+ return nil, err
+ }
+ return NewReader(h, br)
+}
+
+func newEncryptedReader(h textproto.Header, mr *textproto.MultipartReader) (*Reader, error) {
+ p, err := mr.NextPart()
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to read first part in multipart/encrypted message: %v", err)
+ }
+
+ t, _, err := mime.ParseMediaType(p.Header.Get("Content-Type"))
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to parse Content-Type of first part in multipart/encrypted message: %v", err)
+ }
+ if !strings.EqualFold(t, "application/pgp-encrypted") {
+ return nil, fmt.Errorf("gpgmail: first part in multipart/encrypted message has type %q, not application/pgp-encrypted", t)
+ }
+
+ metadata, err := textproto.ReadHeader(bufio.NewReader(p))
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to parse application/pgp-encrypted part: %v", err)
+ }
+ if s := metadata.Get("Version"); s != "1" {
+ return nil, fmt.Errorf("gpgmail: unsupported PGP/MIME version: %q", s)
+ }
+
+ p, err = mr.NextPart()
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to read second part in multipart/encrypted message: %v", err)
+ }
+ t, _, err = mime.ParseMediaType(p.Header.Get("Content-Type"))
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to parse Content-Type of second part in multipart/encrypted message: %v", err)
+ }
+ if !strings.EqualFold(t, "application/octet-stream") {
+ return nil, fmt.Errorf("gpgmail: second part in multipart/encrypted message has type %q, not application/octet-stream", t)
+ }
+
+ md, err := gpgbin.Decrypt(p)
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to read PGP message: %v", err)
+ }
+
+ cleartext := bufio.NewReader(md.Body)
+ cleartextHeader, err := textproto.ReadHeader(cleartext)
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to read encrypted header: %v", err)
+ }
+
+ t, params, err := mime.ParseMediaType(cleartextHeader.Get("Content-Type"))
+ if err != nil {
+ return nil, err
+ }
+
+ if md.IsEncrypted && !md.IsSigned && strings.EqualFold(t, "multipart/signed") && strings.EqualFold(params["protocol"], "application/pgp-signature") {
+ // RFC 1847 encapsulation, see RFC 3156 section 6.1
+ micalg := params["micalg"]
+ mr := textproto.NewMultipartReader(cleartext, params["boundary"])
+ mds, err := newSignedReader(cleartextHeader, mr, micalg)
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to read encapsulated multipart/signed message: %v", err)
+ }
+ mds.MessageDetails.IsEncrypted = md.IsEncrypted
+ mds.MessageDetails.DecryptedWith = md.DecryptedWith
+ mds.MessageDetails.DecryptedWithKeyId = md.DecryptedWithKeyId
+ return mds, nil
+ }
+
+ var headerBuf bytes.Buffer
+ textproto.WriteHeader(&headerBuf, cleartextHeader)
+ md.Body = io.MultiReader(&headerBuf, cleartext)
+
+ return &Reader{
+ Header: h,
+ MessageDetails: md,
+ }, nil
+}
+
+func newSignedReader(h textproto.Header, mr *textproto.MultipartReader, micalg string) (*Reader, error) {
+ micalg = strings.ToLower(micalg)
+ p, err := mr.NextPart()
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to read signed part in multipart/signed message: %v", err)
+ }
+ var headerBuf bytes.Buffer
+ textproto.WriteHeader(&headerBuf, p.Header)
+ var msg bytes.Buffer
+ headerRdr := bytes.NewReader(headerBuf.Bytes())
+ fullMsg := io.MultiReader(headerRdr, p)
+ io.Copy(&msg, fullMsg)
+
+ sig, err := mr.NextPart()
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to read pgp part in multipart/signed message: %v", err)
+ }
+
+ md, err := gpgbin.Verify(&msg, sig)
+ if err != nil {
+ return nil, fmt.Errorf("gpgmail: failed to read PGP message: %v", err)
+ }
+ if md.Micalg != micalg && md.SignatureError == "" {
+ md.SignatureError = "gpg: header hash does not match actual sig hash"
+ }
+
+ return &Reader{
+ Header: h,
+ MessageDetails: md,
+ }, nil
+}
diff --git a/lib/crypto/gpg/reader_test.go b/lib/crypto/gpg/reader_test.go
new file mode 100644
index 00000000..3cd7c4b0
--- /dev/null
+++ b/lib/crypto/gpg/reader_test.go
@@ -0,0 +1,308 @@
+package gpg
+
+import (
+ "bytes"
+ "io"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+func importSecretKey() {
+ r := strings.NewReader(testPrivateKeyArmored)
+ gpgbin.Import(r)
+}
+
+func importPublicKey() {
+ r := strings.NewReader(testPublicKeyArmored)
+ gpgbin.Import(r)
+}
+
+func TestReader_encryptedSignedPGPMIME(t *testing.T) {
+ var expect = models.MessageDetails{
+ IsEncrypted: true,
+ IsSigned: true,
+ SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
+ SignedByKeyId: 3490876580878068068,
+ SignatureError: "",
+ DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>",
+ DecryptedWithKeyId: 3490876580878068068,
+ Body: strings.NewReader(testEncryptedBody),
+ Micalg: "pgp-sha512",
+ }
+
+ importSecretKey()
+ sr := strings.NewReader(testPGPMIMEEncryptedSigned)
+ r, err := Read(sr)
+ if err != nil {
+ t.Fatalf("pgpmail.Read() = %v", err)
+ }
+
+ deepEqual(t, r.MessageDetails, &expect)
+
+ t.Cleanup(CleanUp)
+}
+
+func TestReader_signedPGPMIME(t *testing.T) {
+ var expect = models.MessageDetails{
+ IsEncrypted: false,
+ IsSigned: true,
+ SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
+ SignedByKeyId: 3490876580878068068,
+ SignatureError: "",
+ DecryptedWith: "",
+ DecryptedWithKeyId: 0,
+ Body: strings.NewReader(testSignedBody),
+ Micalg: "pgp-sha256",
+ }
+
+ importSecretKey()
+ importPublicKey()
+ sr := strings.NewReader(testPGPMIMESigned)
+ r, err := Read(sr)
+ if err != nil {
+ t.Fatalf("pgpmail.Read() = %v", err)
+ }
+
+ deepEqual(t, r.MessageDetails, &expect)
+
+ t.Cleanup(CleanUp)
+}
+
+func TestReader_encryptedSignedEncapsulatedPGPMIME(t *testing.T) {
+ var expect = models.MessageDetails{
+ IsEncrypted: true,
+ IsSigned: true,
+ SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
+ SignedByKeyId: 3490876580878068068,
+ SignatureError: "",
+ DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>",
+ DecryptedWithKeyId: 3490876580878068068,
+ Body: strings.NewReader(testSignedBody),
+ Micalg: "pgp-sha256",
+ }
+
+ importSecretKey()
+ importPublicKey()
+ sr := strings.NewReader(testPGPMIMEEncryptedSignedEncapsulated)
+ r, err := Read(sr)
+ if err != nil {
+ t.Fatalf("pgpmail.Read() = %v", err)
+ }
+
+ deepEqual(t, r.MessageDetails, &expect)
+
+ var buf bytes.Buffer
+ if _, err := io.Copy(&buf, r.MessageDetails.Body); err != nil {
+ t.Fatalf("io.Copy() = %v", err)
+ }
+}
+func TestReader_signedPGPMIMEInvalid(t *testing.T) {
+ var expect = models.MessageDetails{
+ IsEncrypted: false,
+ IsSigned: true,
+ SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
+ SignedByKeyId: 3490876580878068068,
+ SignatureError: "gpg: invalid signature",
+ DecryptedWith: "",
+ DecryptedWithKeyId: 0,
+ Body: strings.NewReader(testSignedInvalidBody),
+ Micalg: "",
+ }
+
+ importSecretKey()
+ importPublicKey()
+ sr := strings.NewReader(testPGPMIMESignedInvalid)
+ r, err := Read(sr)
+ if err != nil {
+ t.Fatalf("pgpmail.Read() = %v", err)
+ }
+ deepEqual(t, r.MessageDetails, &expect)
+
+ t.Cleanup(CleanUp)
+}
+
+func TestReader_plaintext(t *testing.T) {
+ sr := strings.NewReader(testPlaintext)
+ r, err := Read(sr)
+ if err != nil {
+ t.Fatalf("pgpmail.Read() = %v", err)
+ }
+
+ var buf bytes.Buffer
+ if _, err := io.Copy(&buf, r.MessageDetails.Body); err != nil {
+ t.Fatalf("io.Copy() = %v", err)
+ }
+
+ if r.MessageDetails.IsEncrypted {
+ t.Errorf("MessageDetails.IsEncrypted != false")
+ }
+ if r.MessageDetails.IsSigned {
+ t.Errorf("MessageDetails.IsSigned != false")
+ }
+
+ if s := buf.String(); s != testPlaintext {
+ t.Errorf("MessagesDetails.UnverifiedBody = \n%v\n but want \n%v", s, testPlaintext)
+ }
+}
+
+var testEncryptedBody = toCRLF(`Content-Type: text/plain
+
+This is an encrypted message!
+`)
+
+var testSignedBody = toCRLF(`Content-Type: text/plain
+
+This is a signed message!
+`)
+
+var testSignedInvalidBody = toCRLF(`Content-Type: text/plain
+
+This is a signed message, but the signature is invalid.
+`)
+
+var testPGPMIMEEncryptedSigned = toCRLF(`From: John Doe <john.doe@example.org>
+To: John Doe <john.doe@example.org>
+Mime-Version: 1.0
+Content-Type: multipart/encrypted; boundary=foo;
+ protocol="application/pgp-encrypted"
+
+--foo
+Content-Type: application/pgp-encrypted
+
+Version: 1
+
+--foo
+Content-Type: application/octet-stream
+
+-----BEGIN PGP MESSAGE-----
+
+hQEMAxF0jxulHQ8+AQf/SBK2FIIgMA4OkCvlqty/1GmAumWq6J0T+pRLppXHvYFb
+jbXRzz2h3pE/OoouI6vWzBwb8xU/5f8neen+fvdsF1N6PyLjZcHRB91oPvP8TuHA
+0vEpiQDbP+0wlQ8BmMnnV06HokWJoKXGmIle0L4QszT/QCbrT80UgKrqXNVHKQtN
+DUcytFsUCmolZRj074FEpEetjH6QGEX5hAYNBUJziXmOv7vdd4AFgNbbgC5j5ezz
+h8tCAKUqeUiproYaAMrI0lfqh/t8bacJNkljI2LOxYfdJ/2317Npwly0OqpCM3YT
+Q4dHuuGM6IuZHtIc9sneIBRhKf8WnWt14hLkHUT80dLA/AHKl0jGYqO34Dxd9JNB
+EEwQ4j6rxauOEbKLAuYYaEqCzNYBasBrPmpNb4Fx2syWkCoYzwvzv7nj4I8vIBmm
+FGsAQLX4c18qtZI4XaG4FPUvFQ01Y0rjTxAV3u51lrYjCxFuI5ZEtiT0J/Tv2Unw
+R6xwtARkEf3W0agegmohEjjkAexKNxGrlulLiPk2j9/dnlAxeGpOuhYuYU2kYbKq
+x3TkcVYRs1FkmCX0YHNJ2zVWLfDYd2f3UVkXINe7mODGx2A2BxvK9Ig7NMuNmWZE
+ELiLSIvQk9jlgqWUMwSGPQKaHPrac02EjcBHef2zCoFbTg0TXQeDr5SV7yguX8jB
+zZnoNs+6+GR1gA6poKzFdiG4NRr0SNgEHazPPkXp3P2KyOINyFJ7SA+HX8iegTqL
+CTPYPK7UNRmb5s2u5B4e9NiQB9L85W4p7p7uemCSu9bxjs8rkCJpvx9Kb8jzPW17
+wnEUe10A4JNDBhxiMg+Fm5oM2VxQVy+eDVFOOq7pDYVcSmZc36wO+EwAKph9shby
+O4sDS4l/8eQTEYUxTavdtQ9O9ZMXvf/L3Rl1uFJXw1lFwPReXwtpA485e031/A==
+=P0jf
+-----END PGP MESSAGE-----
+
+--foo--
+`)
+
+var testPGPMIMEEncryptedSignedEncapsulated = toCRLF(`From: John Doe <john.doe@example.org>
+To: John Doe <john.doe@example.org>
+Mime-Version: 1.0
+Content-Type: multipart/encrypted; boundary=foo;
+ protocol="application/pgp-encrypted"
+
+--foo
+Content-Type: application/pgp-encrypted
+
+Version: 1
+
+--foo
+Content-Type: application/octet-stream
+
+-----BEGIN PGP MESSAGE-----
+
+hQEMAxF0jxulHQ8+AQf9FCth8p+17rzWL0AtKP+aWndvVUYmaKiUZd+Ya8D9cRnc
+FAP//JnRvTPhdOyl8x1FQkVxyuKcgpjaClb6/OLgD0lGYLC15p43G4QyU+jtOOQW
+FFjZj2z8wUuiev8ejNd7DMiOQRSm4d+IIK+Qa2BJ10Y9AuLQtMI8D+joP1D11NeX
+4FO3SYFEuwH5VWlXGo3bRjg8fKFVG/r/xCwBibqRpfjVnS4EgI04XCsnhqdaCRvE
+Bw2XEaF62m2MUNbaan410WajzVSbSIqIHw8U7vpR/1nisS+SZmScuCXWFa6W9YgR
+0nSWi1io2Ratf4F9ORCy0o7QPh7FlpsIUGmp4paF39LpAQ2q0OUnFhkIdLVQscQT
+JJXLbZwp0CYTAgqwdRWFwY7rEPm2k/Oe4cHKJLEn0hS+X7wch9FAYEMifeqa0FcZ
+GjxocAlyhmlM0sXIDYP8xx49t4O8JIQU1ep/SX2+rUAKIh2WRdYDy8GrrHba8V8U
+aBCU9zIMhmOtu7r+FE1djMUhcaSbbvC9zLDMLV8QxogGhxrqaUM8Pj+q1H6myaAr
+o1xd65b6r2Bph6GUmcMwl28i78u9bKoM0mI+EdUuLwS9EbmjtIwEgxNv4LqK8xw2
+/tjCe9JSqg+HDaBYnO4QTM29Y+PltRIe6RxpnBcYULTLcSt1UK3YV1KvhqfXMjoZ
+THsvtxLbmPYFv+g0hiUpuKtyG9NGidKCxrjvNq30KCSUWzNFkh+qv6CPm26sXr5F
+DTsVpFTM/lomg4Po8sE20BZsk/9IzEh4ERSOu3k0m3mI4QAyJmrOpVGUjd//4cqz
+Zhhc3tV78BtEYNh0a+78fAHGtdLocLj5IfOCYQWW//EtOY93TnVAtP0puaiNOc8q
+Vvb5WMamiRJZ9nQXP3paDoqD14B9X6bvNWsDQDkkrWls2sYg7KzqpOM/nlXLBKQd
+Ok4EJfOpd0hICPwo6tJ6sK2meRcDLxtGJybADE7UHJ4t0SrQBfn/sQhRytQtg2wr
+U1Thy6RujlrrrdUryo3Mi+xc9Ot1o35JszCjNQGL6BCFsGi9fx5pjWM+lLiJ15aJ
+jh02mSd/8j7IaJCGgTuyq6uK45EoVqWd1WRSYl4s5tg1g1jckigYYjJdAKNnU/rZ
+iTk5F8GSyv30EXnqvrs=
+=Ibxd
+-----END PGP MESSAGE-----
+
+--foo--
+`)
+
+var testPGPMIMESigned = toCRLF(`From: John Doe <john.doe@example.org>
+To: John Doe <john.doe@example.org>
+Mime-Version: 1.0
+Content-Type: multipart/signed; boundary=bar; micalg=pgp-sha256;
+ protocol="application/pgp-signature"
+
+--bar
+Content-Type: text/plain
+
+This is a signed message!
+
+--bar
+Content-Type: application/pgp-signature
+
+-----BEGIN PGP SIGNATURE-----
+
+iQEzBAABCAAdFiEEsahmk1QVO3mfIhe/MHIVwT33qWQFAl5FRLgACgkQMHIVwT33
+qWSEQQf/YgRlKlQzSyvm6A52lGIRU3F/z9EGjhCryxj+hSdPlk8O7iZFIjnco4Ea
+7QIlsOj6D4AlLdhyK6c8IZV7rZoTNE5rc6I5UZjM4Qa0XoyLjao28zR252TtwwWJ
+e4+wrTQKcVhCyHO6rkvcCpru4qF5CU+Mi8+sf8CNJJyBgw1Pri35rJWMdoTPTqqz
+kcIGN1JySaI8bbVitJQmnm0FtFTiB7zznv94rMBCiPmPUWd9BSpSBJteJoBLZ+K7
+Y7ws2Dzp2sBo/RLUM18oXd0N9PLXvFGI3IuF8ey1SPzQH3QbBdJSTmLzRlPjK7A1
+HVHFb3vTjd71z9j5IGQQ3Awdw30zMg==
+=gOul
+-----END PGP SIGNATURE-----
+
+--bar--
+`)
+
+var testPGPMIMESignedInvalid = toCRLF(`From: John Doe <john.doe@example.org>
+To: John Doe <john.doe@example.org>
+Mime-Version: 1.0
+Content-Type: multipart/signed; boundary=bar; micalg=pgp-sha256;
+ protocol="application/pgp-signature"
+
+--bar
+Content-Type: text/plain
+
+This is a signed message, but the signature is invalid.
+
+--bar
+Content-Type: application/pgp-signature
+
+-----BEGIN PGP SIGNATURE-----
+
+iQEzBAABCAAdFiEEsahmk1QVO3mfIhe/MHIVwT33qWQFAl5FRLgACgkQMHIVwT33
+qWSEQQf/YgRlKlQzSyvm6A52lGIRU3F/z9EGjhCryxj+hSdPlk8O7iZFIjnco4Ea
+7QIlsOj6D4AlLdhyK6c8IZV7rZoTNE5rc6I5UZjM4Qa0XoyLjao28zR252TtwwWJ
+e4+wrTQKcVhCyHO6rkvcCpru4qF5CU+Mi8+sf8CNJJyBgw1Pri35rJWMdoTPTqqz
+kcIGN1JySaI8bbVitJQmnm0FtFTiB7zznv94rMBCiPmPUWd9BSpSBJteJoBLZ+K7
+Y7ws2Dzp2sBo/RLUM18oXd0N9PLXvFGI3IuF8ey1SPzQH3QbBdJSTmLzRlPjK7A1
+HVHFb3vTjd71z9j5IGQQ3Awdw30zMg==
+=gOul
+-----END PGP SIGNATURE-----
+
+--bar--
+`)
+
+var testPlaintext = toCRLF(`From: John Doe <john.doe@example.org>
+To: John Doe <john.doe@example.org>
+Mime-Version: 1.0
+Content-Type: text/plain
+
+This is a plaintext message!
+`)
diff --git a/lib/crypto/gpg/writer.go b/lib/crypto/gpg/writer.go
new file mode 100644
index 00000000..269b4907
--- /dev/null
+++ b/lib/crypto/gpg/writer.go
@@ -0,0 +1,179 @@
+// writer.go largerly mimics github.com/emersion/go-pgpmail, with changes made
+// to interface with the gpg package in aerc
+
+package gpg
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime"
+
+ "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
+ "github.com/emersion/go-message/textproto"
+)
+
+type EncrypterSigner struct {
+ msgBuf bytes.Buffer
+ encryptedWriter io.Writer
+ to []string
+ from string
+}
+
+func (es *EncrypterSigner) Write(p []byte) (int, error) {
+ return es.msgBuf.Write(p)
+}
+
+func (es *EncrypterSigner) Close() (err error) {
+ r := bytes.NewReader(es.msgBuf.Bytes())
+ enc, err := gpgbin.Encrypt(r, es.to, es.from)
+ if err != nil {
+ return err
+ }
+ es.encryptedWriter.Write(enc)
+ return nil
+}
+
+type Signer struct {
+ mw *textproto.MultipartWriter
+ signedMsg bytes.Buffer
+ w io.Writer
+ from string
+ header textproto.Header
+}
+
+func (s *Signer) Write(p []byte) (int, error) {
+ return s.signedMsg.Write(p)
+}
+
+func (s *Signer) Close() (err error) {
+ // TODO should write the whole message up here so we can get the proper micalg from the signature packet
+
+ sig, micalg, err := gpgbin.Sign(bytes.NewReader(s.signedMsg.Bytes()), s.from)
+ if err != nil {
+ return err
+ }
+ params := map[string]string{
+ "boundary": s.mw.Boundary(),
+ "protocol": "application/pgp-signature",
+ "micalg": micalg,
+ }
+ s.header.Set("Content-Type", mime.FormatMediaType("multipart/signed", params))
+
+ if err = textproto.WriteHeader(s.w, s.header); err != nil {
+ return err
+ }
+ boundary := s.mw.Boundary()
+ fmt.Fprintf(s.w, "--%s\r\n", boundary)
+ s.w.Write(s.signedMsg.Bytes())
+ s.w.Write([]byte("\r\n"))
+
+ var signedHeader textproto.Header
+ signedHeader.Set("Content-Type", "application/pgp-signature")
+ signatureWriter, err := s.mw.CreatePart(signedHeader)
+ if err != nil {
+ return err
+ }
+
+ _, err = signatureWriter.Write(sig)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// for tests
+var forceBoundary = ""
+
+type multiCloser []io.Closer
+
+func (mc multiCloser) Close() error {
+ for _, c := range mc {
+ if err := c.Close(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func Encrypt(w io.Writer, h textproto.Header, rcpts []string, from string) (io.WriteCloser, error) {
+ mw := textproto.NewMultipartWriter(w)
+
+ if forceBoundary != "" {
+ mw.SetBoundary(forceBoundary)
+ }
+
+ params := map[string]string{
+ "boundary": mw.Boundary(),
+ "protocol": "application/pgp-encrypted",
+ }
+ h.Set("Content-Type", mime.FormatMediaType("multipart/encrypted", params))
+
+ if err := textproto.WriteHeader(w, h); err != nil {
+ return nil, err
+ }
+
+ var controlHeader textproto.Header
+ controlHeader.Set("Content-Type", "application/pgp-encrypted")
+ controlWriter, err := mw.CreatePart(controlHeader)
+ if err != nil {
+ return nil, err
+ }
+ if _, err = controlWriter.Write([]byte("Version: 1\r\n")); err != nil {
+ return nil, err
+ }
+
+ var encryptedHeader textproto.Header
+ encryptedHeader.Set("Content-Type", "application/octet-stream")
+ encryptedWriter, err := mw.CreatePart(encryptedHeader)
+ if err != nil {
+ return nil, err
+ }
+
+ var buf bytes.Buffer
+ plaintext := &EncrypterSigner{
+ msgBuf: buf,
+ encryptedWriter: encryptedWriter,
+ to: rcpts,
+ from: from,
+ }
+
+ return struct {
+ io.Writer
+ io.Closer
+ }{
+ plaintext,
+ multiCloser{
+ plaintext,
+ mw,
+ },
+ }, nil
+}
+
+func Sign(w io.Writer, h textproto.Header, from string) (io.WriteCloser, error) {
+ mw := textproto.NewMultipartWriter(w)
+
+ if forceBoundary != "" {
+ mw.SetBoundary(forceBoundary)
+ }
+
+ var msg bytes.Buffer
+ plaintext := &Signer{
+ mw: mw,
+ signedMsg: msg,
+ w: w,
+ from: from,
+ header: h,
+ }
+
+ return struct {
+ io.Writer
+ io.Closer
+ }{
+ plaintext,
+ multiCloser{
+ plaintext,
+ mw,
+ },
+ }, nil
+}
diff --git a/lib/crypto/gpg/writer_test.go b/lib/crypto/gpg/writer_test.go
new file mode 100644
index 00000000..0f9ab109
--- /dev/null
+++ b/lib/crypto/gpg/writer_test.go
@@ -0,0 +1,122 @@
+package gpg
+
+import (
+ "bytes"
+ "io"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
+ "git.sr.ht/~rjarry/aerc/models"
+ "github.com/emersion/go-message/textproto"
+)
+
+func init() {
+ forceBoundary = "foo"
+}
+
+func TestEncrypt(t *testing.T) {
+ importPublicKey()
+ importSecretKey()
+ var h textproto.Header
+ h.Set("From", "John Doe <john.doe@example.org>")
+ h.Set("To", "John Doe <john.doe@example.org>")
+
+ var encryptedHeader textproto.Header
+ encryptedHeader.Set("Content-Type", "text/plain")
+
+ var encryptedBody = "This is an encrypted message!\r\n"
+
+ to := []string{"john.doe@example.org"}
+ from := "john.doe@example.org"
+
+ var buf bytes.Buffer
+ cleartext, err := Encrypt(&buf, h, to, from)
+ if err != nil {
+ t.Fatalf("Encrypt() = %v", err)
+ }
+
+ if err = textproto.WriteHeader(cleartext, encryptedHeader); err != nil {
+ t.Fatalf("textproto.WriteHeader() = %v", err)
+ }
+ if _, err = io.WriteString(cleartext, encryptedBody); err != nil {
+ t.Fatalf("io.WriteString() = %v", err)
+ }
+ if err = cleartext.Close(); err != nil {
+ t.Fatalf("ciphertext.Close() = %v", err)
+ }
+
+ md, err := gpgbin.Decrypt(&buf)
+ if err != nil {
+ t.Errorf("Encrypt error: could not decrypt test encryption")
+ }
+ var body bytes.Buffer
+ io.Copy(&body, md.Body)
+ if s := body.String(); s != wantEncrypted {
+ t.Errorf("Encrypt() = \n%v\n but want \n%v", s, wantEncrypted)
+ }
+
+ t.Cleanup(CleanUp)
+}
+
+func TestSign(t *testing.T) {
+ importPublicKey()
+ importSecretKey()
+ var h textproto.Header
+ h.Set("From", "John Doe <john.doe@example.org>")
+ h.Set("To", "John Doe <john.doe@example.org>")
+
+ var signedHeader textproto.Header
+ signedHeader.Set("Content-Type", "text/plain")
+
+ var signedBody = "This is a signed message!\r\n"
+
+ var buf bytes.Buffer
+ cleartext, err := Sign(&buf, h, "john.doe@example.org")
+ if err != nil {
+ t.Fatalf("Encrypt() = %v", err)
+ }
+
+ if err = textproto.WriteHeader(cleartext, signedHeader); err != nil {
+ t.Fatalf("textproto.WriteHeader() = %v", err)
+ }
+ if _, err = io.WriteString(cleartext, signedBody); err != nil {
+ t.Fatalf("io.WriteString() = %v", err)
+ }
+
+ if err = cleartext.Close(); err != nil {
+ t.Fatalf("ciphertext.Close() = %v", err)
+ }
+
+ parts := strings.Split(buf.String(), "\r\n--foo\r\n")
+ msg := strings.NewReader(parts[1])
+ sig := strings.NewReader(parts[2])
+ md, err := gpgbin.Verify(msg, sig)
+ if err != nil {
+ t.Fatalf("gpg.Verify() = %v", err)
+ }
+
+ deepEqual(t, md, &wantSigned)
+}
+
+var wantEncrypted = toCRLF(`Content-Type: text/plain
+
+This is an encrypted message!
+`)
+
+var wantSignedBody = toCRLF(`Content-Type: text/plain
+
+This is a signed message!
+`)
+
+var wantSigned = models.MessageDetails{
+ IsEncrypted: false,
+ IsSigned: true,
+ SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
+ SignedByKeyId: 3490876580878068068,
+ SignatureError: "",
+ DecryptedWith: "",
+ DecryptedWithKeyId: 0,
+ Body: strings.NewReader(wantSignedBody),
+ Micalg: "pgp-sha256",
+}
diff --git a/lib/crypto/pgp/pgp.go b/lib/crypto/pgp/pgp.go
index 70a003a0..92a15ee6 100644
--- a/lib/crypto/pgp/pgp.go
+++ b/lib/crypto/pgp/pgp.go
@@ -79,6 +79,20 @@ func (m *Mail) getEntityByEmail(email string) (e *openpgp.Entity, err error) {
return nil, fmt.Errorf("entity not found in keyring")
}
+func (m *Mail) getSignerEntityByKeyId(id string) (*openpgp.Entity, error) {
+ id = strings.ToUpper(id)
+ for _, key := range Keyring.DecryptionKeys() {
+ if key.Entity == nil {
+ continue
+ }
+ kId := key.Entity.PrimaryKey.KeyIdString()
+ if strings.Contains(kId, id) {
+ return key.Entity, nil
+ }
+ }
+ return nil, fmt.Errorf("entity not found in keyring")
+}
+
func (m *Mail) getSignerEntityByEmail(email string) (e *openpgp.Entity, err error) {
for _, key := range Keyring.DecryptionKeys() {
if key.Entity == nil {
@@ -157,12 +171,12 @@ func (m *Mail) ImportKeys(r io.Reader) error {
return nil
}
-func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signerEmail string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
+func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
var err error
var to []*openpgp.Entity
- var signer *openpgp.Entity
- if signerEmail != "" {
- signer, err = m.getSigner(signerEmail, decryptKeys)
+ var signerEntity *openpgp.Entity
+ if signer != "" {
+ signerEntity, err = m.getSigner(signer, decryptKeys)
if err != nil {
return nil, err
}
@@ -177,45 +191,50 @@ func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signerEmail string, de
}
cleartext, err := pgpmail.Encrypt(buf, header.Header.Header,
- to, signer, nil)
+ to, signerEntity, nil)
if err != nil {
return nil, err
}
return cleartext, nil
}
-func (m *Mail) Sign(buf *bytes.Buffer, signerEmail string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
+func (m *Mail) Sign(buf *bytes.Buffer, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
var err error
- var signer *openpgp.Entity
- if signerEmail != "" {
- signer, err = m.getSigner(signerEmail, decryptKeys)
+ var signerEntity *openpgp.Entity
+ if signer != "" {
+ signerEntity, err = m.getSigner(signer, decryptKeys)
if err != nil {
return nil, err
}
}
- cleartext, err := pgpmail.Sign(buf, header.Header.Header, signer, nil)
+ cleartext, err := pgpmail.Sign(buf, header.Header.Header, signerEntity, nil)
if err != nil {
return nil, err
}
return cleartext, nil
}
-func (m *Mail) getSigner(signerEmail string, decryptKeys openpgp.PromptFunction) (signer *openpgp.Entity, err error) {
- if err != nil {
- return nil, err
- }
- signer, err = m.getSignerEntityByEmail(signerEmail)
- if err != nil {
- return nil, err
+func (m *Mail) getSigner(signer string, decryptKeys openpgp.PromptFunction) (signerEntity *openpgp.Entity, err error) {
+ switch strings.Contains(signer, "@") {
+ case true:
+ signerEntity, err = m.getSignerEntityByEmail(signer)
+ if err != nil {
+ return nil, err
+ }
+ case false:
+ signerEntity, err = m.getSignerEntityByKeyId(signer)
+ if err != nil {
+ return nil, err
+ }
}
- key, ok := signer.SigningKey(time.Now())
+ key, ok := signerEntity.SigningKey(time.Now())
if !ok {
- return nil, fmt.Errorf("no signing key found for %s", signerEmail)
+ return nil, fmt.Errorf("no signing key found for %s", signer)
}
if !key.PrivateKey.Encrypted {
- return signer, nil
+ return signerEntity, nil
}
_, err = decryptKeys([]openpgp.Key{key}, false)
@@ -223,7 +242,7 @@ func (m *Mail) getSigner(signerEmail string, decryptKeys openpgp.PromptFunction)
return nil, err
}
- return signer, nil
+ return signerEntity, nil
}
func handleSignatureError(e string) models.SignatureValidity {