diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/attachment.go | 16 | ||||
-rw-r--r-- | lib/crypto/gpg/gpg_test.go | 57 | ||||
-rw-r--r-- | lib/crypto/gpg/gpgbin/decrypt.go | 21 | ||||
-rw-r--r-- | lib/crypto/gpg/gpgbin/encrypt.go | 12 | ||||
-rw-r--r-- | lib/crypto/gpg/gpgbin/gpgbin.go | 79 | ||||
-rw-r--r-- | lib/crypto/gpg/gpgbin/import-ownertrust.go | 16 | ||||
-rw-r--r-- | lib/crypto/gpg/gpgbin/sign.go | 8 | ||||
-rw-r--r-- | lib/crypto/gpg/gpgbin/verify.go | 3 | ||||
-rw-r--r-- | lib/crypto/gpg/reader.go | 4 | ||||
-rw-r--r-- | lib/crypto/gpg/reader_test.go | 64 | ||||
-rw-r--r-- | lib/crypto/gpg/writer.go | 7 | ||||
-rw-r--r-- | lib/crypto/gpg/writer_test.go | 30 | ||||
-rw-r--r-- | lib/open.go | 2 | ||||
-rw-r--r-- | lib/pinentry/pinentry.go | 71 | ||||
-rw-r--r-- | lib/pinentry/ttyname.go | 45 | ||||
-rw-r--r-- | lib/send/sendmail.go | 2 | ||||
-rw-r--r-- | lib/templates/functions.go | 6 | ||||
-rw-r--r-- | lib/ui/textinput.go | 119 | ||||
-rw-r--r-- | lib/ui/ui.go | 13 | ||||
-rw-r--r-- | lib/xdg/xdg.go | 28 |
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 |