~rjarry/aerc-devel

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
6 4

[PATCH aerc v3 1/2] refactor: refactor pgp implementation

Details
Message ID
<20220413221732.44371-1-tim@timculverhouse.com>
DKIM signature
missing
Download raw message
Patch: +177 -86
This commit refactors the internal PGP implementation to make way for
GPG integration.

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
---
v3: formatting

 aerc.go                  |   5 +-
 commands/account/view.go |   2 +-
 commands/msg/delete.go   |   2 +-
 commands/msgview/next.go |   2 +-
 lib/keystore.go          | 127 +++++++++++++++++++++++++++++++++++++--
 lib/messageview.go       |  19 +++---
 models/models.go         |  21 +++++++
 widgets/aerc.go          |   4 ++
 widgets/compose.go       |  51 ++--------------
 widgets/msglist.go       |   3 +-
 widgets/pgpinfo.go       |  27 +++------
 11 files changed, 177 insertions(+), 86 deletions(-)

diff --git a/aerc.go b/aerc.go
index 9d93a50..6facb03 100644
--- a/aerc.go
+++ b/aerc.go
@@ -189,8 +189,9 @@ func main() {
	}

	logger.Println("Initializing PGP keyring")
	lib.InitKeyring()
	defer lib.UnlockKeyring()
	pgp := new(lib.PGPMail)
	pgp.Init()
	defer pgp.UnlockKeyring()

	logger.Println("Starting Unix server")
	as, err := lib.StartServer(logger)
diff --git a/commands/account/view.go b/commands/account/view.go
index 53ad267..9f71f61 100644
--- a/commands/account/view.go
+++ b/commands/account/view.go
@@ -45,7 +45,7 @@ func (ViewMessage) Execute(aerc *widgets.Aerc, args []string) error {
		aerc.PushError(msg.Error.Error())
		return nil
	}
	lib.NewMessageStoreView(msg, store, aerc.DecryptKeys,
	lib.NewMessageStoreView(msg, store, aerc.Pgp, aerc.DecryptKeys,
		func(view lib.MessageView, err error) {
			if err != nil {
				aerc.PushError(err.Error())
diff --git a/commands/msg/delete.go b/commands/msg/delete.go
index 5eb622d..01b1520 100644
--- a/commands/msg/delete.go
+++ b/commands/msg/delete.go
@@ -68,7 +68,7 @@ func (Delete) Execute(aerc *widgets.Aerc, args []string) error {
				acct.Messages().Invalidate()
				return nil
			}
			lib.NewMessageStoreView(next, store, aerc.DecryptKeys,
			lib.NewMessageStoreView(next, store, aerc.Pgp, aerc.DecryptKeys,
				func(view lib.MessageView, err error) {
					if err != nil {
						aerc.PushError(err.Error())
diff --git a/commands/msgview/next.go b/commands/msgview/next.go
index 742bc66..d0cffaa 100644
--- a/commands/msgview/next.go
+++ b/commands/msgview/next.go
@@ -42,7 +42,7 @@ func (NextPrevMsg) Execute(aerc *widgets.Aerc, args []string) error {
		aerc.RemoveTab(mv)
		return nil
	}
	lib.NewMessageStoreView(nextMsg, store, aerc.DecryptKeys,
	lib.NewMessageStoreView(nextMsg, store, aerc.Pgp, aerc.DecryptKeys,
		func(view lib.MessageView, err error) {
			if err != nil {
				aerc.PushError(err.Error())
diff --git a/lib/keystore.go b/lib/keystore.go
index 0b9d41a..a878886 100644
--- a/lib/keystore.go
+++ b/lib/keystore.go
@@ -1,23 +1,32 @@
package lib

import (
	"bytes"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"path"
	"time"

	"git.sr.ht/~rjarry/aerc/models"
	"github.com/ProtonMail/go-crypto/openpgp"
	"github.com/ProtonMail/go-crypto/openpgp/packet"
	"github.com/emersion/go-message/mail"
	"github.com/emersion/go-pgpmail"
	"github.com/kyoh86/xdg"
	"github.com/pkg/errors"
)

type PGPMail struct{}

var (
	Keyring openpgp.EntityList

	locked bool
)

func InitKeyring() {
func (p *PGPMail) Init() error {
	os.MkdirAll(path.Join(xdg.DataHome(), "aerc"), 0700)

	lockpath := path.Join(xdg.DataHome(), "aerc", "keyring.lock")
@@ -33,7 +42,7 @@ func InitKeyring() {
	keypath := path.Join(xdg.DataHome(), "aerc", "keyring.asc")
	keyfile, err := os.Open(keypath)
	if os.IsNotExist(err) {
		return
		return nil
	} else if err != nil {
		panic(err)
	}
@@ -43,9 +52,10 @@ func InitKeyring() {
	if err != nil {
		panic(err)
	}
	return nil
}

func UnlockKeyring() {
func (p *PGPMail) UnlockKeyring() {
	if !locked {
		return
	}
@@ -53,7 +63,7 @@ func UnlockKeyring() {
	os.Remove(lockpath)
}

func GetEntityByEmail(email string) (e *openpgp.Entity, err error) {
func (p *PGPMail) getEntityByEmail(email string) (e *openpgp.Entity, err error) {
	for _, entity := range Keyring {
		ident := entity.PrimaryIdentity()
		if ident != nil && ident.UserId.Email == email {
@@ -63,7 +73,7 @@ func GetEntityByEmail(email string) (e *openpgp.Entity, err error) {
	return nil, fmt.Errorf("entity not found in keyring")
}

func GetSignerEntityByEmail(email string) (e *openpgp.Entity, err error) {
func (p *PGPMail) getSignerEntityByEmail(email string) (e *openpgp.Entity, err error) {
	for _, key := range Keyring.DecryptionKeys() {
		if key.Entity == nil {
			continue
@@ -76,7 +86,39 @@ func GetSignerEntityByEmail(email string) (e *openpgp.Entity, err error) {
	return nil, fmt.Errorf("entity not found in keyring")
}

func ImportKeys(r io.Reader) error {
func (p *PGPMail) Decrypt(r io.Reader, decryptKeys openpgp.PromptFunction) (*models.PGPMessageDetails, error) {
	md := new(models.PGPMessageDetails)

	pgpReader, err := pgpmail.Read(r, Keyring, decryptKeys, nil)
	if err != nil {
		return nil, err
	}
	ioutil.ReadAll(pgpReader.MessageDetails.UnverifiedBody)
	if pgpReader.MessageDetails.IsEncrypted {
		md.IsEncrypted = true
		md.DecryptedWith = pgpReader.MessageDetails.DecryptedWith.Entity.PrimaryIdentity().Name
		md.DecryptedWithKeyId = pgpReader.MessageDetails.DecryptedWith.PublicKey.KeyId
	}
	if pgpReader.MessageDetails.IsSigned {
		md.IsSigned = true
		md.SignedByKeyId = pgpReader.MessageDetails.SignedByKeyId
		if pgpReader.MessageDetails.SignatureError != nil {
			md.SignatureError = pgpReader.MessageDetails.SignatureError.Error()
		}
		if pgpReader.MessageDetails.SignedBy == nil {
			md.SignedBy = ""
		} else {
			md.SignedBy = pgpReader.MessageDetails.SignedBy.Entity.PrimaryIdentity().Name
		}
	}
	md.Body = pgpReader.MessageDetails.UnverifiedBody
	if err != nil {
		return nil, err
	}
	return md, nil
}

func (p *PGPMail) ImportKeys(r io.Reader) error {
	keys, err := openpgp.ReadKeyRing(r)
	if err != nil {
		return err
@@ -103,3 +145,76 @@ func ImportKeys(r io.Reader) error {
	}
	return nil
}

func (p *PGPMail) Encrypt(buf *bytes.Buffer, rcpts []string, signerEmail string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
	var err error
	var to []*openpgp.Entity
	if err != nil {
		return nil, err
	}

	var signer *openpgp.Entity
	if signerEmail != "" {
		signer, err = p.getSigner(signerEmail, decryptKeys)
		if err != nil {
			return nil, err
		}
	}

	for _, rcpt := range rcpts {
		toEntity, err := p.getEntityByEmail(rcpt)
		if err != nil {
			return nil, errors.Wrap(err, "no key for "+rcpt)
		}
		to = append(to, toEntity)
	}

	cleartext, err := pgpmail.Encrypt(buf, header.Header.Header,
		to, signer, nil)
	if err != nil {
		return nil, err
	}
	return cleartext, nil
}

func (p *PGPMail) Sign(buf *bytes.Buffer, signerEmail string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
	var err error
	var signer *openpgp.Entity
	if signerEmail != "" {
		signer, err = p.getSigner(signerEmail, decryptKeys)
		if err != nil {
			return nil, err
		}
	}
	cleartext, err := pgpmail.Sign(buf, header.Header.Header, signer, nil)
	if err != nil {
		return nil, err
	}
	return cleartext, nil
}

func (p *PGPMail) getSigner(signerEmail string, decryptKeys openpgp.PromptFunction) (signer *openpgp.Entity, err error) {
	if err != nil {
		return nil, err
	}
	signer, err = p.getSignerEntityByEmail(signerEmail)
	if err != nil {
		return nil, err
	}

	key, ok := signer.SigningKey(time.Now())
	if !ok {
		return nil, fmt.Errorf("no signing key found for %s", signerEmail)
	}

	if !key.PrivateKey.Encrypted {
		return signer, nil
	}

	_, err = decryptKeys([]openpgp.Key{key}, false)
	if err != nil {
		return nil, err
	}

	return signer, nil
}
diff --git a/lib/messageview.go b/lib/messageview.go
index 8db7994..5c69ad9 100644
--- a/lib/messageview.go
+++ b/lib/messageview.go
@@ -8,7 +8,6 @@ import (
	"github.com/ProtonMail/go-crypto/openpgp"
	"github.com/emersion/go-message"
	_ "github.com/emersion/go-message/charset"
	"github.com/emersion/go-pgpmail"

	"git.sr.ht/~rjarry/aerc/models"
	"git.sr.ht/~rjarry/aerc/worker/lib"
@@ -30,7 +29,7 @@ type MessageView interface {
	// Fetches a specific body part for this message
	FetchBodyPart(part []int, cb func(io.Reader))

	PGPDetails() *openpgp.MessageDetails
	PGPDetails() *models.PGPMessageDetails
}

func usePGP(info *models.BodyStructure) bool {
@@ -56,31 +55,31 @@ type MessageStoreView struct {
	messageInfo   *models.MessageInfo
	messageStore  *MessageStore
	message       []byte
	details       *openpgp.MessageDetails
	details       *models.PGPMessageDetails
	bodyStructure *models.BodyStructure
}

func NewMessageStoreView(messageInfo *models.MessageInfo,
	store *MessageStore, decryptKeys openpgp.PromptFunction,
	store *MessageStore, pgp models.PGPProvider, decryptKeys openpgp.PromptFunction,
	cb func(MessageView, error)) {

	msv := &MessageStoreView{messageInfo, store,
		nil, nil, messageInfo.BodyStructure}
		nil, new(models.PGPMessageDetails), messageInfo.BodyStructure}

	if usePGP(messageInfo.BodyStructure) {
		store.FetchFull([]uint32{messageInfo.Uid}, func(fm *types.FullMessage) {
			reader := fm.Content.Reader
			pgpReader, err := pgpmail.Read(reader, Keyring, decryptKeys, nil)
			md, err := pgp.Decrypt(reader, decryptKeys)
			if err != nil {
				cb(nil, err)
				return
			}
			msv.message, err = ioutil.ReadAll(pgpReader.MessageDetails.UnverifiedBody)
			msv.message, err = ioutil.ReadAll(md.Body)
			if err != nil {
				cb(nil, err)
				return
			}
			decrypted, err := message.Read(bytes.NewBuffer(msv.message))
			decrypted, err := message.Read(md.Body)
			if err != nil {
				cb(nil, err)
				return
@@ -91,7 +90,7 @@ func NewMessageStoreView(messageInfo *models.MessageInfo,
				return
			}
			msv.bodyStructure = bs
			msv.details = pgpReader.MessageDetails
			msv.details = md
			cb(msv, nil)
		})
	} else {
@@ -112,7 +111,7 @@ func (msv *MessageStoreView) Store() *MessageStore {
	return msv.messageStore
}

func (msv *MessageStoreView) PGPDetails() *openpgp.MessageDetails {
func (msv *MessageStoreView) PGPDetails() *models.PGPMessageDetails {
	return msv.details
}

diff --git a/models/models.go b/models/models.go
index 4087c9d..05f82a8 100644
--- a/models/models.go
+++ b/models/models.go
@@ -1,11 +1,13 @@
package models

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"time"

	"github.com/ProtonMail/go-crypto/openpgp"
	"github.com/emersion/go-message/mail"
)

@@ -185,3 +187,22 @@ type OriginalMail struct {
	MIMEType      string
	RFC822Headers *mail.Header
}

type PGPMessageDetails struct {
	IsEncrypted        bool
	IsSigned           bool
	SignedBy           string // Primary identity of signing key
	SignedByKeyId      uint64
	SignatureError     string
	DecryptedWith      string // Primary Identity of decryption key
	DecryptedWithKeyId uint64 // Public key id of decryption key
	Body               io.Reader
}

type PGPProvider interface {
	Decrypt(io.Reader, openpgp.PromptFunction) (*PGPMessageDetails, error)
	Encrypt(*bytes.Buffer, []string, string, openpgp.PromptFunction, *mail.Header) (io.WriteCloser, error)
	Sign(*bytes.Buffer, string, openpgp.PromptFunction, *mail.Header) (io.WriteCloser, error)
	ImportKeys(io.Reader) error
	Init() error
}
diff --git a/widgets/aerc.go b/widgets/aerc.go
index 94e6754..7ba804e 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -38,6 +38,8 @@ type Aerc struct {
	ui          *ui.UI
	beep        func() error
	dialog      ui.DrawableInteractive

	Pgp models.PGPProvider
}

type Choice struct {
@@ -51,6 +53,7 @@ func NewAerc(conf *config.AercConfig, logger *log.Logger,
	cmdHistory lib.History, deferLoop chan struct{},
) *Aerc {
	tabs := ui.NewTabs(&conf.Ui)
	pgp := new(lib.PGPMail)

	statusbar := ui.NewStack(conf.Ui)
	statusline := NewStatusLine(conf.Ui)
@@ -79,6 +82,7 @@ func NewAerc(conf *config.AercConfig, logger *log.Logger,
		statusline: statusline,
		prompts:    ui.NewStack(conf.Ui),
		tabs:       tabs,
		Pgp:        pgp,
	}

	statusline.SetAerc(aerc)
diff --git a/widgets/compose.go b/widgets/compose.go
index 64eb285..324e6cb 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -15,9 +15,7 @@ import (
	"strings"
	"time"

	"github.com/ProtonMail/go-crypto/openpgp"
	"github.com/emersion/go-message/mail"
	"github.com/emersion/go-pgpmail"
	"github.com/gdamore/tcell/v2"
	"github.com/mattn/go-runewidth"
	"github.com/mitchellh/go-homedir"
@@ -25,7 +23,6 @@ import (

	"git.sr.ht/~rjarry/aerc/completer"
	"git.sr.ht/~rjarry/aerc/config"
	"git.sr.ht/~rjarry/aerc/lib"
	"git.sr.ht/~rjarry/aerc/lib/format"
	"git.sr.ht/~rjarry/aerc/lib/templates"
	"git.sr.ht/~rjarry/aerc/lib/ui"
@@ -455,38 +452,27 @@ func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error {
		var cleartext io.WriteCloser
		var err error

		var signer *openpgp.Entity
		var signerEmail string
		if c.sign {
			signer, err = getSigner(c)
			signerEmail, err = getSenderEmail(c)
			if err != nil {
				return err
			}
		} else {
			signer = nil
			signerEmail = ""
		}

		if c.encrypt {
			var to []*openpgp.Entity
			rcpts, err := getRecipientsEmail(c)
			if err != nil {
				return err
			}
			for _, rcpt := range rcpts {
				toEntity, err := lib.GetEntityByEmail(rcpt)
				if err != nil {
					return errors.Wrap(err, "no key for "+rcpt)
				}
				to = append(to, toEntity)
			}
			cleartext, err = pgpmail.Encrypt(&buf, header.Header.Header,
				to, signer, nil)

			cleartext, err = c.aerc.Pgp.Encrypt(&buf, rcpts, signerEmail, c.aerc.DecryptKeys, header)
			if err != nil {
				return err
			}
		} else {
			cleartext, err = pgpmail.Sign(&buf, header.Header.Header,
				signer, nil)
			cleartext, err = c.aerc.Pgp.Sign(&buf, signerEmail, c.aerc.DecryptKeys, header)
			if err != nil {
				return err
			}
@@ -1033,30 +1019,3 @@ func (rm *reviewMessage) OnInvalidate(fn func(ui.Drawable)) {
func (rm *reviewMessage) Draw(ctx *ui.Context) {
	rm.grid.Draw(ctx)
}

func getSigner(c *Composer) (signer *openpgp.Entity, err error) {
	signerEmail, err := getSenderEmail(c)
	if err != nil {
		return nil, err
	}
	signer, err = lib.GetSignerEntityByEmail(signerEmail)
	if err != nil {
		return nil, err
	}

	key, ok := signer.SigningKey(time.Now())
	if !ok {
		return nil, fmt.Errorf("no signing key found for %s", signerEmail)
	}

	if !key.PrivateKey.Encrypted {
		return signer, nil
	}

	_, err = c.aerc.DecryptKeys([]openpgp.Key{key}, false)
	if err != nil {
		return nil, err
	}

	return signer, nil
}
diff --git a/widgets/msglist.go b/widgets/msglist.go
index 5eee7ed..931d22e 100644
--- a/widgets/msglist.go
+++ b/widgets/msglist.go
@@ -295,7 +295,8 @@ func (ml *MessageList) MouseEvent(localX int, localY int, event tcell.Event) {
				if msg == nil {
					return
				}
				lib.NewMessageStoreView(msg, store, ml.aerc.DecryptKeys,
				lib.NewMessageStoreView(msg, store, ml.aerc.Pgp,
				ml.aerc.DecryptKeys,
					func(view lib.MessageView, err error) {
						if err != nil {
							ml.aerc.PushError(err.Error())
diff --git a/widgets/pgpinfo.go b/widgets/pgpinfo.go
index 6c07ed9..b6abb8d 100644
--- a/widgets/pgpinfo.go
+++ b/widgets/pgpinfo.go
@@ -1,22 +1,18 @@
package widgets

import (
	"errors"

	"git.sr.ht/~rjarry/aerc/config"
	"git.sr.ht/~rjarry/aerc/lib/ui"

	"github.com/ProtonMail/go-crypto/openpgp"
	pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors"
	"git.sr.ht/~rjarry/aerc/models"
)

type PGPInfo struct {
	ui.Invalidatable
	details  *openpgp.MessageDetails
	details  *models.PGPMessageDetails
	uiConfig config.UIConfig
}

func NewPGPInfo(details *openpgp.MessageDetails, uiConfig config.UIConfig) *PGPInfo {
func NewPGPInfo(details *models.PGPMessageDetails, uiConfig config.UIConfig) *PGPInfo {
	return &PGPInfo{details: details, uiConfig: uiConfig}
}

@@ -27,38 +23,33 @@ func (p *PGPInfo) DrawSignature(ctx *ui.Context) {
	defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)

	// TODO: Nicer prompt for TOFU, fetch from keyserver, etc
	if errors.Is(p.details.SignatureError, pgperrors.ErrUnknownIssuer) ||
		p.details.SignedBy == nil {
	if p.details.SignatureError == "openpgp: signature made by unknown entity" ||
		p.details.SignedBy == "" {

		x := ctx.Printf(0, 0, warningStyle, "*")
		x += ctx.Printf(x, 0, defaultStyle,
			" Signed with unknown key (%8X); authenticity unknown",
			p.details.SignedByKeyId)
	} else if p.details.SignatureError != nil {
	} else if p.details.SignatureError != "" {
		x := ctx.Printf(0, 0, errorStyle, "Invalid signature!")
		x += ctx.Printf(x, 0, errorStyle,
			" This message may have been tampered with! (%s)",
			p.details.SignatureError.Error())
			p.details.SignatureError)
	} else {
		entity := p.details.SignedBy.Entity
		ident := entity.PrimaryIdentity()

		x := ctx.Printf(0, 0, validStyle, "✓ Authentic ")
		x += ctx.Printf(x, 0, defaultStyle,
			"Signature from %s (%8X)",
			ident.Name, p.details.SignedByKeyId)
			p.details.SignedBy, p.details.SignedByKeyId)
	}
}

func (p *PGPInfo) DrawEncryption(ctx *ui.Context, y int) {
	validStyle := p.uiConfig.GetStyle(config.STYLE_SUCCESS)
	defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)
	entity := p.details.DecryptedWith.Entity
	ident := entity.PrimaryIdentity()

	x := ctx.Printf(0, y, validStyle, "✓ Encrypted ")
	x += ctx.Printf(x, y, defaultStyle,
		"To %s (%8X) ", ident.Name, p.details.DecryptedWith.PublicKey.KeyId)
		"To %s (%8X) ", p.details.DecryptedWith, p.details.DecryptedWithKeyId)
}

func (p *PGPInfo) Draw(ctx *ui.Context) {
-- 
2.35.2

[PATCH aerc v3 2/2] feat: add gpg integration

Details
Message ID
<20220413221732.44371-2-tim@timculverhouse.com>
In-Reply-To
<20220413221732.44371-1-tim@timculverhouse.com> (view parent)
DKIM signature
missing
Download raw message
Patch: +1386 -3
This commit adds gpg system integration. This is done through two new
packages: gpg, which handles the system calls and parsing; and gpgmail
which is mostly a copy of emersion/go-pgpmail with modifications to
interface with package gpg. gpgmail includes tests for many cases, and
by it's nature also tests package gpg. 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)

A new config option is introduced in the [general] section:
pgp-provider. If it is not explicitly set to "gpg", aerc will default to
it's internal pgp provider

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
---
v3: formatting, use ioutils for temp file, add gnupg to build env

 .builds/alpine-edge.yml         |   1 +
 .builds/fedora-latest.yml       |   1 +
 .builds/openbsd.yml             |   1 +
 config/config.go                |   1 +
 doc/aerc-config.5.scd           |   5 +
 lib/pgp/gpg/decrypt.go          |  34 ++++
 lib/pgp/gpg/encrypt.go          |  34 ++++
 lib/pgp/gpg/gpg.go              | 230 ++++++++++++++++++++++++
 lib/pgp/gpg/import.go           |  16 ++
 lib/pgp/gpg/sign.go             |  27 +++
 lib/pgp/gpg/verify.go           |  41 +++++
 lib/pgp/gpgmail/gpgmail.go      |  41 +++++
 lib/pgp/gpgmail/gpgmail_test.go | 169 ++++++++++++++++++
 lib/pgp/gpgmail/reader.go       | 164 +++++++++++++++++
 lib/pgp/gpgmail/reader_test.go  | 308 ++++++++++++++++++++++++++++++++
 lib/pgp/gpgmail/writer.go       | 182 +++++++++++++++++++
 lib/pgp/gpgmail/writer_test.go  | 122 +++++++++++++
 models/models.go                |   1 +
 widgets/aerc.go                 |   9 +-
 widgets/msglist.go              |   2 +-
 20 files changed, 1386 insertions(+), 3 deletions(-)
 create mode 100644 lib/pgp/gpg/decrypt.go
 create mode 100644 lib/pgp/gpg/encrypt.go
 create mode 100644 lib/pgp/gpg/gpg.go
 create mode 100644 lib/pgp/gpg/import.go
 create mode 100644 lib/pgp/gpg/sign.go
 create mode 100644 lib/pgp/gpg/verify.go
 create mode 100644 lib/pgp/gpgmail/gpgmail.go
 create mode 100644 lib/pgp/gpgmail/gpgmail_test.go
 create mode 100644 lib/pgp/gpgmail/reader.go
 create mode 100644 lib/pgp/gpgmail/reader_test.go
 create mode 100644 lib/pgp/gpgmail/writer.go
 create mode 100644 lib/pgp/gpgmail/writer_test.go

diff --git a/.builds/alpine-edge.yml b/.builds/alpine-edge.yml
index e2467e5..fd4f7e9 100644
--- a/.builds/alpine-edge.yml
+++ b/.builds/alpine-edge.yml
@@ -2,6 +2,7 @@ image: alpine/edge
packages:
- go
- scdoc
- gnupg
sources:
- https://git.sr.ht/~rjarry/aerc
environment:
diff --git a/.builds/fedora-latest.yml b/.builds/fedora-latest.yml
index 2045506..b79bd8c 100644
--- a/.builds/fedora-latest.yml
+++ b/.builds/fedora-latest.yml
@@ -2,6 +2,7 @@ image: fedora/latest
packages:
- golang
- scdoc
- gnupg
sources:
- https://git.sr.ht/~rjarry/aerc
environment:
diff --git a/.builds/openbsd.yml b/.builds/openbsd.yml
index 0213baf..a60c989 100644
--- a/.builds/openbsd.yml
+++ b/.builds/openbsd.yml
@@ -3,6 +3,7 @@ image: openbsd/latest
packages:
  - go
  - scdoc
  - gnupg
sources:
  - https://git.sr.ht/~rjarry/aerc
environment:
diff --git a/config/config.go b/config/config.go
index 2120310..ca7f483 100644
--- a/config/config.go
+++ b/config/config.go
@@ -27,6 +27,7 @@ import (

type GeneralConfig struct {
	DefaultSavePath string `ini:"default-save-path"`
	PgpProvider     string `ini:"pgp-provider"`
}

type UIConfig struct {
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index 885c4f8..e2976a9 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -30,6 +30,11 @@ These options are configured in the *[general]* section of aerc.conf.
*default-save-path*
	Used as a default path for save operations if no other path is specified.

*pgp-provider*
	If set to "gpg", aerc will use system gpg binary and keystore for all
	crypto operations. If not set, will default to the internal pgp
	implementation. 

## UI OPTIONS

These options are configured in the *[ui]* section of aerc.conf.
diff --git a/lib/pgp/gpg/decrypt.go b/lib/pgp/gpg/decrypt.go
new file mode 100644
index 0000000..047d7ae
--- /dev/null
+++ b/lib/pgp/gpg/decrypt.go
@@ -0,0 +1,34 @@
package gpg

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.PGPMessageDetails, error) {
	md := new(models.PGPMessageDetails)
	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/pgp/gpg/encrypt.go b/lib/pgp/gpg/encrypt.go
new file mode 100644
index 0000000..bbe9ca3
--- /dev/null
+++ b/lib/pgp/gpg/encrypt.go
@@ -0,0 +1,34 @@
package gpg

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",
		"--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.PGPMessageDetails
	parse(outRdr, &md)
	var buf bytes.Buffer
	io.Copy(&buf, md.Body)

	return buf.Bytes(), nil
}
diff --git a/lib/pgp/gpg/gpg.go b/lib/pgp/gpg/gpg.go
new file mode 100644
index 0000000..07e5012
--- /dev/null
+++ b/lib/pgp/gpg/gpg.go
@@ -0,0 +1,230 @@
package gpg

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"io"
	"os/exec"
	"strconv"
	"strings"

	"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("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.PGPMessageDetails) 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
		}
		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]
		}
	}
	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",
}
diff --git a/lib/pgp/gpg/import.go b/lib/pgp/gpg/import.go
new file mode 100644
index 0000000..d056f1f
--- /dev/null
+++ b/lib/pgp/gpg/import.go
@@ -0,0 +1,16 @@
package gpg

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/pgp/gpg/sign.go b/lib/pgp/gpg/sign.go
new file mode 100644
index 0000000..44b7688
--- /dev/null
+++ b/lib/pgp/gpg/sign.go
@@ -0,0 +1,27 @@
package gpg

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.PGPMessageDetails
	parse(outRdr, &md)
	var buf bytes.Buffer
	io.Copy(&buf, md.Body)
	return buf.Bytes(), md.Micalg, nil
}
diff --git a/lib/pgp/gpg/verify.go b/lib/pgp/gpg/verify.go
new file mode 100644
index 0000000..e835058
--- /dev/null
+++ b/lib/pgp/gpg/verify.go
@@ -0,0 +1,41 @@
package gpg

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.PGPMessageDetails, 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.PGPMessageDetails)
	parse(out, md)

	md.Body = bytes.NewReader(orig)

	return md, nil
}
diff --git a/lib/pgp/gpgmail/gpgmail.go b/lib/pgp/gpgmail/gpgmail.go
new file mode 100644
index 0000000..2c82af6
--- /dev/null
+++ b/lib/pgp/gpgmail/gpgmail.go
@@ -0,0 +1,41 @@
package gpgmail

import (
	"bytes"
	"io"
	"os/exec"

	"git.sr.ht/~rjarry/aerc/lib/pgp/gpg"
	"git.sr.ht/~rjarry/aerc/models"
	"github.com/ProtonMail/go-crypto/openpgp"
	"github.com/emersion/go-message/mail"
)

// GPGMail satisfies the PGPProvider interface in aerc
type GPGMail struct{}

func (g *GPGMail) Init() error {
	_, err := exec.LookPath("gpg")
	return err
}

func (g *GPGMail) Decrypt(r io.Reader, decryptKeys openpgp.PromptFunction) (*models.PGPMessageDetails, error) {
	gpgReader, err := Read(r)
	if err != nil {
		return nil, err
	}
	return gpgReader.MessageDetails, nil
}

func (g *GPGMail) ImportKeys(r io.Reader) error {
	return gpg.Import(r)
}

func (g *GPGMail) Encrypt(buf *bytes.Buffer, rcpts []string, signerEmail string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {

	return Encrypt(buf, header.Header.Header, rcpts, signerEmail)
}

func (g *GPGMail) Sign(buf *bytes.Buffer, signerEmail string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
	return Sign(buf, header.Header.Header, signerEmail)
}
diff --git a/lib/pgp/gpgmail/gpgmail_test.go b/lib/pgp/gpgmail/gpgmail_test.go
new file mode 100644
index 0000000..581c4dd
--- /dev/null
+++ b/lib/pgp/gpgmail/gpgmail_test.go
@@ -0,0 +1,169 @@
package gpgmail

import (
	"bytes"
	"fmt"
	"io"
	"os/exec"
	"strings"
	"testing"

	"git.sr.ht/~rjarry/aerc/models"
)

func TestHasGpg(t *testing.T) {
	gpgmail := new(GPGMail)
	hasGpg := gpgmail.Init()

	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.PGPMessageDetails, expect *models.PGPMessageDetails) {
	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 r.Micalg != expect.Micalg {
		t.Errorf("micalg = \n%v\n but want \n%v", r.Micalg, expect.Micalg)
	}
	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/pgp/gpgmail/reader.go b/lib/pgp/gpgmail/reader.go
new file mode 100644
index 0000000..c7fd86f
--- /dev/null
+++ b/lib/pgp/gpgmail/reader.go
@@ -0,0 +1,164 @@
// reader.go largerly mimics github.com/emersion/go-pgpmail, with changes made
// to interface with the gpg package in aerc

package gpgmail

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"mime"
	"strings"

	"git.sr.ht/~rjarry/aerc/lib/pgp/gpg"
	"git.sr.ht/~rjarry/aerc/models"
	"github.com/emersion/go-message/textproto"
)

type Reader struct {
	Header         textproto.Header
	MessageDetails *models.PGPMessageDetails
}

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.PGPMessageDetails{
			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("pgpmail: 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("pgpmail: 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("pgpmail: 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("pgpmail: failed to parse application/pgp-encrypted part: %v", err)
	}
	if s := metadata.Get("Version"); s != "1" {
		return nil, fmt.Errorf("pgpmail: unsupported PGP/MIME version: %q", s)
	}

	p, err = mr.NextPart()
	if err != nil {
		return nil, fmt.Errorf("pgpmail: 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("pgpmail: 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("pgpmail: second part in multipart/encrypted message has type %q, not application/octet-stream", t)
	}

	md, err := gpg.Decrypt(p)
	if err != nil {
		return nil, fmt.Errorf("pgpmail: failed to read PGP message: %v", err)
	}

	cleartext := bufio.NewReader(md.Body)
	cleartextHeader, err := textproto.ReadHeader(cleartext)
	if err != nil {
		return nil, fmt.Errorf("pgpmail: 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("pgpmail: 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) {
	p, err := mr.NextPart()
	if err != nil {
		return nil, fmt.Errorf("pgpmail: 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("pgpmail: failed to read pgp part in multipart/signed message: %v", err)
	}

	md, err := gpg.Verify(&msg, sig)
	if err != nil {
		return nil, fmt.Errorf("pgpmail: 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/pgp/gpgmail/reader_test.go b/lib/pgp/gpgmail/reader_test.go
new file mode 100644
index 0000000..38de582
--- /dev/null
+++ b/lib/pgp/gpgmail/reader_test.go
@@ -0,0 +1,308 @@
package gpgmail

import (
	"bytes"
	"io"
	"strings"
	"testing"

	"git.sr.ht/~rjarry/aerc/lib/pgp/gpg"
	"git.sr.ht/~rjarry/aerc/models"
)

func importSecretKey() {
	r := strings.NewReader(testPrivateKeyArmored)
	gpg.Import(r)
}

func importPublicKey() {
	r := strings.NewReader(testPublicKeyArmored)
	gpg.Import(r)
}

func TestReader_encryptedSignedPGPMIME(t *testing.T) {
	var expect = models.PGPMessageDetails{
		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.PGPMessageDetails{
		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.PGPMessageDetails{
		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.PGPMessageDetails{
		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/pgp/gpgmail/writer.go b/lib/pgp/gpgmail/writer.go
new file mode 100644
index 0000000..69d7bfd
--- /dev/null
+++ b/lib/pgp/gpgmail/writer.go
@@ -0,0 +1,182 @@
// write.go largerly mimics github.com/emersion/go-pgpmail, with changes made
// to interface with the gpg package in aerc

package gpgmail

import (
	"bytes"
	"fmt"
	"io"
	"mime"

	"git.sr.ht/~rjarry/aerc/lib/pgp/gpg"
	"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 := gpg.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 := gpg.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,
	}
	if err != nil {
		return nil, err
	}

	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/pgp/gpgmail/writer_test.go b/lib/pgp/gpgmail/writer_test.go
new file mode 100644
index 0000000..505bdd7
--- /dev/null
+++ b/lib/pgp/gpgmail/writer_test.go
@@ -0,0 +1,122 @@
package gpgmail

import (
	"bytes"
	"io"
	"strings"
	"testing"

	"git.sr.ht/~rjarry/aerc/lib/pgp/gpg"
	"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 := gpg.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 := gpg.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.PGPMessageDetails{
	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/models/models.go b/models/models.go
index 05f82a8..5c44ee6 100644
--- a/models/models.go
+++ b/models/models.go
@@ -197,6 +197,7 @@ type PGPMessageDetails struct {
	DecryptedWith      string // Primary Identity of decryption key
	DecryptedWithKeyId uint64 // Public key id of decryption key
	Body               io.Reader
	Micalg             string
}

type PGPProvider interface {
diff --git a/widgets/aerc.go b/widgets/aerc.go
index 7ba804e..ef1c427 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -16,6 +16,7 @@ import (

	"git.sr.ht/~rjarry/aerc/config"
	"git.sr.ht/~rjarry/aerc/lib"
	"git.sr.ht/~rjarry/aerc/lib/pgp/gpgmail"
	"git.sr.ht/~rjarry/aerc/lib/ui"
	"git.sr.ht/~rjarry/aerc/models"
)
@@ -53,7 +54,6 @@ func NewAerc(conf *config.AercConfig, logger *log.Logger,
	cmdHistory lib.History, deferLoop chan struct{},
) *Aerc {
	tabs := ui.NewTabs(&conf.Ui)
	pgp := new(lib.PGPMail)

	statusbar := ui.NewStack(conf.Ui)
	statusline := NewStatusLine(conf.Ui)
@@ -82,7 +82,12 @@ func NewAerc(conf *config.AercConfig, logger *log.Logger,
		statusline: statusline,
		prompts:    ui.NewStack(conf.Ui),
		tabs:       tabs,
		Pgp:        pgp,
	}

	if conf.General.PgpProvider == "gpg" {
		aerc.Pgp = new(gpgmail.GPGMail)
	} else {
		aerc.Pgp = new(lib.PGPMail)
	}

	statusline.SetAerc(aerc)
diff --git a/widgets/msglist.go b/widgets/msglist.go
index 931d22e..7ef22c1 100644
--- a/widgets/msglist.go
+++ b/widgets/msglist.go
@@ -296,7 +296,7 @@ func (ml *MessageList) MouseEvent(localX int, localY int, event tcell.Event) {
					return
				}
				lib.NewMessageStoreView(msg, store, ml.aerc.Pgp,
				ml.aerc.DecryptKeys,
					ml.aerc.DecryptKeys,
					func(view lib.MessageView, err error) {
						if err != nil {
							ml.aerc.PushError(err.Error())
-- 
2.35.2

[aerc/patches] build failed

builds.sr.ht <builds@sr.ht>
Details
Message ID
<CJ9GP5H048V3.2SL0BK2J589M1@cirno2>
In-Reply-To
<20220413221732.44371-2-tim@timculverhouse.com> (view parent)
DKIM signature
missing
Download raw message
aerc/patches: FAILED in 5m40s

[refactor: refactor pgp implementation][0] v3 from [Tim Culverhouse][1]

[0]: https://lists.sr.ht/~rjarry/aerc-devel/patches/31092
[1]: tim@timculverhouse.com

✗ #736404 FAILED  aerc/patches/alpine-edge.yml   https://builds.sr.ht/~rjarry/job/736404
✗ #736405 FAILED  aerc/patches/debian-stable.yml https://builds.sr.ht/~rjarry/job/736405
✓ #736406 SUCCESS aerc/patches/fedora-latest.yml https://builds.sr.ht/~rjarry/job/736406
✗ #736407 FAILED  aerc/patches/openbsd.yml       https://builds.sr.ht/~rjarry/job/736407

Re: [aerc/patches] build failed

Details
Message ID
<CJ9GXXFUZNEU.32GR9A0XJUXHQ@TimBook-Arch>
In-Reply-To
<CJ9GP5H048V3.2SL0BK2J589M1@cirno2> (view parent)
DKIM signature
missing
Download raw message
On Wed Apr 13, 2022 at 5:23 PM CDT, builds.sr.ht wrote:
> ✗ #736404 FAILED  aerc/patches/alpine-edge.yml   https://builds.sr.ht/~rjarry/job/736404
> ✗ #736405 FAILED  aerc/patches/debian-stable.yml https://builds.sr.ht/~rjarry/job/736405
I'm going to remove a check on matching micalgs, it's a config option in
gpg and it seems that distros have different defaults on it. That solves
these two.

> ✗ #736407 FAILED  aerc/patches/openbsd.yml       https://builds.sr.ht/~rjarry/job/736407
Not sure how to handle this one, I'm not an OpenBSD user and it's not
clear to me if gnupg is available. Does anyone have any ideas here?

Re: [aerc/patches] build failed

Details
Message ID
<CJ9SUPQXAYUH.38VDKZI46ZMHA@debian-t400>
In-Reply-To
<CJ9GXXFUZNEU.32GR9A0XJUXHQ@TimBook-Arch> (view parent)
DKIM signature
missing
Download raw message
On Thu Apr 14, 2022 at 12:34 AM CEST, Tim Culverhouse wrote:
> On Wed Apr 13, 2022 at 5:23 PM CDT, builds.sr.ht wrote:
> > ✗ #736407 FAILED  aerc/patches/openbsd.yml       https://builds.sr.ht/~rjarry/job/736407
> Not sure how to handle this one, I'm not an OpenBSD user and it's not
> clear to me if gnupg is available. Does anyone have any ideas here?

After the build fails you can ssh into the build machine to see how the
dev environment works.

Re: [aerc/patches] build failed

Details
Message ID
<9d8174d6-9200-4fec-a1da-84a528545d31@beta.fastmail.com>
In-Reply-To
<CJ9SUPQXAYUH.38VDKZI46ZMHA@debian-t400> (view parent)
DKIM signature
missing
Download raw message
I edited the manifest of #736454 to have gnupg as an additional package here [0]. The build image spins up before the patch is applied, so it didn’t matter that I added the gnupg package in the build files of the patch. Makes sense to not allow patches to modify the build, but it also means this can’t pass the automated build until gnupg is added to the openbsd manifest.

[0]: https://builds.sr.ht/~rockorager/job/736765

Re: [aerc/patches] build failed

Details
Message ID
<CJA86GTUNCDO.1G2UC62LSZO8R@marty>
In-Reply-To
<9d8174d6-9200-4fec-a1da-84a528545d31@beta.fastmail.com> (view parent)
DKIM signature
missing
Download raw message
Tim Culverhouse, Apr 14, 2022 at 12:21:
> I edited the manifest of #736454 to have gnupg as an additional
> package here [0]. The build image spins up before the patch is
> applied, so it didn’t matter that I added the gnupg package in the
> build files of the patch. Makes sense to not allow patches to modify
> the build, but it also means this can’t pass the automated build until
> gnupg is added to the openbsd manifest.
>
> [0]: https://builds.sr.ht/~rockorager/job/736765

I have updated the build images to all include gnupg (including macos).

You can resubmit your patch if you want.
Reply to thread Export thread (mbox)