diff options
author | Marcin Serwin <marcin@serwin.dev> | 2024-10-26 09:17:03 +0200 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2024-10-27 10:15:08 +0100 |
commit | 5ccd2d0d518c8de8c2ca921bcd0101e0d0c69a58 (patch) | |
tree | 0c7452878706c81feff5494ae64229b6a556ba8c | |
parent | e319d3291fb8be957f84cffc6d1f29e8c78a2d5b (diff) | |
download | aerc-5ccd2d0d518c8de8c2ca921bcd0101e0d0c69a58.tar.gz |
gpg: redirect status-fd from stdout to stderr
By preparing a maliciously crafted message an attacker could send an
encrypted message without signature that would appear as signed within
the aerc client. It is caused by the fact that the gpg status messages,
which are used for determining the validity signature, are interspered
with message contents. An example of such malicious message was added to
the `reader_test.go`.
This change redirects the satus-fd to stderr, while the usual stderr
logs are discarded to /dev/null. In addition to fixing the vulnerability
described above, this has the added benefit of stdout containing only
useful output which does not need to be filtered. This simplifies the
logic and avoids needless copies.
Previous stderr parsing logic which detected when no valid OpenPGP data
was present is replaced with detecting `NODATA 1` in status-fd messages.
The stderr logs are different depending on user locale, thus, they
should not be parsed. On the other hand, the status-fd are relatively
stable. The previous method of detecting invalid OpenPGP data would fail
on systems with non-English locale.
Signed-off-by: Marcin Serwin <marcin@serwin.dev>
Acked-by: Robin Jarry <robin@jarry.cc>
-rw-r--r-- | lib/crypto/gpg/gpgbin/decrypt.go | 21 | ||||
-rw-r--r-- | lib/crypto/gpg/gpgbin/encrypt.go | 7 | ||||
-rw-r--r-- | lib/crypto/gpg/gpgbin/gpgbin.go | 69 | ||||
-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_test.go | 57 |
6 files changed, 101 insertions, 64 deletions
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 712bb327..91e0999a 100644 --- a/lib/crypto/gpg/gpgbin/encrypt.go +++ b/lib/crypto/gpg/gpgbin/encrypt.go @@ -23,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 a7eafacd..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" @@ -25,7 +24,7 @@ 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 @@ -36,19 +35,6 @@ func newGpg(stdin io.Reader, args []string) *gpg { 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(strings.Join(lines, ", ")) -} - // fields returns the field name from --status-fd output. See: // https://github.com/gpg/gnupg/blob/master/doc/DETAILS func field(s string) string { @@ -119,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": @@ -149,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": @@ -211,30 +185,32 @@ func parse(r io.Reader, md *models.MessageDetails) error { if t[2] == "10" { return fmt.Errorf("gpg: public key of %s is not trusted", t[3]) } - 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") + 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("%s", strings.TrimPrefix(line, "[GNUPG:] ")) } } - md.Body = bytes.NewReader(msgContent) return nil } @@ -250,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/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_test.go b/lib/crypto/gpg/reader_test.go index fdb5c452..1ea0ef0f 100644 --- a/lib/crypto/gpg/reader_test.go +++ b/lib/crypto/gpg/reader_test.go @@ -52,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{ @@ -125,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! @@ -172,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 |