aboutsummaryrefslogtreecommitdiffstats
path: root/src/m_im/test/ImTokenTest.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/m_im/test/ImTokenTest.cpp')
-rw-r--r--src/m_im/test/ImTokenTest.cpp332
1 files changed, 332 insertions, 0 deletions
diff --git a/src/m_im/test/ImTokenTest.cpp b/src/m_im/test/ImTokenTest.cpp
new file mode 100644
index 0000000..1cb8f72
--- /dev/null
+++ b/src/m_im/test/ImTokenTest.cpp
@@ -0,0 +1,332 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include <gtest/gtest.h>
+
+#include <QElapsedTimer>
+#include <QFile>
+#include <QMap>
+#include <QRegularExpression>
+#include <QSet>
+
+#include "ImToken.h"
+
+/**
+ * @file ImTokenTest.cpp
+ * @brief The token format, moved out of the core unchanged. These are the
+ * core's InstantMessageOperator tests, with the phrase passed rather
+ * than stored, plus tokens the core itself wrote, which pin that the
+ * move changed not one byte of the wire.
+ */
+
+namespace {
+
+constexpr auto kPhrase = "correct horse battery staple";
+
+// A blob shaped like an encrypted OpenPGP message (old-format PKESK, tag 1).
+auto PgpLikeBlob(int len = 200, uint8_t first = 0x84) -> QByteArray {
+ QByteArray b;
+ b.append(static_cast<char>(first));
+ for (int i = 1; i < len; ++i) {
+ b.append(static_cast<char>(((i * 37) + 11) & 0xFF));
+ }
+ return b;
+}
+
+/// Restores the per-message KDF cost around a test that needs the shipped one.
+class ScopedKdfCost {
+ public:
+ explicit ScopedKdfCost(ImToken::KdfCost cost)
+ : previous_(ImToken::CurrentKdfCost()) {
+ ImToken::SetKdfCostForTesting(cost);
+ }
+ ~ScopedKdfCost() { ImToken::SetKdfCostForTesting(previous_); }
+
+ private:
+ ImToken::KdfCost previous_;
+};
+
+/// The suite runs a cheap per-message KDF, as the core's did; the tests that
+/// reason about the shipped cost put it back for their duration.
+class CheapKdf : public ::testing::Environment {
+ public:
+ void SetUp() override {
+ ImToken::SetKdfCostForTesting({1, 8ULL * 1024 * 1024});
+ }
+};
+
+const auto* const kCheapKdf =
+ ::testing::AddGlobalTestEnvironment(new CheapKdf());
+
+auto Encode(const QByteArray& blob, const QString& phrase = {}) -> QString {
+ return ImToken::Encode(blob, phrase);
+}
+
+auto Decode(const QString& token, const QString& phrase = {})
+ -> ImToken::DecodeResult {
+ return ImToken::Decode(token, phrase);
+}
+
+auto Fixtures() -> QMap<QString, QString> {
+ QMap<QString, QString> out;
+ QFile f(QStringLiteral(GF_IM_FIXTURES));
+ if (!f.open(QIODevice::ReadOnly)) return out;
+ for (const auto& line : QString::fromUtf8(f.readAll()).split('\n')) {
+ const auto at = line.indexOf('=');
+ if (at > 0) out.insert(line.left(at), line.mid(at + 1));
+ }
+ return out;
+}
+
+} // namespace
+
+// --- compatibility with the core implementation ---------------------------
+
+// Tokens the core wrote at the shipped cost decode to the same bytes here:
+// the wire did not move with the code.
+TEST(ImTokenTest, TokensTheCoreWroteStillDecode) {
+ ScopedKdfCost cost(ImToken::DefaultKdfCost());
+ const auto f = Fixtures();
+ ASSERT_FALSE(f.isEmpty()) << "missing " << GF_IM_FIXTURES;
+ const auto payload = QByteArray::fromHex(f["payload_hex"].toLatin1());
+ ASSERT_EQ(payload, PgpLikeBlob());
+
+ const auto by_default = Decode(f["default_token"]);
+ EXPECT_TRUE(by_default.ok);
+ EXPECT_EQ(by_default.pgp_message, payload);
+
+ const auto by_phrase = Decode(f["phrase_token"], f["phrase"]);
+ EXPECT_TRUE(by_phrase.ok);
+ EXPECT_EQ(by_phrase.pgp_message, payload);
+
+ // And each only under its own book.
+ EXPECT_FALSE(Decode(f["phrase_token"]).ok);
+ EXPECT_FALSE(Decode(f["default_token"], f["phrase"]).ok);
+}
+
+TEST(ImTokenTest, BookFingerprintsMatchTheCores) {
+ const auto f = Fixtures();
+ ASSERT_FALSE(f.isEmpty());
+ EXPECT_EQ(ImToken::BookFingerprintOf(f["phrase"]), f["phrase_fingerprint"]);
+ EXPECT_EQ(ImToken::BookFingerprintOf({}), f["default_fingerprint"]);
+}
+
+// A token this module writes is one the core's format reads: same shipped
+// cost, same books, round trip.
+TEST(ImTokenTest, ShippedCostRoundTrip) {
+ ScopedKdfCost cost(ImToken::DefaultKdfCost());
+ const auto token = Encode(PgpLikeBlob(), kPhrase);
+ ASSERT_FALSE(token.isEmpty());
+ const auto r = Decode(token, kPhrase);
+ EXPECT_TRUE(r.ok);
+ EXPECT_EQ(r.pgp_message, PgpLikeBlob());
+}
+
+// --- the core's InstantMessageOperator tests ------------------------------
+
+// A token wraps and recovers the OpenPGP message verbatim.
+TEST(ImTokenTest, NormalRoundTrip) {
+ const QByteArray blob = PgpLikeBlob();
+
+ const auto token = Encode(blob);
+ ASSERT_FALSE(token.isEmpty());
+ // Single Base58 word (alphanumeric minus 0 O I l), no markdown/link chars.
+ EXPECT_TRUE(QRegularExpression(QRegularExpression::anchoredPattern(
+ QStringLiteral("[1-9A-HJ-NP-Za-km-z]+")))
+ .match(token)
+ .hasMatch());
+
+ const auto r = Decode(token);
+ EXPECT_TRUE(r.ok);
+ EXPECT_EQ(r.pgp_message, blob);
+}
+
+TEST(ImTokenTest, EncodeEmptyIsEmpty) {
+ EXPECT_TRUE(Encode(QByteArray()).isEmpty());
+}
+
+TEST(ImTokenTest, DecodeNotToken) {
+ EXPECT_FALSE(Decode("hello there, friend!").ok);
+}
+
+TEST(ImTokenTest, DecodeRejectsArmoredMessage) {
+ const QString armored =
+ "-----BEGIN PGP MESSAGE-----\n\nhF4Dabc=\n=abcd\n"
+ "-----END PGP MESSAGE-----";
+ EXPECT_FALSE(Decode(armored).ok);
+}
+
+// A messenger may wrap the token across lines / inject spaces; still decodes.
+TEST(ImTokenTest, DecodeToleratesWhitespace) {
+ const QByteArray blob = PgpLikeBlob();
+ auto token = Encode(blob);
+ token.insert(token.size() / 2, QStringLiteral("\n "));
+
+ const auto r = Decode(token);
+ EXPECT_TRUE(r.ok);
+ EXPECT_EQ(r.pgp_message, blob);
+}
+
+// No stable wire marker: the same message encodes differently every time.
+TEST(ImTokenTest, WhitenedTokenVariesPerMessage) {
+ const QByteArray blob = PgpLikeBlob();
+ const auto a = Encode(blob);
+ const auto b = Encode(blob);
+ ASSERT_FALSE(a.isEmpty());
+ ASSERT_FALSE(b.isEmpty());
+ EXPECT_NE(a, b);
+ EXPECT_EQ(Decode(a).pgp_message, blob);
+ EXPECT_EQ(Decode(b).pgp_message, blob);
+}
+
+// Random padding hides the true length.
+TEST(ImTokenTest, LengthIsPadded) {
+ const auto token = Encode(QByteArray(1, '\x84'));
+ ASSERT_FALSE(token.isEmpty());
+ EXPECT_GT(token.size(), 60);
+}
+
+// Padding is at least 30% of the frame.
+TEST(ImTokenTest, PaddingIsAtLeastThirtyPercent) {
+ for (const int payload : {200, 512, 1000}) {
+ const auto token = Encode(PgpLikeBlob(payload));
+ ASSERT_FALSE(token.isEmpty());
+ const auto wire_bytes = static_cast<double>(token.size()) * 5.858 / 8.0;
+ EXPECT_GT(wire_bytes, payload * 1.3)
+ << "payload " << payload << " token " << token.size();
+ }
+}
+
+// Token lengths are quantized onto a ladder.
+TEST(ImTokenTest, LengthsQuantizeOntoALadder) {
+ QSet<qsizetype> lengths;
+ for (int payload = 200; payload < 260; ++payload) {
+ const auto token = Encode(PgpLikeBlob(payload));
+ ASSERT_FALSE(token.isEmpty());
+ lengths.insert(token.size());
+ }
+ EXPECT_LT(lengths.size(), 20) << "distinct lengths: " << lengths.size();
+}
+
+// A token under one phrase neither decodes nor is recognised under another.
+TEST(ImTokenTest, WrongBookDoesNotDecode) {
+ const auto token = Encode(PgpLikeBlob(), kPhrase);
+ ASSERT_FALSE(token.isEmpty());
+ EXPECT_TRUE(Decode(token, kPhrase).ok);
+ EXPECT_FALSE(Decode(token, "a completely different phrase").ok);
+}
+
+TEST(ImTokenTest, FormatVersionIsStable) {
+ EXPECT_EQ(ImToken::FormatVersion(), 3);
+}
+
+TEST(ImTokenTest, ShortInputIsRejected) {
+ EXPECT_FALSE(Decode(QStringLiteral("hi")).ok);
+ EXPECT_FALSE(Decode(QString(37, 'z')).ok);
+}
+
+TEST(ImTokenTest, NonBase58InputIsRejected) {
+ for (const QChar bad : {QChar('0'), QChar('O'), QChar('I'), QChar('l'),
+ QChar('+'), QChar('/'), QChar('='), QChar('.')}) {
+ QString s(200, 'z');
+ s[100] = bad;
+ EXPECT_FALSE(Decode(s).ok)
+ << "accepted a token containing '" << bad.toLatin1() << "'";
+ }
+}
+
+// A big paste of ordinary text must not reach the decoder or the KDF.
+TEST(ImTokenTest, PreFilterIsFastOnLargeInput) {
+ ScopedKdfCost cost(ImToken::DefaultKdfCost());
+
+ const QString prose = QStringLiteral("the quick brown fox. ").repeated(50000);
+ const QString base58_only = QString(qsizetype{1024} * 1024, 'z');
+
+ QElapsedTimer timer;
+ timer.start();
+ EXPECT_FALSE(Decode(prose).ok);
+ EXPECT_FALSE(Decode(base58_only).ok);
+ EXPECT_LT(timer.elapsed(), 500) << "elapsed: " << timer.elapsed() << "ms";
+}
+
+TEST(ImTokenTest, EncodeRejectsOversizedPayload) {
+ EXPECT_TRUE(Encode(PgpLikeBlob(ImToken::MaxPayloadBytes() + 1)).isEmpty());
+}
+
+TEST(ImTokenTest, MaximumPayloadAlwaysRoundTrips) {
+ const QByteArray blob = PgpLikeBlob(ImToken::MaxPayloadBytes());
+ for (int i = 0; i < 10; ++i) {
+ const auto token = Encode(blob);
+ ASSERT_FALSE(token.isEmpty()) << "iteration " << i;
+ const auto r = Decode(token);
+ EXPECT_TRUE(r.ok) << "iteration " << i << " token " << token.size();
+ EXPECT_EQ(r.pgp_message, blob) << "iteration " << i;
+ }
+}
+
+TEST(ImTokenTest, MaxPayloadBytesIsPinned) {
+ EXPECT_EQ(ImToken::MaxPayloadBytes(), 7000);
+}
+
+// The fingerprint identifies the book: stable for one phrase, different
+// across phrases, and short enough to compare by eye.
+TEST(ImTokenTest, BookFingerprintIdentifiesBook) {
+ const auto fpr = ImToken::BookFingerprintOf(kPhrase);
+ EXPECT_TRUE(QRegularExpression(QStringLiteral("^[0-9A-F]{4}-[0-9A-F]{4}$"))
+ .match(fpr)
+ .hasMatch());
+ EXPECT_EQ(ImToken::BookFingerprintOf(kPhrase), fpr);
+
+ const auto other = ImToken::BookFingerprintOf("a completely different one");
+ EXPECT_NE(other, fpr);
+ const auto def = ImToken::BookFingerprintOf({});
+ EXPECT_NE(def, fpr);
+ EXPECT_NE(def, other);
+
+ // Whitespace around a phrase is not part of it.
+ EXPECT_EQ(ImToken::BookFingerprintOf(QStringLiteral(" ") + kPhrase + " "),
+ fpr);
+}
+
+// Every copy of GpgFrontend builds the same default book.
+TEST(ImTokenTest, DefaultBookIsPinned) {
+ EXPECT_EQ(ImToken::BookFingerprintOf({}), QStringLiteral("9138-57AC"));
+}
+
+TEST(ImTokenTest, GeneratePhraseIsLongAndUnique) {
+ const auto a = ImToken::GeneratePhrase();
+ const auto b = ImToken::GeneratePhrase();
+ EXPECT_EQ(a.size(), 256);
+ EXPECT_EQ(b.size(), 256);
+ EXPECT_NE(a, b);
+ EXPECT_TRUE(QRegularExpression(QRegularExpression::anchoredPattern(
+ QStringLiteral("[1-9A-HJ-NP-Za-km-z]+")))
+ .match(a)
+ .hasMatch());
+ EXPECT_NE(ImToken::BookFingerprintOf(a), ImToken::BookFingerprintOf({}));
+}