init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib/crypto/gpg"
|
||||
"git.sr.ht/~rjarry/aerc/lib/crypto/pgp"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Decrypt(io.Reader, openpgp.PromptFunction) (*models.MessageDetails, 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
|
||||
Close()
|
||||
GetSignerKeyId(string) (string, error)
|
||||
GetKeyId(string) (string, error)
|
||||
ExportKey(string) (io.Reader, error)
|
||||
}
|
||||
|
||||
func New() Provider {
|
||||
switch config.General.PgpProvider {
|
||||
case "auto":
|
||||
internal := &pgp.Mail{}
|
||||
if internal.KeyringExists() {
|
||||
log.Debugf("internal pgp keyring exists")
|
||||
return internal
|
||||
}
|
||||
log.Debugf("no internal pgp keyring, using system gpg")
|
||||
fallthrough
|
||||
case "gpg":
|
||||
return &gpg.Mail{}
|
||||
case "internal":
|
||||
return &pgp.Mail{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func IsEncrypted(bs *models.BodyStructure) bool {
|
||||
if bs == nil {
|
||||
return false
|
||||
}
|
||||
if bs.MIMEType == "application" && bs.MIMESubType == "pgp-encrypted" {
|
||||
return true
|
||||
}
|
||||
for _, part := range bs.Parts {
|
||||
if IsEncrypted(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package gpg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"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{}
|
||||
|
||||
func (m *Mail) Init() error {
|
||||
_, 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 (m *Mail) GetSignerKeyId(s string) (string, error) {
|
||||
return gpgbin.GetPrivateKeyId(s)
|
||||
}
|
||||
|
||||
func (m *Mail) GetKeyId(s string) (string, error) {
|
||||
return gpgbin.GetKeyId(s)
|
||||
}
|
||||
|
||||
func (m *Mail) ExportKey(k string) (io.Reader, error) {
|
||||
return gpgbin.ExportPublicKey(k)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package gpg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
func initGPGtest(t *testing.T) {
|
||||
if _, err := exec.LookPath("gpg"); err != nil {
|
||||
t.Skipf("%s", err)
|
||||
}
|
||||
// temp dir is automatically deleted by the test runtime
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GNUPGHOME", dir)
|
||||
t.Logf("using GNUPGHOME = %s", dir)
|
||||
}
|
||||
|
||||
func toCRLF(s string) string {
|
||||
return strings.ReplaceAll(s, "\n", "\r\n")
|
||||
}
|
||||
|
||||
func deepEqual(t *testing.T, name string, r *models.MessageDetails, expect *models.MessageDetails) {
|
||||
var resBuf bytes.Buffer
|
||||
if _, err := io.Copy(&resBuf, r.Body); err != nil {
|
||||
t.Fatalf("%s: io.Copy() = %v", name, err)
|
||||
}
|
||||
|
||||
var expBuf bytes.Buffer
|
||||
if _, err := io.Copy(&expBuf, expect.Body); err != nil {
|
||||
t.Fatalf("%s: io.Copy() = %v", name, err)
|
||||
}
|
||||
|
||||
if resBuf.String() != expBuf.String() {
|
||||
t.Errorf("%s: MessagesDetails.Body = \n%v\n but want \n%v", name, resBuf.String(), expBuf.String())
|
||||
}
|
||||
|
||||
if r.IsEncrypted != expect.IsEncrypted {
|
||||
t.Errorf("%s: IsEncrypted = \n%v\n but want \n%v", name, r.IsEncrypted, expect.IsEncrypted)
|
||||
}
|
||||
if r.IsSigned != expect.IsSigned {
|
||||
t.Errorf("%s: IsSigned = \n%v\n but want \n%v", name, r.IsSigned, expect.IsSigned)
|
||||
}
|
||||
if r.SignedBy != expect.SignedBy {
|
||||
t.Errorf("%s: SignedBy = \n%v\n but want \n%v", name, r.SignedBy, expect.SignedBy)
|
||||
}
|
||||
if r.SignedByKeyId != expect.SignedByKeyId {
|
||||
t.Errorf("%s: SignedByKeyId = \n%v\n but want \n%v", name, r.SignedByKeyId, expect.SignedByKeyId)
|
||||
}
|
||||
if r.SignatureError != expect.SignatureError {
|
||||
t.Errorf("%s: SignatureError = \n%v\n but want \n%v", name, r.SignatureError, expect.SignatureError)
|
||||
}
|
||||
if r.DecryptedWith != expect.DecryptedWith {
|
||||
t.Errorf("%s: DecryptedWith = \n%v\n but want \n%v", name, r.DecryptedWith, expect.DecryptedWith)
|
||||
}
|
||||
if r.DecryptedWithKeyId != expect.DecryptedWithKeyId {
|
||||
t.Errorf("%s: DecryptedWithKeyId = \n%v\n but want \n%v", name, 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-----
|
||||
|
||||
mQENBGcUGPEBCACox9bw5BiN9M+1qVtU90bkHl5xzPDl8SqX/2ieYSx0ZfUpmRAH
|
||||
9EbW4j54cTFM6mX18Yv2LRWQhHjzslPietJ1Lb3PGY2ffDDxJsq/uQHK/ztqePc7
|
||||
omJJjUuF5D7BjuOq/MFyu7dWSCXOrj8soY9HIS96pPNTF9ykLDhqKWIqGA7pORKk
|
||||
RFczMLmEojLKefHvgtp9ikNNbIJyq/P5hNHr/DfC7rFaMTrXNc2xP2MD7MYNdVmT
|
||||
N2NN/X676rTsu8ltUi96F5PR33mGez6Z66yMjJf863bd+muq8552ExoQGQ/uGo5y
|
||||
wvwoEOF7hx1Z6JYl56hAICXPL/ZOZTPdBf+9ABEBAAG0NEphbmUgRG9lIChUaGlz
|
||||
IGlzIGEgdGVzdCBrZXkpIDxqYW5lLmRvZUBleGFtcGxlLm9yZz6JAVEEEwEIADsW
|
||||
IQSoQ3iEudN9vdxgn6xy8nGZUc/d5AUCZxQY8QIbAwULCQgHAgIiAgYVCgkICwIE
|
||||
FgIDAQIeBwIXgAAKCRBy8nGZUc/d5ConB/9Z39ufzGmplm0m9ylN+x8iNYJJ5rk6
|
||||
WhnwDsKSEDPoYnSUuESQ7zxhPkqr2amgAcFWba6vm+GvdFBB+y8JzSGIBmNmQfuw
|
||||
dtBd5EI+cTSTzuXo4NXR7TrMJGPP8IvJNSrliG61JnW3kcz9U9dywum+XF57+2X1
|
||||
KCt3npJI64sMX39QZ1ReaRbKWrKcBdCWZqW79KbFn4yl4ooMS9aKggQQP91feMA9
|
||||
dP3onL+TWLRKVMQ657OngTKi8rIez+RasRmVV3Av+GMl0Tdcg3sWHrlliBexmC/X
|
||||
mHzbl/PR8HAjWxie+pObGPz1aodJpeI0Lr5LQgJxZtx49kov9Ua5xVUxuQENBGcU
|
||||
GPEBCACmVEII6Igka7AVqCrUrdRonSzuelT6X6/VToBoJMER7q5MENtqWd0iby4N
|
||||
kIJxaJQFyXY7mYyZqf2aRbCu+cvh/F77iSZEOzNoJuut5sjPg7MM+s/9GRlYboq9
|
||||
RGqDJwoT7+k6cdUJON5UPvdJj8GnFGGu9ZFs/cOz2psggzfeV4YbTKXzFm2yKMpx
|
||||
LdeBeLXLYG46d0ChZMmKyBLLJWtUb71MU2TTWyrmtDoN02bxDQpAeJu+3Qp6lq+/
|
||||
CGe5f407jkx2PDKvV6HkuYzjs8apVFVZsBkDlhkaX5YdFI2r1TxIbxC9k2UG9VLJ
|
||||
lGNeqO3iUCsjuKd7iaiLGGBIeqKnABEBAAGJATYEGAEIACAWIQSoQ3iEudN9vdxg
|
||||
n6xy8nGZUc/d5AUCZxQY8QIbDAAKCRBy8nGZUc/d5OxbB/sEqrdtCMFrXLOU7dur
|
||||
or1lfrlYaOIaOup+/SnTSi688O0ixZ2XjV7CW3z1E8JjWAVsQPdfpC2QOZATWZ/q
|
||||
ZMuEMwNpzhCVZDwBJR7nw+Pv/xFv9DvLEiJYHCyBrQtQ6vopG0t2yxJ4R/R48fQC
|
||||
m2xT54mb4flIV/C8zRy3eK2wY/kR5FVxnLwwFlYayR7+wuLTiHqqxRyeZA3hQcF3
|
||||
YDOgvRu3YzmESPtIBI6iNphfSSAAtkUqNJnwPAIxyky8xEInUZ7maOADRWgEH8uG
|
||||
+1FjPta6cgZ1tJzFtJ7Bwa2///UAp7BQqDl7DyMQAfOZGkUI9mqEXdra4YqMv5X0
|
||||
Y2UQ
|
||||
=QL1U
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
`
|
||||
|
||||
const testOwnertrust = "B1A8669354153B799F2217BF307215C13DF7A964:6:\n"
|
||||
@@ -0,0 +1,35 @@
|
||||
package gpgbin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"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 := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return md, err
|
||||
}
|
||||
args := []string{"--decrypt"}
|
||||
g := newGpg(bytes.NewReader(orig), args)
|
||||
_ = g.cmd.Run()
|
||||
// Always parse stdout, even if there was an error running command.
|
||||
// We'll find the error in the parsing
|
||||
err = parseStatusFd(bytes.NewReader(g.stderr.Bytes()), md)
|
||||
|
||||
if errors.Is(err, NoValidOpenPgpData) {
|
||||
md.Body = bytes.NewReader(orig)
|
||||
return md, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
md.Body = bytes.NewReader(g.stdout.Bytes())
|
||||
return md, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gpgbin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
// Encrypt runs gpg --encrypt [--sign] -r [recipient]
|
||||
func Encrypt(r io.Reader, to []string, from string) ([]byte, error) {
|
||||
args := []string{
|
||||
"--armor",
|
||||
}
|
||||
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()
|
||||
var md models.MessageDetails
|
||||
err := parseStatusFd(bytes.NewReader(g.stderr.Bytes()), &md)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpg: failure to encrypt: %w. check public key(s)", err)
|
||||
}
|
||||
|
||||
return g.stdout.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package gpgbin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pinentry"
|
||||
"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", "2", "--log-file", "/dev/null", "--batch")
|
||||
g.cmd.Args = append(g.cmd.Args, args...)
|
||||
g.cmd.Stdin = stdin
|
||||
g.cmd.Stdout = &g.stdout
|
||||
g.cmd.Stderr = &g.stderr
|
||||
|
||||
pinentry.SetCmdEnv(g.cmd)
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// fields returns the field name from --status-fd output. See:
|
||||
// https://github.com/gpg/gnupg/blob/master/doc/DETAILS
|
||||
func field(s string) string {
|
||||
tokens := strings.SplitN(s, " ", 3)
|
||||
if tokens[0] == "[GNUPG:]" {
|
||||
return tokens[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getIdentity returns the identity of the given key
|
||||
func getIdentity(key uint64) string {
|
||||
fpr := fmt.Sprintf("%X", key)
|
||||
cmd := exec.Command("gpg", "--with-colons", "--batch", "--list-keys", fpr)
|
||||
|
||||
var outbuf strings.Builder
|
||||
cmd.Stdout = &outbuf
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
log.Errorf("gpg: failed to get identity: %v", err)
|
||||
return ""
|
||||
}
|
||||
out := strings.Split(outbuf.String(), "\n")
|
||||
for _, line := range out {
|
||||
if strings.HasPrefix(line, "uid") {
|
||||
flds := strings.Split(line, ":")
|
||||
return flds[9]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getKeyId returns the 16 digit key id, if key exists
|
||||
func getKeyId(s string, private bool) string {
|
||||
cmd := exec.Command("gpg", "--with-colons", "--batch")
|
||||
listArg := "--list-keys"
|
||||
if private {
|
||||
listArg = "--list-secret-keys"
|
||||
}
|
||||
cmd.Args = append(cmd.Args, listArg, s)
|
||||
|
||||
var outbuf strings.Builder
|
||||
cmd.Stdout = &outbuf
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
log.Errorf("gpg: failed to get key ID: %v", err)
|
||||
return ""
|
||||
}
|
||||
out := strings.Split(outbuf.String(), "\n")
|
||||
for _, line := range out {
|
||||
if strings.HasPrefix(line, "fpr") {
|
||||
flds := strings.Split(line, ":")
|
||||
id := flds[9]
|
||||
return id[len(id)-16:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// longKeyToUint64 returns a uint64 version of the given key
|
||||
func longKeyToUint64(key string) (uint64, error) {
|
||||
fpr := string(key[len(key)-16:])
|
||||
fprUint64, err := strconv.ParseUint(fpr, 16, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return fprUint64, nil
|
||||
}
|
||||
|
||||
// parse parses the output of gpg --status-fd
|
||||
func parseStatusFd(r io.Reader, md *models.MessageDetails) error {
|
||||
var err error
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if field(line) == "PLAINTEXT_LENGTH" {
|
||||
continue
|
||||
}
|
||||
log.Tracef(line)
|
||||
|
||||
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 EncryptionFailed
|
||||
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 "INV_RECP":
|
||||
t := strings.SplitN(line, " ", 4)
|
||||
if t[2] == "10" {
|
||||
return fmt.Errorf("gpg: public key of %s is not trusted", t[3])
|
||||
}
|
||||
case "SIG_CREATED":
|
||||
fields := strings.Split(line, " ")
|
||||
micalg, err := strconv.Atoi(fields[4])
|
||||
if err != nil {
|
||||
return MicalgNotFound
|
||||
}
|
||||
md.Micalg = micalgs[micalg]
|
||||
case "VALIDSIG":
|
||||
fields := strings.Split(line, " ")
|
||||
micalg, err := strconv.Atoi(fields[9])
|
||||
if err != nil {
|
||||
return MicalgNotFound
|
||||
}
|
||||
md.Micalg = micalgs[micalg]
|
||||
case "NODATA":
|
||||
t := strings.SplitN(line, " ", 3)
|
||||
if t[2] == "4" {
|
||||
md.SignatureError = "gpg: no signature packet found"
|
||||
}
|
||||
if t[2] == "1" {
|
||||
return NoValidOpenPgpData
|
||||
}
|
||||
case "FAILURE":
|
||||
return fmt.Errorf("%s", strings.TrimPrefix(line, "[GNUPG:] "))
|
||||
}
|
||||
}
|
||||
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 StatusFdParsingError int32
|
||||
|
||||
const (
|
||||
EncryptionFailed StatusFdParsingError = iota + 1
|
||||
MicalgNotFound
|
||||
NoValidOpenPgpData
|
||||
)
|
||||
|
||||
func (err StatusFdParsingError) Error() string {
|
||||
switch err {
|
||||
case EncryptionFailed:
|
||||
return "gpg: decryption failed"
|
||||
case MicalgNotFound:
|
||||
return "gpg: micalg not found"
|
||||
case NoValidOpenPgpData:
|
||||
return "gpg: no valid OpenPGP data found"
|
||||
default:
|
||||
return "gpg: unknown status fd parsing error"
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package gpgbin
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// Import runs gpg --import-ownertrust and thus imports trusts for keys
|
||||
func ImportOwnertrust(r io.Reader) error {
|
||||
args := []string{"--import-ownertrust"}
|
||||
g := newGpg(r, args)
|
||||
err := g.cmd.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package gpgbin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetPrivateKeyId runs gpg --list-secret-keys s
|
||||
func GetPrivateKeyId(s string) (string, error) {
|
||||
private := true
|
||||
id := getKeyId(s, private)
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("no private key found")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetKeyId runs gpg --list-keys s
|
||||
func GetKeyId(s string) (string, error) {
|
||||
private := false
|
||||
id := getKeyId(s, private)
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("no public key found")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ExportPublicKey exports the public key identified by k in armor format
|
||||
func ExportPublicKey(k string) (io.Reader, error) {
|
||||
cmd := exec.Command("gpg", "--armor",
|
||||
"--export-options", "export-minimal", "--export", k)
|
||||
|
||||
var outbuf bytes.Buffer
|
||||
var stderr strings.Builder
|
||||
cmd.Stdout = &outbuf
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpg: export failed: %w", err)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "gpg") {
|
||||
return nil, fmt.Errorf("gpg: error exporting key")
|
||||
}
|
||||
return &outbuf, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package gpgbin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"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()
|
||||
|
||||
var md models.MessageDetails
|
||||
err := parseStatusFd(bytes.NewReader(g.stderr.Bytes()), &md)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse messagedetails: %w", err)
|
||||
}
|
||||
|
||||
return g.stdout.Bytes(), md.Micalg, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package gpgbin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"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 := os.CreateTemp("", "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 := io.ReadAll(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := newGpg(bytes.NewReader(orig), args)
|
||||
_ = g.cmd.Run()
|
||||
|
||||
md := new(models.MessageDetails)
|
||||
_ = parseStatusFd(bytes.NewReader(g.stderr.Bytes()), md)
|
||||
|
||||
md.Body = bytes.NewReader(orig)
|
||||
|
||||
return md, nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// 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/lib/pinentry"
|
||||
"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: %w", 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: %w", 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: %w", 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: %w", 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: %w", 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)
|
||||
}
|
||||
|
||||
pinentry.Enable()
|
||||
defer pinentry.Disable()
|
||||
|
||||
md, err := gpgbin.Decrypt(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgmail: failed to read PGP message: %w", err)
|
||||
}
|
||||
|
||||
cleartext := bufio.NewReader(md.Body)
|
||||
cleartextHeader, err := textproto.ReadHeader(cleartext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgmail: failed to read encrypted header: %w", 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: %w", 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: %w", 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: %w", err)
|
||||
}
|
||||
|
||||
md, err := gpgbin.Verify(&msg, sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgmail: failed to read PGP message: %w", 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
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package gpg
|
||||
|
||||
import (
|
||||
"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 importOwnertrust() {
|
||||
r := strings.NewReader(testOwnertrust)
|
||||
gpgbin.ImportOwnertrust(r)
|
||||
}
|
||||
|
||||
type readerTestCase struct {
|
||||
name string
|
||||
want models.MessageDetails
|
||||
input string
|
||||
}
|
||||
|
||||
func TestReader(t *testing.T) {
|
||||
initGPGtest(t)
|
||||
importSecretKey()
|
||||
importOwnertrust()
|
||||
|
||||
testCases := []readerTestCase{
|
||||
{
|
||||
name: "Encrypted and Signed",
|
||||
input: testPGPMIMEEncryptedSigned,
|
||||
want: models.MessageDetails{
|
||||
IsEncrypted: true,
|
||||
IsSigned: true,
|
||||
SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
|
||||
SignedByKeyId: 3490876580878068068,
|
||||
SignatureValidity: 0,
|
||||
SignatureError: "",
|
||||
DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>",
|
||||
DecryptedWithKeyId: 3490876580878068068,
|
||||
Body: strings.NewReader(testEncryptedBody),
|
||||
Micalg: "pgp-sha512",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Encrypted but not signed",
|
||||
input: testPGPMIMEEncryptedButNotSigned,
|
||||
want: models.MessageDetails{
|
||||
IsEncrypted: true,
|
||||
IsSigned: false,
|
||||
SignatureValidity: 0,
|
||||
SignatureError: "",
|
||||
DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>",
|
||||
DecryptedWithKeyId: 3490876580878068068,
|
||||
Body: strings.NewReader(testEncryptedButNotSignedBody),
|
||||
Micalg: "pgp-sha512",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Signed",
|
||||
input: testPGPMIMESigned,
|
||||
want: models.MessageDetails{
|
||||
IsEncrypted: false,
|
||||
IsSigned: true,
|
||||
SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
|
||||
SignedByKeyId: 3490876580878068068,
|
||||
SignatureValidity: 0,
|
||||
SignatureError: "",
|
||||
DecryptedWith: "",
|
||||
DecryptedWithKeyId: 0,
|
||||
Body: strings.NewReader(testSignedBody),
|
||||
Micalg: "pgp-sha256",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Encapsulated Signature",
|
||||
input: testPGPMIMEEncryptedSignedEncapsulated,
|
||||
want: models.MessageDetails{
|
||||
IsEncrypted: true,
|
||||
IsSigned: true,
|
||||
SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
|
||||
SignedByKeyId: 3490876580878068068,
|
||||
SignatureValidity: 0,
|
||||
SignatureError: "",
|
||||
DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>",
|
||||
DecryptedWithKeyId: 3490876580878068068,
|
||||
Body: strings.NewReader(testSignedBody),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid Signature",
|
||||
input: testPGPMIMESignedInvalid,
|
||||
want: models.MessageDetails{
|
||||
IsEncrypted: false,
|
||||
IsSigned: true,
|
||||
SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
|
||||
SignedByKeyId: 3490876580878068068,
|
||||
SignatureValidity: 0,
|
||||
SignatureError: "gpg: invalid signature",
|
||||
DecryptedWith: "",
|
||||
DecryptedWithKeyId: 0,
|
||||
Body: strings.NewReader(testSignedInvalidBody),
|
||||
Micalg: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Plain text",
|
||||
input: testPlaintext,
|
||||
want: models.MessageDetails{
|
||||
IsEncrypted: false,
|
||||
IsSigned: false,
|
||||
Body: strings.NewReader(testPlaintext),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Logf("Test case: %s", tc.name)
|
||||
sr := strings.NewReader(tc.input)
|
||||
r, err := Read(sr)
|
||||
if err != nil {
|
||||
t.Fatalf("gpg.Read() = %v", err)
|
||||
}
|
||||
deepEqual(t, tc.name, r.MessageDetails, &tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
var testEncryptedBody = toCRLF(`Content-Type: text/plain
|
||||
|
||||
This is an encrypted message!
|
||||
`)
|
||||
|
||||
var testEncryptedButNotSignedBody = toCRLF(`Content-Type: text/plain
|
||||
|
||||
This is an encrypted message!
|
||||
[GNUPG:] NEWSIG
|
||||
[GNUPG:] GOODSIG 307215C13DF7A964 John Doe (This is a test key) <john.doe@example.org>
|
||||
|
||||
It is unsigned but it will appear as signed due to the lines above!
|
||||
`)
|
||||
|
||||
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 testPGPMIMEEncryptedButNotSigned = 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+AQf9HTht3ottGv3EP/jJTI6ZISyjhul9bPNVGgCNb4Wy3IuM
|
||||
fYC8EEC5VV9A0Wr8jBGcyt12iNCJCorCud5OgYjpfrX4KeWbj9eE6SZyUskbuWtA
|
||||
g/CHGvheYEN4+EFMC5XvM3xlj40chMpwqs+pBHmDjJAAT8aATn1kLTzXBADBhXdA
|
||||
xrsRB2o7yfLbnY8wcF9HZRK4NH4DgEmTexmUR8WdS4ASe6MK5XgNWqX/RFJzTbLM
|
||||
xdR5wBovQnspVt2wzoWxYdWhb4N2NgjbslHmviNmDwrYA0hHg8zQaSxKXxvWPcuJ
|
||||
Oe9JqC20C2BUeIx03srNvF3pEL+MCyZnFBEtiDvoRdLAQgES23MWuKhouywlpzaF
|
||||
Gl4wqTZQC7ulThqq887zC1UaMsvVDmeub5UdK803iOywjfch2CoPE6DsUwpiAZZ1
|
||||
U7yS04xttrmKqmEOLrA5SJNn9SfB7Ilz4BUaUDcWMDwhLTL0eBsvFFEXSdALg3jA
|
||||
3tTAqA8D2WM0y84YCgZPFzns6MVv+oeCc2W9eDMS3DZ/qg5llaXIulOiHw5R255g
|
||||
yMoJ1gzo7DMHfT/cL7eTbW7OUUvo94h3EmSojDhjeiRCFpZ8wC1BcHzWn+FLsum4
|
||||
lrnUpgKI5tQjyiu0bvS1ZSCGtOPIvx7MYt5m/C91Qtp3psHdMjoHH6SvLRbbliwG
|
||||
mgyp3g==
|
||||
=aoPf
|
||||
-----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!
|
||||
`)
|
||||
@@ -0,0 +1,221 @@
|
||||
// writer.go largerly mimics github.com/emersion/go-pgpmail, with changes made
|
||||
// to interface with the gpg package in aerc
|
||||
|
||||
package gpg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pinentry"
|
||||
"git.sr.ht/~rjarry/aerc/lib/rfc822"
|
||||
"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) {
|
||||
pinentry.Enable()
|
||||
defer pinentry.Disable()
|
||||
|
||||
r := bytes.NewReader(es.msgBuf.Bytes())
|
||||
enc, err := gpgbin.Encrypt(r, es.to, es.from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(es.encryptedWriter, rfc822.NewCRLFReader(bytes.NewReader(enc)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gpg: failed to write encrypted writer: %w", err)
|
||||
}
|
||||
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) {
|
||||
reader := bufio.NewReader(&s.signedMsg)
|
||||
header, err := textproto.ReadHeader(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make sure that MIME-Version is *not* set on the signed part header.
|
||||
// It must be set *only* on the top level header.
|
||||
//
|
||||
// Some MTAs actually normalize the case of all headers (including
|
||||
// signed text parts). MIME-Version can be normalized to different
|
||||
// casing depending on the implementation (MIME- vs Mime-).
|
||||
//
|
||||
// Since the signature is computed on the whole part, including its
|
||||
// header, changing the case can cause the signature to become invalid.
|
||||
header.Del("Mime-Version")
|
||||
|
||||
var buf bytes.Buffer
|
||||
_ = textproto.WriteHeader(&buf, header)
|
||||
_, _ = io.Copy(&buf, reader)
|
||||
|
||||
pinentry.Enable()
|
||||
defer pinentry.Disable()
|
||||
|
||||
sig, micalg, err := gpgbin.Sign(bytes.NewReader(buf.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))
|
||||
// Ensure Mime-Version header is set on the top level to be compliant
|
||||
// with RFC 2045
|
||||
s.header.Set("Mime-Version", "1.0")
|
||||
|
||||
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(buf.Bytes())
|
||||
_, _ = s.w.Write([]byte("\r\n"))
|
||||
|
||||
var signedHeader textproto.Header
|
||||
signedHeader.Set("Content-Type", "application/pgp-signature; name=\"signature.asc\"")
|
||||
signatureWriter, err := s.mw.CreatePart(signedHeader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(signatureWriter, rfc822.NewCRLFReader(bytes.NewReader(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 != "" {
|
||||
err := mw.SetBoundary(forceBoundary)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpg: failed to set boundary: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
params := map[string]string{
|
||||
"boundary": mw.Boundary(),
|
||||
"protocol": "application/pgp-encrypted",
|
||||
}
|
||||
h.Set("Content-Type", mime.FormatMediaType("multipart/encrypted", params))
|
||||
// Ensure Mime-Version header is set on the top level to be compliant
|
||||
// with RFC 2045
|
||||
h.Set("Mime-Version", "1.0")
|
||||
|
||||
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 != "" {
|
||||
err := mw.SetBoundary(forceBoundary)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpg: failed to set boundary: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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"
|
||||
}
|
||||
|
||||
type writerTestCase struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
to []string
|
||||
expectedErr string
|
||||
}
|
||||
|
||||
func TestWriter(t *testing.T) {
|
||||
initGPGtest(t)
|
||||
importSecretKey()
|
||||
importPublicKey()
|
||||
importOwnertrust()
|
||||
|
||||
testCases := []writerTestCase{
|
||||
{
|
||||
name: "Encrypt",
|
||||
method: "encrypt",
|
||||
body: "This is an encrypted message!\r\n",
|
||||
to: []string{"john.doe@example.org"},
|
||||
},
|
||||
{
|
||||
name: "Sign",
|
||||
method: "sign",
|
||||
body: "This is a signed message!\r\n",
|
||||
to: []string{"john.doe@example.org"},
|
||||
},
|
||||
{
|
||||
name: "Encrypt to untrusted",
|
||||
method: "encrypt",
|
||||
body: "This is an encrypted message!\r\n",
|
||||
to: []string{"jane.doe@example.org"},
|
||||
expectedErr: "gpg: failure to encrypt: gpg: public key of jane.doe@example.org is not trusted. check public key(s)",
|
||||
},
|
||||
}
|
||||
var h textproto.Header
|
||||
h.Set("From", "John Doe <john.doe@example.org>")
|
||||
h.Set("To", "John Doe <john.doe@example.org>")
|
||||
|
||||
var header textproto.Header
|
||||
header.Set("Content-Type", "text/plain")
|
||||
|
||||
from := "john.doe@example.org"
|
||||
|
||||
var err error
|
||||
for _, tc := range testCases {
|
||||
t.Logf("Test case: %s", tc.name)
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
cleartext io.WriteCloser
|
||||
)
|
||||
switch tc.method {
|
||||
case "encrypt":
|
||||
cleartext, err = Encrypt(&buf, h, tc.to, from)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt() = %v", err)
|
||||
}
|
||||
case "sign":
|
||||
cleartext, err = Sign(&buf, h, from)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt() = %v", err)
|
||||
}
|
||||
}
|
||||
if err = textproto.WriteHeader(cleartext, header); err != nil {
|
||||
t.Fatalf("textproto.WriteHeader() = %v", err)
|
||||
}
|
||||
if _, err = io.WriteString(cleartext, tc.body); err != nil {
|
||||
t.Fatalf("io.WriteString() = %v", err)
|
||||
}
|
||||
if err = cleartext.Close(); err != nil {
|
||||
if err.Error() == tc.expectedErr {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("ciphertext.Close() = %v", err)
|
||||
}
|
||||
if tc.expectedErr != "" {
|
||||
t.Fatalf("Expected error %v, but got %v", tc.expectedErr, err)
|
||||
}
|
||||
switch tc.method {
|
||||
case "encrypt":
|
||||
validateEncrypt(t, buf)
|
||||
case "sign":
|
||||
validateSign(t, buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateEncrypt(t *testing.T, buf bytes.Buffer) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func validateSign(t *testing.T, buf bytes.Buffer) {
|
||||
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, "Sign", 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",
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/emersion/go-pgpmail"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Mail struct{}
|
||||
|
||||
var (
|
||||
Keyring openpgp.EntityList
|
||||
|
||||
locked bool
|
||||
)
|
||||
|
||||
func (m *Mail) KeyringExists() bool {
|
||||
keypath := xdg.DataPath("aerc", "keyring.asc")
|
||||
keyfile, err := os.Open(keypath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer keyfile.Close()
|
||||
_, err = openpgp.ReadKeyRing(keyfile)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *Mail) Init() error {
|
||||
log.Debugf("Initializing PGP keyring")
|
||||
err := os.MkdirAll(xdg.DataPath("aerc"), 0o700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
lockpath := xdg.DataPath("aerc", "keyring.lock")
|
||||
lockfile, err := os.OpenFile(lockpath, os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
// TODO: Consider connecting to main process over IPC socket
|
||||
locked = false
|
||||
} else {
|
||||
locked = true
|
||||
lockfile.Close()
|
||||
}
|
||||
|
||||
keypath := xdg.DataPath("aerc", "keyring.asc")
|
||||
keyfile, err := os.Open(keypath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
defer keyfile.Close()
|
||||
|
||||
Keyring, err = openpgp.ReadKeyRing(keyfile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mail) Close() {
|
||||
if !locked {
|
||||
return
|
||||
}
|
||||
lockpath := xdg.DataPath("aerc", "keyring.lock")
|
||||
os.Remove(lockpath)
|
||||
}
|
||||
|
||||
func (m *Mail) getEntityByEmail(email string) (e *openpgp.Entity, err error) {
|
||||
for _, entity := range Keyring {
|
||||
ident := entity.PrimaryIdentity()
|
||||
if ident != nil && ident.UserId.Email == email {
|
||||
return entity, nil
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 (m *Mail) Decrypt(r io.Reader, decryptKeys openpgp.PromptFunction) (*models.MessageDetails, error) {
|
||||
md := new(models.MessageDetails)
|
||||
|
||||
pgpReader, err := pgpmail.Read(r, Keyring, decryptKeys, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
// we should consume the UnverifiedBody until EOF in order
|
||||
// to get the correct signature data
|
||||
data, err := io.ReadAll(pgpReader.MessageDetails.UnverifiedBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pgpReader.MessageDetails.UnverifiedBody = bytes.NewReader(data)
|
||||
|
||||
md.IsSigned = true
|
||||
md.SignedBy = ""
|
||||
md.SignedByKeyId = pgpReader.MessageDetails.SignedByKeyId
|
||||
md.SignatureValidity = models.Valid
|
||||
if pgpReader.MessageDetails.SignatureError != nil {
|
||||
md.SignatureError = pgpReader.MessageDetails.SignatureError.Error()
|
||||
md.SignatureValidity = handleSignatureError(md.SignatureError)
|
||||
}
|
||||
if pgpReader.MessageDetails.SignedBy != nil {
|
||||
md.SignedBy = pgpReader.MessageDetails.SignedBy.Entity.PrimaryIdentity().Name
|
||||
}
|
||||
}
|
||||
md.Body = pgpReader.MessageDetails.UnverifiedBody
|
||||
return md, nil
|
||||
}
|
||||
|
||||
func (m *Mail) ImportKeys(r io.Reader) error {
|
||||
keys, err := openpgp.ReadKeyRing(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Keyring = append(Keyring, keys...)
|
||||
if locked {
|
||||
keypath := xdg.DataPath("aerc", "keyring.asc")
|
||||
keyfile, err := os.OpenFile(keypath, os.O_CREATE|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer keyfile.Close()
|
||||
|
||||
for _, key := range keys {
|
||||
if key.PrivateKey != nil {
|
||||
err = key.SerializePrivate(keyfile, &packet.Config{})
|
||||
} else {
|
||||
err = key.Serialize(keyfile)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 signerEntity *openpgp.Entity
|
||||
if signer != "" {
|
||||
signerEntity, err = m.getSigner(signer, decryptKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, rcpt := range rcpts {
|
||||
toEntity, err := m.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, signerEntity, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cleartext, nil
|
||||
}
|
||||
|
||||
func (m *Mail) Sign(buf *bytes.Buffer, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
|
||||
var err error
|
||||
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, signerEntity, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cleartext, nil
|
||||
}
|
||||
|
||||
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 := signerEntity.SigningKey(time.Now())
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no signing key found for %s", signer)
|
||||
}
|
||||
|
||||
if !key.PrivateKey.Encrypted {
|
||||
return signerEntity, nil
|
||||
}
|
||||
|
||||
_, err = decryptKeys([]openpgp.Key{key}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return signerEntity, nil
|
||||
}
|
||||
|
||||
func (m *Mail) GetSignerKeyId(s string) (string, error) {
|
||||
var err error
|
||||
var signerEntity *openpgp.Entity
|
||||
switch strings.Contains(s, "@") {
|
||||
case true:
|
||||
signerEntity, err = m.getSignerEntityByEmail(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
case false:
|
||||
signerEntity, err = m.getSignerEntityByKeyId(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return signerEntity.PrimaryKey.KeyIdString(), nil
|
||||
}
|
||||
|
||||
func (m *Mail) GetKeyId(s string) (string, error) {
|
||||
entity, err := m.getEntityByEmail(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return entity.PrimaryKey.KeyIdString(), nil
|
||||
}
|
||||
|
||||
func (m *Mail) ExportKey(k string) (io.Reader, error) {
|
||||
var err error
|
||||
var entity *openpgp.Entity
|
||||
switch strings.Contains(k, "@") {
|
||||
case true:
|
||||
entity, err = m.getSignerEntityByEmail(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case false:
|
||||
entity, err = m.getSignerEntityByKeyId(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
pks := bytes.NewBuffer(nil)
|
||||
err = entity.Serialize(pks)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgp: error exporting key: %w", err)
|
||||
}
|
||||
pka := bytes.NewBuffer(nil)
|
||||
w, err := armor.Encode(pka, "PGP PUBLIC KEY BLOCK", map[string]string{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgp: error exporting key: %w", err)
|
||||
}
|
||||
_, err = w.Write(pks.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgp: error exporting key: %w", err)
|
||||
}
|
||||
w.Close()
|
||||
return pka, nil
|
||||
}
|
||||
|
||||
func handleSignatureError(e string) models.SignatureValidity {
|
||||
if e == "openpgp: signature made by unknown entity" {
|
||||
return models.UnknownEntity
|
||||
}
|
||||
if strings.HasPrefix(e, "pgpmail: unsupported micalg") {
|
||||
return models.UnsupportedMicalg
|
||||
}
|
||||
if strings.HasPrefix(e, "pgpmail") {
|
||||
return models.InvalidSignature
|
||||
}
|
||||
return models.UnknownValidity
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package cryptoutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/lib/rfc822"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
func Cleartext(r io.Reader, header mail.Header) ([]byte, error) {
|
||||
msg, err := app.CryptoProvider().Decrypt(
|
||||
rfc822.NewCRLFReader(r), app.DecryptKeys)
|
||||
if err != nil {
|
||||
return nil, errors.New("decrypt error")
|
||||
}
|
||||
full, err := createMessage(header, msg.Body)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to create decrypted message")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
func createMessage(header mail.Header, body io.Reader) ([]byte, error) {
|
||||
e, err := rfc822.ReadMessage(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// copy the header values from the "decrypted body". This should set
|
||||
// the correct content type.
|
||||
hf := e.Header.Fields()
|
||||
for hf.Next() {
|
||||
header.Set(hf.Key(), hf.Value())
|
||||
}
|
||||
|
||||
ctype, params, err := header.ContentType()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// in case there remains a multipart/{encrypted,signed} content type,
|
||||
// manually correct them to multipart/mixed as a fallback.
|
||||
ct := strings.ToLower(ctype)
|
||||
if strings.Contains(ct, "multipart/encrypted") ||
|
||||
strings.Contains(ct, "multipart/signed") {
|
||||
delete(params, "protocol")
|
||||
delete(params, "micalg")
|
||||
header.SetContentType("multipart/mixed", params)
|
||||
}
|
||||
|
||||
// a SingleInlineWriter is sufficient since the "decrypted body"
|
||||
// already contains the proper boundaries of the parts; we just want to
|
||||
// combine it with the headers.
|
||||
var message bytes.Buffer
|
||||
w, err := mail.CreateSingleInlineWriter(&message, header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.Copy(w, e.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Close()
|
||||
|
||||
return message.Bytes(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user