aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/attachment.go16
-rw-r--r--lib/crypto/gpg/gpg_test.go57
-rw-r--r--lib/crypto/gpg/gpgbin/decrypt.go21
-rw-r--r--lib/crypto/gpg/gpgbin/encrypt.go12
-rw-r--r--lib/crypto/gpg/gpgbin/gpgbin.go79
-rw-r--r--lib/crypto/gpg/gpgbin/import-ownertrust.go16
-rw-r--r--lib/crypto/gpg/gpgbin/sign.go8
-rw-r--r--lib/crypto/gpg/gpgbin/verify.go3
-rw-r--r--lib/crypto/gpg/reader.go4
-rw-r--r--lib/crypto/gpg/reader_test.go64
-rw-r--r--lib/crypto/gpg/writer.go7
-rw-r--r--lib/crypto/gpg/writer_test.go30
-rw-r--r--lib/open.go2
-rw-r--r--lib/pinentry/pinentry.go71
-rw-r--r--lib/pinentry/ttyname.go45
-rw-r--r--lib/send/sendmail.go2
-rw-r--r--lib/templates/functions.go6
-rw-r--r--lib/ui/textinput.go119
-rw-r--r--lib/ui/ui.go13
-rw-r--r--lib/xdg/xdg.go28
20 files changed, 456 insertions, 147 deletions
diff --git a/lib/attachment.go b/lib/attachment.go
index 4dd3f41d..63b8f161 100644
--- a/lib/attachment.go
+++ b/lib/attachment.go
@@ -90,6 +90,8 @@ func (fa *FileAttachment) WriteTo(w *mail.Writer) error {
// setting the filename auto sets the content disposition
ah.SetFilename(filename)
+ fixContentTransferEncoding(mimeType, &ah)
+
aw, err := w.CreateAttachment(ah)
if err != nil {
return errors.Wrap(err, "CreateAttachment")
@@ -127,6 +129,8 @@ func (pa *PartAttachment) WriteTo(w *mail.Writer) error {
// setting the filename auto sets the content disposition
ah.SetFilename(pa.Name())
+ fixContentTransferEncoding(pa.part.MimeType, &ah)
+
aw, err := w.CreateAttachment(ah)
if err != nil {
return errors.Wrap(err, "CreateAttachment")
@@ -174,3 +178,15 @@ func FindMimeType(filename string, reader *bufio.Reader) (string, map[string]str
// so we need to break them apart before passing them to the headers
return mime.ParseMediaType(mimeString)
}
+
+// fixContentTransferEncoding checks the mime type of the attachment and
+// corrects the content-transfer-encoding if necessary.
+//
+// It's expressly forbidden by RFC2046 to set any other
+// content-transfer-encoding than 7bit, 8bit, or binary for
+// message/rfc822 mime types (see RFC2046, section 5.2.1)
+func fixContentTransferEncoding(mimeType string, header *mail.AttachmentHeader) {
+ if strings.ToLower(mimeType) == "message/rfc822" {
+ header.Add("Content-Transfer-Encoding", "binary")
+ }
+}
diff --git a/lib/crypto/gpg/gpg_test.go b/lib/crypto/gpg/gpg_test.go
index 25d73693..2cd0c3dc 100644
--- a/lib/crypto/gpg/gpg_test.go
+++ b/lib/crypto/gpg/gpg_test.go
@@ -126,32 +126,35 @@ hsgCjGTIAOQ5lNwpLEMjwLias6e5sM6hcK9Wo+A9Sw23f8lMau5clOZTJeyAUAff
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
+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"
diff --git a/lib/crypto/gpg/gpgbin/decrypt.go b/lib/crypto/gpg/gpgbin/decrypt.go
index 86d5575e..65f7a73d 100644
--- a/lib/crypto/gpg/gpgbin/decrypt.go
+++ b/lib/crypto/gpg/gpgbin/decrypt.go
@@ -2,6 +2,7 @@ package gpgbin
import (
"bytes"
+ "errors"
"io"
"git.sr.ht/~rjarry/aerc/models"
@@ -18,19 +19,17 @@ func Decrypt(r io.Reader) (*models.MessageDetails, error) {
args := []string{"--decrypt"}
g := newGpg(bytes.NewReader(orig), args)
_ = g.cmd.Run()
- outRdr := bytes.NewReader(g.stdout.Bytes())
// Always parse stdout, even if there was an error running command.
// We'll find the error in the parsing
- err = parse(outRdr, md)
- 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:
- return nil, err
- }
+ 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
}
diff --git a/lib/crypto/gpg/gpgbin/encrypt.go b/lib/crypto/gpg/gpgbin/encrypt.go
index fa33e466..91e0999a 100644
--- a/lib/crypto/gpg/gpgbin/encrypt.go
+++ b/lib/crypto/gpg/gpgbin/encrypt.go
@@ -8,13 +8,10 @@ import (
"git.sr.ht/~rjarry/aerc/models"
)
-// Encrypt runs gpg --encrypt [--sign] -r [recipient]. The default is to have
-// --trust-model always set
+// Encrypt runs gpg --encrypt [--sign] -r [recipient]
func Encrypt(r io.Reader, to []string, from string) ([]byte, error) {
- // TODO probably shouldn't have --trust-model always a default
args := []string{
"--armor",
- "--trust-model", "always",
}
if from != "" {
args = append(args, "--sign", "--default-key", from)
@@ -26,14 +23,11 @@ func Encrypt(r io.Reader, to []string, from string) ([]byte, error) {
g := newGpg(r, args)
_ = g.cmd.Run()
- outRdr := bytes.NewReader(g.stdout.Bytes())
var md models.MessageDetails
- err := parse(outRdr, &md)
+ 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)
}
- var buf bytes.Buffer
- _, _ = io.Copy(&buf, md.Body)
- return buf.Bytes(), nil
+ return g.stdout.Bytes(), nil
}
diff --git a/lib/crypto/gpg/gpgbin/gpgbin.go b/lib/crypto/gpg/gpgbin/gpgbin.go
index 69f290fd..b4985328 100644
--- a/lib/crypto/gpg/gpgbin/gpgbin.go
+++ b/lib/crypto/gpg/gpgbin/gpgbin.go
@@ -3,7 +3,6 @@ package gpgbin
import (
"bufio"
"bytes"
- "errors"
"fmt"
"io"
"os/exec"
@@ -11,6 +10,7 @@ import (
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
+ "git.sr.ht/~rjarry/aerc/lib/pinentry"
"git.sr.ht/~rjarry/aerc/models"
)
@@ -24,26 +24,15 @@ type gpg struct {
// 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 = 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
- return g
-}
+ pinentry.SetCmdEnv(g.cmd)
-// 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(strings.Join(lines, ", "))
+ return g
}
// fields returns the field name from --status-fd output. See:
@@ -116,25 +105,15 @@ func longKeyToUint64(key string) (uint64, error) {
}
// parse parses the output of gpg --status-fd
-func parse(r io.Reader, md *models.MessageDetails) error {
+func parseStatusFd(r io.Reader, md *models.MessageDetails) 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
- log.Tracef(line)
- }
- if msgCollecting {
- msgContent = append(msgContent, scanner.Bytes()...)
- msgContent = append(msgContent, newLine...)
- }
+ log.Tracef(line)
switch field(line) {
case "ENC_TO":
@@ -146,9 +125,7 @@ func parse(r io.Reader, md *models.MessageDetails) error {
return err
}
case "DECRYPTION_FAILED":
- return fmt.Errorf("gpg: decryption failed")
- case "PLAINTEXT":
- msgCollecting = true
+ return EncryptionFailed
case "NEWSIG":
md.IsSigned = true
case "GOODSIG":
@@ -203,30 +180,37 @@ func parse(r io.Reader, md *models.MessageDetails) error {
md.SignatureError = "gpg: unsupported algorithm"
}
md.SignedBy = "(unknown signer)"
- case "BEGIN_ENCRYPTION":
- msgCollecting = true
+ 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 fmt.Errorf("gpg: micalg not found")
+ return MicalgNotFound
}
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")
+ return MicalgNotFound
}
md.Micalg = micalgs[micalg]
case "NODATA":
- md.SignatureError = "gpg: no signature packet found"
+ 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(strings.TrimPrefix(line, "[GNUPG:] "))
+ return fmt.Errorf("%s", strings.TrimPrefix(line, "[GNUPG:] "))
}
}
- md.Body = bytes.NewReader(msgContent)
return nil
}
@@ -242,14 +226,25 @@ func parseDecryptionKey(l string) (uint64, error) {
return fprUint64, nil
}
-type GPGError int32
+type StatusFdParsingError int32
const (
- ERROR_NO_PGP_DATA_FOUND GPGError = iota + 1
+ EncryptionFailed StatusFdParsingError = iota + 1
+ MicalgNotFound
+ NoValidOpenPgpData
)
-var GPGErrors = map[string]GPGError{
- "gpg: no valid openpgp data found.": ERROR_NO_PGP_DATA_FOUND,
+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
diff --git a/lib/crypto/gpg/gpgbin/import-ownertrust.go b/lib/crypto/gpg/gpgbin/import-ownertrust.go
new file mode 100644
index 00000000..05499917
--- /dev/null
+++ b/lib/crypto/gpg/gpgbin/import-ownertrust.go
@@ -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
+}
diff --git a/lib/crypto/gpg/gpgbin/sign.go b/lib/crypto/gpg/gpgbin/sign.go
index 163aedfd..63bbd15c 100644
--- a/lib/crypto/gpg/gpgbin/sign.go
+++ b/lib/crypto/gpg/gpgbin/sign.go
@@ -19,13 +19,11 @@ func Sign(r io.Reader, from string) ([]byte, string, error) {
g := newGpg(r, args)
_ = g.cmd.Run()
- outRdr := bytes.NewReader(g.stdout.Bytes())
var md models.MessageDetails
- err := parse(outRdr, &md)
+ err := parseStatusFd(bytes.NewReader(g.stderr.Bytes()), &md)
if err != nil {
return nil, "", fmt.Errorf("failed to parse messagedetails: %w", err)
}
- var buf bytes.Buffer
- _, _ = io.Copy(&buf, md.Body)
- return buf.Bytes(), md.Micalg, nil
+
+ return g.stdout.Bytes(), md.Micalg, nil
}
diff --git a/lib/crypto/gpg/gpgbin/verify.go b/lib/crypto/gpg/gpgbin/verify.go
index 8208dc0d..a3ea4b4a 100644
--- a/lib/crypto/gpg/gpgbin/verify.go
+++ b/lib/crypto/gpg/gpgbin/verify.go
@@ -30,9 +30,8 @@ func Verify(m io.Reader, s io.Reader) (*models.MessageDetails, error) {
g := newGpg(bytes.NewReader(orig), args)
_ = g.cmd.Run()
- out := bytes.NewReader(g.stdout.Bytes())
md := new(models.MessageDetails)
- _ = parse(out, md)
+ _ = parseStatusFd(bytes.NewReader(g.stderr.Bytes()), md)
md.Body = bytes.NewReader(orig)
diff --git a/lib/crypto/gpg/reader.go b/lib/crypto/gpg/reader.go
index 07553c97..77022962 100644
--- a/lib/crypto/gpg/reader.go
+++ b/lib/crypto/gpg/reader.go
@@ -12,6 +12,7 @@ import (
"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"
)
@@ -92,6 +93,9 @@ func newEncryptedReader(h textproto.Header, mr *textproto.MultipartReader) (*Rea
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)
diff --git a/lib/crypto/gpg/reader_test.go b/lib/crypto/gpg/reader_test.go
index 957a727d..1ea0ef0f 100644
--- a/lib/crypto/gpg/reader_test.go
+++ b/lib/crypto/gpg/reader_test.go
@@ -18,6 +18,11 @@ func importPublicKey() {
gpgbin.Import(r)
}
+func importOwnertrust() {
+ r := strings.NewReader(testOwnertrust)
+ gpgbin.ImportOwnertrust(r)
+}
+
type readerTestCase struct {
name string
want models.MessageDetails
@@ -26,8 +31,8 @@ type readerTestCase struct {
func TestReader(t *testing.T) {
initGPGtest(t)
- importPublicKey()
importSecretKey()
+ importOwnertrust()
testCases := []readerTestCase{
{
@@ -47,6 +52,20 @@ func TestReader(t *testing.T) {
},
},
{
+ 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{
@@ -120,6 +139,15 @@ 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!
@@ -167,6 +195,40 @@ O4sDS4l/8eQTEYUxTavdtQ9O9ZMXvf/L3Rl1uFJXw1lFwPReXwtpA485e031/A==
--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
diff --git a/lib/crypto/gpg/writer.go b/lib/crypto/gpg/writer.go
index c879bc7f..9c12c6bb 100644
--- a/lib/crypto/gpg/writer.go
+++ b/lib/crypto/gpg/writer.go
@@ -11,6 +11,7 @@ import (
"net/mail"
"git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
+ "git.sr.ht/~rjarry/aerc/lib/pinentry"
"github.com/emersion/go-message"
"github.com/emersion/go-message/textproto"
)
@@ -27,6 +28,9 @@ func (es *EncrypterSigner) Write(p []byte) (int, error) {
}
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 {
@@ -72,6 +76,9 @@ func (s *Signer) Close() (err error) {
_ = textproto.WriteHeader(&buf, header.Header)
_, _ = io.Copy(&buf, msg.Body)
+ pinentry.Enable()
+ defer pinentry.Disable()
+
sig, micalg, err := gpgbin.Sign(bytes.NewReader(buf.Bytes()), s.from)
if err != nil {
return err
diff --git a/lib/crypto/gpg/writer_test.go b/lib/crypto/gpg/writer_test.go
index 0355db5e..26f3def7 100644
--- a/lib/crypto/gpg/writer_test.go
+++ b/lib/crypto/gpg/writer_test.go
@@ -16,26 +16,38 @@ func init() {
}
type writerTestCase struct {
- name string
- method string
- body string
+ name string
+ method string
+ body string
+ to []string
+ expectedErr string
}
func TestWriter(t *testing.T) {
initGPGtest(t)
- importPublicKey()
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
@@ -45,18 +57,18 @@ func TestWriter(t *testing.T) {
var header textproto.Header
header.Set("Content-Type", "text/plain")
- to := []string{"john.doe@example.org"}
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, to, from)
+ cleartext, err = Encrypt(&buf, h, tc.to, from)
if err != nil {
t.Fatalf("Encrypt() = %v", err)
}
@@ -73,8 +85,14 @@ func TestWriter(t *testing.T) {
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)
diff --git a/lib/open.go b/lib/open.go
index 5ca819e0..edbc7ff6 100644
--- a/lib/open.go
+++ b/lib/open.go
@@ -6,7 +6,7 @@ import (
"runtime"
"strings"
- "git.sr.ht/~rjarry/go-opt"
+ "git.sr.ht/~rjarry/go-opt/v2"
"github.com/danwakefield/fnmatch"
"git.sr.ht/~rjarry/aerc/config"
diff --git a/lib/pinentry/pinentry.go b/lib/pinentry/pinentry.go
new file mode 100644
index 00000000..51b54920
--- /dev/null
+++ b/lib/pinentry/pinentry.go
@@ -0,0 +1,71 @@
+package pinentry
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "sync/atomic"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/log"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+)
+
+var pinentryMode int32 = 0
+
+func Enable() {
+ if !config.General.UsePinentry {
+ return
+ }
+ if atomic.SwapInt32(&pinentryMode, 1) == 1 {
+ // cannot enter pinentry mode twice
+ return
+ }
+ ui.SuspendScreen()
+}
+
+func Disable() {
+ if atomic.SwapInt32(&pinentryMode, 0) == 0 {
+ // not in pinentry mode
+ return
+ }
+ ui.ResumeScreen()
+}
+
+func SetCmdEnv(cmd *exec.Cmd) {
+ if cmd == nil || atomic.LoadInt32(&pinentryMode) == 0 {
+ return
+ }
+
+ env := cmd.Env
+ if env == nil {
+ env = os.Environ()
+ }
+
+ hasTerm := false
+ hasGPGTTY := false
+ for _, e := range env {
+ switch {
+ case strings.HasPrefix(strings.ToUpper(e), "TERM="):
+ log.Debugf("pinentry: use %v", e)
+ hasTerm = true
+ case strings.HasPrefix(strings.ToUpper(e), "GPG_TTY="):
+ log.Debugf("pinentry: use %v", e)
+ hasGPGTTY = true
+ }
+ }
+
+ if !hasTerm {
+ env = append(env, "TERM=xterm-256color")
+ log.Debugf("pinentry: set TERM=xterm-256color")
+ }
+
+ if !hasGPGTTY {
+ tty := ttyname()
+ env = append(env, fmt.Sprintf("GPG_TTY=%s", tty))
+ log.Debugf("pinentry: set GPG_TTY=%s", tty)
+ }
+
+ cmd.Env = env
+}
diff --git a/lib/pinentry/ttyname.go b/lib/pinentry/ttyname.go
new file mode 100644
index 00000000..053cff74
--- /dev/null
+++ b/lib/pinentry/ttyname.go
@@ -0,0 +1,45 @@
+package pinentry
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "git.sr.ht/~rjarry/aerc/lib/log"
+)
+
+var missingGPGTTYmsg = `
+You need to set GPG_TTY manually before starting aerc. Add the following to your
+.bashrc or whatever initialization file is used for shell invocations:
+
+ GPG_TTY=$(tty)
+ export GPG_TTY
+
+Further information can be found here:
+https://www.gnupg.org/documentation/manuals/gnupg/Invoking-GPG_002dAGENT.html
+`
+
+// ttyname returns current name of the pty. This is necessary in order to tell
+// pinentry where to ask for the passphrase.
+//
+// If there is a GPG_TTY environment variable set, use this one. Otherwise, try
+// readline() on /proc/<pid>/fd/0.
+//
+// If both approaches fail, the user's only option is to set GPG_TTY manually.
+//
+// If tty name could not be determined, an empty string is returned.
+func ttyname() string {
+ if s := os.Getenv("GPG_TTY"); s != "" {
+ return s
+ }
+
+ // try readlink or else show missing GPG_TTY warning msg
+ tty, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/0", os.Getpid()))
+ if err != nil {
+ log.Debugf("readlink: '%s' with err: %v", tty, err)
+ log.Warnf(missingGPGTTYmsg)
+ return ""
+ }
+
+ return strings.TrimSpace(tty)
+}
diff --git a/lib/send/sendmail.go b/lib/send/sendmail.go
index b267721e..9d98cf8f 100644
--- a/lib/send/sendmail.go
+++ b/lib/send/sendmail.go
@@ -6,7 +6,7 @@ import (
"net/url"
"os/exec"
- "git.sr.ht/~rjarry/go-opt"
+ "git.sr.ht/~rjarry/go-opt/v2"
"github.com/emersion/go-message/mail"
"github.com/pkg/errors"
)
diff --git a/lib/templates/functions.go b/lib/templates/functions.go
index 0d204b8f..8a415b1f 100644
--- a/lib/templates/functions.go
+++ b/lib/templates/functions.go
@@ -84,7 +84,11 @@ func quote(text string) string {
quoted.WriteString(">\n")
continue
}
- quoted.WriteString("> ")
+ if strings.HasPrefix(line, ">") {
+ quoted.WriteString(">")
+ } else {
+ quoted.WriteString("> ")
+ }
quoted.WriteString(line)
quoted.WriteRune('\n')
}
diff --git a/lib/ui/textinput.go b/lib/ui/textinput.go
index 6c4551b3..08fc2c92 100644
--- a/lib/ui/textinput.go
+++ b/lib/ui/textinput.go
@@ -1,6 +1,7 @@
package ui
import (
+ "context"
"math"
"strings"
"sync"
@@ -10,6 +11,7 @@ import (
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
+ "git.sr.ht/~rjarry/go-opt/v2"
"git.sr.ht/~rockorager/vaxis"
)
@@ -27,8 +29,9 @@ type TextInput struct {
text []vaxis.Character
change []func(ti *TextInput)
focusLost []func(ti *TextInput)
- tabcomplete func(s string) ([]string, string)
- completions []string
+ tabcomplete func(ctx context.Context, s string) ([]opt.Completion, string)
+ tabcompleteCancel context.CancelFunc
+ completions []opt.Completion
prefix string
completeIndex int
completeDelay time.Duration
@@ -44,10 +47,11 @@ type TextInput struct {
func NewTextInput(text string, ui *config.UIConfig) *TextInput {
chars := vaxis.Characters(text)
return &TextInput{
- cells: -1,
- text: chars,
- index: len(chars),
- uiConfig: ui,
+ cells: -1,
+ text: chars,
+ index: len(chars),
+ uiConfig: ui,
+ tabcompleteCancel: func() {},
}
}
@@ -62,7 +66,7 @@ func (ti *TextInput) Prompt(prompt string) *TextInput {
}
func (ti *TextInput) TabComplete(
- tabcomplete func(s string) ([]string, string),
+ tabcomplete func(ctx context.Context, s string) ([]opt.Completion, string),
d time.Duration, minChars int, key *config.KeyStroke,
) *TextInput {
ti.tabcomplete = tabcomplete
@@ -142,8 +146,24 @@ func (ti *TextInput) drawPopover(ctx *Context) {
if len(ti.completions) == 0 {
return
}
- cmp := &completions{ti: ti}
- width := maxLen(ti.completions) + 3
+
+ valWidth := 0
+ descWidth := 0
+ for _, c := range ti.completions {
+ valWidth = max(valWidth, runewidth.StringWidth(unquote(c.Value)))
+ descWidth = max(descWidth, runewidth.StringWidth(c.Description))
+ }
+ descWidth = min(descWidth, 80)
+ // one space padding
+ width := 1 + valWidth
+ if descWidth != 0 {
+ // two spaces padding + parentheses
+ width += 2 + descWidth + 2
+ }
+ // one space padding + gutter
+ width += 2
+
+ cmp := &completions{ti: ti, valWidth: valWidth, descWidth: descWidth}
height := len(ti.completions)
pos := len(ti.prefix) - ti.scroll
@@ -275,7 +295,7 @@ func (ti *TextInput) backspace() {
func (ti *TextInput) executeCompletion() {
if len(ti.completions) > 0 {
- ti.Set(ti.prefix + ti.completions[ti.completeIndex] + ti.StringRight())
+ ti.Set(ti.prefix + ti.completions[ti.completeIndex].Value + ti.StringRight())
}
}
@@ -325,17 +345,34 @@ func (ti *TextInput) showCompletions(explicit bool) {
// no completer
return
}
- ti.completions, ti.prefix = ti.tabcomplete(ti.StringLeft())
-
- if explicit && len(ti.completions) == 1 {
- // automatically accept if there is only one choice
- ti.completeIndex = 0
- ti.executeCompletion()
- ti.invalidateCompletions()
- } else {
- ti.completeIndex = -1
- }
- Invalidate()
+ if ti.tabcompleteCancel != nil {
+ // Cancel any inflight completions we currently have
+ ti.tabcompleteCancel()
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ ti.tabcompleteCancel = cancel
+ go func() {
+ defer log.PanicHandler()
+ matches, prefix := ti.tabcomplete(ctx, ti.StringLeft())
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ ti.Lock()
+ defer ti.Unlock()
+ ti.completions = matches
+ ti.prefix = prefix
+ if explicit && len(ti.completions) == 1 {
+ // automatically accept if there is only one choice
+ ti.completeIndex = 0
+ ti.executeCompletion()
+ ti.invalidateCompletions()
+ } else {
+ ti.completeIndex = -1
+ }
+ Invalidate()
+ }
+ }()
}
func (ti *TextInput) OnChange(onChange func(ti *TextInput)) {
@@ -402,7 +439,9 @@ func (ti *TextInput) Event(event vaxis.Event) bool {
}
type completions struct {
- ti *TextInput
+ ti *TextInput
+ valWidth int
+ descWidth int
}
func unquote(s string) string {
@@ -412,22 +451,13 @@ func unquote(s string) string {
return s
}
-func maxLen(ss []string) int {
- max := 0
- for _, s := range ss {
- l := runewidth.StringWidth(unquote(s))
- if l > max {
- max = l
- }
- }
- return max
-}
-
func (c *completions) Draw(ctx *Context) {
bg := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_DEFAULT)
+ bgDesc := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_DESCRIPTION)
gutter := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_GUTTER)
pill := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_PILL)
sel := c.ti.uiConfig.GetStyleSelected(config.STYLE_COMPLETION_DEFAULT)
+ selDesc := c.ti.uiConfig.GetStyleSelected(config.STYLE_COMPLETION_DESCRIPTION)
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', bg)
@@ -445,11 +475,20 @@ func (c *completions) Draw(ctx *Context) {
if idx > endIdx {
continue
}
+ val := runewidth.FillRight(unquote(opt.Value), c.valWidth)
+ desc := opt.Description
+ if desc != "" {
+ if runewidth.StringWidth(desc) > c.descWidth {
+ desc = runewidth.Truncate(desc, c.descWidth, "…")
+ }
+ desc = " " + runewidth.FillRight("("+desc+")", c.descWidth+2)
+ }
if c.index() == idx {
- ctx.Fill(0, idx-startIdx, ctx.Width(), 1, ' ', sel)
- ctx.Printf(0, idx-startIdx, sel, " %s ", unquote(opt))
+ n := ctx.Printf(0, idx-startIdx, sel, " %s", val)
+ ctx.Printf(n, idx-startIdx, selDesc, "%s ", desc)
} else {
- ctx.Printf(0, idx-startIdx, bg, " %s ", unquote(opt))
+ n := ctx.Printf(0, idx-startIdx, bg, " %s", val)
+ ctx.Printf(n, idx-startIdx, bgDesc, "%s ", desc)
}
}
@@ -548,23 +587,23 @@ func (c *completions) stem(stem string) {
c.ti.index = len(vaxis.Characters(c.ti.prefix + stem))
}
-func findStem(words []string) string {
+func findStem(words []opt.Completion) string {
if len(words) == 0 {
return ""
}
if len(words) == 1 {
- return words[0]
+ return words[0].Value
}
var stem string
stemLen := 1
- firstWord := []rune(words[0])
+ firstWord := []rune(words[0].Value)
for {
if len(firstWord) < stemLen {
return stem
}
var r rune = firstWord[stemLen-1]
for _, word := range words[1:] {
- runes := []rune(word)
+ runes := []rune(word.Value)
if len(runes) < stemLen {
return stem
}
diff --git a/lib/ui/ui.go b/lib/ui/ui.go
index c20eac37..3680fb1a 100644
--- a/lib/ui/ui.go
+++ b/lib/ui/ui.go
@@ -99,6 +99,19 @@ func QueueSuspend() {
}
}
+// SuspendScreen should be called from the main thread.
+func SuspendScreen() {
+ _ = state.vx.Suspend()
+}
+
+func ResumeScreen() {
+ err := state.vx.Resume()
+ if err != nil {
+ log.Errorf("ui: cannot resume after suspend: %v", err)
+ }
+ Invalidate()
+}
+
func Suspend() error {
var err error
if atomic.SwapUint32(&state.suspending, 0) != 0 {
diff --git a/lib/xdg/xdg.go b/lib/xdg/xdg.go
index c1eaab03..0c578fad 100644
--- a/lib/xdg/xdg.go
+++ b/lib/xdg/xdg.go
@@ -67,9 +67,35 @@ func DataPath(paths ...string) string {
return res
}
+// Return a path relative to the user state home dir
+func StatePath(paths ...string) string {
+ res := filepath.Join(paths...)
+ if !filepath.IsAbs(res) {
+ data := os.Getenv("XDG_STATE_HOME")
+ if data == "" {
+ data = ExpandHome("~/.local/state")
+ }
+ res = filepath.Join(data, res)
+ }
+ return res
+}
+
// ugly: there's no other way to allow mocking a function in go...
var userRuntimePath = func() string {
- return filepath.Join("/run/user", strconv.Itoa(os.Getuid()))
+ uid := strconv.Itoa(os.Getuid())
+ path := filepath.Join("/run/user", uid)
+ fi, err := os.Stat(path)
+ if err != nil || !fi.Mode().IsDir() {
+ // OpenRC does not create /run/user. TMUX and Neovim
+ // create /tmp/$program-$uid instead. Mimic that.
+ path = filepath.Join(os.TempDir(), "aerc-"+uid)
+ err = os.MkdirAll(path, 0o700)
+ if err != nil {
+ // Fallback to /tmp if all else fails.
+ path = os.TempDir()
+ }
+ }
+ return path
}
// Return a path relative to the user runtime dir