diff options
| author | saturneric <[email protected]> | 2026-09-13 13:11:44 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-09-13 13:11:44 +0200 |
| commit | 4685b4690bc3e09c186d712aeea093495d1ea090 (patch) | |
| tree | a24be10c175fc20c64399b4b3fb84ee8f7209b7e /src | |
| parent | style(email): reformat email module code (diff) | |
| download | modules-4685b4690bc3e09c186d712aeea093495d1ea090.tar.gz modules-4685b4690bc3e09c186d712aeea093495d1ea090.zip | |
test(email): cover security boundary regressions
- test export risk checks and bounded regular-file reads
- verify MIME transfer decoding and Content-Type parameters
- cover failed crypto result cleanup and error propagation
- validate failure payload preservation and security badge states
- test IMAP UID fetching, size limits, quoting, and TLS pins
- cover secure secret conversion and non-ASCII passwords
- extend crypto stubs to model SDK failure allocations
Diffstat (limited to 'src')
| -rw-r--r-- | src/m_email/test/EMailCorpusTest.cpp | 199 | ||||
| -rw-r--r-- | src/m_email/test/EMailCryptoBoundaryTest.cpp | 363 | ||||
| -rw-r--r-- | src/m_email/test/EMailCryptoRecorder.h | 14 | ||||
| -rw-r--r-- | src/m_email/test/EMailCryptoSdkStubs.cpp | 48 | ||||
| -rw-r--r-- | src/m_email/test/EMailMimeTest.cpp | 227 | ||||
| -rw-r--r-- | src/m_email/test/EMailNetTest.cpp | 244 | ||||
| -rw-r--r-- | src/m_email/test/EMailSecretTest.cpp | 46 |
7 files changed, 1130 insertions, 11 deletions
diff --git a/src/m_email/test/EMailCorpusTest.cpp b/src/m_email/test/EMailCorpusTest.cpp index 7f6b6e9..32fc296 100644 --- a/src/m_email/test/EMailCorpusTest.cpp +++ b/src/m_email/test/EMailCorpusTest.cpp @@ -30,8 +30,14 @@ #include <QDir> #include <QFile> +#include <QFileInfo> #include <QRegularExpression> #include <QSet> +#include <QTemporaryDir> + +#ifdef Q_OS_UNIX +#include <sys/stat.h> +#endif #include "EMailHelper.h" #include "EMailModel.h" @@ -2009,3 +2015,196 @@ TEST(EMailCorpusTest, EncryptedMessagesAreClassifiedBeforeAnyDecryption) { } } } + +// --- the export check ------------------------------------------------------- +// +// What a save should stop and ask about. This used to live inside the module +// event handler, tangled up with building a QMessageBox, which meant the +// policy could not be tested and the parse ran on whichever thread the dialog +// happened to want. The decision is now its own function. + +TEST(EMailCorpusTest, ExportCheckAsksAboutAMessageCarryingABccHeader) { + QByteArray eml; + eml += "From: [email protected]\r\n"; + eml += "To: [email protected]\r\n"; + eml += "Bcc: [email protected]\r\n"; + eml += "Subject: s\r\n"; + eml += "MIME-Version: 1.0\r\n"; + eml += "Content-Type: text/plain\r\n\r\n"; + eml += "body\r\n"; + + const auto check = CheckBeforeExport(eml); + + EXPECT_TRUE(check.needs_confirmation); + EXPECT_FALSE(check.risks.isEmpty()); +} + +TEST(EMailCorpusTest, ExportCheckStaysQuietAboutAnOrdinaryMessage) { + QByteArray eml; + eml += "From: [email protected]\r\n"; + eml += "To: [email protected]\r\n"; + eml += "Subject: s\r\n"; + eml += "MIME-Version: 1.0\r\n"; + eml += "Content-Type: text/plain\r\n\r\n"; + eml += "body\r\n"; + + const auto check = CheckBeforeExport(eml); + + // It has notes -- an unsigned message is worth a word -- but a note is not + // worth interrupting a save for, and interrupting for one is how a check + // gets clicked through without being read. + EXPECT_FALSE(check.needs_confirmation); + EXPECT_TRUE(check.risks.isEmpty()); +} + +TEST(EMailCorpusTest, ExportCheckDoesNotBlockWhatItCannotRead) { + // A message this code cannot parse is not one it can make claims about, and + // refusing to save it would be worse than saving it. + for (const auto& source : {QByteArray(), QByteArray("not a message at all"), + QByteArray("\r\n\r\n")}) { + const auto check = CheckBeforeExport(source); + EXPECT_FALSE(check.needs_confirmation); + EXPECT_TRUE(check.risks.isEmpty()); + EXPECT_TRUE(check.notes.isEmpty()); + } +} + +TEST(EMailCorpusTest, ExportCheckSortsFindingsByHowMuchTheyMatter) { + QByteArray eml; + eml += "From: [email protected]\r\n"; + eml += "Bcc: [email protected]\r\n"; + eml += "MIME-Version: 1.0\r\n"; + eml += "Content-Type: text/plain\r\n\r\n"; + eml += "body\r\n"; + + const auto check = CheckBeforeExport(eml); + + // Risks are what the dialog leads with; notes go behind "Show Details". + EXPECT_TRUE(check.needs_confirmation); + for (const auto& line : check.risks) EXPECT_FALSE(line.isEmpty()); + for (const auto& line : check.notes) EXPECT_FALSE(line.isEmpty()); +} + +// --- taking a file in ------------------------------------------------------- +// +// Attaching a file and opening a .eml are the two places something from +// outside enters this module, and they need the same three guards. The open +// path had them and said why; the attach path rejected only directories and +// then called readAll() on the GUI thread -- so dropping a FIFO or /dev/zero +// onto a compose window hung or exhausted the application with no way to +// cancel. Both now go through one helper, and this is its contract. + +namespace { + +auto ReadOf(const QString& path, qint64 max_bytes, QByteArray& out) + -> EMailFileAdmission { + QString error; + qint64 size = 0; + return ReadFileWithin(path, max_bytes, out, error, size); +} + +} // namespace + +TEST(EMailCorpusTest, AnOrdinaryFileIsReadByteForByte) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + QByteArray written("bytes\r\nwith a "); + written += '\0'; + written += " NUL and no trailing newline"; + + const auto path = dir.filePath("m.eml"); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + ASSERT_EQ(file.write(written), written.size()); + file.close(); + + QByteArray out; + EXPECT_EQ(ReadOf(path, kMaxReadFileSize, out), EMailFileAdmission::kOK); + EXPECT_EQ(out, written); +} + +TEST(EMailCorpusTest, ADirectoryIsRefusedRatherThanRead) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + QByteArray out; + EXPECT_EQ(ReadOf(dir.path(), kMaxReadFileSize, out), + EMailFileAdmission::kNOT_REGULAR); + EXPECT_TRUE(out.isEmpty()); +} + +TEST(EMailCorpusTest, AFileOverTheCeilingIsRefusedWithItsSize) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const auto path = dir.filePath("big.bin"); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + ASSERT_EQ(file.write(QByteArray(4096, 'x')), 4096); + file.close(); + + QByteArray out; + QString error; + qint64 size = 0; + EXPECT_EQ(ReadFileWithin(path, 1024, out, error, size), + EMailFileAdmission::kTOO_LARGE); + EXPECT_EQ(size, 4096) << "the refusal has to be able to say how big it was"; + EXPECT_TRUE(out.isEmpty()); +} + +TEST(EMailCorpusTest, AFileThatIsNotThereSaysSoRatherThanBlamingItsKind) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + QByteArray out; + QString error; + qint64 size = 0; + EXPECT_EQ(ReadFileWithin(dir.filePath("absent"), kMaxReadFileSize, out, error, + size), + EMailFileAdmission::kUNREADABLE); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(EMailCorpusTest, AnEmptyFileIsOrdinaryAndNotAnError) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const auto path = dir.filePath("empty"); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.close(); + + QByteArray out; + EXPECT_EQ(ReadOf(path, kMaxReadFileSize, out), EMailFileAdmission::kOK); + EXPECT_TRUE(out.isEmpty()); +} + +#ifdef Q_OS_UNIX +TEST(EMailCorpusTest, AFifoIsRefusedInsteadOfBlockingForever) { + // The one that mattered. A FIFO reports a size of 0, so it sails through any + // ceiling check, and the read then blocks until somebody writes to the other + // end -- which, on the GUI thread, is the whole application stopped with no + // way to cancel. + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const auto path = dir.filePath("pipe"); + ASSERT_EQ(::mkfifo(path.toLocal8Bit().constData(), 0600), 0); + + QByteArray out; + EXPECT_EQ(ReadOf(path, kMaxReadFileSize, out), + EMailFileAdmission::kNOT_REGULAR); + EXPECT_TRUE(out.isEmpty()); +} + +TEST(EMailCorpusTest, ADeviceNodeIsRefusedInsteadOfGrowingWithoutBound) { + // /dev/zero is an endless stream reporting a size of 0. + QByteArray out; + if (!QFileInfo::exists("/dev/zero")) GTEST_SKIP() << "no /dev/zero here"; + + EXPECT_EQ(ReadOf("/dev/zero", kMaxReadFileSize, out), + EMailFileAdmission::kNOT_REGULAR); + EXPECT_TRUE(out.isEmpty()); +} +#endif diff --git a/src/m_email/test/EMailCryptoBoundaryTest.cpp b/src/m_email/test/EMailCryptoBoundaryTest.cpp index 92201da..bf72f0a 100644 --- a/src/m_email/test/EMailCryptoBoundaryTest.cpp +++ b/src/m_email/test/EMailCryptoBoundaryTest.cpp @@ -611,3 +611,366 @@ TEST_F(CryptoBoundaryTest, AVerifyAfterADecryptListsEachAttachmentOnce) { EXPECT_FALSE(decrypted.signed_entity_digest.isEmpty()); EXPECT_EQ(decrypted.micalg, QString("pgp-sha256")); } + +// --- transfer encoding ------------------------------------------------------ +// +// A MIME part carries a Content-Transfer-Encoding, and the OpenPGP payload is +// what is left AFTER undoing it. Reading the part with body::generate() yields +// the wire form instead -- base64 text where the signature should be -- so a +// perfectly good signature fails as bad data and the user is shown a +// verification failure indistinguishable from a forgery. + +namespace { + +constexpr auto kArmoredSignature = + "-----BEGIN PGP SIGNATURE-----\r\n" + "\r\n" + "iQEzBAABCgAdFiEE\r\n" + "=ArMr\r\n" + "-----END PGP SIGNATURE-----\r\n"; + +constexpr auto kArmoredMessage = + "-----BEGIN PGP MESSAGE-----\r\n" + "\r\n" + "hQEMAwAAAAAAAAAA\r\n" + "=CiPh\r\n" + "-----END PGP MESSAGE-----\r\n"; + +/// A signed message whose SIGNATURE part is transfer-encoded. +auto SignedMessageWithEncodedSignature(const QByteArray& cte, + const QByteArray& encoded_body) + -> QByteArray { + QByteArray eml; + eml += "From: Alice <[email protected]>\r\n"; + eml += "To: Bob <[email protected]>\r\n"; + eml += "MIME-Version: 1.0\r\n"; + eml += QByteArray("Content-Type: multipart/signed; micalg=pgp-sha256; ") + + "protocol=\"application/pgp-signature\"; boundary=\"" + kBoundary + + "\"\r\n"; + eml += "\r\n"; + eml += QByteArray("--") + kBoundary + "\r\n"; + eml += "Content-Type: text/plain; charset=utf-8\r\n"; + eml += "\r\n"; + eml += "signed body\r\n"; + eml += QByteArray("\r\n--") + kBoundary + "\r\n"; + eml += "Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n"; + eml += "Content-Transfer-Encoding: " + cte + "\r\n"; + eml += "\r\n"; + eml += encoded_body; + eml += QByteArray("\r\n--") + kBoundary + "--\r\n"; + return eml; +} + +/// An encrypted message whose CIPHERTEXT part is transfer-encoded. +auto EncryptedMessageWithEncodedCiphertext(const QByteArray& cte, + const QByteArray& encoded_body) + -> QByteArray { + QByteArray eml; + eml += "From: Alice <[email protected]>\r\n"; + eml += "To: Bob <[email protected]>\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"; + eml += "Content-Transfer-Encoding: " + cte + "\r\n"; + eml += "\r\n"; + eml += encoded_body; + eml += "\r\n--encb--\r\n"; + return eml; +} + +} // namespace + +TEST_F(CryptoBoundaryTest, ABase64SignaturePartIsDecodedBeforeVerifying) { + const QByteArray armor(kArmoredSignature); + const auto encoded = armor.toBase64(); + + EMailMetaData meta; + QString error; + gpgme_error_t err = 0; + QString capsule; + VerifyEMLData(0, SignedMessageWithEncodedSignature("base64", encoded), meta, + error, err, capsule); + + ASSERT_EQ(Get().verify.size(), 1); + const auto handed = Get().verify.first().signature; + + EXPECT_EQ(handed, armor); + // The failure this exists to catch: the base64 text reaching the engine + // instead of the signature it encodes. + EXPECT_FALSE(handed.contains(encoded)) + << "the engine was handed the wire form, not the signature"; +} + +TEST_F(CryptoBoundaryTest, AQuotedPrintableSignaturePartIsDecoded) { + // The armor checksum line begins with '=', which is exactly the character + // quoted-printable escapes, so this is not a contrived case. + QByteArray armor(kArmoredSignature); + QByteArray encoded = armor; + encoded.replace("=", "=3D"); + + EMailMetaData meta; + QString error; + gpgme_error_t err = 0; + QString capsule; + VerifyEMLData(0, + SignedMessageWithEncodedSignature("quoted-printable", encoded), + meta, error, err, capsule); + + ASSERT_EQ(Get().verify.size(), 1); + const auto handed = Get().verify.first().signature; + + EXPECT_TRUE(handed.contains("=ArMr")); + EXPECT_FALSE(handed.contains("=3DArMr")) + << "the quoted-printable escaping was never undone"; +} + +TEST_F(CryptoBoundaryTest, ABase64CiphertextPartIsDecodedBeforeDecrypting) { + const QByteArray armor(kArmoredMessage); + const auto encoded = armor.toBase64(); + + Get().decrypt_output = "Content-Type: text/plain\r\n\r\nplain\r\n"; + + EMailMetaData meta; + QByteArray out; + gpgme_error_t err = 0; + QString capsule; + DecryptEMLData(0, EncryptedMessageWithEncodedCiphertext("base64", encoded), + meta, out, err, capsule); + + ASSERT_EQ(Get().decrypt.size(), 1); + const auto handed = Get().decrypt.first().data; + + EXPECT_EQ(handed, armor); + EXPECT_FALSE(handed.contains(encoded)) + << "the engine was handed the wire form, not the ciphertext"; +} + +TEST_F(CryptoBoundaryTest, AnUnencodedSignaturePartIsStillPassedVerbatim) { + // The ordinary case must be unchanged by the decode: a 7bit part's decoded + // form is its literal bytes. + const QByteArray armor(kArmoredSignature); + + EMailMetaData meta; + QString error; + gpgme_error_t err = 0; + QString capsule; + VerifyEMLData(0, SignedMessageWithEncodedSignature("7bit", armor), meta, + error, err, capsule); + + ASSERT_EQ(Get().verify.size(), 1); + EXPECT_EQ(Get().verify.first().signature, armor); +} + +// --- Content-Type parameters ------------------------------------------------ +// +// Wrapping a message changes what its top-level Content-Type describes, and +// every parameter of the OLD type has to go with it: a charset belonging to a +// text/plain body says nothing about a multipart, and a micalg naming the hash +// of a signature that has just been sealed inside ciphertext is a claim this +// layer cannot make. +// +// That happens to be free today. setValue(const string&) does not set a value; +// it re-PARSES the field, and parameterizedHeaderField::parseImpl() resets the +// parameter list before reading the new one. So the old parameters are gone +// before appendParameter() adds a single one of its own, and the duplicate +// protocol parameter this looks like it should produce never appears. +// +// It is free only because of that overload. The typed setValue(), taking a +// mediaType, sets the value object and leaves the parameters untouched -- +// switching to it, which reads like a pure cleanup, would silently start +// emitting two protocol parameters naming different protocols. RFC 2045 +// forbids the duplicate; vmime reads the last and would round-trip its own +// output, so nothing here would notice, while a reader taking the first is +// told a signed message arrived when an encrypted one was sent. +// +// These tests pin the output shape so that swap cannot be made quietly. + +namespace { + +/// The outermost Content-Type field, exactly as it was written. +auto OuterContentTypeLine(const QByteArray& eml) -> QByteArray { + const auto end = eml.indexOf("\r\n\r\n"); + const auto block = end >= 0 ? eml.left(end + 2) : eml; + for (const auto& field : SplitRawHeaderFields(block)) { + if (field.name.compare("Content-Type", Qt::CaseInsensitive) == 0) { + return field.raw_line; + } + } + return {}; +} + +auto CountOf(const QByteArray& haystack, const QByteArray& needle) -> int { + int n = 0; + for (auto at = haystack.indexOf(needle); at >= 0; + at = haystack.indexOf(needle, at + needle.size())) { + ++n; + } + return n; +} + +auto MetaForSigning() -> EMailMetaData { + EMailMetaData meta; + meta.from = "[email protected]"; + meta.to = QStringList{"[email protected]"}; + meta.subject = "s"; + return meta; +} + +} // namespace + +TEST_F(CryptoBoundaryTest, SigningDoesNotLeaveACharsetOnTheMultipart) { + // The charset belonged to the text/plain body, which is now one part down. + // On a multipart/signed it describes nothing at all. + QByteArray eml; + gpgme_error_t err = 0; + QString capsule; + ASSERT_EQ(SignPlainText(0, "DEADBEEF", MetaForSigning(), "hello", eml, err, + capsule), + kSUCCESS); + + const auto line = OuterContentTypeLine(eml); + ASSERT_FALSE(line.isEmpty()); + EXPECT_TRUE(line.contains("multipart/signed")) << line.constData(); + EXPECT_FALSE(line.contains("charset")) << line.constData(); + EXPECT_EQ(CountOf(line, "protocol="), 1) << line.constData(); +} + +TEST_F(CryptoBoundaryTest, EncryptingASignedMessageEmitsOneProtocol) { + // The exact shape encrypt-and-sign produces, asserted on a message that + // really does arrive carrying a protocol and a micalg of its own. + QByteArray signed_eml; + gpgme_error_t err = 0; + QString capsule; + ASSERT_EQ(SignPlainText(0, "DEADBEEF", MetaForSigning(), "hello", signed_eml, + err, capsule), + kSUCCESS); + ASSERT_TRUE(OuterContentTypeLine(signed_eml).contains("micalg")); + + vmime::shared_ptr<vmime::message> parsed; + ASSERT_TRUE(CheckIfEMLMessage(signed_eml, parsed)); + + QByteArray encrypted_eml; + ASSERT_EQ(EncryptEMLData(0, QStringList{"DEADBEEF"}, parsed, signed_eml, + encrypted_eml, err, capsule), + kSUCCESS); + + const auto line = OuterContentTypeLine(encrypted_eml); + ASSERT_FALSE(line.isEmpty()); + + EXPECT_TRUE(line.contains("multipart/encrypted")) << line.constData(); + EXPECT_EQ(CountOf(line, "protocol="), 1) << line.constData(); + EXPECT_TRUE(line.contains("application/pgp-encrypted")) << line.constData(); + EXPECT_FALSE(line.contains("application/pgp-signature")) << line.constData(); + + // The micalg described the signature that is now sealed inside the + // ciphertext. Leaving it on the outside announces a hash for a signature + // this layer does not have. + EXPECT_FALSE(line.contains("micalg")) << line.constData(); +} + +TEST_F(CryptoBoundaryTest, EncryptingStillCarriesItsOwnBoundary) { + // The other direction: whatever clears the old parameters must not take the + // new ones with it. A multipart with no boundary is unreadable. + QByteArray eml; + gpgme_error_t err = 0; + QString capsule; + ASSERT_EQ(EncryptPlainText(0, QStringList{"DEADBEEF"}, MetaForSigning(), + "hello", eml, err, capsule), + kSUCCESS); + + const auto line = OuterContentTypeLine(eml); + EXPECT_TRUE(line.contains("multipart/encrypted")) << line.constData(); + EXPECT_TRUE(line.contains("boundary=")) << line.constData(); + EXPECT_EQ(CountOf(line, "protocol="), 1) << line.constData(); + EXPECT_FALSE(line.contains("charset")) << line.constData(); +} + +// --- results handed back with a failure ------------------------------------- +// +// The SDK allocates its result struct before it can know whether the operation +// will succeed, so a non-zero return still comes back with a live struct -- +// and usually with the engine's own account of what went wrong inside it. +// Checking only the return code leaked the struct and left the user with +// "Operation Failed." where a reason existed. + +TEST_F(CryptoBoundaryTest, AFailedSignReclaimsItsResultAndSaysWhy) { + Get().fail_next = true; + Get().fail_error_string = "No secret key for DEADBEEF"; + + const auto before = crypto_recorder::OutstandingAllocations(); + + QByteArray eml; + gpgme_error_t err = 0; + QString capsule; + EXPECT_EQ(SignPlainText(0, "DEADBEEF", MetaForSigning(), "hello", eml, err, + capsule), + kFAILED); + + EXPECT_TRUE(QString::fromUtf8(eml).contains("No secret key for DEADBEEF")) + << "the engine's reason never reached the user: " << eml.constData(); + EXPECT_EQ(crypto_recorder::OutstandingAllocations(), before) + << "the result the SDK allocated alongside the failure was never freed"; +} + +TEST_F(CryptoBoundaryTest, AFailedEncryptReclaimsItsResultAndSaysWhy) { + Get().fail_next = true; + Get().fail_error_string = "Unusable public key"; + + const auto before = crypto_recorder::OutstandingAllocations(); + + QByteArray eml; + gpgme_error_t err = 0; + QString capsule; + EXPECT_EQ(EncryptPlainText(0, QStringList{"DEADBEEF"}, MetaForSigning(), + "hello", eml, err, capsule), + kFAILED); + + EXPECT_TRUE(QString::fromUtf8(eml).contains("Unusable public key")) + << eml.constData(); + EXPECT_EQ(crypto_recorder::OutstandingAllocations(), before); +} + +TEST_F(CryptoBoundaryTest, AFailedVerifyReclaimsItsResultAndSaysWhy) { + QByteArray entity; + entity += "Content-Type: text/plain\r\n\r\nhi"; + const auto eml = SignedMessage(entity); + + Get().fail_next = true; + Get().fail_error_string = "Engine unavailable"; + + const auto before = crypto_recorder::OutstandingAllocations(); + + EMailMetaData meta; + QString error; + gpgme_error_t err = 0; + QString capsule; + EXPECT_EQ(VerifyEMLData(0, eml, meta, error, err, capsule), kFAILED); + + EXPECT_TRUE(error.contains("Engine unavailable")) << error.toStdString(); + EXPECT_EQ(crypto_recorder::OutstandingAllocations(), before); +} + +TEST_F(CryptoBoundaryTest, AFailureWithNoMessageStillReadsAsAFailure) { + // Two of the SDK's failure paths set no message at all. Saying nothing is + // better than inventing a cause, but it must still read as a failure. + Get().fail_next = true; + Get().fail_error_string = {}; + + const auto before = crypto_recorder::OutstandingAllocations(); + + QByteArray eml; + gpgme_error_t err = 0; + QString capsule; + EXPECT_EQ(SignPlainText(0, "DEADBEEF", MetaForSigning(), "hello", eml, err, + capsule), + kFAILED); + + EXPECT_TRUE(QString::fromUtf8(eml).contains("Failed")) << eml.constData(); + EXPECT_EQ(crypto_recorder::OutstandingAllocations(), before); +} diff --git a/src/m_email/test/EMailCryptoRecorder.h b/src/m_email/test/EMailCryptoRecorder.h index ae1e420..556ea21 100644 --- a/src/m_email/test/EMailCryptoRecorder.h +++ b/src/m_email/test/EMailCryptoRecorder.h @@ -73,8 +73,22 @@ struct Recording { QByteArray encrypt_output{ "-----BEGIN PGP MESSAGE-----\nstub\n" "-----END PGP MESSAGE-----\n"}; + + /// Makes the next crypto call fail the way the REAL SDK fails: it allocates + /// its result struct before it can know whether the operation will work, so + /// a non-zero return still comes back with a live struct -- and, on most of + /// those paths, with the engine's own account of what went wrong in it. A + /// caller that only checks the return code leaks the struct and loses the + /// explanation. See src/sdk/GFSDKGpg.cpp. + bool fail_next{false}; + /// What the engine "said". Empty models the paths that set no message. + QByteArray fail_error_string; }; +/// Buffers the fake SDK has handed out and not had back. A result struct that +/// a failure path forgot to reclaim shows up here. +auto OutstandingAllocations() -> int; + void Reset(); auto Get() -> Recording&; diff --git a/src/m_email/test/EMailCryptoSdkStubs.cpp b/src/m_email/test/EMailCryptoSdkStubs.cpp index 9cb5cbb..f8038ad 100644 --- a/src/m_email/test/EMailCryptoSdkStubs.cpp +++ b/src/m_email/test/EMailCryptoSdkStubs.cpp @@ -68,6 +68,10 @@ Recording g_recording; void Reset() { g_recording = Recording{}; } +auto OutstandingAllocations() -> int { + return static_cast<int>(NormalArena().size() + SecureArena().size()); +} + auto Get() -> Recording& { return g_recording; } } // namespace crypto_recorder @@ -176,6 +180,17 @@ int GFGpgVerifyDataN(int, const char* data, size_t data_size, auto* mem = static_cast<GFGpgVerifyResult*>( GFAllocateMemory(sizeof(GFGpgVerifyResult))); std::memset(mem, 0, sizeof(GFGpgVerifyResult)); + if (g_recording.fail_next) { + // Exactly the real shape: allocated, populated with the reason, and + // handed back alongside a non-zero return. + if (!g_recording.fail_error_string.isEmpty()) { + mem->error_string = + GFModuleStrDup(g_recording.fail_error_string.constData()); + } + *ps = mem; + return -1; + } + mem->capsule_id = GFModuleStrDup("stub-capsule"); mem->error_string = GFModuleStrDup("Success"); mem->gpgme_error = 0; @@ -202,6 +217,17 @@ int GFGpgDecryptDataN(int, const char* data, size_t data_size, auto* mem = static_cast<GFGpgDecryptResult*>( GFAllocateMemory(sizeof(GFGpgDecryptResult))); std::memset(mem, 0, sizeof(GFGpgDecryptResult)); + if (g_recording.fail_next) { + // Exactly the real shape: allocated, populated with the reason, and + // handed back alongside a non-zero return. + if (!g_recording.fail_error_string.isEmpty()) { + mem->error_string = + GFModuleStrDup(g_recording.fail_error_string.constData()); + } + *ps = mem; + return -1; + } + mem->decrypted_data = BytesOut(g_recording.decrypt_output, &mem->decrypted_data_size); mem->capsule_id = GFModuleStrDup("stub-capsule"); @@ -228,6 +254,17 @@ int GFGpgSignDataN(int, char** key_ids, int key_ids_size, const char* data, auto* mem = static_cast<GFGpgSignResult*>(GFAllocateMemory(sizeof(GFGpgSignResult))); std::memset(mem, 0, sizeof(GFGpgSignResult)); + if (g_recording.fail_next) { + // Exactly the real shape: allocated, populated with the reason, and + // handed back alongside a non-zero return. + if (!g_recording.fail_error_string.isEmpty()) { + mem->error_string = + GFModuleStrDup(g_recording.fail_error_string.constData()); + } + *ps = mem; + return -1; + } + mem->signature = BytesOut(g_recording.sign_output, &mem->signature_size); mem->hash_algo = GFModuleStrDup("SHA256"); mem->capsule_id = GFModuleStrDup("stub-capsule"); @@ -255,6 +292,17 @@ int GFGpgEncryptDataN(int, char** key_ids, int key_ids_size, const char* data, auto* mem = static_cast<GFGpgEncryptionResult*>( GFAllocateMemory(sizeof(GFGpgEncryptionResult))); std::memset(mem, 0, sizeof(GFGpgEncryptionResult)); + if (g_recording.fail_next) { + // Exactly the real shape: allocated, populated with the reason, and + // handed back alongside a non-zero return. + if (!g_recording.fail_error_string.isEmpty()) { + mem->error_string = + GFModuleStrDup(g_recording.fail_error_string.constData()); + } + *ps = mem; + return -1; + } + mem->encrypted_data = BytesOut(g_recording.encrypt_output, &mem->encrypted_data_size); mem->capsule_id = GFModuleStrDup("stub-capsule"); diff --git a/src/m_email/test/EMailMimeTest.cpp b/src/m_email/test/EMailMimeTest.cpp index b47f6fa..7e43a79 100644 --- a/src/m_email/test/EMailMimeTest.cpp +++ b/src/m_email/test/EMailMimeTest.cpp @@ -1375,3 +1375,230 @@ TEST(EMailMimeTest, MergingKeepsWhatTheVerifyNeverSaw) { EXPECT_EQ(decrypted.body, QByteArray("kept body")); EXPECT_EQ(decrypted.encrypted_data, QByteArray("CIPHER")); } + +// --- what a failed operation leaves behind ---------------------------------- + +TEST(EMailMimeTest, AFailedOperationLeavesTheDocumentByteIdentical) { + // The host writes the result's data straight into the editor, so a failing + // operation's payload REPLACES what the user was composing. Encrypt used to + // answer with a base64 rendering of it, which is how a cancelled passphrase + // prompt turned a message into a screen of base64. + QByteArray original; + original += "From: [email protected]\r\n"; + original += "Subject: \xC3\xA9t\xC3\xA9\r\n"; // UTF-8, not ASCII + original += "\r\n"; + original += "body with an embedded "; + original += '\0'; + original += " NUL and a trailing space \r\n"; + + const auto kept = DocumentUnchangedOnFailure(original); + + EXPECT_EQ(kept, original); + EXPECT_NE(kept, original.toBase64()); + EXPECT_TRUE(kept.contains('\0')); +} + +TEST(EMailMimeTest, AFailedOperationDoesNotInventAnEmptyDocument) { + // Handing back nothing would be its own kind of loss: the host writes what + // it is given, so an empty payload empties the tab. + const QByteArray original("composed text"); + EXPECT_FALSE(DocumentUnchangedOnFailure(original).isEmpty()); +} + +// --- what the security surface may claim ------------------------------------- +// +// The badge was derived from ClassifyOpenPGPStructure() alone, which does no +// cryptography. A multipart/signed whose signature part held forty bytes of +// garbage therefore read as "Signed", in the accent colour, and nothing on the +// message surface ever said otherwise -- the honest wording lived in a Security +// tab the user had to go and open. + +namespace { + +auto Sig(int validity, const QString& uid = "Alice <[email protected]>") + -> EMailSignatureResult { + EMailSignatureResult r; + r.validity = validity; + r.uid = uid; + return r; +} + +constexpr auto kFrom = "Alice <[email protected]>"; + +} // namespace + +TEST(EMailMimeTest, AStructurallySignedMessageIsNotYetAVerifiedOne) { + // The whole point. Nothing has looked at it, so nothing may say it is good. + EXPECT_EQ(DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kNOT_ATTEMPTED, {}, kFrom), + EMailBadgeState::kSIGNED_UNVERIFIED); + + // A verification that ran and found nothing is equally not a good verdict. + EXPECT_EQ(DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kATTEMPTED_EMPTY, {}, kFrom), + EMailBadgeState::kSIGNED_UNVERIFIED); +} + +TEST(EMailMimeTest, AVerifiedSignatureIsTheOnlyThingCalledGood) { + for (const auto validity : {0, 2}) { + EXPECT_EQ(DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kVERIFIED, {Sig(validity)}, + kFrom), + EMailBadgeState::kSIGNED_GOOD) + << "validity " << validity; + EXPECT_EQ(ToneForBadge(EMailBadgeState::kSIGNED_GOOD), + EMailBadgeTone::kGOOD); + } +} + +TEST(EMailMimeTest, ARedSignatureIsBadNotAMinorIssue) { + // validity 1 is GPGME_SIGSUM_RED. It was labelled "valid, with issues" and + // painted the same amber as a missing key. + const auto badge = + DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kVERIFIED, {Sig(1)}, kFrom); + + EXPECT_EQ(badge, EMailBadgeState::kSIGNED_BAD); + EXPECT_EQ(ToneForBadge(badge), EMailBadgeTone::kDANGER); +} + +TEST(EMailMimeTest, AnInvalidOrRevokedSignatureIsAlsoBad) { + for (const auto validity : {3, 5}) { + EXPECT_EQ(DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kVERIFIED, {Sig(validity)}, + kFrom), + EMailBadgeState::kSIGNED_BAD) + << "validity " << validity; + } +} + +TEST(EMailMimeTest, AMissingKeyIsItsOwnAnswerAndNotAForgery) { + const auto badge = + DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kVERIFIED, {Sig(4)}, kFrom); + + EXPECT_EQ(badge, EMailBadgeState::kSIGNED_UNKNOWN_KEY); + // Worth checking, not an alarm: importing a key resolves it. + EXPECT_EQ(ToneForBadge(badge), EMailBadgeTone::kWARN); +} + +TEST(EMailMimeTest, AnExpiredSignatureIsDistinguishedFromABadOne) { + for (const auto validity : {6, 7}) { + EXPECT_EQ(DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kVERIFIED, {Sig(validity)}, + kFrom), + EMailBadgeState::kSIGNED_EXPIRED) + << "validity " << validity; + } +} + +TEST(EMailMimeTest, AnUnknownValidityIsNeverTreatedAsGood) { + // -1 is the default in EMailSignatureResult precisely so that an absent or + // unparseable field cannot read as fully valid. + for (const auto validity : {-1, 99}) { + EXPECT_NE(DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kVERIFIED, {Sig(validity)}, + kFrom), + EMailBadgeState::kSIGNED_GOOD) + << "validity " << validity; + } +} + +TEST(EMailMimeTest, TheWorstSignatureDecides) { + // One good signature beside one bad one is not a good message. Reporting the + // best of them would let an attacker simply add a second signature. + EXPECT_EQ( + DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kVERIFIED, {Sig(0), Sig(1)}, kFrom), + EMailBadgeState::kSIGNED_BAD); + + EXPECT_EQ( + DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kVERIFIED, {Sig(0), Sig(4)}, kFrom), + EMailBadgeState::kSIGNED_UNKNOWN_KEY); +} + +TEST(EMailMimeTest, AValidSignatureByAnotherAddressIsFlagged) { + // The transplanted-signature shape: the cryptography is fine and the key + // belongs to somebody else entirely. + const auto badge = DeriveSecurityBadge( + EMailSecurityState::kSIGNED, EMailVerifyState::kVERIFIED, + {Sig(0, "Mallory <[email protected]>")}, kFrom); + + EXPECT_EQ(badge, EMailBadgeState::kSIGNED_MISMATCH); + EXPECT_EQ(ToneForBadge(badge), EMailBadgeTone::kWARN); +} + +TEST(EMailMimeTest, IdentityIsCheckedOnlyAfterTheCryptographyPasses) { + // A bad signature by another address is reported as bad. Leading with the + // address would bury the fact that the signature does not verify at all. + EXPECT_EQ(DeriveSecurityBadge( + EMailSecurityState::kSIGNED, EMailVerifyState::kVERIFIED, + {Sig(1, "Mallory <[email protected]>")}, kFrom), + EMailBadgeState::kSIGNED_BAD); +} + +TEST(EMailMimeTest, MatchingAddressesAreComparedNotStringified) { + // Display names and case differ constantly and mean nothing; the address is + // what is being compared. + EXPECT_TRUE(SignerMatchesAddress(Sig(0, "A. Person <[email protected]>"), + "[email protected]")); + EXPECT_TRUE(SignerMatchesAddress(Sig(0, "[email protected]"), kFrom)); + EXPECT_FALSE(SignerMatchesAddress(Sig(0, "[email protected]"), + "[email protected]")); +} + +TEST(EMailMimeTest, AnAddressThatCannotBeComparedIsNotCalledAMismatch) { + // A result with no UID is the unknown-key case, which the badge already + // reports on its own. Calling it a mismatch as well would say the same thing + // twice, and in stronger words than the evidence supports. + EXPECT_TRUE(SignerMatchesAddress(Sig(0, ""), kFrom)); + EXPECT_TRUE(SignerMatchesAddress(Sig(0), "")); +} + +TEST(EMailMimeTest, AnUnprotectedMessageIsStatedQuietly) { + const auto badge = DeriveSecurityBadge( + EMailSecurityState::kPLAIN, EMailVerifyState::kNOT_ATTEMPTED, {}, kFrom); + + EXPECT_EQ(badge, EMailBadgeState::kNOT_PROTECTED); + // Not a fault. Painting the ordinary case as a warning is how people learn + // to ignore the warnings that matter. + EXPECT_EQ(ToneForBadge(badge), EMailBadgeTone::kMUTED); +} + +TEST(EMailMimeTest, EncryptionAloneSaysNothingAboutWhoSent) { + const auto badge = + DeriveSecurityBadge(EMailSecurityState::kENCRYPTED, + EMailVerifyState::kNOT_ATTEMPTED, {}, kFrom); + + EXPECT_EQ(badge, EMailBadgeState::kENCRYPTED_ONLY); + EXPECT_EQ(ToneForBadge(badge), EMailBadgeTone::kMUTED); +} + +TEST(EMailMimeTest, ASignedAndEncryptedMessageIsStillJudgedOnItsSignature) { + EXPECT_EQ(DeriveSecurityBadge(EMailSecurityState::kSIGNED_ENCRYPTED, + EMailVerifyState::kNOT_ATTEMPTED, {}, kFrom), + EMailBadgeState::kSIGNED_UNVERIFIED); + + EXPECT_EQ(DeriveSecurityBadge(EMailSecurityState::kSIGNED_ENCRYPTED, + EMailVerifyState::kVERIFIED, {Sig(0)}, kFrom), + EMailBadgeState::kSIGNED_GOOD); +} + +TEST(EMailMimeTest, AMalformedStructureIsReportedAsWrong) { + const auto badge = + DeriveSecurityBadge(EMailSecurityState::kMALFORMED_PGP, + EMailVerifyState::kNOT_ATTEMPTED, {}, kFrom); + + EXPECT_EQ(badge, EMailBadgeState::kMALFORMED); + EXPECT_EQ(ToneForBadge(badge), EMailBadgeTone::kDANGER); +} + +TEST(EMailMimeTest, ResultsWithoutAVerifiedStateAreNotTrusted) { + // Results present but the state says the walk never completed: the results + // cannot be the whole story, so they must not produce a good verdict. + EXPECT_EQ( + DeriveSecurityBadge(EMailSecurityState::kSIGNED, + EMailVerifyState::kNOT_ATTEMPTED, {Sig(0)}, kFrom), + EMailBadgeState::kSIGNED_UNVERIFIED); +} diff --git a/src/m_email/test/EMailNetTest.cpp b/src/m_email/test/EMailNetTest.cpp index 6241a1d..cc59757 100644 --- a/src/m_email/test/EMailNetTest.cpp +++ b/src/m_email/test/EMailNetTest.cpp @@ -145,7 +145,6 @@ auto TrySmtp(const QStringList& mechanisms, const QString& password, return attempt; } - /// The outcome of a real submission against the fake server. struct SubmitAttempt { bool finished{false}; @@ -218,8 +217,7 @@ auto Submit(FakeSmtpServer::AfterBody after_body, bool cancel_at_body = false, }); } - worker.Submit(1, account, EMailSecret::CopyFrom("correct-horse"), - message); + worker.Submit(1, account, EMailSecret::CopyFrom("correct-horse"), message); stop_watching.store(true); if (canceller.joinable()) canceller.join(); @@ -355,12 +353,10 @@ TEST(EMailSmtpNetTest, ACancelBeforeAnythingIsSentIsACleanStop) { TEST(EMailSmtpNetTest, AnAmbiguousOutcomeIsNeverMarkedRetryable) { // Whatever else happens, nothing may invite an automatic resend after the // body is on the wire. - for (const auto after : - {FakeSmtpServer::AfterBody::kDropConnection, - FakeSmtpServer::AfterBody::kStall}) { - const auto attempt = - Submit(after, /*cancel_at_body=*/after == - FakeSmtpServer::AfterBody::kStall); + for (const auto after : {FakeSmtpServer::AfterBody::kDropConnection, + FakeSmtpServer::AfterBody::kStall}) { + const auto attempt = Submit( + after, /*cancel_at_body=*/after == FakeSmtpServer::AfterBody::kStall); ASSERT_TRUE(attempt.finished); if (attempt.receipt.ambiguous) { EXPECT_FALSE(attempt.receipt.error.transient) @@ -799,3 +795,233 @@ TEST_F(EMailImapNetTest, SearchResultsAreNewestFirst) { std::sort(sorted.begin(), sorted.end(), std::greater<>()); EXPECT_EQ(seen, sorted); } + +// --- identity, not position ------------------------------------------------- +// +// A sequence number is where a message sits in the folder right now; a UID is +// which message it is. Rows carried the sequence number in their uid field and +// a fetch addressed by it, so an expunge by another client renumbered +// everything after it and the row the user clicked and the message that came +// back were two different messages, with nothing to say so. + +TEST_F(EMailImapNetTest, ARowCarriesTheRealUidNotItsPosition) { + server_.search_uids = {7}; + worker_.ListFolders(2); + worker_.ListMessages(3, "INBOX", 0, 50, 0); + + ASSERT_FALSE(page_.rows.isEmpty()); + for (const auto& row : page_.rows) { + // The fake answers each FETCH with "UID <n>" for the number asked about, + // so uid and seq agree here -- what matters is that the UID came from the + // server's UID field and is recorded as its own thing. + EXPECT_NE(row.uid, 0U); + EXPECT_NE(row.seq, 0U); + } +} + +TEST_F(EMailImapNetTest, AMessageIsFetchedByUid) { + worker_.ListFolders(2); + server_.ResetCommands(); + worker_.FetchMessage(3, "INBOX", 1); + + bool by_uid = false; + for (const auto& command : server_.Commands()) { + if (command.contains("BODY.PEEK[]", Qt::CaseInsensitive) || + command.contains("BODY[]", Qt::CaseInsensitive)) { + by_uid = + command.section(' ', 1, 1).compare("UID", Qt::CaseInsensitive) == 0; + } + } + + EXPECT_TRUE(by_uid) + << "the body fetch addressed a position rather than a message"; +} + +// --- the server's own account of the size is not a bound -------------------- + +TEST_F(EMailImapNetTest, AMessageLargerThanItClaimedIsCutOff) { + // The size check before a fetch reads RFC822.SIZE, which is what the SERVER + // says. This fake reports 120 bytes and then sends megabytes, which is all + // it takes: the receiving stream had no bound of its own, and the only + // timeout in the way is reset by every read. + server_.message_body = QByteArray(4 * 1024 * 1024, 'A'); + + worker_.ListFolders(2); + last_error_ = {}; + bool fetched = false; + QObject::connect(&worker_, &EMailImapWorker::SignalMessageFetched, &worker_, + [&fetched](quint64, const QByteArray&) { fetched = true; }); + + worker_.FetchMessage(3, "INBOX", 1); + + EXPECT_FALSE(fetched) << "an over-long body was accepted in full"; + EXPECT_FALSE(last_error_.title.isEmpty()) + << "the refusal has to reach the user"; +} + +TEST_F(EMailImapNetTest, AnOrdinaryMessageIsStillFetchedWhole) { + // The bound must not cost the ordinary case. + QByteArray fetched; + QObject::connect( + &worker_, &EMailImapWorker::SignalMessageFetched, &worker_, + [&fetched](quint64, const QByteArray& raw) { fetched = raw; }); + + worker_.ListFolders(2); + worker_.FetchMessage(3, "INBOX", 1); + + EXPECT_EQ(fetched, server_.message_body); +} + +// --- what may be put inside an IMAP quoted string --------------------------- + +TEST(EMailImapQuotingTest, OrdinaryTextPassesThrough) { + const auto quoted = ImapQuotable("invoice"); + ASSERT_TRUE(quoted.has_value()); + EXPECT_EQ(*quoted, "invoice"); +} + +TEST(EMailImapQuotingTest, AQuoteIsEscapedRatherThanEndingTheString) { + // Unescaped, `a" BODY "b` turns SUBJECT "a" BODY "b" into two search terms. + const auto quoted = ImapQuotable(R"(a" BODY "b)"); + ASSERT_TRUE(quoted.has_value()); + EXPECT_EQ(*quoted, R"(a\" BODY \"b)"); +} + +TEST(EMailImapQuotingTest, ABackslashIsEscapedToo) { + const auto quoted = ImapQuotable(R"(back\slash)"); + ASSERT_TRUE(quoted.has_value()); + EXPECT_EQ(*quoted, R"(back\\slash)"); +} + +TEST(EMailImapQuotingTest, LineBreaksAreRefusedNotEscaped) { + // A CRLF ends the COMMAND. There is no escaping that makes it safe, so the + // only correct answer is to refuse to send it. + EXPECT_FALSE(ImapQuotable("a\r\nA001 LOGOUT").has_value()); + EXPECT_FALSE(ImapQuotable("a\nb").has_value()); + EXPECT_FALSE(ImapQuotable("a\rb").has_value()); +} + +TEST(EMailImapQuotingTest, ControlCharactersAreRefused) { + EXPECT_FALSE(ImapQuotable(QString("a") + QChar(0x00) + "b").has_value()); + EXPECT_FALSE(ImapQuotable(QString("a") + QChar(0x7F)).has_value()); +} + +TEST(EMailImapQuotingTest, NonAsciiIsRefusedBecauseAQuotedStringIs7Bit) { + // Sending 8-bit bytes without a CHARSET argument -- which vmime cannot emit + // -- is a protocol violation servers may reject outright. Refusing is + // honest; silently sending something the server may misread is not. + EXPECT_FALSE(ImapQuotable(QString::fromUtf8("caf\xC3\xA9")).has_value()); +} + +TEST(EMailImapQuotingTest, AnEmptyStringIsStillQuotable) { + const auto quoted = ImapQuotable(""); + ASSERT_TRUE(quoted.has_value()); + EXPECT_TRUE(quoted->empty()); +} + +TEST(EMailImapQuotingTest, AHostileMessageIdCannotReachTheCommand) { + // The one that is not about typing. A Message-ID is read out of message + // bytes on the byte-preserving send path, so it is chosen by whoever wrote + // that message. + EXPECT_FALSE(ImapQuotable("x@y\r\nA001 DELETE \"INBOX\"").has_value()); + + const auto quoted = ImapQuotable(R"(x"@y)"); + ASSERT_TRUE(quoted.has_value()); + EXPECT_EQ(*quoted, R"(x\"@y)"); +} + +// --- the certificate belongs to the connection that saw it ------------------ +// +// What a verifier observed used to go into one process-global slot that every +// verification in the process wrote to. Two connections in flight -- a +// settings-page probe and a mailbox browser, which is an ordinary thing to +// have at once -- overwrote each other, so the "trust this certificate?" +// question could show one server's fingerprint and pin it to another account. + +namespace { + +/// One SMTP connection test with the pin under the test's control. +auto TrySmtpWithPin(const QString& pin) -> SmtpAttempt { + FakeSmtpServer server; + server.mechanisms = QStringList{"PLAIN"}; + server.use_tls = true; + if (!server.listen(QHostAddress::LocalHost, 0)) return {}; + + ServerThread<FakeSmtpServer> running(&server); + + MailAccountConfig account; + account.id = "test"; + account.address = "[email protected]"; + account.smtp.enabled = true; + account.smtp.host = "127.0.0.1"; + account.smtp.port = server.serverPort(); + account.smtp.tls = MailTlsMode::kIMPLICIT; + account.smtp.username = "user"; + account.smtp.pinned_cert_sha256 = pin; + + EMailSmtpWorker worker; + SmtpAttempt attempt; + QObject::connect(&worker, &EMailSmtpWorker::SignalFinished, &worker, + [&attempt](quint64, const EMailSendReceipt& receipt) { + attempt.accepted = receipt.accepted; + attempt.error = receipt.error; + }); + + // The password the fake accepts, so that anything this test reports is + // about the certificate and not about the credentials. + worker.TestConnection(1, account, EMailSecret::CopyFrom("correct-horse")); + attempt.commands = server.Commands(); + return attempt; +} + +} // namespace + +TEST(EMailSmtpNetTest, AnUntrustedCertificateIsRefusedAndNamed) { + // No pin, and the fake is self-signed: no authority vouches for it. + const auto attempt = TrySmtpWithPin({}); + + EXPECT_FALSE(attempt.accepted); + ASSERT_TRUE(attempt.error.IsError()); + EXPECT_TRUE(attempt.error.IsPinnable()) + << "an untrusted chain is exactly the case a pin exists for"; + + // And it names the certificate THIS connection was offered, which is what + // makes offering a pin for it honest. + EXPECT_EQ(attempt.error.cert_fingerprint, FakeSmtpServer::Fingerprint()); + EXPECT_FALSE(attempt.error.cert_summary.isEmpty()); +} + +TEST(EMailSmtpNetTest, AWrongPinDoesNotExcuseAnUntrustedCertificate) { + // A pin is a way to trust ONE certificate, not a way to stop checking. + const auto attempt = TrySmtpWithPin(QString(64, 'a')); + + EXPECT_FALSE(attempt.accepted); + ASSERT_TRUE(attempt.error.IsError()); + + // The fingerprint reported is the server's own, not the one that was asked + // for -- otherwise the dialog would offer to pin a certificate nobody sent. + EXPECT_EQ(attempt.error.cert_fingerprint, FakeSmtpServer::Fingerprint()); + EXPECT_NE(attempt.error.cert_fingerprint, QString(64, 'a')); +} + +TEST(EMailSmtpNetTest, TheRightPinIsStillAccepted) { + // The change must not have broken the case that works. + const auto attempt = TrySmtpWithPin(FakeSmtpServer::Fingerprint()); + EXPECT_TRUE(attempt.accepted) << attempt.error.title.toStdString(); +} + +TEST(EMailSmtpNetTest, TwoConnectionsDoNotShareOneCertificateSlot) { + // The actual defect. Both run against their own server, and each error has + // to describe the server it came from. With a process-global slot the second + // attempt's verification overwrote the first's before it was ever read. + const auto untrusted = TrySmtpWithPin({}); + const auto trusted = TrySmtpWithPin(FakeSmtpServer::Fingerprint()); + + EXPECT_FALSE(untrusted.accepted); + EXPECT_TRUE(trusted.accepted) << trusted.error.title.toStdString(); + + // The successful one carries no certificate to pin, and the failed one still + // carries its own. + EXPECT_TRUE(trusted.error.cert_fingerprint.isEmpty()); + EXPECT_EQ(untrusted.error.cert_fingerprint, FakeSmtpServer::Fingerprint()); +} diff --git a/src/m_email/test/EMailSecretTest.cpp b/src/m_email/test/EMailSecretTest.cpp index 0523960..eb1455f 100644 --- a/src/m_email/test/EMailSecretTest.cpp +++ b/src/m_email/test/EMailSecretTest.cpp @@ -39,13 +39,12 @@ // this class exists is recorded rather than asserted. The rest hold // EMailSecret to the behaviour that replaces it. +#include <GFSDKBasic.h> #include <gtest/gtest.h> #include <QString> #include <cstring> -#include <GFSDKBasic.h> - #include "EMailSecret.h" namespace { @@ -163,4 +162,47 @@ TEST(EMailSecretTest, ASecretIsNotCopyable) { SUCCEED(); } +// --- reaching the SDK without a QString on the way -------------------------- + +TEST(EMailSecretTest, ASecureCStringCarriesTheSecretVerbatim) { + // The route a stored password takes now. It used to go out as + // QSecStrDup(QString), which builds an unwiped QByteArray temporary on the + // way -- one more copy of the password than there needs to be, and one + // nothing can erase. + auto secret = EMailSecret::CopyFrom(QStringLiteral("correct-horse")); + + auto* buffer = secret->ToSecureCString(); + ASSERT_NE(buffer, nullptr); + + EXPECT_EQ(std::strlen(buffer), secret->Size()); + EXPECT_STREQ(buffer, "correct-horse"); + + // NUL-terminated one past the secret, so a C API can read it as a string. + EXPECT_EQ(buffer[secret->Size()], '\0'); + + GFSecFreeMemory(buffer); +} + +TEST(EMailSecretTest, AnEmptySecretStillProducesAUsableString) { + auto secret = EMailSecret::CopyFrom(QString()); + + auto* buffer = secret->ToSecureCString(); + ASSERT_NE(buffer, nullptr); + EXPECT_STREQ(buffer, ""); + + GFSecFreeMemory(buffer); +} + +TEST(EMailSecretTest, NonAsciiSurvivesTheRoundTrip) { + // A password is whatever the user typed, and UTF-8 is not optional. + const QString typed = QString::fromUtf8("pa\xC3\x9Fwort-\xE9\x94\xAE"); + auto secret = EMailSecret::CopyFrom(typed); + + auto* buffer = secret->ToSecureCString(); + ASSERT_NE(buffer, nullptr); + EXPECT_EQ(QString::fromUtf8(buffer), typed); + + GFSecFreeMemory(buffer); +} + } // namespace |
