pgp: PGP/MIME signing for outgoing emails

implements PGP/MIME signing with go-pgpmail. The Sign() function of
go-pgpmail requires a private (signing) key. The signing key which matches
the senders email address (from field in email header) is looked up
in aerc's copy of the keyring.

Private keys can be exported from gpg into aerc as follows:
$ gpg --export-secret-keys  >> ~/.local/share/aerc/keyring.asc

A message is signed with the ":sign" command. The sign command sets
a bool flag in the Composer struct. Using the command repeatedly will
toggle the flag.

References: https://todo.sr.ht/~rjarry/aerc/6
Signed-off-by: Koni Marti <koni.marti@gmail.com>
This commit is contained in:
Koni Marti 2021-12-30 10:25:08 +01:00 committed by Robin Jarry
parent 8813fadfe9
commit 69d4e3895f
3 changed files with 157 additions and 20 deletions

44
commands/compose/sign.go Normal file
View file

@ -0,0 +1,44 @@
package compose
import (
"errors"
"time"
"git.sr.ht/~rjarry/aerc/widgets"
)
type Sign struct{}
func init() {
register(Sign{})
}
func (Sign) Aliases() []string {
return []string{"sign"}
}
func (Sign) Complete(aerc *widgets.Aerc, args []string) []string {
return nil
}
func (Sign) Execute(aerc *widgets.Aerc, args []string) error {
if len(args) != 1 {
return errors.New("Usage: sign")
}
composer, _ := aerc.SelectedTab().(*widgets.Composer)
composer.SetSign(!composer.Sign())
var statusline string
if composer.Sign() {
statusline = "Message will be signed."
} else {
statusline = "Message will not be signed."
}
aerc.PushStatus(statusline, 10*time.Second)
return nil
}

View file

@ -1,6 +1,7 @@
package lib
import (
"fmt"
"io"
"os"
"path"
@ -52,6 +53,19 @@ func UnlockKeyring() {
os.Remove(lockpath)
}
func GetSignerEntityByEmail(email string) (e *openpgp.Entity, err error) {
for _, key := range Keyring.DecryptionKeys() {
if key.Entity == nil {
continue
}
ident := key.Entity.PrimaryIdentity()
if ident != nil && ident.UserId.Email == email {
return key.Entity, nil
}
}
return nil, fmt.Errorf("entity not found in keyring")
}
func ImportKeys(r io.Reader) error {
keys, err := openpgp.ReadKeyRing(r)
if err != nil {

View file

@ -17,6 +17,7 @@ import (
"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"
@ -24,6 +25,7 @@ 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"
@ -49,6 +51,7 @@ type Composer struct {
review *reviewMessage
worker *types.Worker
completer *completer.Completer
sign bool
layout HeaderLayout
focusable []ui.MouseableDrawableInteractive
@ -173,6 +176,15 @@ func (c *Composer) Sent() bool {
return c.sent
}
func (c *Composer) SetSign(sign bool) *Composer {
c.sign = sign
return c
}
func (c *Composer) Sign() bool {
return c.sign
}
// Note: this does not reload the editor. You must call this before the first
// Draw() call.
func (c *Composer) SetContents(reader io.Reader) *Composer {
@ -393,34 +405,74 @@ func (c *Composer) PrepareHeader() (*mail.Header, error) {
return c.header, nil
}
func getSenderEmail(c *Composer) (string, error) {
// add the from: field also to the 'recipients' list
if c.acctConfig.From == "" {
return "", errors.New("No 'From' configured for this account")
}
from, err := mail.ParseAddress(c.acctConfig.From)
if err != nil {
return "", errors.Wrap(err, "ParseAddress(config.From)")
}
return from.Address, nil
}
func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error {
if err := c.reloadEmail(); err != nil {
return err
}
if len(c.attachments) == 0 {
// don't create a multipart email if we only have text
return writeInlineBody(header, c.email, writer)
}
if c.sign {
// otherwise create a multipart email,
// with a multipart/alternative part for the text
w, err := mail.CreateWriter(writer, *header)
if err != nil {
return errors.Wrap(err, "CreateWriter")
}
defer w.Close()
if err := writeMultipartBody(c.email, w); err != nil {
return errors.Wrap(err, "writeMultipartBody")
}
for _, a := range c.attachments {
if err := writeAttachment(a, w); err != nil {
return errors.Wrap(err, "writeAttachment")
signer, err := getSigner(c)
if err != nil {
return err
}
}
var signedHeader mail.Header
signedHeader.SetContentType("text/plain", nil)
var buf bytes.Buffer
var cleartext io.WriteCloser
cleartext, err = pgpmail.Sign(&buf, header.Header.Header, signer, nil)
if err != nil {
return err
}
err = writeMsgImpl(c, &signedHeader, cleartext)
if err != nil {
return err
}
cleartext.Close()
io.Copy(writer, &buf)
return nil
} else {
return writeMsgImpl(c, header, writer)
}
}
func writeMsgImpl(c *Composer, header *mail.Header, writer io.Writer) error {
if len(c.attachments) == 0 {
// no attachements
return writeInlineBody(header, c.email, writer)
} else {
// with attachements
w, err := mail.CreateWriter(writer, *header)
if err != nil {
return errors.Wrap(err, "CreateWriter")
}
if err := writeMultipartBody(c.email, w); err != nil {
return errors.Wrap(err, "writeMultipartBody")
}
for _, a := range c.attachments {
if err := writeAttachment(a, w); err != nil {
return errors.Wrap(err, "writeAttachment")
}
}
w.Close()
}
return nil
}
@ -885,3 +937,30 @@ 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
}