diff options
| author | saturneric <[email protected]> | 2026-09-13 12:28:04 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-09-13 12:28:04 +0200 |
| commit | 1185b7432bb8e0a7f3a08abdaec0070699aec53c (patch) | |
| tree | 3ae51234ac4299c237af9db870f0e7f144cd43f6 | |
| parent | i18n(email): update email translations (diff) | |
| download | modules-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.cpp | 71 | ||||
| -rw-r--r-- | src/m_email/EMailHelper.h | 46 | ||||
| -rw-r--r-- | src/m_email/EMailModule.cpp | 85 | ||||
| -rw-r--r-- | src/m_email/test/EMailCryptoBoundaryTest.cpp | 161 | ||||
| -rw-r--r-- | src/m_email/test/EMailMimeTest.cpp | 167 |
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")); +} |
