diff --git a/config/aerc.conf b/config/aerc.conf index 00c6c49..1a3c1f4 100644 --- a/config/aerc.conf +++ b/config/aerc.conf @@ -3,6 +3,12 @@ [general] # +# If set to "gpg", aerc will use system gpg binary and keystore for all crypto +# operations. Otherwise, the internal openpgp implemenation will be used. +# +# Default: internal +pgp-provider=internal + # By default, the file permissions of accounts.conf must be restrictive and # only allow reading by the file owner (0600). Set this option to true to # ignore this permission check. Use this with care as it may expose your diff --git a/config/config.go b/config/config.go index 0c9a40f..8480f10 100644 --- a/config/config.go +++ b/config/config.go @@ -104,6 +104,7 @@ type AccountConfig struct { SignatureCmd string EnableFoldersSort bool `ini:"enable-folders-sort"` FoldersSort []string `ini:"folders-sort" delim:","` + PgpKeyId string `ini:"pgp-key-id"` } type BindingConfig struct { @@ -248,6 +249,8 @@ func loadAccountConfig(path string) ([]AccountConfig, error) { account.Archive = val } else if key == "enable-folders-sort" { account.EnableFoldersSort, _ = strconv.ParseBool(val) + } else if key == "pgp-key-id" { + account.PgpKeyId = val } else if key != "name" { account.Params[key] = val } @@ -582,13 +585,14 @@ func validateBorderChars(section *ini.Section, config *UIConfig) error { func validatePgpProvider(section *ini.Section) error { m := map[string]bool{ + "gpg": true, "internal": true, } for key, val := range section.KeysHash() { switch key { case "pgp-provider": if !m[strings.ToLower(val)] { - return fmt.Errorf("%v must be 'internal'", key) + return fmt.Errorf("%v must be either 'gpg' or 'internal'", key) } } } diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd index 7143cb6..6f5c50a 100644 --- a/doc/aerc-config.5.scd +++ b/doc/aerc-config.5.scd @@ -30,6 +30,13 @@ These options are configured in the *[general]* section of aerc.conf. *default-save-path* Used as a default path for save operations if no other path is specified. +*pgp-provider* + If set to "gpg", aerc will use system gpg binary and keystore for all + crypto operations. Otherwise, the internal openpgp implemenation will be + used. + + Default: internal + *unsafe-accounts-conf* By default, the file permissions of accounts.conf must be restrictive and only allow reading by the file owner (_0600_). Set this option to @@ -579,6 +586,10 @@ Note that many of these configuration options are written for you, such as Default: none +*pgp-key-id* + Specify the key id to use when signing a message. Can be either short or + long key id. If unset, aerc will look up the key by email + *postpone* Specifies the folder to save postponed messages to. diff --git a/lib/crypto/crypto.go b/lib/crypto/crypto.go index 47cb954..47eca99 100644 --- a/lib/crypto/crypto.go +++ b/lib/crypto/crypto.go @@ -5,6 +5,7 @@ import ( "io" "log" + "git.sr.ht/~rjarry/aerc/lib/crypto/gpg" "git.sr.ht/~rjarry/aerc/lib/crypto/pgp" "git.sr.ht/~rjarry/aerc/models" "github.com/ProtonMail/go-crypto/openpgp" @@ -22,6 +23,8 @@ type Provider interface { func New(s string) Provider { switch s { + case "gpg": + return &gpg.Mail{} default: return &pgp.Mail{} } diff --git a/lib/crypto/gpg/gpg.go b/lib/crypto/gpg/gpg.go new file mode 100644 index 0000000..66cd372 --- /dev/null +++ b/lib/crypto/gpg/gpg.go @@ -0,0 +1,62 @@ +package gpg + +import ( + "bytes" + "io" + "log" + "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 { + logger *log.Logger +} + +func (m *Mail) Init(l *log.Logger) error { + m.logger = l + _, 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 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 +} diff --git a/lib/crypto/gpg/gpg_test.go b/lib/crypto/gpg/gpg_test.go new file mode 100644 index 0000000..61c2ec6 --- /dev/null +++ b/lib/crypto/gpg/gpg_test.go @@ -0,0 +1,167 @@ +package gpg + +import ( + "bytes" + "fmt" + "io" + "log" + "os/exec" + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/models" +) + +func TestHasGpg(t *testing.T) { + gpgmail := new(Mail) + hasGpg := gpgmail.Init(new(log.Logger)) + + if hasGpg != nil { + t.Errorf("System does not have GPG") + } +} + +func CleanUp() { + cmd := exec.Command("gpg", "--batch", "--yes", "--delete-secret-and-public-keys", testKeyId) + err := cmd.Run() + if err != nil { + fmt.Println("Test cleanup failed: you may need to delete the test keys from your GPG keyring") + return + } +} + +func toCRLF(s string) string { + return strings.ReplaceAll(s, "\n", "\r\n") +} + +func deepEqual(t *testing.T, r *models.MessageDetails, expect *models.MessageDetails) { + var resBuf bytes.Buffer + if _, err := io.Copy(&resBuf, r.Body); err != nil { + t.Fatalf("io.Copy() = %v", err) + } + + var expBuf bytes.Buffer + if _, err := io.Copy(&expBuf, expect.Body); err != nil { + t.Fatalf("io.Copy() = %v", err) + } + + if resBuf.String() != expBuf.String() { + t.Errorf("MessagesDetails.Body = \n%v\n but want \n%v", resBuf.String(), expBuf.String()) + } + + if r.IsEncrypted != expect.IsEncrypted { + t.Errorf("IsEncrypted = \n%v\n but want \n%v", r.IsEncrypted, expect.IsEncrypted) + } + if r.IsSigned != expect.IsSigned { + t.Errorf("IsSigned = \n%v\n but want \n%v", r.IsSigned, expect.IsSigned) + } + if r.SignedBy != expect.SignedBy { + t.Errorf("SignedBy = \n%v\n but want \n%v", r.SignedBy, expect.SignedBy) + } + if r.SignedByKeyId != expect.SignedByKeyId { + t.Errorf("SignedByKeyId = \n%v\n but want \n%v", r.SignedByKeyId, expect.SignedByKeyId) + } + if r.SignatureError != expect.SignatureError { + t.Errorf("SignatureError = \n%v\n but want \n%v", r.SignatureError, expect.SignatureError) + } + if r.DecryptedWith != expect.DecryptedWith { + t.Errorf("DecryptedWith = \n%v\n but want \n%v", r.DecryptedWith, expect.DecryptedWith) + } + if r.DecryptedWithKeyId != expect.DecryptedWithKeyId { + t.Errorf("DecryptedWithKeyId = \n%v\n but want \n%v", r.DecryptedWithKeyId, expect.DecryptedWithKeyId) + } +} + +const testKeyId = `B1A8669354153B799F2217BF307215C13DF7A964` + +const testPrivateKeyArmored = `-----BEGIN PGP PRIVATE KEY BLOCK----- + +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 ++5anTnUn +=gemU +-----END PGP PRIVATE KEY BLOCK----- +` + +const testPublicKeyArmored = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBF5FJf8BCACvlKhSSsv4P8C3Wbv391SrNUBtFquoMuWKtuCr/Ks6KHuofGLn +bM55uBSQp908aITBDPkaOPsQ3OvwgF7SM8bNIDVpO7FHzCEg2Ysp99iPET/+LsbY +ugc8oYSuvA5aFFIOMYbAbI+HmbIBuCs+xp0AcU1cemAPzPBDCZs4xl5Y+/ce2yQz +ZGK9O/tQQIKoBUOWLo/0byAWyD6Gwn/Le3fVxxK6RPeeizDV6VfzHLxhxBNkMgmd +QUkBkvqF154wYxhzsHn72ushbJpspKz1LQN7d5u6QOq3h2sLwcLbT457qbMfZsZs +HLhoOibOd+yJ7C6TRbbyC4sQRr+K1CNGcvhJABEBAAG0NEpvaG4gRG9lIChUaGlz +IGlzIGEgdGVzdCBrZXkpIDxqb2huLmRvZUBleGFtcGxlLm9yZz6JAU4EEwEIADgW +IQSxqGaTVBU7eZ8iF78wchXBPfepZAUCXkUl/wIbAwULCQgHAgYVCgkICwIEFgID +AQIeAQIXgAAKCRAwchXBPfepZF4iB/49B7q4AfO3xHEa8LK2H+f7Mnm4dRfS2YPo +v2p6TRe1h2DxwpTevNQUhXw2U0nfRIEKBAZqgb7NVktkoh0DWtKatms2yHMAS+ah +lQoHb2gRgXa9M9Tq0x5u9sl0NYnx7Wu5uu6Ybw9luPKoAfO91T0vei0p3eMn3fIV +0O012ITvmgKJPppQDKFJHGZJMbVDO4TNxP89HgyhB41RO7AZadvu73S00x2K6x+O +R4s/++4Y98vScCPm3DUOXeoHXKGqFcNYTxJL9bsE2I0uYgvJSxNoK1dVnmvxp3zz +hcxAdzizgMz0ufY6YLMCjy5MDOzPARkmYPXdkJ6jceOIqGLUw1kquQENBF5FJf8B +CACpsh5cyHB7eEwQvLzJVsXpTW0Rh/Fe36AwC2Vz13WeE6GFrOvw1qATvtYB1919 +M4B44YH9J7I5SrFZad86Aw4n5Gi0BwLlGNa/oCMvYzlNHaTXURA271ghJqdZizqV +UETj3WNoaYm4mYMfb0dcayDJvVPWP7InzOsdIRU9WXBUSyVMxNMXccr2UvIuhdPg +lmVT8NtsWR+q8xBoL2Dp0ojYLVD3MlwKe1pE5mEwasYCkWePLWyGdfDW1MhUDsPH +3K1IjpPLWU9FBk8KM4z8WooY9/kyMIyRw39MvOHGfgcFBpiZwlELNZGSFhbRun03 +PMk2Qd3k+0FGV1IhFAYsr7QRABEBAAGJATYEGAEIACAWIQSxqGaTVBU7eZ8iF78w +chXBPfepZAUCXkUl/wIbDAAKCRAwchXBPfepZOtqB/9xsGEgQgm70KYID39H91k4 +ef/RlpRDY1ndC0MoPfqE03IEXTC/MjtU+ksPKEoZeQsxVaUJ2WBueI5WGJ3Y73pO +HAd7N0SyGHT5s6gK1FSx29be1qiPwUu5KR2jpm3RjgpbymnOWe4C6iiYCFQ85IX+ +LzpE+p9bB02PUrmzOb4MBV6E5mg30UjXIX01+bwZq5XSB4/FaUrQOAxLuRvVRjK0 +CEcFbPGIlkPSW6s4M9xCC2sQi7caFKVK6Zqf78KbOwAHqfS0x9u2jtTIhsgCjGTI +AOQ5lNwpLEMjwLias6e5sM6hcK9Wo+A9Sw23f8lMau5clOZTJeyAUAff+5anTnUn +=ZjQT +-----END PGP PUBLIC KEY BLOCK----- +` diff --git a/lib/crypto/gpg/gpgbin/decrypt.go b/lib/crypto/gpg/gpgbin/decrypt.go new file mode 100644 index 0000000..4b8d8f2 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/decrypt.go @@ -0,0 +1,34 @@ +package gpgbin + +import ( + "bytes" + "io" + "io/ioutil" + + "git.sr.ht/~rjarry/aerc/models" +) + +// Decrypt runs gpg --decrypt on the contents of r. If the packet is signed, +// the signature is also verified +func Decrypt(r io.Reader) (*models.MessageDetails, error) { + md := new(models.MessageDetails) + orig, err := ioutil.ReadAll(r) + if err != nil { + return md, err + } + args := []string{"--decrypt"} + g := newGpg(bytes.NewReader(orig), args) + err = g.cmd.Run() + if err != nil { + err = parseError(g.stderr.String()) + switch GPGErrors[err.Error()] { + case ERROR_NO_PGP_DATA_FOUND: + md.Body = bytes.NewReader(orig) + return md, nil + default: + } + } + outRdr := bytes.NewReader(g.stdout.Bytes()) + parse(outRdr, md) + return md, nil +} diff --git a/lib/crypto/gpg/gpgbin/encrypt.go b/lib/crypto/gpg/gpgbin/encrypt.go new file mode 100644 index 0000000..4cbac37 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/encrypt.go @@ -0,0 +1,35 @@ +package gpgbin + +import ( + "bytes" + "io" + + "git.sr.ht/~rjarry/aerc/models" +) + +// Encrypt runs gpg --encrypt [--sign] -r [recipient]. The default is to have +// --trust-model always set +func Encrypt(r io.Reader, to []string, from string) ([]byte, error) { + //TODO probably shouldn't have --trust-model always a default + args := []string{ + "--armor", + "--trust-model", "always", + } + 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() + outRdr := bytes.NewReader(g.stdout.Bytes()) + var md models.MessageDetails + parse(outRdr, &md) + var buf bytes.Buffer + io.Copy(&buf, md.Body) + + return buf.Bytes(), nil +} diff --git a/lib/crypto/gpg/gpgbin/gpgbin.go b/lib/crypto/gpg/gpgbin/gpgbin.go new file mode 100644 index 0000000..da046f4 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/gpgbin.go @@ -0,0 +1,262 @@ +package gpgbin + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "os/exec" + "strconv" + "strings" + + "git.sr.ht/~rjarry/aerc/models" + "github.com/mattn/go-isatty" +) + +// gpg represents a gpg command with buffers attached to stdout and stderr +type gpg struct { + cmd *exec.Cmd + stdout bytes.Buffer + stderr bytes.Buffer +} + +// newGpg creates a new gpg command with buffers attached +func newGpg(stdin io.Reader, args []string) *gpg { + g := new(gpg) + g.cmd = exec.Command("gpg", "--status-fd", "1", "--batch") + g.cmd.Args = append(g.cmd.Args, args...) + g.cmd.Stdin = stdin + g.cmd.Stdout = &g.stdout + g.cmd.Stderr = &g.stderr + + return g +} + +// parseError parses errors returned by gpg that don't show up with a [GNUPG:] +// prefix +func parseError(s string) error { + lines := strings.Split(s, "\n") + for _, line := range lines { + line = strings.ToLower(line) + if GPGErrors[line] > 0 { + return errors.New(line) + } + } + return errors.New("unknown gpg error") +} + +// fields returns the field name from --status-fd output. See: +// https://github.com/gpg/gnupg/blob/master/doc/DETAILS +func field(s string) string { + tokens := strings.SplitN(s, " ", 3) + if tokens[0] == "[GNUPG:]" { + return tokens[1] + } + return "" +} + +// getIdentity returns the identity of the given key +func getIdentity(key uint64) string { + fpr := fmt.Sprintf("%X", key) + cmd := exec.Command("gpg", "--with-colons", "--batch", "--list-keys", fpr) + + var outbuf strings.Builder + cmd.Stdout = &outbuf + cmd.Run() + out := strings.Split(outbuf.String(), "\n") + for _, line := range out { + if strings.HasPrefix(line, "uid") { + flds := strings.Split(line, ":") + return flds[9] + } + } + return "" +} + +// longKeyToUint64 returns a uint64 version of the given key +func longKeyToUint64(key string) (uint64, error) { + fpr := string(key[len(key)-16:]) + fprUint64, err := strconv.ParseUint(fpr, 16, 64) + if err != nil { + return 0, err + } + return fprUint64, nil +} + +// parse parses the output of gpg --status-fd +func parse(r io.Reader, md *models.MessageDetails) error { + var ( + logOut io.Writer + logger *log.Logger + ) + if !isatty.IsTerminal(os.Stdout.Fd()) { + logOut = os.Stdout + } else { + logOut = ioutil.Discard + os.Stdout, _ = os.Open(os.DevNull) + } + logger = log.New(logOut, "", log.LstdFlags) + 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 + logger.Println(line) + } + if msgCollecting { + msgContent = append(msgContent, scanner.Bytes()...) + msgContent = append(msgContent, newLine...) + } + + switch field(line) { + case "ENC_TO": + md.IsEncrypted = true + case "DECRYPTION_KEY": + md.DecryptedWithKeyId, err = parseDecryptionKey(line) + md.DecryptedWith = getIdentity(md.DecryptedWithKeyId) + if err != nil { + return err + } + case "DECRYPTION_FAILED": + return fmt.Errorf("gpg: decryption failed") + case "PLAINTEXT": + msgCollecting = true + case "NEWSIG": + md.IsSigned = true + case "GOODSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignedBy = t[3] + case "BADSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignatureError = "gpg: invalid signature" + md.SignedBy = t[3] + case "EXPSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignatureError = "gpg: expired signature" + md.SignedBy = t[3] + case "EXPKEYSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignatureError = "gpg: signature made with expired key" + md.SignedBy = t[3] + case "REVKEYSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignatureError = "gpg: signature made with revoked key" + md.SignedBy = t[3] + case "ERRSIG": + t := strings.SplitN(line, " ", 9) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + if t[7] == "9" { + md.SignatureError = "gpg: missing public key" + } + if t[7] == "4" { + md.SignatureError = "gpg: unsupported algorithm" + } + md.SignedBy = "(unknown signer)" + case "BEGIN_ENCRYPTION": + msgCollecting = true + case "SIG_CREATED": + fields := strings.Split(line, " ") + micalg, err := strconv.Atoi(fields[4]) + if err != nil { + return fmt.Errorf("gpg: micalg not found") + } + md.Micalg = micalgs[micalg] + msgCollecting = true + case "VALIDSIG": + fields := strings.Split(line, " ") + micalg, err := strconv.Atoi(fields[9]) + if err != nil { + return fmt.Errorf("gpg: micalg not found") + } + md.Micalg = micalgs[micalg] + case "NODATA": + md.SignatureError = "gpg: no signature packet found" + } + } + md.Body = bytes.NewReader(msgContent) + return nil +} + +// parseDecryptionKey returns primary key from DECRYPTION_KEY line +func parseDecryptionKey(l string) (uint64, error) { + key := strings.Split(l, " ")[3] + fpr := string(key[len(key)-16:]) + fprUint64, err := longKeyToUint64(fpr) + if err != nil { + return 0, err + } + getIdentity(fprUint64) + return fprUint64, nil +} + +type GPGError int32 + +const ( + ERROR_NO_PGP_DATA_FOUND GPGError = iota + 1 +) + +var GPGErrors = map[string]GPGError{ + "gpg: no valid openpgp data found.": ERROR_NO_PGP_DATA_FOUND, +} + +// micalgs represent hash algorithms for signatures. These are ignored by many +// email clients, but can be used as an additional verification so are sent. +// Both gpgmail and pgpmail implementations in aerc check for matching micalgs +var micalgs = map[int]string{ + 1: "pgp-md5", + 2: "pgp-sha1", + 3: "pgp-ripemd160", + 8: "pgp-sha256", + 9: "pgp-sha384", + 10: "pgp-sha512", + 11: "pgp-sha224", +} + +func logger(s string) { + var ( + logOut io.Writer + logger *log.Logger + ) + if !isatty.IsTerminal(os.Stdout.Fd()) { + logOut = os.Stdout + } else { + logOut = ioutil.Discard + os.Stdout, _ = os.Open(os.DevNull) + } + logger = log.New(logOut, "", log.LstdFlags) + logger.Println(s) +} diff --git a/lib/crypto/gpg/gpgbin/import.go b/lib/crypto/gpg/gpgbin/import.go new file mode 100644 index 0000000..49e178b --- /dev/null +++ b/lib/crypto/gpg/gpgbin/import.go @@ -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 +} diff --git a/lib/crypto/gpg/gpgbin/sign.go b/lib/crypto/gpg/gpgbin/sign.go new file mode 100644 index 0000000..35ab7e7 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/sign.go @@ -0,0 +1,27 @@ +package gpgbin + +import ( + "bytes" + "io" + + "git.sr.ht/~rjarry/aerc/models" +) + +// Sign creates a detached signature based on the contents of r +func Sign(r io.Reader, from string) ([]byte, string, error) { + args := []string{ + "--armor", + "--detach-sign", + "--default-key", from, + } + + g := newGpg(r, args) + g.cmd.Run() + + outRdr := bytes.NewReader(g.stdout.Bytes()) + var md models.MessageDetails + parse(outRdr, &md) + var buf bytes.Buffer + io.Copy(&buf, md.Body) + return buf.Bytes(), md.Micalg, nil +} diff --git a/lib/crypto/gpg/gpgbin/verify.go b/lib/crypto/gpg/gpgbin/verify.go new file mode 100644 index 0000000..be9f26f --- /dev/null +++ b/lib/crypto/gpg/gpgbin/verify.go @@ -0,0 +1,41 @@ +package gpgbin + +import ( + "bytes" + "io" + "io/ioutil" + "os" + + "git.sr.ht/~rjarry/aerc/models" +) + +// Verify runs gpg --verify. If s is not nil, then gpg interprets the +// arguments as a detached signature +func Verify(m io.Reader, s io.Reader) (*models.MessageDetails, error) { + args := []string{"--verify"} + if s != nil { + // Detached sig, save the sig to a tmp file and send msg over stdin + sig, err := ioutil.TempFile("", "sig") + if err != nil { + return nil, err + } + io.Copy(sig, s) + sig.Close() + defer os.Remove(sig.Name()) + args = append(args, sig.Name(), "-") + } + orig, err := ioutil.ReadAll(m) + if err != nil { + return nil, err + } + g := newGpg(bytes.NewReader(orig), args) + g.cmd.Run() + + out := bytes.NewReader(g.stdout.Bytes()) + md := new(models.MessageDetails) + parse(out, md) + + md.Body = bytes.NewReader(orig) + + return md, nil +} diff --git a/lib/crypto/gpg/reader.go b/lib/crypto/gpg/reader.go new file mode 100644 index 0000000..bf977ed --- /dev/null +++ b/lib/crypto/gpg/reader.go @@ -0,0 +1,165 @@ +// 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/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: %v", 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: %v", 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: %v", 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: %v", 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: %v", 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) + } + + md, err := gpgbin.Decrypt(p) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read PGP message: %v", err) + } + + cleartext := bufio.NewReader(md.Body) + cleartextHeader, err := textproto.ReadHeader(cleartext) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read encrypted header: %v", err) + } + + t, params, err := mime.ParseMediaType(cleartextHeader.Get("Content-Type")) + if err != nil { + return nil, err + } + + if md.IsEncrypted && !md.IsSigned && strings.EqualFold(t, "multipart/signed") && strings.EqualFold(params["protocol"], "application/pgp-signature") { + // RFC 1847 encapsulation, see RFC 3156 section 6.1 + micalg := params["micalg"] + mr := textproto.NewMultipartReader(cleartext, params["boundary"]) + mds, err := newSignedReader(cleartextHeader, mr, micalg) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read encapsulated multipart/signed message: %v", err) + } + mds.MessageDetails.IsEncrypted = md.IsEncrypted + mds.MessageDetails.DecryptedWith = md.DecryptedWith + mds.MessageDetails.DecryptedWithKeyId = md.DecryptedWithKeyId + return mds, nil + } + + var headerBuf bytes.Buffer + textproto.WriteHeader(&headerBuf, cleartextHeader) + md.Body = io.MultiReader(&headerBuf, cleartext) + + return &Reader{ + Header: h, + MessageDetails: md, + }, nil +} + +func newSignedReader(h textproto.Header, mr *textproto.MultipartReader, micalg string) (*Reader, error) { + 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: %v", err) + } + var headerBuf bytes.Buffer + textproto.WriteHeader(&headerBuf, p.Header) + var msg bytes.Buffer + headerRdr := bytes.NewReader(headerBuf.Bytes()) + fullMsg := io.MultiReader(headerRdr, p) + io.Copy(&msg, fullMsg) + + sig, err := mr.NextPart() + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read pgp part in multipart/signed message: %v", err) + } + + md, err := gpgbin.Verify(&msg, sig) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read PGP message: %v", err) + } + if md.Micalg != micalg && md.SignatureError == "" { + md.SignatureError = "gpg: header hash does not match actual sig hash" + } + + return &Reader{ + Header: h, + MessageDetails: md, + }, nil +} diff --git a/lib/crypto/gpg/reader_test.go b/lib/crypto/gpg/reader_test.go new file mode 100644 index 0000000..3cd7c4b --- /dev/null +++ b/lib/crypto/gpg/reader_test.go @@ -0,0 +1,308 @@ +package gpg + +import ( + "bytes" + "io" + "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 TestReader_encryptedSignedPGPMIME(t *testing.T) { + var expect = models.MessageDetails{ + IsEncrypted: true, + IsSigned: true, + SignedBy: "John Doe (This is a test key) ", + SignedByKeyId: 3490876580878068068, + SignatureError: "", + DecryptedWith: "John Doe (This is a test key) ", + DecryptedWithKeyId: 3490876580878068068, + Body: strings.NewReader(testEncryptedBody), + Micalg: "pgp-sha512", + } + + importSecretKey() + sr := strings.NewReader(testPGPMIMEEncryptedSigned) + r, err := Read(sr) + if err != nil { + t.Fatalf("pgpmail.Read() = %v", err) + } + + deepEqual(t, r.MessageDetails, &expect) + + t.Cleanup(CleanUp) +} + +func TestReader_signedPGPMIME(t *testing.T) { + var expect = models.MessageDetails{ + IsEncrypted: false, + IsSigned: true, + SignedBy: "John Doe (This is a test key) ", + SignedByKeyId: 3490876580878068068, + SignatureError: "", + DecryptedWith: "", + DecryptedWithKeyId: 0, + Body: strings.NewReader(testSignedBody), + Micalg: "pgp-sha256", + } + + importSecretKey() + importPublicKey() + sr := strings.NewReader(testPGPMIMESigned) + r, err := Read(sr) + if err != nil { + t.Fatalf("pgpmail.Read() = %v", err) + } + + deepEqual(t, r.MessageDetails, &expect) + + t.Cleanup(CleanUp) +} + +func TestReader_encryptedSignedEncapsulatedPGPMIME(t *testing.T) { + var expect = models.MessageDetails{ + IsEncrypted: true, + IsSigned: true, + SignedBy: "John Doe (This is a test key) ", + SignedByKeyId: 3490876580878068068, + SignatureError: "", + DecryptedWith: "John Doe (This is a test key) ", + DecryptedWithKeyId: 3490876580878068068, + Body: strings.NewReader(testSignedBody), + Micalg: "pgp-sha256", + } + + importSecretKey() + importPublicKey() + sr := strings.NewReader(testPGPMIMEEncryptedSignedEncapsulated) + r, err := Read(sr) + if err != nil { + t.Fatalf("pgpmail.Read() = %v", err) + } + + deepEqual(t, r.MessageDetails, &expect) + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r.MessageDetails.Body); err != nil { + t.Fatalf("io.Copy() = %v", err) + } +} +func TestReader_signedPGPMIMEInvalid(t *testing.T) { + var expect = models.MessageDetails{ + IsEncrypted: false, + IsSigned: true, + SignedBy: "John Doe (This is a test key) ", + SignedByKeyId: 3490876580878068068, + SignatureError: "gpg: invalid signature", + DecryptedWith: "", + DecryptedWithKeyId: 0, + Body: strings.NewReader(testSignedInvalidBody), + Micalg: "", + } + + importSecretKey() + importPublicKey() + sr := strings.NewReader(testPGPMIMESignedInvalid) + r, err := Read(sr) + if err != nil { + t.Fatalf("pgpmail.Read() = %v", err) + } + deepEqual(t, r.MessageDetails, &expect) + + t.Cleanup(CleanUp) +} + +func TestReader_plaintext(t *testing.T) { + sr := strings.NewReader(testPlaintext) + r, err := Read(sr) + if err != nil { + t.Fatalf("pgpmail.Read() = %v", err) + } + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r.MessageDetails.Body); err != nil { + t.Fatalf("io.Copy() = %v", err) + } + + if r.MessageDetails.IsEncrypted { + t.Errorf("MessageDetails.IsEncrypted != false") + } + if r.MessageDetails.IsSigned { + t.Errorf("MessageDetails.IsSigned != false") + } + + if s := buf.String(); s != testPlaintext { + t.Errorf("MessagesDetails.UnverifiedBody = \n%v\n but want \n%v", s, testPlaintext) + } +} + +var testEncryptedBody = toCRLF(`Content-Type: text/plain + +This is an encrypted message! +`) + +var testSignedBody = toCRLF(`Content-Type: text/plain + +This is a signed message! +`) + +var testSignedInvalidBody = toCRLF(`Content-Type: text/plain + +This is a signed message, but the signature is invalid. +`) + +var testPGPMIMEEncryptedSigned = toCRLF(`From: John Doe +To: John Doe +Mime-Version: 1.0 +Content-Type: multipart/encrypted; boundary=foo; + protocol="application/pgp-encrypted" + +--foo +Content-Type: application/pgp-encrypted + +Version: 1 + +--foo +Content-Type: application/octet-stream + +-----BEGIN PGP MESSAGE----- + +hQEMAxF0jxulHQ8+AQf/SBK2FIIgMA4OkCvlqty/1GmAumWq6J0T+pRLppXHvYFb +jbXRzz2h3pE/OoouI6vWzBwb8xU/5f8neen+fvdsF1N6PyLjZcHRB91oPvP8TuHA +0vEpiQDbP+0wlQ8BmMnnV06HokWJoKXGmIle0L4QszT/QCbrT80UgKrqXNVHKQtN +DUcytFsUCmolZRj074FEpEetjH6QGEX5hAYNBUJziXmOv7vdd4AFgNbbgC5j5ezz +h8tCAKUqeUiproYaAMrI0lfqh/t8bacJNkljI2LOxYfdJ/2317Npwly0OqpCM3YT +Q4dHuuGM6IuZHtIc9sneIBRhKf8WnWt14hLkHUT80dLA/AHKl0jGYqO34Dxd9JNB +EEwQ4j6rxauOEbKLAuYYaEqCzNYBasBrPmpNb4Fx2syWkCoYzwvzv7nj4I8vIBmm +FGsAQLX4c18qtZI4XaG4FPUvFQ01Y0rjTxAV3u51lrYjCxFuI5ZEtiT0J/Tv2Unw +R6xwtARkEf3W0agegmohEjjkAexKNxGrlulLiPk2j9/dnlAxeGpOuhYuYU2kYbKq +x3TkcVYRs1FkmCX0YHNJ2zVWLfDYd2f3UVkXINe7mODGx2A2BxvK9Ig7NMuNmWZE +ELiLSIvQk9jlgqWUMwSGPQKaHPrac02EjcBHef2zCoFbTg0TXQeDr5SV7yguX8jB +zZnoNs+6+GR1gA6poKzFdiG4NRr0SNgEHazPPkXp3P2KyOINyFJ7SA+HX8iegTqL +CTPYPK7UNRmb5s2u5B4e9NiQB9L85W4p7p7uemCSu9bxjs8rkCJpvx9Kb8jzPW17 +wnEUe10A4JNDBhxiMg+Fm5oM2VxQVy+eDVFOOq7pDYVcSmZc36wO+EwAKph9shby +O4sDS4l/8eQTEYUxTavdtQ9O9ZMXvf/L3Rl1uFJXw1lFwPReXwtpA485e031/A== +=P0jf +-----END PGP MESSAGE----- + +--foo-- +`) + +var testPGPMIMEEncryptedSignedEncapsulated = toCRLF(`From: John Doe +To: John Doe +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 +To: John Doe +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 +To: John Doe +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 +To: John Doe +Mime-Version: 1.0 +Content-Type: text/plain + +This is a plaintext message! +`) diff --git a/lib/crypto/gpg/writer.go b/lib/crypto/gpg/writer.go new file mode 100644 index 0000000..269b490 --- /dev/null +++ b/lib/crypto/gpg/writer.go @@ -0,0 +1,179 @@ +// writer.go largerly mimics github.com/emersion/go-pgpmail, with changes made +// to interface with the gpg package in aerc + +package gpg + +import ( + "bytes" + "fmt" + "io" + "mime" + + "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin" + "github.com/emersion/go-message/textproto" +) + +type EncrypterSigner struct { + msgBuf bytes.Buffer + encryptedWriter io.Writer + to []string + from string +} + +func (es *EncrypterSigner) Write(p []byte) (int, error) { + return es.msgBuf.Write(p) +} + +func (es *EncrypterSigner) Close() (err error) { + r := bytes.NewReader(es.msgBuf.Bytes()) + enc, err := gpgbin.Encrypt(r, es.to, es.from) + if err != nil { + return err + } + es.encryptedWriter.Write(enc) + return nil +} + +type Signer struct { + mw *textproto.MultipartWriter + signedMsg bytes.Buffer + w io.Writer + from string + header textproto.Header +} + +func (s *Signer) Write(p []byte) (int, error) { + return s.signedMsg.Write(p) +} + +func (s *Signer) Close() (err error) { + // TODO should write the whole message up here so we can get the proper micalg from the signature packet + + sig, micalg, err := gpgbin.Sign(bytes.NewReader(s.signedMsg.Bytes()), s.from) + if err != nil { + return err + } + params := map[string]string{ + "boundary": s.mw.Boundary(), + "protocol": "application/pgp-signature", + "micalg": micalg, + } + s.header.Set("Content-Type", mime.FormatMediaType("multipart/signed", params)) + + if err = textproto.WriteHeader(s.w, s.header); err != nil { + return err + } + boundary := s.mw.Boundary() + fmt.Fprintf(s.w, "--%s\r\n", boundary) + s.w.Write(s.signedMsg.Bytes()) + s.w.Write([]byte("\r\n")) + + var signedHeader textproto.Header + signedHeader.Set("Content-Type", "application/pgp-signature") + signatureWriter, err := s.mw.CreatePart(signedHeader) + if err != nil { + return err + } + + _, err = signatureWriter.Write(sig) + if err != nil { + return err + } + return nil +} + +// for tests +var forceBoundary = "" + +type multiCloser []io.Closer + +func (mc multiCloser) Close() error { + for _, c := range mc { + if err := c.Close(); err != nil { + return err + } + } + return nil +} + +func Encrypt(w io.Writer, h textproto.Header, rcpts []string, from string) (io.WriteCloser, error) { + mw := textproto.NewMultipartWriter(w) + + if forceBoundary != "" { + mw.SetBoundary(forceBoundary) + } + + params := map[string]string{ + "boundary": mw.Boundary(), + "protocol": "application/pgp-encrypted", + } + h.Set("Content-Type", mime.FormatMediaType("multipart/encrypted", params)) + + if err := textproto.WriteHeader(w, h); err != nil { + return nil, err + } + + var controlHeader textproto.Header + controlHeader.Set("Content-Type", "application/pgp-encrypted") + controlWriter, err := mw.CreatePart(controlHeader) + if err != nil { + return nil, err + } + if _, err = controlWriter.Write([]byte("Version: 1\r\n")); err != nil { + return nil, err + } + + var encryptedHeader textproto.Header + encryptedHeader.Set("Content-Type", "application/octet-stream") + encryptedWriter, err := mw.CreatePart(encryptedHeader) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + plaintext := &EncrypterSigner{ + msgBuf: buf, + encryptedWriter: encryptedWriter, + to: rcpts, + from: from, + } + + return struct { + io.Writer + io.Closer + }{ + plaintext, + multiCloser{ + plaintext, + mw, + }, + }, nil +} + +func Sign(w io.Writer, h textproto.Header, from string) (io.WriteCloser, error) { + mw := textproto.NewMultipartWriter(w) + + if forceBoundary != "" { + mw.SetBoundary(forceBoundary) + } + + var msg bytes.Buffer + plaintext := &Signer{ + mw: mw, + signedMsg: msg, + w: w, + from: from, + header: h, + } + + return struct { + io.Writer + io.Closer + }{ + plaintext, + multiCloser{ + plaintext, + mw, + }, + }, nil +} diff --git a/lib/crypto/gpg/writer_test.go b/lib/crypto/gpg/writer_test.go new file mode 100644 index 0000000..0f9ab10 --- /dev/null +++ b/lib/crypto/gpg/writer_test.go @@ -0,0 +1,122 @@ +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" +} + +func TestEncrypt(t *testing.T) { + importPublicKey() + importSecretKey() + var h textproto.Header + h.Set("From", "John Doe ") + h.Set("To", "John Doe ") + + var encryptedHeader textproto.Header + encryptedHeader.Set("Content-Type", "text/plain") + + var encryptedBody = "This is an encrypted message!\r\n" + + to := []string{"john.doe@example.org"} + from := "john.doe@example.org" + + var buf bytes.Buffer + cleartext, err := Encrypt(&buf, h, to, from) + if err != nil { + t.Fatalf("Encrypt() = %v", err) + } + + if err = textproto.WriteHeader(cleartext, encryptedHeader); err != nil { + t.Fatalf("textproto.WriteHeader() = %v", err) + } + if _, err = io.WriteString(cleartext, encryptedBody); err != nil { + t.Fatalf("io.WriteString() = %v", err) + } + if err = cleartext.Close(); err != nil { + t.Fatalf("ciphertext.Close() = %v", err) + } + + md, err := 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) + } + + t.Cleanup(CleanUp) +} + +func TestSign(t *testing.T) { + importPublicKey() + importSecretKey() + var h textproto.Header + h.Set("From", "John Doe ") + h.Set("To", "John Doe ") + + var signedHeader textproto.Header + signedHeader.Set("Content-Type", "text/plain") + + var signedBody = "This is a signed message!\r\n" + + var buf bytes.Buffer + cleartext, err := Sign(&buf, h, "john.doe@example.org") + if err != nil { + t.Fatalf("Encrypt() = %v", err) + } + + if err = textproto.WriteHeader(cleartext, signedHeader); err != nil { + t.Fatalf("textproto.WriteHeader() = %v", err) + } + if _, err = io.WriteString(cleartext, signedBody); err != nil { + t.Fatalf("io.WriteString() = %v", err) + } + + if err = cleartext.Close(); err != nil { + t.Fatalf("ciphertext.Close() = %v", err) + } + + parts := strings.Split(buf.String(), "\r\n--foo\r\n") + msg := strings.NewReader(parts[1]) + sig := strings.NewReader(parts[2]) + md, err := gpgbin.Verify(msg, sig) + if err != nil { + t.Fatalf("gpg.Verify() = %v", err) + } + + deepEqual(t, md, &wantSigned) +} + +var wantEncrypted = toCRLF(`Content-Type: text/plain + +This is an encrypted message! +`) + +var wantSignedBody = toCRLF(`Content-Type: text/plain + +This is a signed message! +`) + +var wantSigned = models.MessageDetails{ + IsEncrypted: false, + IsSigned: true, + SignedBy: "John Doe (This is a test key) ", + SignedByKeyId: 3490876580878068068, + SignatureError: "", + DecryptedWith: "", + DecryptedWithKeyId: 0, + Body: strings.NewReader(wantSignedBody), + Micalg: "pgp-sha256", +} diff --git a/lib/crypto/pgp/pgp.go b/lib/crypto/pgp/pgp.go index 70a003a..92a15ee 100644 --- a/lib/crypto/pgp/pgp.go +++ b/lib/crypto/pgp/pgp.go @@ -79,6 +79,20 @@ func (m *Mail) getEntityByEmail(email string) (e *openpgp.Entity, err error) { 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 { @@ -157,12 +171,12 @@ func (m *Mail) ImportKeys(r io.Reader) error { return nil } -func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signerEmail string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) { +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 signer *openpgp.Entity - if signerEmail != "" { - signer, err = m.getSigner(signerEmail, decryptKeys) + var signerEntity *openpgp.Entity + if signer != "" { + signerEntity, err = m.getSigner(signer, decryptKeys) if err != nil { return nil, err } @@ -177,45 +191,50 @@ func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signerEmail string, de } cleartext, err := pgpmail.Encrypt(buf, header.Header.Header, - to, signer, nil) + to, signerEntity, nil) if err != nil { return nil, err } return cleartext, nil } -func (m *Mail) Sign(buf *bytes.Buffer, signerEmail string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) { +func (m *Mail) Sign(buf *bytes.Buffer, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) { var err error - var signer *openpgp.Entity - if signerEmail != "" { - signer, err = m.getSigner(signerEmail, decryptKeys) + 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, signer, nil) + cleartext, err := pgpmail.Sign(buf, header.Header.Header, signerEntity, nil) if err != nil { return nil, err } return cleartext, nil } -func (m *Mail) getSigner(signerEmail string, decryptKeys openpgp.PromptFunction) (signer *openpgp.Entity, err error) { - if err != nil { - return nil, err - } - signer, err = m.getSignerEntityByEmail(signerEmail) - if err != nil { - return nil, err +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 := signer.SigningKey(time.Now()) + key, ok := signerEntity.SigningKey(time.Now()) if !ok { - return nil, fmt.Errorf("no signing key found for %s", signerEmail) + return nil, fmt.Errorf("no signing key found for %s", signer) } if !key.PrivateKey.Encrypted { - return signer, nil + return signerEntity, nil } _, err = decryptKeys([]openpgp.Key{key}, false) @@ -223,7 +242,7 @@ func (m *Mail) getSigner(signerEmail string, decryptKeys openpgp.PromptFunction) return nil, err } - return signer, nil + return signerEntity, nil } func handleSignatureError(e string) models.SignatureValidity { diff --git a/widgets/compose.go b/widgets/compose.go index 6298be3..13c56a5 100644 --- a/widgets/compose.go +++ b/widgets/compose.go @@ -452,14 +452,16 @@ func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error { var cleartext io.WriteCloser var err error - var signerEmail string + signer := "" if c.sign { - signerEmail, err = getSenderEmail(c) - if err != nil { - return err + if c.acctConfig.PgpKeyId != "" { + signer = c.acctConfig.PgpKeyId + } else { + signer, err = getSenderEmail(c) + if err != nil { + return err + } } - } else { - signerEmail = "" } if c.encrypt { @@ -467,12 +469,12 @@ func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error { if err != nil { return err } - cleartext, err = c.aerc.Crypto.Encrypt(&buf, rcpts, signerEmail, c.aerc.DecryptKeys, header) + cleartext, err = c.aerc.Crypto.Encrypt(&buf, rcpts, signer, c.aerc.DecryptKeys, header) if err != nil { return err } } else { - cleartext, err = c.aerc.Crypto.Sign(&buf, signerEmail, c.aerc.DecryptKeys, header) + cleartext, err = c.aerc.Crypto.Sign(&buf, signer, c.aerc.DecryptKeys, header) if err != nil { return err }