aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-09-13 12:28:04 +0200
committersaturneric <[email protected]>2026-09-13 12:28:04 +0200
commit1185b7432bb8e0a7f3a08abdaec0070699aec53c (patch)
tree3ae51234ac4299c237af9db870f0e7f144cd43f6
parenti18n(email): update email translations (diff)
downloadmodules-1185b7432bb8e0a7f3a08abdaec0070699aec53c.tar.gz
modules-1185b7432bb8e0a7f3a08abdaec0070699aec53c.zip
feat(email): preserve decrypted unsigned messages
- distinguish unsigned, unreadable, and signed plaintext - verify only top-level multipart/signed messages - merge verification metadata without duplicating content - retain decrypted headers, body, and attachments - report unsigned messages as successfully decrypted - add coverage for decrypt-and-verify planning and metadata merging
-rw-r--r--src/m_email/EMAilHelper.cpp71
-rw-r--r--src/m_email/EMailHelper.h46
-rw-r--r--src/m_email/EMailModule.cpp85
-rw-r--r--src/m_email/test/EMailCryptoBoundaryTest.cpp161
-rw-r--r--src/m_email/test/EMailMimeTest.cpp167
5 files changed, 501 insertions, 29 deletions
diff --git a/src/m_email/EMAilHelper.cpp b/src/m_email/EMAilHelper.cpp
index 11ae3c5..3739714 100644
--- a/src/m_email/EMAilHelper.cpp
+++ b/src/m_email/EMAilHelper.cpp
@@ -1172,6 +1172,77 @@ auto ClassifyOpenPGPStructure(const EMailPart& root,
return EMailSecurityState::kPLAIN;
}
+auto PlanVerifyAfterDecrypt(const QByteArray& plaintext)
+ -> EMailPostDecryptPlan {
+ if (plaintext.trimmed().isEmpty()) return EMailPostDecryptPlan::kUNREADABLE;
+
+ vmime::shared_ptr<vmime::message> message;
+ if (!CheckIfEMLMessage(plaintext, message) || !message) {
+ return EMailPostDecryptPlan::kUNREADABLE;
+ }
+
+ // The outermost layer and nothing else. VerifyEMLData() refuses anything
+ // whose top-level content type is not multipart/signed, so that single
+ // question decides whether handing it over can produce an answer at all.
+ //
+ // A message that CLAIMS multipart/signed and is malformed in some other way
+ // -- a missing protocol parameter, an unusable micalg -- still goes to the
+ // verify on purpose: the refusal it returns names what is wrong, and that
+ // diagnosis is worth more than silently calling the message unsigned.
+ if (PartContentType(message) == "multipart/signed") {
+ return EMailPostDecryptPlan::kVERIFY;
+ }
+
+ return EMailPostDecryptPlan::kNOT_SIGNED;
+}
+
+void MergeVerifiedMetaData(EMailMetaData& decrypted,
+ const EMailMetaData& verified) {
+ // Only the verify produces these; there is nothing on the decrypt side to
+ // weigh them against.
+ decrypted.micalg = verified.micalg;
+ decrypted.signed_entity_digest = verified.signed_entity_digest;
+ decrypted.signed_entity_digest_algo = verified.signed_entity_digest_algo;
+ decrypted.signed_entity_non_canonical = verified.signed_entity_non_canonical;
+ decrypted.signature_regions = verified.signature_regions;
+ if (!verified.public_keys.isEmpty()) {
+ decrypted.public_keys = verified.public_keys;
+ }
+
+ // Headers prefer the verified side where it has them: those are the headers
+ // that travelled inside the ciphertext, and they are the ones an outer
+ // envelope cannot have rewritten.
+ const auto prefer = [](QString& into, const QString& from) {
+ if (!from.isEmpty()) into = from;
+ };
+ const auto prefer_list = [](QStringList& into, const QStringList& from) {
+ if (!from.isEmpty()) into = from;
+ };
+
+ prefer(decrypted.from, verified.from);
+ prefer(decrypted.subject, verified.subject);
+ prefer(decrypted.reply_to, verified.reply_to);
+ prefer(decrypted.organization, verified.organization);
+ prefer(decrypted.message_id, verified.message_id);
+ prefer(decrypted.in_reply_to, verified.in_reply_to);
+ prefer_list(decrypted.to, verified.to);
+ prefer_list(decrypted.cc, verified.cc);
+ prefer_list(decrypted.bcc_header, verified.bcc_header);
+ prefer_list(decrypted.references, verified.references);
+ if (verified.datetime.isValid()) decrypted.datetime = verified.datetime;
+
+ // Content is NEVER concatenated. Both sides walked the same plaintext, so
+ // appending one to the other is what listed every attachment twice; the
+ // verified side is consulted only where the decrypt found nothing at all.
+ if (decrypted.body.isEmpty() && !verified.body.isEmpty()) {
+ decrypted.body = verified.body;
+ decrypted.body_content_type = verified.body_content_type;
+ }
+ if (decrypted.attachments.isEmpty() && !verified.attachments.isEmpty()) {
+ decrypted.attachments = verified.attachments;
+ }
+}
+
auto AddressOfUid(const QString& uid) -> QString {
QString name;
QString email;
diff --git a/src/m_email/EMailHelper.h b/src/m_email/EMailHelper.h
index 2613c1f..98b989f 100644
--- a/src/m_email/EMailHelper.h
+++ b/src/m_email/EMailHelper.h
@@ -362,6 +362,52 @@ auto ClassifyOpenPGPStructure(const EMailPart& root,
-> EMailSecurityState;
/**
+ * @brief What a decrypt-and-verify should do with the plaintext it just got.
+ *
+ * Verification applies to a signed message. Plaintext that carries no
+ * signature is the ordinary case, not a failure: the decrypt succeeded and
+ * there is simply nothing to check. This is modelled as its own answer rather
+ * than as a verify error so that a caller cannot mistake "nothing to verify"
+ * for "verification failed" and discard a plaintext the user has just paid a
+ * passphrase for.
+ */
+enum class EMailPostDecryptPlan : uint8_t {
+ kVERIFY = 0, ///< outermost layer is multipart/signed: verify it
+ kNOT_SIGNED, ///< a readable message with no signature layer on top
+ kUNREADABLE, ///< not parseable as a message; nothing can be verified
+};
+
+/**
+ * @brief Decides whether @p plaintext, as it came out of a decrypt, is
+ * something VerifyEMLData() can act on.
+ *
+ * Only the OUTERMOST layer is considered, because that is all VerifyEMLData()
+ * examines -- it refuses anything whose top-level content type is not
+ * multipart/signed before any cryptography happens. A signature nested deeper
+ * is the per-region walk's business (see VerifyEMLRegions) and is not a reason
+ * to run, and fail, the top-level verify.
+ */
+auto PlanVerifyAfterDecrypt(const QByteArray& plaintext)
+ -> EMailPostDecryptPlan;
+
+/**
+ * @brief Folds what a verify learned into what a decrypt already produced.
+ *
+ * Decrypt and verify each walk the same plaintext, so handing ONE metadata
+ * object to both appends every attachment twice -- ExtractParts() only ever
+ * appends. Instead the verify runs against its own object and this merges the
+ * two.
+ *
+ * The signature-side fields come from @p verified, since only the verify
+ * produced them. Header fields prefer @p verified where it has them: those are
+ * the headers that travelled inside the ciphertext, and they are the ones
+ * worth believing. Content -- body and attachments -- is kept from
+ * @p decrypted and is never concatenated.
+ */
+void MergeVerifiedMetaData(EMailMetaData& decrypted,
+ const EMailMetaData& verified);
+
+/**
* @brief Every node of @p root, pre-order, as flat pointers.
*
* Convenience for views and tests that need to walk the tree without
diff --git a/src/m_email/EMailModule.cpp b/src/m_email/EMailModule.cpp
index 3d7ee1d..2789e47 100644
--- a/src/m_email/EMailModule.cpp
+++ b/src/m_email/EMailModule.cpp
@@ -34,8 +34,8 @@
#include "EMailAccountSettingsPage.h"
#include "EMailAccountStore.h"
-#include "EMailSecret.h"
#include "EMailImapController.h"
+#include "EMailSecret.h"
#include "EMailSendDialog.h"
// qt
@@ -147,12 +147,11 @@ auto BuildAttachmentCard(const EMailMetaData& m) -> QJsonObject {
auto description =
QString("%1, %2").arg(att.mime_type, FormatSize(att.data.size()));
if (att.is_openpgp_key) {
- description +=
- QApplication::translate("EMailModule", ", an OpenPGP key");
+ description += QApplication::translate("EMailModule", ", an OpenPGP key");
}
if (!att.inside_signed_part) {
description += QApplication::translate("EMailModule",
- ", not covered by the signature");
+ ", not covered by the signature");
}
fields.append({name, description});
@@ -1660,7 +1659,8 @@ auto DoDecryptVerifyEMLData(int channel, const QByteArray& data,
QString& result_detail, QString& result_cards,
QByteArray& eml_data, QString& error_string,
EMailMetaData& meta_data,
- QByteArray& decrypt_info_json) -> int {
+ QByteArray& decrypt_info_json,
+ EMailPostDecryptPlan& plan) -> int {
QString decrypt_cards;
if (DoDecryptEMLData(channel, data, event, result_status, result_detail,
decrypt_cards, eml_data, meta_data,
@@ -1668,18 +1668,52 @@ auto DoDecryptVerifyEMLData(int channel, const QByteArray& data,
return -1;
}
+ // The decrypt has succeeded and the plaintext is in hand. Whether there is
+ // anything to VERIFY is a separate question, and answering it "no" must not
+ // cost the user the plaintext they have just paid a passphrase for.
+ //
+ // VerifyEMLData() refuses any message whose outermost layer is not
+ // multipart/signed, and that refusal used to be treated as a failure of the
+ // whole operation: the callback carried no data, so the host -- which writes
+ // the editor only when data is present -- discarded the plaintext and showed
+ // an RFC 3156 lecture instead. An encrypted message that is not signed is
+ // the ordinary case, not a fault.
+ plan = PlanVerifyAfterDecrypt(eml_data);
+ if (plan != EMailPostDecryptPlan::kVERIFY) {
+ result_cards = decrypt_cards;
+ const auto note =
+ plan == EMailPostDecryptPlan::kNOT_SIGNED
+ ? QApplication::translate(
+ "EMailModule",
+ "This message is not signed, so there is no signature to "
+ "check. It was decrypted successfully.")
+ : QApplication::translate(
+ "EMailModule",
+ "The decrypted content is not a MIME message, so there is no "
+ "signature to check. It was decrypted successfully.");
+ result_detail = note + "\n" + result_detail;
+ return kSUCCESS;
+ }
+
int t_result_status = 0;
QString t_result_detail;
QString verify_cards;
+ // Its OWN metadata object, not the one the decrypt filled. Both walk the
+ // same plaintext and ExtractParts() only ever appends, so sharing one object
+ // listed every attachment twice.
+ EMailMetaData verified_meta;
+
// UTF-8, not Latin-1: this is the decrypted plaintext and the signature
// inside it is checked against these exact bytes. See DoEncryptSignEMLData.
if (DoVerifyEMLData(channel, eml_data, event, t_result_status,
t_result_detail, verify_cards, error_string,
- meta_data) != kSUCCESS) {
+ verified_meta) != kSUCCESS) {
return -1;
}
+ MergeVerifiedMetaData(meta_data, verified_meta);
+
result_status = WorseStatus(t_result_status, result_status);
result_detail = t_result_detail + "\n" + result_detail;
result_cards = MergeCardArrays(verify_cards, decrypt_cards);
@@ -1707,12 +1741,14 @@ REGISTER_EVENT_HANDLER(
QString result_cards;
QByteArray decrypt_info_json;
+ auto plan = EMailPostDecryptPlan::kVERIFY;
if (DoDecryptVerifyEMLData(channel, data, event, result_status,
result_detail, result_cards, eml_data,
- error_string, meta_data,
- decrypt_info_json) != kSUCCESS) {
+ error_string, meta_data, decrypt_info_json,
+ plan) != kSUCCESS) {
return -1;
}
+ const bool verified = plan == EMailPostDecryptPlan::kVERIFY;
QString email_info;
email_info.append("# E-Mail Information\n\n");
@@ -1740,16 +1776,27 @@ REGISTER_EVENT_HANDLER(
email_info.append("# OpenPGP Information\n\n");
- email_info.append(
- QString("- %1: %2\n")
- .arg(QApplication::translate(
- "EMailModule", "Digest of Signed MIME Entity (SHA-256)"))
- .arg(meta_data.signed_entity_digest));
- email_info.append(
- QString("- %1: %2\n")
- .arg(QApplication::translate("EMailModule",
- "Declared Signature Hash (micalg)"))
- .arg(meta_data.micalg));
+ // Only when something actually verified. An unsigned message has no
+ // signed entity and no declared hash, and printing those labels with
+ // nothing after them reads as a missing answer rather than as the
+ // absence of a question.
+ if (!verified) {
+ email_info.append(QString("- %1\n").arg(QApplication::translate(
+ "EMailModule",
+ "This message was decrypted. It carries no signature, so nothing "
+ "here says who sent it.")));
+ } else {
+ email_info.append(
+ QString("- %1: %2\n")
+ .arg(QApplication::translate(
+ "EMailModule", "Digest of Signed MIME Entity (SHA-256)"))
+ .arg(meta_data.signed_entity_digest));
+ email_info.append(
+ QString("- %1: %2\n")
+ .arg(QApplication::translate(
+ "EMailModule", "Declared Signature Hash (micalg)"))
+ .arg(meta_data.micalg));
+ }
// Without this, a message whose line endings were rewritten after
// signing reads exactly like a forged one, and the user has no way to
@@ -1770,7 +1817,7 @@ REGISTER_EVENT_HANDLER(
email_info.append("#" + result_detail + "\n");
- auto dv_meta_cards = BuildReadMetaCards(meta_data, true);
+ auto dv_meta_cards = BuildReadMetaCards(meta_data, verified);
const auto dv_recipient_card =
BuildRecipientCheckCard(meta_data, decrypt_info_json);
if (!dv_recipient_card.isEmpty()) dv_meta_cards.append(dv_recipient_card);
diff --git a/src/m_email/test/EMailCryptoBoundaryTest.cpp b/src/m_email/test/EMailCryptoBoundaryTest.cpp
index 2858e07..92201da 100644
--- a/src/m_email/test/EMailCryptoBoundaryTest.cpp
+++ b/src/m_email/test/EMailCryptoBoundaryTest.cpp
@@ -337,7 +337,6 @@ TEST_F(CryptoBoundaryTest, EncryptingPassesTheBodyOctetsWhole) {
<< "the plaintext was cut at the embedded NUL before encryption";
}
-
TEST_F(CryptoBoundaryTest, AnOversizedDecryptedPlaintextIsRefused) {
// The ceiling on the way in bounds the CIPHERTEXT. An OpenPGP compressed
// packet a few megabytes long expands to gigabytes, so the expansion has to
@@ -373,7 +372,6 @@ TEST_F(CryptoBoundaryTest, AnOversizedDecryptedPlaintextIsRefused) {
<< "the oversized plaintext was carried forward anyway";
}
-
// --- bounded verification work ----------------------------------------------
namespace {
@@ -390,15 +388,17 @@ auto ManySignedRegions(int count) -> QByteArray {
for (int i = 0; i < count; ++i) {
const auto b = QByteArray("sig") + QByteArray::number(i);
eml += "--outer\r\n";
- eml += "Content-Type: multipart/signed; micalg=pgp-sha256; "
- "protocol=\"application/pgp-signature\"; boundary=\"" + b +
- "\"\r\n\r\n";
+ eml +=
+ "Content-Type: multipart/signed; micalg=pgp-sha256; "
+ "protocol=\"application/pgp-signature\"; boundary=\"" +
+ b + "\"\r\n\r\n";
eml += "--" + b + "\r\n";
eml += "Content-Type: text/plain\r\n\r\npart\r\n";
eml += "--" + b + "\r\n";
eml += "Content-Type: application/pgp-signature\r\n\r\n";
- eml += "-----BEGIN PGP SIGNATURE-----\r\naGVsbG8=\r\n"
- "-----END PGP SIGNATURE-----\r\n";
+ eml +=
+ "-----BEGIN PGP SIGNATURE-----\r\naGVsbG8=\r\n"
+ "-----END PGP SIGNATURE-----\r\n";
eml += "--" + b + "--\r\n";
}
eml += "--outer--\r\n";
@@ -461,12 +461,153 @@ TEST_F(CryptoBoundaryTest, EachRegionIsVerifiedOnItsOwnExactBytes) {
ASSERT_EQ(Get().verify.size(), regions.size());
for (int i = 0; i < regions.size(); ++i) {
- const auto expected =
- raw.mid(static_cast<int>(regions[i].raw_offset),
- static_cast<int>(regions[i].raw_length));
+ const auto expected = raw.mid(static_cast<int>(regions[i].raw_offset),
+ static_cast<int>(regions[i].raw_length));
EXPECT_EQ(Get().verify[i].data, expected)
<< "region " << i << " was verified on the wrong bytes";
}
}
} // namespace
+
+// --- decrypt then verify -----------------------------------------------------
+//
+// An encrypted message that is not signed is the ordinary case. The operation
+// used to route its plaintext into VerifyEMLData() unconditionally, which
+// refuses anything that is not multipart/signed -- and that refusal was
+// reported as a failure of the whole operation, with no data on the callback,
+// so the host discarded the plaintext the user had just decrypted.
+
+namespace {
+
+/// A PGP/MIME encrypted message. The ciphertext is a stub; what comes back out
+/// of the fake engine is whatever the test put in `decrypt_output`.
+auto EncryptedMessage() -> QByteArray {
+ QByteArray eml;
+ eml += "From: Alice <[email protected]>\r\n";
+ eml += "To: Bob <[email protected]>\r\n";
+ eml += "Subject: ...\r\n";
+ eml += "MIME-Version: 1.0\r\n";
+ eml +=
+ "Content-Type: multipart/encrypted; "
+ "protocol=\"application/pgp-encrypted\"; boundary=\"encb\"\r\n";
+ eml += "\r\n--encb\r\n";
+ eml += "Content-Type: application/pgp-encrypted\r\n\r\n";
+ eml += "Version: 1\r\n";
+ eml += "\r\n--encb\r\n";
+ eml += "Content-Type: application/octet-stream\r\n\r\n";
+ eml +=
+ "-----BEGIN PGP MESSAGE-----\r\nc3R1Yg==\r\n"
+ "-----END PGP MESSAGE-----\r\n";
+ eml += "\r\n--encb--\r\n";
+ return eml;
+}
+
+/// An ordinary, unsigned message carrying one attachment.
+auto UnsignedInnerMessage() -> QByteArray {
+ QByteArray inner;
+ inner += "From: Alice <[email protected]>\r\n";
+ inner += "To: Bob <[email protected]>\r\n";
+ inner += "Subject: the real subject\r\n";
+ inner += "MIME-Version: 1.0\r\n";
+ inner += "Content-Type: multipart/mixed; boundary=\"inb\"\r\n";
+ inner += "\r\n--inb\r\n";
+ inner += "Content-Type: text/plain; charset=utf-8\r\n\r\n";
+ inner += "the secret\r\n";
+ inner += "--inb\r\n";
+ inner +=
+ "Content-Type: application/pdf; name=\"report.pdf\"\r\n"
+ "Content-Disposition: attachment; filename=\"report.pdf\"\r\n\r\n";
+ inner += "PDFBYTES\r\n";
+ inner += "--inb--\r\n";
+ return inner;
+}
+
+} // namespace
+
+TEST_F(CryptoBoundaryTest, VerifyRefusesAnUnsignedMessage) {
+ // The reason the old decrypt-and-verify lost the plaintext. This is correct
+ // behaviour for VerifyEMLData on its own -- the mistake was treating it as a
+ // failure of the whole operation.
+ EMailMetaData meta;
+ QString error;
+ gpgme_error_t err = 0;
+ QString capsule;
+
+ EXPECT_EQ(VerifyEMLData(0, UnsignedInnerMessage(), meta, error, err, capsule),
+ kEML_FAILED);
+}
+
+TEST_F(CryptoBoundaryTest, DecryptOfAnUnsignedMessageKeepsThePlaintext) {
+ Get().decrypt_output = UnsignedInnerMessage();
+
+ EMailMetaData meta;
+ QByteArray out;
+ gpgme_error_t err = 0;
+ QString capsule;
+
+ ASSERT_EQ(DecryptEMLData(0, EncryptedMessage(), meta, out, err, capsule),
+ kSUCCESS);
+
+ // The plaintext survives, and the operation must not go on to call the
+ // top-level verify with it.
+ EXPECT_TRUE(out.contains("the secret"));
+ EXPECT_EQ(PlanVerifyAfterDecrypt(out), EMailPostDecryptPlan::kNOT_SIGNED);
+
+ // One walk, one listing. Handing this same object to a verify afterwards was
+ // what listed the attachment twice.
+ ASSERT_EQ(meta.attachments.size(), 1);
+ EXPECT_EQ(meta.attachments.first().filename, QString("report.pdf"));
+}
+
+TEST_F(CryptoBoundaryTest, AVerifyAfterADecryptListsEachAttachmentOnce) {
+ // The signed case: decrypt fills one object, verify fills its own, and the
+ // merge joins them. Both walked the same bytes, so a concatenation here is
+ // exactly the duplication this replaced.
+ QByteArray inner;
+ inner += "From: Alice <[email protected]>\r\n";
+ inner += "MIME-Version: 1.0\r\n";
+ inner +=
+ "Content-Type: multipart/signed; micalg=pgp-sha256; "
+ "protocol=\"application/pgp-signature\"; boundary=\"sb\"\r\n";
+ inner += "\r\n--sb\r\n";
+ inner += "Content-Type: multipart/mixed; boundary=\"mb\"\r\n";
+ inner += "\r\n--mb\r\n";
+ inner += "Content-Type: text/plain\r\n\r\ninner body\r\n";
+ inner += "--mb\r\n";
+ inner +=
+ "Content-Type: application/pdf; name=\"a.pdf\"\r\n"
+ "Content-Disposition: attachment; filename=\"a.pdf\"\r\n\r\n";
+ inner += "BYTES\r\n";
+ inner += "--mb--\r\n";
+ inner += "\r\n--sb\r\n";
+ inner += "Content-Type: application/pgp-signature\r\n\r\n";
+ inner += "-----BEGIN PGP SIGNATURE-----\r\nx\r\n";
+ inner += "-----END PGP SIGNATURE-----\r\n";
+ inner += "\r\n--sb--\r\n";
+
+ Get().decrypt_output = inner;
+
+ EMailMetaData decrypted;
+ QByteArray out;
+ gpgme_error_t err = 0;
+ QString capsule;
+ ASSERT_EQ(DecryptEMLData(0, EncryptedMessage(), decrypted, out, err, capsule),
+ kSUCCESS);
+ ASSERT_EQ(PlanVerifyAfterDecrypt(out), EMailPostDecryptPlan::kVERIFY);
+
+ EMailMetaData verified;
+ QString error;
+ ASSERT_EQ(VerifyEMLData(0, out, verified, error, err, capsule), kSUCCESS);
+
+ const auto before = decrypted.attachments.size();
+ MergeVerifiedMetaData(decrypted, verified);
+
+ EXPECT_EQ(decrypted.attachments.size(), before);
+ ASSERT_EQ(decrypted.attachments.size(), 1);
+ EXPECT_EQ(decrypted.attachments.first().filename, QString("a.pdf"));
+
+ // And the signature side arrived.
+ EXPECT_FALSE(decrypted.signed_entity_digest.isEmpty());
+ EXPECT_EQ(decrypted.micalg, QString("pgp-sha256"));
+}
diff --git a/src/m_email/test/EMailMimeTest.cpp b/src/m_email/test/EMailMimeTest.cpp
index 63ffb6c..b47f6fa 100644
--- a/src/m_email/test/EMailMimeTest.cpp
+++ b/src/m_email/test/EMailMimeTest.cpp
@@ -1208,3 +1208,170 @@ TEST(EMailMimeTest, SuggestedNameAvoidsReservedDeviceNames) {
EXPECT_NE(SuggestedEMailFileName("NUL").toUpper(), QString("NUL.EML"));
EXPECT_NE(SuggestedEMailFileName("CON").toUpper(), QString("CON.EML"));
}
+
+// --- decrypt-then-verify -----------------------------------------------------
+//
+// What happens to a plaintext AFTER it has been decrypted is a policy decision,
+// and it used to live inside the module event handler where nothing could test
+// it. The rule it got wrong: an encrypted message that is not signed is the
+// ordinary case, and "there is no signature to check" must never be reported as
+// a failure of the operation -- the handler discarded the plaintext when it
+// was, which cost the user the message they had just decrypted.
+
+namespace {
+
+auto EncryptedThenPlaintext(const QByteArray& inner) -> QByteArray {
+ return inner;
+}
+
+auto SignedPlaintext() -> QByteArray {
+ QByteArray eml;
+ eml += "From: [email protected]\r\n";
+ eml += "To: [email protected]\r\n";
+ eml += "MIME-Version: 1.0\r\n";
+ eml +=
+ "Content-Type: multipart/signed; micalg=pgp-sha256; "
+ "protocol=\"application/pgp-signature\"; boundary=\"b\"\r\n";
+ eml += "\r\n";
+ eml += "--b\r\n";
+ eml += "Content-Type: text/plain\r\n\r\n";
+ eml += "hello\r\n";
+ eml += "--b\r\n";
+ eml += "Content-Type: application/pgp-signature\r\n\r\n";
+ eml += "-----BEGIN PGP SIGNATURE-----\r\nx\r\n";
+ eml += "-----END PGP SIGNATURE-----\r\n";
+ eml += "--b--\r\n";
+ return eml;
+}
+
+auto UnsignedPlaintext() -> QByteArray {
+ QByteArray eml;
+ eml += "From: [email protected]\r\n";
+ eml += "To: [email protected]\r\n";
+ eml += "Subject: inner\r\n";
+ eml += "MIME-Version: 1.0\r\n";
+ eml += "Content-Type: text/plain; charset=utf-8\r\n";
+ eml += "\r\n";
+ eml += "the secret\r\n";
+ return eml;
+}
+
+} // namespace
+
+TEST(EMailMimeTest, AnUnsignedPlaintextIsNotSomethingToVerify) {
+ // The case the whole change exists for. Nothing here is an error: the
+ // decrypt worked and there is simply no signature.
+ EXPECT_EQ(PlanVerifyAfterDecrypt(EncryptedThenPlaintext(UnsignedPlaintext())),
+ EMailPostDecryptPlan::kNOT_SIGNED);
+}
+
+TEST(EMailMimeTest, ASignedPlaintextIsHandedToTheVerify) {
+ EXPECT_EQ(PlanVerifyAfterDecrypt(SignedPlaintext()),
+ EMailPostDecryptPlan::kVERIFY);
+}
+
+TEST(EMailMimeTest, AMalformedSignedPlaintextStillReachesTheVerify) {
+ // It CLAIMS multipart/signed and is missing the protocol parameter. The
+ // verify refuses it and names what is wrong, and that diagnosis is worth
+ // more than quietly calling the message unsigned.
+ QByteArray eml;
+ eml += "From: [email protected]\r\n";
+ eml += "MIME-Version: 1.0\r\n";
+ eml += "Content-Type: multipart/signed; boundary=\"b\"\r\n";
+ eml += "\r\n--b\r\nContent-Type: text/plain\r\n\r\nhi\r\n--b--\r\n";
+
+ EXPECT_EQ(PlanVerifyAfterDecrypt(eml), EMailPostDecryptPlan::kVERIFY);
+}
+
+TEST(EMailMimeTest, PlaintextThatIsNotAMessageIsUnreadableNotUnsigned) {
+ EXPECT_EQ(PlanVerifyAfterDecrypt(QByteArray()),
+ EMailPostDecryptPlan::kUNREADABLE);
+ EXPECT_EQ(PlanVerifyAfterDecrypt(" \r\n "),
+ EMailPostDecryptPlan::kUNREADABLE);
+ EXPECT_EQ(PlanVerifyAfterDecrypt("just some words, no headers at all"),
+ EMailPostDecryptPlan::kUNREADABLE);
+}
+
+TEST(EMailMimeTest, NestedSignatureDoesNotForceATopLevelVerify) {
+ // A signature deeper inside is the per-region walk's business. Sending this
+ // to VerifyEMLData would only produce a refusal.
+ QByteArray eml;
+ eml += "From: [email protected]\r\n";
+ eml += "MIME-Version: 1.0\r\n";
+ eml += "Content-Type: multipart/mixed; boundary=\"out\"\r\n";
+ eml += "\r\n--out\r\n";
+ eml +=
+ "Content-Type: multipart/signed; micalg=pgp-sha256; "
+ "protocol=\"application/pgp-signature\"; boundary=\"in\"\r\n";
+ eml += "\r\n--in\r\nContent-Type: text/plain\r\n\r\nhi\r\n--in\r\n";
+ eml += "Content-Type: application/pgp-signature\r\n\r\nSIG\r\n--in--\r\n";
+ eml += "\r\n--out--\r\n";
+
+ EXPECT_EQ(PlanVerifyAfterDecrypt(eml), EMailPostDecryptPlan::kNOT_SIGNED);
+}
+
+TEST(EMailMimeTest, MergingVerifiedMetaDataDoesNotDuplicateAttachments) {
+ // Decrypt and verify walk the SAME plaintext, and ExtractParts() only ever
+ // appends. Sharing one metadata object between them listed every attachment
+ // twice; this is the join that replaced it.
+ EMailMetaData decrypted;
+ decrypted.body = "body";
+ decrypted.body_content_type = "text/plain";
+ for (const auto* name : {"a.pdf", "b.png"}) {
+ EMailAttachment att;
+ att.filename = name;
+ att.data = "x";
+ decrypted.attachments.append(att);
+ }
+
+ EMailMetaData verified = decrypted; // the same walk, done again
+ verified.signed_entity_digest = "abcd";
+ verified.signed_entity_digest_algo = "SHA-256";
+ verified.micalg = "pgp-sha256";
+
+ MergeVerifiedMetaData(decrypted, verified);
+
+ ASSERT_EQ(decrypted.attachments.size(), 2);
+ EXPECT_EQ(decrypted.attachments[0].filename, QString("a.pdf"));
+ EXPECT_EQ(decrypted.attachments[1].filename, QString("b.png"));
+ EXPECT_EQ(decrypted.body, QByteArray("body"));
+
+ // The signature side is what the verify was for.
+ EXPECT_EQ(decrypted.signed_entity_digest, QString("abcd"));
+ EXPECT_EQ(decrypted.micalg, QString("pgp-sha256"));
+}
+
+TEST(EMailMimeTest, MergingPrefersTheHeadersFromInsideTheCiphertext) {
+ // The inner headers are the ones an outer envelope cannot have rewritten.
+ EMailMetaData decrypted;
+ decrypted.from = "[email protected]";
+ decrypted.subject = "...";
+ decrypted.to = QStringList{"[email protected]"};
+
+ EMailMetaData verified;
+ verified.from = "[email protected]";
+ verified.subject = "the real subject";
+ verified.to = QStringList{"[email protected]"};
+
+ MergeVerifiedMetaData(decrypted, verified);
+
+ EXPECT_EQ(decrypted.from, QString("[email protected]"));
+ EXPECT_EQ(decrypted.subject, QString("the real subject"));
+ EXPECT_EQ(decrypted.to, QStringList{"[email protected]"});
+}
+
+TEST(EMailMimeTest, MergingKeepsWhatTheVerifyNeverSaw) {
+ // A verify that reports nothing must not blank out what the decrypt found.
+ EMailMetaData decrypted;
+ decrypted.from = "[email protected]";
+ decrypted.subject = "kept";
+ decrypted.body = "kept body";
+ decrypted.encrypted_data = "CIPHER";
+
+ MergeVerifiedMetaData(decrypted, EMailMetaData{});
+
+ EXPECT_EQ(decrypted.from, QString("[email protected]"));
+ EXPECT_EQ(decrypted.subject, QString("kept"));
+ EXPECT_EQ(decrypted.body, QByteArray("kept body"));
+ EXPECT_EQ(decrypted.encrypted_data, QByteArray("CIPHER"));
+}