aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/m_email/test/CMakeLists.txt3
-rw-r--r--src/m_im/CMakeLists.txt40
-rw-r--r--src/m_im/ImCodec.cpp159
-rw-r--r--src/m_im/ImCodec.h80
-rw-r--r--src/m_im/ImModule.cpp273
-rw-r--r--src/m_im/ImSettingsPage.cpp300
-rw-r--r--src/m_im/ImSettingsPage.h81
-rw-r--r--src/m_im/ImToken.cpp796
-rw-r--r--src/m_im/ImToken.h143
-rw-r--r--src/m_im/module.json23
-rw-r--r--src/m_im/test/CMakeLists.txt58
-rw-r--r--src/m_im/test/ImCodecTest.cpp132
-rw-r--r--src/m_im/test/ImTokenTest.cpp332
-rw-r--r--src/m_im/test/im_core_fixtures.txt6
-rw-r--r--src/m_im/ts/ModuleIM.de_DE.ts6
-rw-r--r--src/m_im/ts/ModuleIM.en_US.ts6
-rw-r--r--src/m_im/ts/ModuleIM.es_ES.ts6
-rw-r--r--src/m_im/ts/ModuleIM.fr_FR.ts6
-rw-r--r--src/m_im/ts/ModuleIM.it_IT.ts6
-rw-r--r--src/m_im/ts/ModuleIM.pl_PL.ts6
-rw-r--r--src/m_im/ts/ModuleIM.ru_RU.ts6
-rw-r--r--src/m_im/ts/ModuleIM.zh_CN.ts6
-rw-r--r--src/m_im/ts/ModuleIM.zh_TW.ts6
-rw-r--r--src/m_im/ui/main.lua31
24 files changed, 2511 insertions, 0 deletions
diff --git a/src/m_email/test/CMakeLists.txt b/src/m_email/test/CMakeLists.txt
index d38182b..df01505 100644
--- a/src/m_email/test/CMakeLists.txt
+++ b/src/m_email/test/CMakeLists.txt
@@ -82,6 +82,7 @@ target_compile_definitions(gf_mod_email_test PRIVATE
set_target_properties(gf_mod_email_test PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${GPGFRONTEND_MODULE_TEST_OUTPUT_DIR}")
+gf_register_module_test(gf_mod_email_test)
# --- transport tests that need a server -------------------------------------
#
@@ -130,6 +131,7 @@ target_compile_definitions(gf_mod_email_net_test PRIVATE
set_target_properties(gf_mod_email_net_test PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${GPGFRONTEND_MODULE_TEST_OUTPUT_DIR}")
+gf_register_module_test(gf_mod_email_net_test)
# --- crypto boundary tests --------------------------------------------------
#
@@ -174,6 +176,7 @@ target_compile_definitions(gf_mod_email_crypto_test PRIVATE
set_target_properties(gf_mod_email_crypto_test PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${GPGFRONTEND_MODULE_TEST_OUTPUT_DIR}")
+gf_register_module_test(gf_mod_email_crypto_test)
# --- fuzzing (opt-in, clang only) -------------------------------------------
#
diff --git a/src/m_im/CMakeLists.txt b/src/m_im/CMakeLists.txt
new file mode 100644
index 0000000..99e27b2
--- /dev/null
+++ b/src/m_im/CMakeLists.txt
@@ -0,0 +1,40 @@
+# 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
+
+# Everything about this module -- id, version, name, description, author,
+# capabilities and translation context -- lives in module.json beside this file.
+gf_add_module(
+ NAME im
+ QT Core Widgets
+ SOURCES ImModule.cpp
+ ImSettingsPage.cpp
+ ImCodec.cpp
+ ImToken.cpp
+ LINK sodium
+ LUA_SCRIPTS ui/main.lua)
+
+if(GPGFRONTEND_MODULES_BUILD_TESTS)
+ add_subdirectory(test)
+endif()
diff --git a/src/m_im/ImCodec.cpp b/src/m_im/ImCodec.cpp
new file mode 100644
index 0000000..54efc60
--- /dev/null
+++ b/src/m_im/ImCodec.cpp
@@ -0,0 +1,159 @@
+/**
+ * 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 "ImCodec.h"
+
+#include <QCoreApplication>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+
+#include "ImToken.h"
+
+namespace ImCodec {
+
+namespace {
+
+constexpr char kPhraseBlobVersion = '\x01';
+
+auto Tr(const char* text) -> QString {
+ return QCoreApplication::translate("GTrC", text);
+}
+
+/**
+ * @brief The "Instant Messaging" card: how the token is framed and, above
+ * all, whether a shared book phrase is in use -- the default book only hides
+ * the format from naive scanners, so it is reported as a warning.
+ */
+auto BookCard(const QString& phrase, qsizetype payload, qsizetype token_len)
+ -> QJsonObject {
+ const bool configured = !phrase.isEmpty();
+ QJsonArray fields;
+ const auto add = [&fields](const QString& k, const QString& v) {
+ fields.append(QJsonArray{k, v});
+ };
+
+ add(Tr("Encoding"), QStringLiteral("Base58 (Bitcoin/IPFS)"));
+ add(Tr("Container Format"), QString("v%1").arg(ImToken::FormatVersion()));
+ add(Tr("Message Book"), configured ? Tr("Shared phrase (Argon2id)")
+ : Tr("Default, no shared phrase set"));
+ // Lets both sides confirm they are on the same book. Only meaningful with a
+ // phrase: everyone shares the default book, so its digest says nothing.
+ if (configured) {
+ add(Tr("Book Fingerprint"), ImToken::BookFingerprintOf(phrase));
+ } else {
+ add(Tr("Set a Phrase"), Tr("Settings > Instant Messaging"));
+ }
+ if (payload > 0) {
+ add(Tr("OpenPGP Payload"), Tr("%1 bytes").arg(payload));
+ }
+ if (token_len > 0) {
+ add(Tr("Token Length"), Tr("%1 characters").arg(token_len));
+ }
+ if (payload > 0 && token_len > 0) {
+ // Random padding plus Base58 expansion. The padding part is deliberately
+ // random, so this ratio does not pin down the true payload length.
+ const auto ratio =
+ static_cast<double>(token_len) / static_cast<double>(payload);
+ add(Tr("Wire Overhead"),
+ QString("+%1%").arg((ratio * 100.0) - 100.0, 0, 'f', 0));
+ }
+
+ return QJsonObject{
+ {QStringLiteral("title"), Tr("Instant Messaging")},
+ {QStringLiteral("status"),
+ configured ? QStringLiteral("ok") : QStringLiteral("warn")},
+ {QStringLiteral("fields"), fields}};
+}
+
+auto CardsJson(const QJsonObject& card) -> QString {
+ const QJsonObject payload{
+ {QStringLiteral("description"),
+ Tr("An Instant Messaging section followed by the OpenPGP result.")},
+ {QStringLiteral("cards"), QJsonArray{card}}};
+ return QString::fromUtf8(
+ QJsonDocument(payload).toJson(QJsonDocument::Compact));
+}
+
+} // namespace
+
+auto DecodeInput(const QByteArray& input, const QString& phrase) -> Answer {
+ // The token format rejects ordinary text and armored OpenPGP on its shape
+ // alone, before the memory-hard derivation.
+ const auto result = ImToken::Decode(QString::fromUtf8(input), phrase);
+ if (!result.ok) return {};
+
+ Answer a;
+ a.outcome = Outcome::kHandled;
+ a.output = result.pgp_message;
+ a.cards = CardsJson(BookCard(phrase, 0, 0));
+ return a;
+}
+
+auto EncodeOutput(const QByteArray& pgp, const QString& phrase) -> Answer {
+ Answer a;
+ a.outcome = Outcome::kFailed;
+
+ // The payload limit exists so a token we emit is one we can read back.
+ // Checked here so the user is told what went wrong and what to do about it.
+ const auto limit = ImToken::MaxPayloadBytes();
+ if (pgp.size() > limit) {
+ a.error = Tr("This message is too long to send as an instant message.\n\n"
+ "The encrypted message is %1 bytes, and the "
+ "instant-messaging format carries at most %2. Shorten the "
+ "text, or send it as a normal OpenPGP message instead.")
+ .arg(pgp.size())
+ .arg(limit);
+ return a;
+ }
+
+ const auto token = ImToken::Encode(pgp, phrase);
+ if (token.isEmpty()) {
+ a.error = Tr("Failed to prepare the instant message: the encrypted "
+ "message could not be converted into a token.");
+ return a;
+ }
+
+ a.outcome = Outcome::kHandled;
+ a.output = token.toUtf8();
+ a.cards = CardsJson(BookCard(phrase, pgp.size(), token.size()));
+ return a;
+}
+
+auto EncodePhraseBlob(const QString& phrase) -> QByteArray {
+ QByteArray blob(1, kPhraseBlobVersion);
+ blob.append(phrase.trimmed().toUtf8());
+ return blob;
+}
+
+auto DecodePhraseBlob(const QByteArray& blob) -> QString {
+ if (blob.isEmpty() || blob.at(0) != kPhraseBlobVersion) return {};
+ return QString::fromUtf8(blob.mid(1)).trimmed();
+}
+
+} // namespace ImCodec
diff --git a/src/m_im/ImCodec.h b/src/m_im/ImCodec.h
new file mode 100644
index 0000000..5dd6649
--- /dev/null
+++ b/src/m_im/ImCodec.h
@@ -0,0 +1,80 @@
+/**
+ * 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
+ *
+ */
+
+#pragma once
+
+#include <QByteArray>
+#include <QString>
+
+/**
+ * @file ImCodec.h
+ * @brief What the module answers the Host's codec calls with, without the SDK
+ * in the way, so it can be tested on its own.
+ */
+namespace ImCodec {
+
+/// Mirrors gf::cmd::host::CodecOutcome.
+enum class Outcome { kNotHandled = 0, kHandled = 1, kFailed = 2 };
+
+struct Answer {
+ Outcome outcome = Outcome::kNotHandled;
+ QByteArray output;
+ QString error;
+ QString cards; ///< info-board card JSON, the Host's result_cards format
+};
+
+/**
+ * @brief The pre-decrypt decoder: claims @p input only when it is one of our
+ * tokens under the book @p phrase names.
+ *
+ * Anything else -- armored OpenPGP, ordinary text, a token of another book --
+ * is not ours, and the Host decrypts it as it is.
+ */
+auto DecodeInput(const QByteArray& input, const QString& phrase) -> Answer;
+
+/**
+ * @brief The encoder: wraps the binary OpenPGP message @p pgp as a token.
+ *
+ * Fails, with a message for the user, when the message is too long for the
+ * format or the token cannot be made.
+ */
+auto EncodeOutput(const QByteArray& pgp, const QString& phrase) -> Answer;
+
+/**
+ * @brief The phrase as the secure cache holds it.
+ *
+ * The cache flushes only non-empty values, so "no phrase" cannot be an empty
+ * value or clearing it would not survive a restart: a one-character version
+ * prefix comes first, and a cleared phrase is the prefix alone. The Host's
+ * core stored it the same way, which is what lets the migrated value be read
+ * unchanged.
+ */
+auto EncodePhraseBlob(const QString& phrase) -> QByteArray;
+auto DecodePhraseBlob(const QByteArray& blob) -> QString;
+
+} // namespace ImCodec
diff --git a/src/m_im/ImModule.cpp b/src/m_im/ImModule.cpp
new file mode 100644
index 0000000..008ec13
--- /dev/null
+++ b/src/m_im/ImModule.cpp
@@ -0,0 +1,273 @@
+/**
+ * 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 <GFModule.h>
+#include <GFSDK.hpp>
+#include <GFSDKHostCommands.hpp>
+
+#include <QtWidgets>
+
+#include "GFModuleIdentity.h"
+#include "ImCodec.h"
+#include "ImSettingsPage.h"
+
+/**
+ * @file ImModule.cpp
+ * @brief Instant messaging: OpenPGP messages as single Base58 tokens.
+ *
+ * The module owns the token format, the book phrase and the page to set it.
+ * The Host keeps everything else: key selection, the OpenPGP operations, the
+ * editor and the result display. The two meet at two codecs:
+ *
+ * - `.decode` is offered every text the user decrypts, before the Host's own
+ * decrypt. It claims only its own tokens, so a normal Decrypt of a token
+ * just works, and anything else is decrypted as before.
+ * - `.encode` turns the Host's binary encrypt output into a token, when the
+ * user asks for IM Encrypt.
+ *
+ * Both run on the module runner, never the GUI thread: no dialog, no network.
+ */
+
+namespace {
+
+namespace host = gf::cmd::host;
+using Severity = host::AppMessage::Severity;
+
+/// Whether to warn before encrypting with the default book. Its own setting,
+/// read in the group the Host used to keep it in.
+constexpr auto kWarnDefaultBookKey = "warn_default_book";
+
+auto Tr(const char* text) -> QString {
+ return QCoreApplication::translate("GTrC", text);
+}
+
+auto ToResult(const ImCodec::Answer& a) -> host::CodecResult {
+ host::CodecResult r;
+ r.outcome = static_cast<host::CodecOutcome>(a.outcome);
+ if (a.outcome == ImCodec::Outcome::kHandled) {
+ r.output = gf::cmd::MakeBlob(a.output.constData(),
+ static_cast<size_t>(a.output.size()));
+ }
+ r.error = a.error;
+ r.cards = a.cards;
+ return r;
+}
+
+auto BytesOf(const gf::cmd::Blob& blob) -> QByteArray {
+ return {blob.Data(), static_cast<qsizetype>(blob.Size())};
+}
+
+struct Decode {
+ static constexpr gf::cmd::Meta kMeta{
+ GF_MODULE_ID ".decode", GC_TR("Instant Message Token"),
+ GC_TR("Recognise and unwrap instant messaging tokens before decrypting"),
+ "", 0, gf::cmd::kInputDecoder};
+ using Args = host::CodecArgs;
+ using Result = host::CodecResult;
+};
+
+struct Encode {
+ static constexpr gf::cmd::Meta kMeta{
+ GF_MODULE_ID ".encode", GC_TR("Instant Message Token"),
+ GC_TR("Wrap an encrypted message as an instant messaging token"), "", 0,
+ gf::cmd::kOutputEncoder};
+ using Args = host::CodecArgs;
+ using Result = host::CodecResult;
+};
+
+auto DoDecode(const gf::cmd::CommandContext&, const host::CodecArgs& a)
+ -> gf::cmd::Outcome<host::CodecResult> {
+ return gf::cmd::Outcome<host::CodecResult>::Success(
+ ToResult(ImCodec::DecodeInput(BytesOf(a.input), ImBookPhrase())));
+}
+
+auto DoEncode(const gf::cmd::CommandContext&, const host::CodecArgs& a)
+ -> gf::cmd::Outcome<host::CodecResult> {
+ return gf::cmd::Outcome<host::CodecResult>::Success(
+ ToResult(ImCodec::EncodeOutput(BytesOf(a.input), ImBookPhrase())));
+}
+
+/**
+ * @brief Ask before encrypting with the default book, unless told not to.
+ *
+ * @return whether to go on
+ */
+auto ConfirmDefaultBook() -> bool {
+ if (!ImBookPhrase().isEmpty()) return true;
+ auto* ctx = GFModuleSdkContext();
+ if (!gf::sdk::Setting(ctx, GF_SETTING_MODULE, kWarnDefaultBookKey, true)
+ .toBool()) {
+ return true;
+ }
+
+ QMessageBox box;
+ box.setIcon(QMessageBox::Information);
+ box.setWindowTitle(Tr("No Message Book Phrase Set"));
+ box.setText(Tr("You have not set a Message Book phrase."));
+ box.setInformativeText(Tr(
+ "Instant messages are hidden using a shared \"Message Book\". "
+ "Without a phrase, GpgFrontend falls back to the built-in default "
+ "book and that book ships in every copy of the program. It hides "
+ "the format from a simple scanner, but anyone who knows GpgFrontend "
+ "can still recognise your message for what it is.\n\n"
+ "Your message is OpenPGP-encrypted either way; what is at stake here "
+ "is only whether it is recognisable as an encrypted message at all.\n\n"
+ "To get that, set a phrase and share it privately with the person you "
+ "are writing to. You must both use exactly the same one."));
+
+ auto* settings_button =
+ box.addButton(Tr("Open Settings..."), QMessageBox::ActionRole);
+ auto* continue_button =
+ box.addButton(Tr("Continue with Default"), QMessageBox::AcceptRole);
+ auto* never_button =
+ box.addButton(Tr("Continue, Don't Ask Again"), QMessageBox::AcceptRole);
+ box.addButton(QMessageBox::Cancel);
+ box.setDefaultButton(settings_button);
+ box.exec();
+
+ auto* clicked = box.clickedButton();
+ if (clicked == settings_button) {
+ // The settings dialog is not modal, so it cannot be waited on from here:
+ // open it and let the user run the operation again.
+ Commands().Invoke<host::AppOpenSettings>({});
+ return false;
+ }
+ if (clicked == never_button) {
+ gf::sdk::SetSetting(ctx, GF_SETTING_MODULE, kWarnDefaultBookKey, false);
+ }
+ return clicked == continue_button || clicked == never_button;
+}
+
+/// Both IM encrypt commands act on a document, named by the Host's context.
+struct TargetArgs {
+ gf::cmd::DocumentRef target;
+ static constexpr auto Fields() {
+ return std::make_tuple(gf::cmd::F("target", &TargetArgs::target));
+ }
+};
+
+struct EncryptIm {
+ static constexpr gf::cmd::Meta kMeta{
+ GF_MODULE_ID ".encrypt", GC_TR("IM Encrypt"),
+ GC_TR("Encrypt the message as a single-line token for chat apps"), "",
+ GF_HOST_CAP_EDITOR, gf::cmd::kNeedsGuiThread};
+ using Args = TargetArgs;
+ using Result = gf::cmd::Unit;
+ static auto State(const gf::cmd::CommandContext&) -> uint32_t;
+};
+
+struct EncryptSignIm {
+ static constexpr gf::cmd::Meta kMeta{
+ GF_MODULE_ID ".encrypt_sign", GC_TR("IM Encrypt && Sign"),
+ GC_TR("Encrypt and sign the message as a single-line token for chat "
+ "apps"),
+ "", GF_HOST_CAP_EDITOR, gf::cmd::kNeedsGuiThread};
+ using Args = TargetArgs;
+ using Result = gf::cmd::Unit;
+ static auto State(const gf::cmd::CommandContext&) -> uint32_t;
+};
+
+/**
+ * @brief Enabled exactly when the Host's own operation is: IM Encrypt whenever
+ * the Host could encrypt at all (to the checked keys, or with a
+ * passphrase when none are checked), IM Encrypt & Sign only when its
+ * Encrypt & Sign could -- which needs checked keys, since signing has
+ * no passphrase fallback.
+ *
+ * Asked each time the menu is shown, never while the module loads: the Host's
+ * commands are registered with its window, after modules start.
+ */
+auto HostState(const char* host_command) -> uint32_t {
+ uint32_t bits = 0;
+ if (GFCommandQueryState(GFModuleSdkContext(), host_command, &bits) != 0) {
+ return GF_CMD_STATE_VISIBLE;
+ }
+ return (bits & GF_CMD_STATE_ENABLED) != 0
+ ? GF_CMD_STATE_ENABLED | GF_CMD_STATE_VISIBLE
+ : GF_CMD_STATE_VISIBLE;
+}
+
+template <bool kSign>
+auto DoEncryptIm(const gf::cmd::CommandContext&, const TargetArgs& a)
+ -> gf::cmd::Outcome<gf::cmd::Unit> {
+ // Ask before the Host makes the user pick recipients, not after.
+ if (!ConfirmDefaultBook()) {
+ return gf::cmd::Outcome<gf::cmd::Unit>::Success({});
+ }
+ Commands().Invoke<host::CryptoEncryptEncoded>(
+ {a.target, QStringLiteral(GF_MODULE_ID ".encode"), kSign});
+ return gf::cmd::Outcome<gf::cmd::Unit>::Success({});
+}
+
+auto EncryptIm::State(const gf::cmd::CommandContext&) -> uint32_t {
+ return HostState(host::CryptoEncryptEncoded::kMeta.id);
+}
+
+auto EncryptSignIm::State(const gf::cmd::CommandContext&) -> uint32_t {
+ return HostState(host::CryptoEncryptSign::kMeta.id);
+}
+
+auto OnActivate() -> GFResult {
+ // Registered untranslated: the Host translates presentation when shown.
+ const bool settings = gf::ui::RegisterNativeWidget<ImSettingsPage>(
+ "settings",
+ {GC_TR("Instant Messaging"),
+ GC_TR("instant messaging,message book,phrase,fingerprint,token"), "",
+ "", "", 0, 0},
+ [](const QCborMap& /*args*/) { return new ImSettingsPage(); });
+ return settings ? GFResult::Ok()
+ : GFResult::Fail("the settings page was not registered");
+}
+
+const std::array<gf::cmd::Binding, 4> kCommands = {
+ gf::cmd::Bind<Decode, &DoDecode>(),
+ gf::cmd::Bind<Encode, &DoEncode>(),
+ gf::cmd::Bind<EncryptIm, &DoEncryptIm<false>>(),
+ gf::cmd::Bind<EncryptSignIm, &DoEncryptIm<true>>(),
+};
+
+const GFModuleHooks kHooks = {
+ sizeof(GFModuleHooks),
+ GF_MODULE_ID,
+ GF_MODULE_VERSION,
+ GF_MODULE_TRANSLATION_CONTEXT,
+ &OnActivate,
+ nullptr, // nothing runs between calls; the Host withdraws the rest
+ nullptr,
+ nullptr, // no events
+ 0,
+ kCommands.data(),
+ kCommands.size(),
+};
+
+} // namespace
+
+extern "C" GF_MODULE_EXPORT auto GFModuleGetApi(uint32_t abi)
+ -> const GFModuleApi * {
+ return GFModuleRuntimeGetApi(abi, &kHooks);
+}
diff --git a/src/m_im/ImSettingsPage.cpp b/src/m_im/ImSettingsPage.cpp
new file mode 100644
index 0000000..a2f1d84
--- /dev/null
+++ b/src/m_im/ImSettingsPage.cpp
@@ -0,0 +1,300 @@
+/**
+ * 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 "ImSettingsPage.h"
+
+#include <GFSDK.hpp>
+
+#include <QtWidgets>
+
+#include "ImCodec.h"
+#include "ImToken.h"
+
+namespace {
+
+/// Typing pause before the (memory-hard) fingerprint derivation is worth doing.
+constexpr int kFingerprintDebounceMs = 500;
+
+/// A phrase pasted from a chat app may arrive wrapped across lines; it is one
+/// unbroken word, so every kind of whitespace simply comes back out.
+auto CleanPhrase(const QString& text) -> QString {
+ QString out;
+ out.reserve(text.size());
+ for (const auto c : text) {
+ if (!c.isSpace()) out.append(c);
+ }
+ return out;
+}
+
+/// Starts from the widget's own font on purpose, so the token and the
+/// fingerprint keep the size of the page around them and only change family.
+auto MonospaceFont(const QWidget* widget) -> QFont {
+ auto font = widget->font();
+ font.setFamilies(
+ QFontDatabase::systemFont(QFontDatabase::FixedFont).families());
+ return font;
+}
+
+auto Tr(const char* text) -> QString {
+ return QCoreApplication::translate("GTrC", text);
+}
+
+/// Where the phrase lives: the module's own secret store.
+constexpr auto kPhraseKey = "book_phrase";
+
+} // namespace
+
+ImSettingsPage::ImSettingsPage(QWidget* parent) : QWidget(parent) {
+ // ---- the phrase itself -------------------------------------------------
+ // The Message Book phrase whitens every instant-messaging token: its bytes
+ // derive the shuffle + XOR that hide the message, so without the same phrase
+ // a token is indistinguishable from random text.
+ auto* book_box = new QGroupBox(Tr("Message Book Phrase"), this);
+ auto* book_layout = new QVBoxLayout(book_box);
+
+ auto* intro = new QLabel(
+ Tr("A long secret you share with one friend. It makes your messages look "
+ "like random text, so nobody can tell they are PGP at all. You and "
+ "your friend must use exactly the same phrase."),
+ book_box);
+ intro->setWordWrap(true);
+ book_layout->addWidget(intro);
+
+ // A generated phrase is 256 characters, far too long for a one-line field,
+ // so it gets a wrapping box the user can see and select in full.
+ phrase_edit_ = new QPlainTextEdit(book_box);
+ phrase_edit_->setFont(MonospaceFont(this));
+ phrase_edit_->setLineWrapMode(QPlainTextEdit::WidgetWidth);
+ phrase_edit_->setPlaceholderText(
+ Tr("No phrase set. Messages use the built-in default book."));
+ phrase_edit_->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ phrase_edit_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ phrase_edit_->setFixedHeight(phrase_edit_->fontMetrics().lineSpacing() * 5 +
+ 12);
+ connect(phrase_edit_, &QPlainTextEdit::textChanged, this, [this]() {
+ if (syncing_ || !revealed_) return;
+ set_phrase(CleanPhrase(phrase_edit_->toPlainText()));
+ });
+ book_layout->addWidget(phrase_edit_);
+
+ phrase_state_label_ = new QLabel(book_box);
+ phrase_state_label_->setWordWrap(true);
+
+ // One click gives a phrase no one will ever guess; the user's only job is to
+ // get it to their friend privately.
+ auto* generate_button = new QPushButton(Tr("Generate"), book_box);
+ generate_button->setToolTip(
+ Tr("Create a new random phrase. Share it with your friend so you both "
+ "use the same one."));
+ connect(generate_button, &QPushButton::clicked, this, [this]() {
+ const auto phrase = ImToken::GeneratePhrase();
+ if (phrase.isEmpty()) return;
+ set_phrase(phrase);
+ // A fresh phrase has to be seen to be shared.
+ set_revealed(true);
+ });
+
+ reveal_button_ = new QPushButton(Tr("Show"), book_box);
+ reveal_button_->setToolTip(Tr("Show or hide the phrase."));
+ connect(reveal_button_, &QPushButton::clicked, this,
+ [this]() { set_revealed(!revealed_); });
+
+ auto* copy_button = new QPushButton(Tr("Copy"), book_box);
+ copy_button->setToolTip(Tr("Copy the phrase to the clipboard."));
+ connect(copy_button, &QPushButton::clicked, this,
+ [this]() { QApplication::clipboard()->setText(phrase_); });
+
+ auto* paste_button = new QPushButton(Tr("Paste"), book_box);
+ paste_button->setToolTip(
+ Tr("Replace the phrase with the one on the clipboard."));
+ connect(paste_button, &QPushButton::clicked, this, [this]() {
+ const auto text = CleanPhrase(QApplication::clipboard()->text());
+ if (!text.isEmpty()) set_phrase(text);
+ });
+
+ auto* clear_button = new QPushButton(Tr("Clear"), book_box);
+ clear_button->setToolTip(
+ Tr("Remove the phrase and fall back to the default book."));
+ connect(clear_button, &QPushButton::clicked, this,
+ [this]() { set_phrase({}); });
+
+ // The buttons keep their natural width on the right; the state line gets a
+ // row of its own so it never squeezes them into a narrow column.
+ auto* button_row = new QHBoxLayout();
+ button_row->setContentsMargins(0, 0, 0, 0);
+ button_row->addStretch(1);
+ button_row->addWidget(generate_button);
+ button_row->addWidget(reveal_button_);
+ button_row->addWidget(copy_button);
+ button_row->addWidget(paste_button);
+ button_row->addWidget(clear_button);
+ book_layout->addWidget(phrase_state_label_);
+ book_layout->addLayout(button_row);
+
+ // ---- checking both sides agree -----------------------------------------
+ auto* check_box = new QGroupBox(Tr("Book Fingerprint"), this);
+ auto* check_layout = new QVBoxLayout(check_box);
+
+ auto* check_note = new QLabel(
+ Tr("A short code made from your phrase. Read it out with your friend to "
+ "be sure you both have the same one. Unlike the phrase, this code is "
+ "safe to say out loud."),
+ check_box);
+ check_note->setWordWrap(true);
+ check_layout->addWidget(check_note);
+
+ fingerprint_label_ = new QLabel(check_box);
+ auto fpr_font = MonospaceFont(this);
+ fpr_font.setBold(true);
+ fpr_font.setPointSize(fpr_font.pointSize() + 2);
+ fingerprint_label_->setFont(fpr_font);
+ fingerprint_label_->setTextInteractionFlags(Qt::TextSelectableByMouse);
+
+ auto* copy_fpr_button = new QPushButton(Tr("Copy"), check_box);
+ copy_fpr_button->setToolTip(Tr("Copy the fingerprint to the clipboard."));
+ connect(copy_fpr_button, &QPushButton::clicked, this,
+ [this]() { QApplication::clipboard()->setText(fingerprint_); });
+
+ auto* fpr_row = new QHBoxLayout();
+ fpr_row->setContentsMargins(0, 0, 0, 0);
+ fpr_row->addWidget(fingerprint_label_);
+ fpr_row->addStretch(1);
+ fpr_row->addWidget(copy_fpr_button);
+ check_layout->addLayout(fpr_row);
+
+ auto* storage_note = new QLabel(
+ Tr("The phrase is stored in the encrypted cache, never in the settings "
+ "file. Send it to your friend over a private channel."),
+ this);
+ storage_note->setWordWrap(true);
+
+ auto* layout = new QVBoxLayout(this);
+ layout->addWidget(book_box);
+ layout->addWidget(check_box);
+ layout->addWidget(storage_note);
+ layout->addStretch(1);
+ setLayout(layout);
+
+ // Deriving a fingerprint costs one Argon2id (64 MiB), so wait for a pause in
+ // typing rather than doing it per keystroke.
+ fingerprint_timer_ = new QTimer(this);
+ fingerprint_timer_->setSingleShot(true);
+ fingerprint_timer_->setInterval(kFingerprintDebounceMs);
+ connect(fingerprint_timer_, &QTimer::timeout, this,
+ &ImSettingsPage::start_fingerprint_update);
+
+ ImSettingsPage::LoadSettings();
+}
+
+void ImSettingsPage::LoadSettings() {
+ set_phrase(ImBookPhrase());
+ // Nothing to hide when there is no phrase yet.
+ set_revealed(phrase_.isEmpty());
+}
+
+auto ImSettingsPage::ApplySettings() -> bool {
+ if (phrase_ != ImBookPhrase()) ImSetBookPhrase(phrase_);
+ return true;
+}
+
+void ImSettingsPage::set_revealed(bool revealed) {
+ revealed_ = revealed;
+ reveal_button_->setText(revealed ? Tr("Hide") : Tr("Show"));
+
+ const QScopedValueRollback<bool> guard(syncing_, true);
+ // Masked, the box is a placeholder rather than an editor: editing dots
+ // would only destroy the phrase behind them.
+ phrase_edit_->setReadOnly(!revealed);
+ phrase_edit_->setPlainText(revealed || phrase_.isEmpty()
+ ? phrase_
+ : QString(phrase_.size(), QChar(u'•')));
+}
+
+void ImSettingsPage::set_phrase(const QString& phrase) {
+ phrase_ = phrase;
+
+ if (phrase_edit_->toPlainText() != phrase_) {
+ const QScopedValueRollback<bool> guard(syncing_, true);
+ phrase_edit_->setPlainText(phrase_);
+ }
+
+ phrase_state_label_->setText(
+ phrase_.isEmpty() ? Tr("No phrase set. Using the built-in default.")
+ : Tr("Phrase set. %1 characters.").arg(phrase_.size()));
+
+ schedule_fingerprint_update();
+}
+
+void ImSettingsPage::schedule_fingerprint_update() {
+ // Invalidate any derivation already in flight: its result is for an older
+ // phrase and must not overwrite what we are about to compute.
+ ++fingerprint_request_;
+ set_fingerprint({});
+ fingerprint_label_->setText(Tr("Calculating…"));
+ fingerprint_timer_->start();
+}
+
+void ImSettingsPage::start_fingerprint_update() {
+ const auto phrase = phrase_;
+ const auto request = fingerprint_request_;
+ QPointer<ImSettingsPage> self(this);
+
+ // One Argon2id (64 MiB): off the GUI thread, back to it with the answer,
+ // and dropped if the page is gone or the phrase changed meanwhile.
+ QThreadPool::globalInstance()->start([self, phrase, request]() {
+ const auto result = ImToken::BookFingerprintOf(phrase);
+ if (self.isNull()) return;
+ QMetaObject::invokeMethod(
+ self.data(),
+ [self, result, request]() {
+ if (self.isNull() || self->fingerprint_request_ != request) return;
+ self->set_fingerprint(result);
+ },
+ Qt::QueuedConnection);
+ });
+}
+
+void ImSettingsPage::set_fingerprint(const QString& fingerprint) {
+ fingerprint_ = fingerprint;
+ fingerprint_label_->setText(fingerprint.isEmpty() ? QString("...")
+ : fingerprint);
+}
+
+
+auto ImBookPhrase() -> QString {
+ return ImCodec::DecodePhraseBlob(
+ gf::sdk::CacheText(GFModuleSdkContext(), GF_STORE_SECURE_DURABLE,
+ kPhraseKey)
+ .toUtf8());
+}
+
+void ImSetBookPhrase(const QString& phrase) {
+ gf::sdk::SetCacheText(
+ GFModuleSdkContext(), GF_STORE_SECURE_DURABLE, kPhraseKey,
+ QString::fromUtf8(ImCodec::EncodePhraseBlob(phrase)));
+}
diff --git a/src/m_im/ImSettingsPage.h b/src/m_im/ImSettingsPage.h
new file mode 100644
index 0000000..ae0e2c7
--- /dev/null
+++ b/src/m_im/ImSettingsPage.h
@@ -0,0 +1,81 @@
+/**
+ * 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
+ *
+ */
+
+#pragma once
+
+#include <GFModule.h>
+
+#include <QWidget>
+
+class QLabel;
+class QTimer;
+class QPlainTextEdit;
+class QPushButton;
+
+/**
+ * @brief The Instant Messaging page of the application's Settings dialog.
+ *
+ * Holds the shared "Message Book Phrase", the secret that whitens every
+ * instant-messaging token so it cannot be detected as a GpgFrontend/PGP
+ * message. Both sides must set the same phrase; an empty phrase uses a shared
+ * default that only hides the format from naive scanners.
+ *
+ * Edits are staged and written only on ApplySettings(), to the module's
+ * secure cache, never to the settings file.
+ */
+class ImSettingsPage : public QWidget, public gf::ui::SettingsWidget {
+ public:
+ explicit ImSettingsPage(QWidget* parent = nullptr);
+
+ void LoadSettings() override;
+ auto ApplySettings() -> bool override;
+
+ private:
+ void set_revealed(bool revealed);
+ void set_phrase(const QString& phrase);
+ void schedule_fingerprint_update();
+ void start_fingerprint_update();
+ void set_fingerprint(const QString& fingerprint);
+
+ QPlainTextEdit* phrase_edit_{}; ///< shows the phrase, or its mask
+ QPushButton* reveal_button_{}; ///< toggles between the phrase and the mask
+ QLabel* fingerprint_label_{}; ///< our fingerprint, e.g. "3F9A-1C4E"
+ QLabel* phrase_state_label_{}; ///< "no phrase set" / phrase length
+ QTimer* fingerprint_timer_{}; ///< debounce for typing
+ QString phrase_; ///< the edited phrase, source of truth
+ QString fingerprint_; ///< last computed fingerprint, may be empty
+ bool revealed_{false}; ///< whether the phrase is on screen
+ bool syncing_{false}; ///< guards programmatic edits of the view
+ quint64 fingerprint_request_{0}; ///< discards results of stale derivations
+};
+
+/// The configured book phrase, or empty for the default book.
+auto ImBookPhrase() -> QString;
+
+/// Replace the configured book phrase; empty clears it.
+void ImSetBookPhrase(const QString& phrase);
diff --git a/src/m_im/ImToken.cpp b/src/m_im/ImToken.cpp
new file mode 100644
index 0000000..2e03f12
--- /dev/null
+++ b/src/m_im/ImToken.cpp
@@ -0,0 +1,796 @@
+/**
+ * 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 "ImToken.h"
+
+#include <sodium.h>
+
+#include <QCryptographicHash>
+#include <QDebug>
+#include <QHash>
+#include <QMutex>
+#include <QVector>
+#include <algorithm>
+#include <array>
+#include <atomic>
+#include <cstring>
+#include <string_view>
+
+// The token format, moved out of the Host's core unchanged: the wire is a
+// contract between every copy of GpgFrontend, so not one byte of what Encode()
+// writes or Decode() accepts may differ from what the core used to do. The
+// fixtures in test/ were written by the core implementation and pin that.
+
+namespace ImToken {
+
+namespace {
+
+auto EnsureSodiumInit() -> bool {
+ static const bool kReady = sodium_init() >= 0;
+ return kReady;
+}
+
+/// @p n bytes from libsodium's CSPRNG, or empty when it is unavailable.
+auto RandomBytes(int n) -> QByteArray {
+ if (!EnsureSodiumInit() || n < 0) return {};
+ QByteArray out(n, Qt::Uninitialized);
+ randombytes_buf(out.data(), static_cast<size_t>(n));
+ return out;
+}
+
+} // namespace
+
+namespace {
+
+// ---------------------------------------------------------------------------
+// Base58 (Bitcoin/IPFS alphabet). Purely alphanumeric, no 0 O I l, and nothing
+// a messenger turns into markdown/link/soft-wrap, so a token stays one word.
+// ---------------------------------------------------------------------------
+
+constexpr std::string_view kB58Alphabet =
+ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+
+// Reverse of kB58Alphabet: byte value -> digit, or -1 when not in the alphabet.
+// Built at compile time so decoding — and the shape pre-filter in Normalize() —
+// costs one table lookup per character instead of a scan of the alphabet.
+constexpr uint8_t kB58Invalid = 0xFF;
+
+constexpr auto MakeB58Reverse() -> std::array<uint8_t, 256> {
+ std::array<uint8_t, 256> t{};
+ for (auto& v : t) v = kB58Invalid;
+ for (size_t i = 0; i < kB58Alphabet.size(); ++i) {
+ t[static_cast<uint8_t>(kB58Alphabet[i])] = static_cast<uint8_t>(i);
+ }
+ return t;
+}
+constexpr std::array<uint8_t, 256> kB58Reverse = MakeB58Reverse();
+
+// Whether @p qc is a Base58 character. ASCII-only by construction: every
+// character outside 0x00-0x7F is rejected before the table is consulted.
+constexpr auto IsBase58Char(QChar qc) -> bool {
+ return qc.unicode() <= 0x7F && kB58Reverse[qc.unicode()] != kB58Invalid;
+}
+
+// Base58 is a big-integer base conversion, and the reference implementations --
+// Bitcoin Core's base58.cpp among them -- do it a digit at a time: one pass
+// over everything accumulated so far per input byte, i.e. O(n^2). At this
+// format's ~12 KB wire that is ~210 ms to encode and ~50 ms to decode, paid on
+// every message a user pastes.
+//
+// Same structure and the same output, but the accumulator holds limbs rather
+// than single digits: base 58^5 when encoding (58^5 fits a quint32 and is
+// exactly five output characters) and base 2^32 when decoding. That is five
+// times fewer inner steps, and four bytes -- or five characters -- are absorbed
+// per outer step, which puts the same conversion at ~6.9 ms / ~2.5 ms.
+//
+// Verified byte-identical to both the previous implementation and Bitcoin
+// Core's over exhaustive inputs to 64 bytes, random inputs to 16 KB (random,
+// all-zero, leading-zero and all-0xFF shapes), and Bitcoin's published vectors.
+// It has to be identical: the token is a wire format, so a divergence here
+// would split old and new builds silently.
+constexpr quint32 kB58Pow5 = 656356768U; // 58^5 -- one limb, five characters
+
+constexpr std::array<quint64, 6> kB58Pow58 = {1, 58, 3364,
+ 195112, 11316496, 656356768ULL};
+
+auto Base58Encode(const QByteArray& in) -> QString {
+ // Leading zero bytes are not part of the number; they become leading '1's.
+ qsizetype zeroes = 0;
+ while (zeroes < in.size() && in.at(zeroes) == '\0') ++zeroes;
+ const auto body = in.size() - zeroes;
+
+ // log(256)/log(58) rounded up, then five characters to the limb.
+ QVector<quint32> limbs;
+ limbs.reserve((body * 138 / 100 / 5) + 1);
+
+ const auto absorb = [&limbs](quint64 chunk, quint64 base) {
+ quint64 carry = chunk;
+ for (auto& limb : limbs) {
+ const quint64 cur = static_cast<quint64>(limb) * base + carry;
+ limb = static_cast<quint32>(cur % kB58Pow5);
+ carry = cur / kB58Pow5;
+ }
+ while (carry != 0) {
+ limbs.append(static_cast<quint32>(carry % kB58Pow5));
+ carry /= kB58Pow5;
+ }
+ };
+
+ // The odd bytes at the front go in one short chunk so every later step is a
+ // full 32 bits. limb * 2^32 + carry stays inside quint64 because a limb is
+ // below 58^5.
+ auto i = zeroes;
+ if (const auto head = body % 4; head != 0) {
+ quint64 chunk = 0;
+ for (qsizetype k = 0; k < head; ++k) {
+ chunk = (chunk << 8) | static_cast<quint8>(in.at(i + k));
+ }
+ absorb(chunk, quint64{1} << (8 * head));
+ i += head;
+ }
+ for (; i < in.size(); i += 4) {
+ const quint64 chunk =
+ (static_cast<quint64>(static_cast<quint8>(in.at(i))) << 24) |
+ (static_cast<quint64>(static_cast<quint8>(in.at(i + 1))) << 16) |
+ (static_cast<quint64>(static_cast<quint8>(in.at(i + 2))) << 8) |
+ static_cast<quint64>(static_cast<quint8>(in.at(i + 3)));
+ absorb(chunk, quint64{1} << 32);
+ }
+
+ QString out;
+ out.reserve(zeroes + (limbs.size() * 5) + 1);
+ for (qsizetype k = 0; k < zeroes; ++k) {
+ out.append(QLatin1Char(kB58Alphabet[0]));
+ }
+ if (limbs.isEmpty()) return out;
+
+ // The top limb contributes only its significant digits; every limb below it
+ // contributes exactly five, zero-padded -- that padding is what makes the
+ // limbs reassemble into the same string the digit-at-a-time version emits.
+ std::array<char, 5> buf{};
+ auto top = limbs.back();
+ int digits = 0;
+ do {
+ buf[digits++] = kB58Alphabet[top % 58];
+ top /= 58;
+ } while (top != 0);
+ for (int k = digits - 1; k >= 0; --k) out.append(QLatin1Char(buf[k]));
+
+ for (auto li = limbs.size() - 2; li >= 0; --li) {
+ auto value = limbs[li];
+ for (int k = 4; k >= 0; --k) {
+ buf[k] = kB58Alphabet[value % 58];
+ value /= 58;
+ }
+ for (const char ch : buf) out.append(QLatin1Char(ch));
+ }
+ return out;
+}
+
+auto Base58Decode(const QString& in, bool& ok) -> QByteArray {
+ ok = false;
+ // Validate up front, so the conversion below can read the table unguarded.
+ // Deliberately stricter than Bitcoin Core's decoder, which skips surrounding
+ // whitespace: here anything that is not a Base58 character is a rejection,
+ // and the caller has already stripped the whitespace it means to tolerate.
+ for (const QChar qc : in) {
+ if (!IsBase58Char(qc)) return {};
+ }
+
+ qsizetype zeroes = 0;
+ while (zeroes < in.size() && in.at(zeroes) == QLatin1Char(kB58Alphabet[0])) {
+ ++zeroes;
+ }
+ const auto body = in.size() - zeroes;
+
+ // log(58)/log(256) rounded up, then four bytes to the limb.
+ QVector<quint32> limbs;
+ limbs.reserve((body * 733 / 1000 / 4) + 1);
+
+ const auto absorb = [&limbs](quint64 chunk, quint64 base) {
+ quint64 carry = chunk;
+ for (auto& limb : limbs) {
+ const quint64 cur = static_cast<quint64>(limb) * base + carry;
+ limb = static_cast<quint32>(cur & 0xFFFFFFFFULL);
+ carry = cur >> 32;
+ }
+ while (carry != 0) {
+ limbs.append(static_cast<quint32>(carry & 0xFFFFFFFFULL));
+ carry >>= 32;
+ }
+ };
+
+ auto i = zeroes;
+ if (const auto head = body % 5; head != 0) {
+ quint64 chunk = 0;
+ for (qsizetype k = 0; k < head; ++k) {
+ chunk = chunk * 58 + kB58Reverse[in.at(i + k).unicode()];
+ }
+ absorb(chunk, kB58Pow58[head]);
+ i += head;
+ }
+ for (; i < in.size(); i += 5) {
+ quint64 chunk = 0;
+ for (int k = 0; k < 5; ++k) {
+ chunk = chunk * 58 + kB58Reverse[in.at(i + k).unicode()];
+ }
+ absorb(chunk, kB58Pow58[5]);
+ }
+
+ QByteArray out;
+ out.reserve(zeroes + (limbs.size() * 4));
+ out.append(zeroes, '\0');
+ if (!limbs.isEmpty()) {
+ // The top limb keeps only its significant bytes, for the same reason the
+ // encoder's top limb keeps only its significant digits.
+ const auto top = limbs.back();
+ const std::array<char, 4> top_bytes = {
+ static_cast<char>(top >> 24), static_cast<char>(top >> 16),
+ static_cast<char>(top >> 8), static_cast<char>(top)};
+ int skip = 0;
+ while (skip < 3 && top_bytes[skip] == '\0') ++skip;
+ for (int k = skip; k < 4; ++k) out.append(top_bytes[k]);
+
+ for (auto li = limbs.size() - 2; li >= 0; --li) {
+ const auto value = limbs[li];
+ out.append(static_cast<char>(value >> 24));
+ out.append(static_cast<char>(value >> 16));
+ out.append(static_cast<char>(value >> 8));
+ out.append(static_cast<char>(value));
+ }
+ }
+ ok = true;
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Password book: a shared 256-byte table, phrase-derived (Argon2id) or default.
+// It is the whitening key: everything below is derived from it plus a fresh
+// per-message seed.
+// ---------------------------------------------------------------------------
+
+// A book is a 256-byte permutation, so the space is log2(256!) ≈ 1684 bits.
+// Both the phrase-derived and the default book are seeded with a full 256-bit
+// key and shuffled from a ChaCha20 keystream, so neither is the weak one: the
+// default is exactly as strong a permutation as anything a phrase produces.
+// (What the default cannot give is *secrecy*: it ships in every copy of
+// GpgFrontend, so only a shared phrase hides messages from someone who knows
+// this program.)
+constexpr size_t kBookSeedLen = 32;
+using BookSeed = std::array<unsigned char, kBookSeedLen>;
+
+// Fixed, from the platform CSPRNG at authoring time.
+constexpr BookSeed kDefaultBookSeed = {
+ 0x97, 0x47, 0x00, 0x3E, 0xDB, 0x08, 0xA8, 0x4F, 0x72, 0xE6, 0xAE,
+ 0xAA, 0xA3, 0x15, 0x54, 0xF5, 0x4C, 0x65, 0x03, 0x7B, 0x0E, 0x75,
+ 0x2A, 0xE1, 0x52, 0x59, 0x8B, 0x46, 0xB5, 0x20, 0x64, 0xF0};
+
+constexpr std::array<unsigned char, crypto_pwhash_SALTBYTES> kBookKdfSalt = {
+ 'G', 'F', '-', 'I', 'M', '-', 'b', 'o',
+ 'o', 'k', '-', 's', 'a', 'l', 't', '1'};
+constexpr unsigned long long kBookKdfOps = 3;
+constexpr size_t kBookKdfMem = 65536ULL * 1024ULL; // 64 MiB
+
+// Per-message whitening KDF: intentionally heavier than the (memoized) phrase
+// KDF above, because it runs on EVERY encode/decode and is the anti-detection
+// cost. A large-scale scanner that holds the default book still has to pay one
+// full Argon2id per candidate Base58 message — there is no cheap pre-filter and
+// no precomputation (the per-message seed is the salt, so no rainbow table and
+// no cross-message amortization). Sized so a single detect/decode is ~0.1-0.2s
+// and memory-bound (hard to parallelize on GPU/ASIC at platform scale). This is
+// paid symmetrically by legitimate peers; the protection is scale — the scanner
+// tests billions of messages, a user tests one.
+constexpr unsigned long long kMasterKdfOps = 3;
+constexpr size_t kMasterKdfMem = 128ULL * 1024ULL * 1024ULL; // 128 MiB
+
+// The cost actually used, so tests can stand the suite down from ~139 ms per
+// encode and per decode to something they can afford to run hundreds of times.
+// Atomic because Encode/Decode run on worker threads; relaxed because the two
+// halves are only ever moved together, by a test, with nothing else in flight.
+std::atomic<unsigned long long> g_master_kdf_ops{kMasterKdfOps};
+std::atomic<size_t> g_master_kdf_mem{kMasterKdfMem};
+
+auto DeriveBookSeed(const QString& phrase, BookSeed& seed) -> bool {
+ if (!EnsureSodiumInit()) return false;
+
+ const QByteArray pw = phrase.toUtf8();
+ const int rc = crypto_pwhash(seed.data(), seed.size(), pw.constData(),
+ static_cast<unsigned long long>(pw.size()),
+ kBookKdfSalt.data(), kBookKdfOps, kBookKdfMem,
+ crypto_pwhash_ALG_ARGON2ID13);
+ if (rc != 0) {
+ qWarning() << "argon2id derivation for instant-messaging book failed";
+ return false;
+ }
+ return true;
+}
+
+// Fisher-Yates over a ChaCha20 keystream: every one of the 256! permutations is
+// reachable, and the seed is the only thing bounding the entropy. Draws are 64
+// bits wide, so the modulo bias is below 2^-56, far under anything that could
+// bias the resulting table.
+auto BuildBook(const BookSeed& seed) -> std::array<uint8_t, 256> {
+ std::array<uint8_t, 256> sub{};
+ for (int i = 0; i < 256; ++i) sub[i] = static_cast<uint8_t>(i);
+
+ constexpr std::array<unsigned char, crypto_stream_chacha20_NONCEBYTES>
+ kBookStreamNonce = {'G', 'F', '-', 'I', 'M', '-', 'b', 'k'};
+
+ std::array<unsigned char, 255 * sizeof(uint64_t)> keystream{};
+ crypto_stream_chacha20(keystream.data(), keystream.size(),
+ kBookStreamNonce.data(), seed.data());
+
+ for (int i = 255; i > 0; --i) {
+ uint64_t z = 0;
+ std::memcpy(&z, keystream.data() + (255 - i) * sizeof(uint64_t), sizeof(z));
+ const int j = static_cast<int>(z % static_cast<uint64_t>(i + 1));
+ std::swap(sub[i], sub[j]);
+ }
+
+ sodium_memzero(keystream.data(), keystream.size());
+ return sub;
+}
+
+// The raw book bytes for a phrase. Deriving one runs Argon2id, so results are
+// memoized by phrase: Decode() calls this on every token, and the settings UI
+// previews the fingerprint of phrases the user types.
+auto BookBytesFor(const QString& phrase) -> QByteArray {
+ static QMutex mutex;
+ static QHash<QString, QByteArray> cache;
+
+ QMutexLocker locker(&mutex);
+ if (const auto it = cache.constFind(phrase); it != cache.constEnd()) {
+ return *it;
+ }
+
+ BookSeed seed = kDefaultBookSeed;
+ if (!phrase.isEmpty()) {
+ BookSeed derived{};
+ if (DeriveBookSeed(phrase, derived)) seed = derived;
+ }
+ const auto sub = BuildBook(seed);
+ auto book = QByteArray(reinterpret_cast<const char*>(sub.data()),
+ static_cast<qsizetype>(sub.size()));
+
+ // Bounded: a user tries a handful of phrases per session, and every entry
+ // holds a derived secret we would rather not keep around indefinitely.
+ if (cache.size() >= 8) cache.clear();
+ cache.insert(phrase, book);
+ return book;
+}
+
+// Short, domain-separated digest of a book. See BookFingerprint()'s docs for
+// why it is truncated and must never travel on the wire.
+auto FingerprintOfBook(const QByteArray& book) -> QString {
+ QCryptographicHash hash(QCryptographicHash::Sha256);
+ hash.addData(QByteArrayLiteral("GpgFrontend-IM-book-fingerprint-v1"));
+ hash.addData(book);
+
+ const auto hex = hash.result().left(4).toHex().toUpper();
+ return QString("%1-%2").arg(QString::fromLatin1(hex.left(4)),
+ QString::fromLatin1(hex.mid(4, 4)));
+}
+
+// ---------------------------------------------------------------------------
+// Whitened wire container. There is NO cleartext marker: the whole inner frame
+// (a book-derived recognition tag, the header and the OpenPGP payload, plus
+// secret-controlled random padding) is XORed with a keystream and shuffled by a
+// permutation, both derived — via a memory-hard KDF — from the book and a fresh
+// per-message seed. Only the seed travels in the clear, and it is uniform
+// random.
+//
+// wire = Base58( seed(16) || permute( inner XOR keystream ) )
+// inner = tag(16) | version(1) | type(1) | payload_len(u32) | payload | pad
+//
+// The padding is at least 30% of the content and the total is then quantized
+// onto a ladder of Base58 lengths (see SnapFrameBytes), so the token's length
+// is a coarse, keyed function of the payload's rather than a reflection of it.
+// ---------------------------------------------------------------------------
+
+// 0x03: books are now full 256-bit-seeded permutations, so every book,
+// the default included, differs from the 0x02 ones. Old tokens do not decode.
+constexpr uint8_t kVersion = 0x03;
+constexpr uint8_t kTypeNormal = 0x00;
+
+constexpr int kSeedLen = 16; // == Argon2 salt length; ChaCha20 nonce is the
+ // first 8 bytes of it.
+constexpr int kTagLen = 16; // secret book-derived recognition value
+constexpr int kHeaderLen = kTagLen + 1 + 1 + 4; // tag|version|type|payload_len
+constexpr int kMinFrame = kHeaderLen;
+constexpr int kPrefixLen = kTagLen + 4; // PRG bytes read before the length (m)
+ // is known: tag + jitter u32
+
+// Random padding, as a fraction of the real content, in 1024ths: 30.0% to
+// 50.0%. Drawn from the keystream, so the amount is itself a secret of the
+// book — an observer cannot subtract a known overhead to recover the true
+// payload size, and repeated sends of the same message land on different
+// lengths.
+constexpr quint32 kPadFracMin = 307; // 307/1024 ≈ 30.0%
+constexpr quint32 kPadFracSpan = 206; // up to 512/1024 = 50.0%
+
+static_assert(kSeedLen == crypto_pwhash_SALTBYTES);
+static_assert(kSeedLen >= crypto_stream_chacha20_NONCEBYTES);
+
+void AppendU32BE(QByteArray& out, quint32 v) {
+ out.append(static_cast<char>((v >> 24) & 0xFF));
+ out.append(static_cast<char>((v >> 16) & 0xFF));
+ out.append(static_cast<char>((v >> 8) & 0xFF));
+ out.append(static_cast<char>(v & 0xFF));
+}
+auto ReadU32BE(const QByteArray& in, int off) -> quint32 {
+ return (static_cast<quint32>(static_cast<uint8_t>(in[off])) << 24) |
+ (static_cast<quint32>(static_cast<uint8_t>(in[off + 1])) << 16) |
+ (static_cast<quint32>(static_cast<uint8_t>(in[off + 2])) << 8) |
+ static_cast<quint32>(static_cast<uint8_t>(in[off + 3]));
+}
+
+// Wire lengths (in bytes) whose Base58 encodings are lengths that already
+// occur in the wild, so a token that fits one is not merely "some Base58
+// blob" but the same size as things people paste into chats every day:
+//
+// 25 -> 34 chars P2PKH / P2SH address 32 -> 44 chars Solana pubkey
+// 37 -> 51 chars WIF (uncompressed) 38 -> 52 chars WIF
+// 39 -> 53 chars BIP38 encrypted key 64 -> 88 chars Ed25519 sig
+// 82 -> 111 chars xpub / xprv
+//
+// An OpenPGP message is much larger than any of these, so in practice only a
+// contrived payload lands on one; they are the bottom of the ladder so that it
+// happens whenever it can, and cost nothing when it cannot.
+constexpr std::array<int, 7> kClassicFrameBytes = {25, 32, 37, 38, 39, 64, 82};
+
+// Past the classical rungs the ladder grows by an eighth per step. That caps
+// what snapping costs (≤12.5% on top of the random padding) while keeping the
+// set of reachable token lengths small: many different messages quantize onto
+// the same rung, so the length of a token says little about the length of what
+// is inside it.
+auto SnapFrameBytes(int n) -> int {
+ for (const int c : kClassicFrameBytes) {
+ if (n <= c) return c;
+ }
+ int v = kClassicFrameBytes.back();
+ while (v < n) v += (v + 7) / 8;
+ return v;
+}
+
+// ---------------------------------------------------------------------------
+// Input bounds.
+//
+// Base58Decode is O(n^2) in the length of its input, and Decode() is handed
+// whatever happens to be in the editor, so an unbounded input is a self-
+// inflicted denial of service. The cap is deliberately generous: it sits far
+// above any message a chat app will carry, and its only job is to stop a
+// pathological paste.
+//
+// The two directions have to agree. If Encode() could emit a token longer than
+// Decode() accepts, two copies of GpgFrontend would silently fail to talk to
+// each other — so the encode-side limit is *derived* from the decode-side one
+// and the agreement is a static_assert, not a comment.
+// ---------------------------------------------------------------------------
+
+constexpr int kMaxTokenChars = 16384;
+
+// 732/1000 under-estimates log(58)/log(256) = 0.7322476, so this never
+// over-states how many bytes kMaxTokenChars characters can carry.
+constexpr int kMaxWireBytes = kMaxTokenChars * 732 / 1000;
+
+// Base58 never shrinks its input (58 < 256), so n characters decode to at most
+// n bytes: a token shorter than the smallest wire Decode() could accept can be
+// rejected on length alone, without decoding anything.
+constexpr int kMinTokenChars = kSeedLen + kMinFrame;
+
+// The largest wire Encode() can produce for a payload of @p payload bytes.
+// Mirrors the length arithmetic in Encode():
+// content = kHeaderLen + payload
+// padded = content + (content * frac)/1024, frac <=
+// kPadFracMin+kPadFracSpan
+// <= content + content/2 (the fraction caps at
+// 1/2)
+// wire = SnapFrameBytes(kSeedLen + padded)
+// and SnapFrameBytes stops at the first rung >= n, each rung being the previous
+// one plus an eighth, so it returns at most (n-1) + (n+6)/8.
+constexpr auto WorstCaseWireBytes(int payload) -> int {
+ const int content = kHeaderLen + payload;
+ const int padded = content + (content / 2);
+ const int n = kSeedLen + padded;
+ return (n - 1) + ((n + 6) / 8);
+}
+
+// Round, with margin: WorstCaseWireBytes(7000) = 11867 against a 11992 budget.
+constexpr int kMaxPayloadBytes = 7000;
+
+static_assert(WorstCaseWireBytes(kMaxPayloadBytes) <= kMaxWireBytes,
+ "Encode() may emit a wire longer than Decode() accepts");
+
+// And the other direction: 1366/1000 over-estimates log(256)/log(58) =
+// 1.3656552, so a wire of kMaxWireBytes never Base58s past kMaxTokenChars.
+static_assert((kMaxWireBytes * 1366 / 1000) + 1 <= kMaxTokenChars,
+ "Encode() may emit a token longer than Decode() accepts");
+
+// Memory-hard master key from the book (as password) and the seed (as salt).
+// This is what makes even *detecting* a token cost an Argon2id per candidate
+// book — there is no cheap pre-filter.
+auto DeriveMaster(const QByteArray& book_bytes, const QByteArray& seed,
+ std::array<unsigned char, 32>& master) -> bool {
+ if (!EnsureSodiumInit()) return false;
+ const int rc =
+ crypto_pwhash(master.data(), master.size(), book_bytes.constData(),
+ static_cast<unsigned long long>(book_bytes.size()),
+ reinterpret_cast<const unsigned char*>(seed.constData()),
+ g_master_kdf_ops.load(std::memory_order_relaxed),
+ g_master_kdf_mem.load(std::memory_order_relaxed),
+ crypto_pwhash_ALG_ARGON2ID13);
+ return rc == 0;
+}
+
+// Deterministic keystream: ChaCha20(master) with the seed's first 8 bytes as
+// nonce. The tag, jitter, permutation material and XOR keystream are disjoint
+// slices of this one stream.
+auto Keystream(const std::array<unsigned char, 32>& master,
+ const QByteArray& seed, int len) -> QByteArray {
+ QByteArray out(len, Qt::Uninitialized);
+ crypto_stream_chacha20(
+ reinterpret_cast<unsigned char*>(out.data()),
+ static_cast<unsigned long long>(len),
+ reinterpret_cast<const unsigned char*>(seed.constData()), master.data());
+ return out;
+}
+
+// Keyed Fisher-Yates permutation P of [0, m) driven by 4 bytes per step.
+auto BuildPermutation(const QByteArray& material, int m) -> QVector<int> {
+ QVector<int> p(m);
+ for (int i = 0; i < m; ++i) p[i] = i;
+ int off = 0;
+ for (int i = m - 1; i > 0; --i) {
+ const quint32 r = ReadU32BE(material, off);
+ off += 4;
+ const int j = static_cast<int>(r % static_cast<quint32>(i + 1));
+ std::swap(p[i], p[j]);
+ }
+ return p;
+}
+
+// Strip whitespace a messenger may have injected, reject armored PGP text, and
+// test the token's public shape.
+//
+// This is the ONLY pre-filter, and it deliberately tests nothing an observer
+// does not already compute for free: the compacted length and whether every
+// character is in the Base58 alphabet. It must never consult the book, the tag
+// or any derived value — a book-dependent pre-filter would hand a large-scale
+// scanner exactly the cheap discriminator this design spends one memory-hard
+// derivation per candidate to deny it. Everything that survives this test still
+// pays a full Argon2id, so the adversary's cost per candidate is unchanged.
+//
+// What it does buy us is local: ordinary text now stops at its first space or
+// punctuation mark instead of reaching the O(n^2) decoder and the 128 MiB KDF.
+auto Normalize(const QString& text, QString& compact) -> bool {
+ if (text.contains(QLatin1String("-----BEGIN PGP"))) return false;
+
+ compact.clear();
+ compact.reserve(std::min<qsizetype>(text.size(), kMaxTokenChars));
+ for (const QChar qc : text) {
+ if (qc.isSpace()) continue;
+ if (!IsBase58Char(qc)) return false;
+ if (compact.size() == kMaxTokenChars) return false;
+ compact.append(qc);
+ }
+ return compact.size() >= kMinTokenChars;
+}
+
+} // namespace
+
+auto Encode(const QByteArray& pgp, const QString& phrase) -> QString {
+ if (pgp.isEmpty()) return {};
+
+ // Refuse anything Decode() would then refuse to read back. Callers should
+ // check MaxPayloadBytes() first so they can say something useful about it;
+ // this is the invariant guard for everyone else.
+ if (pgp.size() > kMaxPayloadBytes) {
+ qWarning() << "instant-messaging payload too long:" << pgp.size()
+ << "limit:" << kMaxPayloadBytes;
+ return {};
+ }
+
+ const auto book = BookBytesFor(phrase.trimmed());
+
+ const QByteArray seed = RandomBytes(kSeedLen);
+ if (seed.size() != kSeedLen) return {};
+
+ std::array<unsigned char, 32> master{};
+ if (!DeriveMaster(book, seed, master)) return {};
+
+ // Read the tag and jitter first; they fix the padded frame length m.
+ const QByteArray prefix = Keystream(master, seed, kPrefixLen);
+ const QByteArray tag = prefix.left(kTagLen);
+ const quint32 jitter = ReadU32BE(prefix, kTagLen);
+
+ // Length: pad by a keystream-chosen 30-50% of the content, then snap the
+ // whole wire (seed included, since that is what gets Base58'd) up onto the
+ // length ladder.
+ const int content = kHeaderLen + static_cast<int>(pgp.size());
+ const int padded =
+ content + static_cast<int>((static_cast<qint64>(content) *
+ (kPadFracMin + (jitter % kPadFracSpan))) /
+ 1024);
+ const int m = std::max(padded, SnapFrameBytes(kSeedLen + padded) - kSeedLen);
+ const int pad_len = m - content;
+
+ // Unreachable given the payload check above; see the WorstCaseWireBytes
+ // static_assert. Kept so a future change to the padding or the ladder cannot
+ // quietly start emitting tokens Decode() refuses.
+ if (kSeedLen + m > kMaxWireBytes) {
+ qWarning() << "instant-messaging frame exceeds the wire budget:" << m;
+ sodium_memzero(master.data(), master.size());
+ return {};
+ }
+
+ QByteArray inner;
+ inner.reserve(m);
+ inner.append(tag);
+ inner.append(static_cast<char>(kVersion));
+ inner.append(static_cast<char>(kTypeNormal));
+ AppendU32BE(inner, static_cast<quint32>(pgp.size()));
+ inner.append(pgp);
+ if (pad_len > 0) {
+ const auto pad = RandomBytes(pad_len);
+ if (pad.size() != pad_len) {
+ sodium_memzero(master.data(), master.size());
+ return {};
+ }
+ inner.append(pad);
+ }
+
+ const QByteArray stream = Keystream(master, seed, kPrefixLen + 4 * m + m);
+ const QByteArray perm_material = stream.mid(kPrefixLen, 4 * m);
+ const QByteArray xor_ks = stream.mid(kPrefixLen + 4 * m, m);
+ sodium_memzero(master.data(), master.size());
+
+ QByteArray t(m, Qt::Uninitialized);
+ for (int i = 0; i < m; ++i) {
+ t[i] = static_cast<char>(inner[i] ^ xor_ks[i]);
+ }
+
+ const QVector<int> p = BuildPermutation(perm_material, m);
+ QByteArray cipher(m, Qt::Uninitialized);
+ for (int i = 0; i < m; ++i) cipher[i] = t[p[i]];
+
+ QByteArray wire = seed;
+ wire.append(cipher);
+ return Base58Encode(wire);
+}
+
+auto Decode(const QString& text, const QString& phrase) -> DecodeResult {
+ DecodeResult r;
+
+ QString compact;
+ if (!Normalize(text, compact)) return r;
+
+ bool ok = false;
+ const QByteArray wire = Base58Decode(compact, ok);
+ if (!ok || wire.size() < kSeedLen + kMinFrame ||
+ wire.size() > kMaxWireBytes) {
+ return r;
+ }
+
+ const QByteArray seed = wire.left(kSeedLen);
+ const QByteArray cipher = wire.mid(kSeedLen);
+ const int m = static_cast<int>(cipher.size());
+
+ const auto book = BookBytesFor(phrase.trimmed());
+ std::array<unsigned char, 32> master{};
+ if (!DeriveMaster(book, seed, master)) return r;
+
+ const QByteArray stream = Keystream(master, seed, kPrefixLen + 4 * m + m);
+ sodium_memzero(master.data(), master.size());
+ const QByteArray tag_expected = stream.left(kTagLen);
+ const QByteArray perm_material = stream.mid(kPrefixLen, 4 * m);
+ const QByteArray xor_ks = stream.mid(kPrefixLen + 4 * m, m);
+
+ const QVector<int> p = BuildPermutation(perm_material, m);
+ QByteArray t(m, Qt::Uninitialized);
+ for (int i = 0; i < m; ++i) t[p[i]] = cipher[i];
+
+ QByteArray inner(m, Qt::Uninitialized);
+ for (int i = 0; i < m; ++i) {
+ inner[i] = static_cast<char>(t[i] ^ xor_ks[i]);
+ }
+
+ // Recognition: the leading bytes must equal the secret, book-derived tag.
+ if (sodium_memcmp(inner.constData(), tag_expected.constData(), kTagLen) !=
+ 0) {
+ return r; // not one of ours (or a different book)
+ }
+ if (static_cast<uint8_t>(inner[kTagLen]) != kVersion) return r;
+ if (static_cast<uint8_t>(inner[kTagLen + 1]) != kTypeNormal) return r;
+
+ const quint32 payload_len = ReadU32BE(inner, kTagLen + 2);
+ if (kHeaderLen + static_cast<qint64>(payload_len) > m) return r;
+
+ r.pgp_message = inner.mid(kHeaderLen, static_cast<int>(payload_len));
+ r.ok = true;
+ return r;
+}
+
+auto FormatVersion() -> int { return kVersion; }
+
+auto MaxPayloadBytes() -> int {
+ return kMaxPayloadBytes;
+}
+
+// A short, domain-separated digest of the active book, so two peers can
+// eyeball that they are on the same book without revealing the phrase.
+//
+// Truncated on purpose: this value identifies a book, and anyone who learns
+// it can test candidate phrases offline (derive book, hash, compare). Eight
+// hex digits keep it comparable by eye while leaving enough collisions that a
+// match is evidence, not proof. The dictionary attack still costs one
+// Argon2id (64 MiB) per candidate: the same wall the rest of the design
+// leans on. Never put this on the wire.
+auto GeneratePhrase() -> QString {
+ constexpr int kPhraseLen = 256;
+ // Largest multiple of 58 that fits in a byte: values at or above it are
+ // rejected rather than folded, so every character is uniform.
+ constexpr int kRejectAbove = 232;
+
+ QString out;
+ out.reserve(kPhraseLen);
+ while (out.size() < kPhraseLen) {
+ const auto bytes = RandomBytes(static_cast<int>(kPhraseLen - out.size()));
+ if (bytes.isEmpty()) {
+ qWarning() << "csprng unavailable, cannot generate a book phrase";
+ return {};
+ }
+
+ for (const char byte : bytes) {
+ const auto value = static_cast<uint8_t>(byte);
+ if (value >= kRejectAbove) continue;
+ out.append(QLatin1Char(kB58Alphabet[value % 58]));
+ }
+ }
+ return out;
+}
+
+auto BookFingerprintOf(const QString& phrase) -> QString {
+ return FingerprintOfBook(BookBytesFor(phrase.trimmed()));
+}
+
+auto DefaultKdfCost() -> KdfCost {
+ return {kMasterKdfOps, kMasterKdfMem};
+}
+
+auto CurrentKdfCost() -> KdfCost {
+ return {g_master_kdf_ops.load(std::memory_order_relaxed),
+ g_master_kdf_mem.load(std::memory_order_relaxed)};
+}
+
+void SetKdfCostForTesting(KdfCost cost) {
+ if (cost.ops == 0 || cost.mem == 0) return;
+ g_master_kdf_ops.store(cost.ops, std::memory_order_relaxed);
+ g_master_kdf_mem.store(cost.mem, std::memory_order_relaxed);
+}
+
+} // namespace ImToken
diff --git a/src/m_im/ImToken.h b/src/m_im/ImToken.h
new file mode 100644
index 0000000..866ed8a
--- /dev/null
+++ b/src/m_im/ImToken.h
@@ -0,0 +1,143 @@
+/**
+ * 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
+ *
+ */
+
+#pragma once
+
+#include <QByteArray>
+#include <QString>
+#include <cstddef>
+
+/**
+ * @file ImToken.h
+ *
+ * @brief Compact, instant-messaging-friendly container for an OpenPGP message,
+ * emitted as a single Base58 word so it survives pasting into any chat app.
+ *
+ * A normal ASCII-armored PGP message is verbose and multi-line; pasted into a
+ * messenger its boilerplate, line-wraps and CRC footer get mangled. This codec
+ * emits one unbreakable Base58 word (the Bitcoin/IPFS alphabet, no ambiguous
+ * 0 O I l and nothing a messenger turns into markdown, a link or a soft-wrap).
+ *
+ * There is deliberately no marker, prefix or magic on the wire: the whole
+ * container is "whitened" with the shared password book. A per-message random
+ * seed and the book derive — via a memory-hard KDF — a set of secret bytes;
+ * part of them re-shuffles the byte order, the rest XORs the payload, and a
+ * secret-controlled amount of random padding is interleaved to hide the true
+ * length. The GpgFrontend recognition tag is a secret check value derived from
+ * the book (not a fixed constant), so there is no known-plaintext to search
+ * for; it lives inside this whitened blob, invisible on the wire and revealed
+ * only after a receiver un-whitens with the same book.
+ *
+ * The consequence: without the book, a token is indistinguishable from random
+ * Base58. Even *detecting* whether a token is one of ours costs the adversary a
+ * memory-hard derivation per candidate book, since there is no cheap
+ * pre-filter.
+ *
+ * Decode() does apply a shape test before that derivation — length bounds and
+ * Base58 alphabet membership — but only over information an observer already
+ * has for free, so it does not lower the adversary's per-candidate cost. It
+ * exists so that ordinary text a user hits Decrypt on stops at its first space
+ * or punctuation mark rather than paying the KDF.
+ *
+ * The shared "password book" is a 256-byte table derived from an optional
+ * phrase (Argon2id) or a fixed default. The empty-phrase default only hides the
+ * format from naive scanners; real indistinguishability against a GpgFrontend-
+ * aware adversary requires both sides to share a secret phrase.
+ */
+namespace ImToken {
+
+/// The result of decoding a token.
+struct DecodeResult {
+ bool ok{false}; ///< the token was one of ours and un-whitened
+ QByteArray pgp_message; ///< the wrapped OpenPGP message to decrypt
+};
+
+/**
+ * @brief Wrap an already-OpenPGP-encrypted message as one whitened Base58
+ * token, with the book @p phrase names (empty: the default book).
+ *
+ * @return the Base58 token, or empty on failure
+ */
+auto Encode(const QByteArray& pgp_message, const QString& phrase) -> QString;
+
+/**
+ * @brief Un-whiten @p text with the book @p phrase names and recover the
+ * wrapped OpenPGP message. @c ok is false when @p text is not one of our
+ * tokens (plain chat, or a different book).
+ */
+auto Decode(const QString& text, const QString& phrase) -> DecodeResult;
+
+/// The current container format version (the inner tag's version).
+auto FormatVersion() -> int;
+
+/**
+ * @brief The largest OpenPGP message Encode() will wrap, in bytes.
+ *
+ * Derived from the token-length bound Decode() enforces, so any token this
+ * codec produces is one this codec accepts.
+ */
+auto MaxPayloadBytes() -> int;
+
+/**
+ * @brief A fresh book phrase from the platform CSPRNG: 256 characters over the
+ * Base58 alphabet, or empty if the CSPRNG was unavailable.
+ */
+auto GeneratePhrase() -> QString;
+
+/**
+ * @brief A short, domain-separated digest (e.g. "3F9A-1C4E") of the book
+ * @p phrase produces, for peers to confirm by eye that they share a book.
+ *
+ * @warning Identifies the book: anyone who learns it can test candidate
+ * phrases offline. Display it locally; never put it on the wire. Costs one
+ * Argon2id (64 MiB) per distinct phrase; call it off the UI thread.
+ */
+auto BookFingerprintOf(const QString& phrase) -> QString;
+
+/// The per-message Argon2id cost, as (ops, memory in bytes).
+struct KdfCost {
+ unsigned long long ops;
+ size_t mem;
+};
+
+/// The per-message Argon2id cost the format ships with.
+auto DefaultKdfCost() -> KdfCost;
+
+/// The per-message Argon2id cost currently in effect.
+auto CurrentKdfCost() -> KdfCost;
+
+/**
+ * @brief Override the per-message Argon2id cost. **Tests only.**
+ *
+ * The cost is a protocol constant rather than something carried in the token,
+ * so Encode() and Decode() must agree on it, and it is the feature's whole
+ * security argument: lowering it anywhere but a test removes the protection.
+ */
+void SetKdfCostForTesting(KdfCost cost);
+
+} // namespace ImToken
diff --git a/src/m_im/module.json b/src/m_im/module.json
new file mode 100644
index 0000000..b107fd8
--- /dev/null
+++ b/src/m_im/module.json
@@ -0,0 +1,23 @@
+{
+ "schema_version": 1,
+ "id": "com.bktus.gpgfrontend.module.im",
+ "version": "1.0.0",
+ "name": "InstantMessaging",
+ "description": "Send OpenPGP messages as single-line tokens that look like random text.",
+ "author": "Saturneric",
+ "capabilities": [
+ "editor",
+ "gpg",
+ "storage",
+ "ui",
+ "ui.custom"
+ ],
+ "events": [],
+ "commands": [
+ "com.bktus.gpgfrontend.module.im.decode",
+ "com.bktus.gpgfrontend.module.im.encode",
+ "com.bktus.gpgfrontend.module.im.encrypt",
+ "com.bktus.gpgfrontend.module.im.encrypt_sign"
+ ],
+ "translation_context": "ModuleIM"
+}
diff --git a/src/m_im/test/CMakeLists.txt b/src/m_im/test/CMakeLists.txt
new file mode 100644
index 0000000..46d9d25
--- /dev/null
+++ b/src/m_im/test/CMakeLists.txt
@@ -0,0 +1,58 @@
+# 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
+
+# Unit tests for the SDK-free half of the instant messaging module: the token
+# format and the codec answers. Neither carries a GF SDK symbol or a widget,
+# so they compile straight into a plain gtest binary with Qt Core and
+# libsodium behind them. Keep it that way, or this target stops linking.
+
+if(NOT TARGET GTest::gtest)
+ find_package(GTest QUIET)
+endif()
+
+if(NOT TARGET GTest::gtest)
+ message(STATUS "GTest not found; skipping instant messaging module tests")
+ return()
+endif()
+
+add_executable(gf_mod_im_test
+ ImTokenTest.cpp
+ ImCodecTest.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/../ImToken.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/../ImCodec.cpp)
+
+target_include_directories(gf_mod_im_test PRIVATE
+ "${CMAKE_CURRENT_SOURCE_DIR}/..")
+
+# The fixtures were written by the core implementation the module replaced.
+target_compile_definitions(gf_mod_im_test PRIVATE
+ GF_IM_FIXTURES="${CMAKE_CURRENT_SOURCE_DIR}/im_core_fixtures.txt")
+
+target_link_libraries(gf_mod_im_test PRIVATE
+ GTest::gtest GTest::gtest_main Qt::Core sodium)
+
+set_target_properties(gf_mod_im_test PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${GPGFRONTEND_MODULE_TEST_OUTPUT_DIR}")
+gf_register_module_test(gf_mod_im_test)
diff --git a/src/m_im/test/ImCodecTest.cpp b/src/m_im/test/ImCodecTest.cpp
new file mode 100644
index 0000000..dacbe53
--- /dev/null
+++ b/src/m_im/test/ImCodecTest.cpp
@@ -0,0 +1,132 @@
+/**
+ * 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 <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+
+#include "ImCodec.h"
+#include "ImToken.h"
+
+/**
+ * @file ImCodecTest.cpp
+ * @brief What the module answers the Host's decoder and encoder with.
+ */
+
+namespace {
+
+auto Blob(int len = 200) -> QByteArray {
+ QByteArray b;
+ b.append('\x84');
+ for (int i = 1; i < len; ++i) b.append(static_cast<char>((i * 37 + 11)));
+ return b;
+}
+
+auto CardOf(const QString& json) -> QJsonObject {
+ const auto doc = QJsonDocument::fromJson(json.toUtf8()).object();
+ const auto cards = doc.value("cards").toArray();
+ return cards.isEmpty() ? QJsonObject{} : cards.first().toObject();
+}
+
+} // namespace
+
+// An IM token: the decoder claims it and hands back the OpenPGP message.
+TEST(ImCodecTest, ATokenIsClaimedAndUnwrapped) {
+ const auto token = ImToken::Encode(Blob(), "phrase");
+ ASSERT_FALSE(token.isEmpty());
+ const auto a = ImCodec::DecodeInput(token.toUtf8(), "phrase");
+ EXPECT_EQ(a.outcome, ImCodec::Outcome::kHandled);
+ EXPECT_EQ(a.output, Blob());
+ EXPECT_EQ(CardOf(a.cards).value("title").toString(), "Instant Messaging");
+ EXPECT_EQ(CardOf(a.cards).value("status").toString(), "ok");
+}
+
+// Ordinary OpenPGP: not ours, so the Host's own decrypt runs on it.
+TEST(ImCodecTest, ArmoredOpenPgpIsNotClaimed) {
+ const QByteArray armored =
+ "-----BEGIN PGP MESSAGE-----\n\nhF4DAAAAAAAAAAASAQdAabc=\n=abcd\n"
+ "-----END PGP MESSAGE-----\n";
+ const auto a = ImCodec::DecodeInput(armored, {});
+ EXPECT_EQ(a.outcome, ImCodec::Outcome::kNotHandled);
+ EXPECT_TRUE(a.output.isEmpty());
+ EXPECT_TRUE(a.cards.isEmpty());
+}
+
+// Plain text: not ours either; the Host's normal fallback applies.
+TEST(ImCodecTest, PlainTextIsNotClaimed) {
+ EXPECT_EQ(ImCodec::DecodeInput("hello, see you at eight", {}).outcome,
+ ImCodec::Outcome::kNotHandled);
+ EXPECT_EQ(ImCodec::DecodeInput({}, {}).outcome,
+ ImCodec::Outcome::kNotHandled);
+}
+
+// A token of another book is, to this book, random text: not claimed, so the
+// behaviour is what it always was -- the text is decrypted as it is.
+TEST(ImCodecTest, AnotherBooksTokenIsNotClaimed) {
+ const auto token = ImToken::Encode(Blob(), "theirs");
+ EXPECT_EQ(ImCodec::DecodeInput(token.toUtf8(), "mine").outcome,
+ ImCodec::Outcome::kNotHandled);
+}
+
+TEST(ImCodecTest, EncodeMakesATokenTheDecoderClaims) {
+ const auto e = ImCodec::EncodeOutput(Blob(), "phrase");
+ ASSERT_EQ(e.outcome, ImCodec::Outcome::kHandled);
+ const auto d = ImCodec::DecodeInput(e.output, "phrase");
+ EXPECT_EQ(d.outcome, ImCodec::Outcome::kHandled);
+ EXPECT_EQ(d.output, Blob());
+}
+
+// Too long for the format: a failure the user is told about, not a token.
+TEST(ImCodecTest, AnOversizedMessageFailsWithAReason) {
+ const auto e = ImCodec::EncodeOutput(Blob(ImToken::MaxPayloadBytes() + 1), {});
+ EXPECT_EQ(e.outcome, ImCodec::Outcome::kFailed);
+ EXPECT_TRUE(e.output.isEmpty());
+ EXPECT_TRUE(e.error.contains("too long")) << e.error.toStdString();
+}
+
+// The default book only hides the format from naive scanners: its card warns.
+TEST(ImCodecTest, TheDefaultBookCardWarns) {
+ const auto e = ImCodec::EncodeOutput(Blob(), {});
+ ASSERT_EQ(e.outcome, ImCodec::Outcome::kHandled);
+ EXPECT_EQ(CardOf(e.cards).value("status").toString(), "warn");
+}
+
+// The phrase survives the cache's "no empty values" rule, and is read back
+// the way the core wrote it, so a migrated value needs no conversion.
+TEST(ImCodecTest, PhraseBlobRoundTripsIncludingEmpty) {
+ EXPECT_EQ(ImCodec::DecodePhraseBlob(ImCodec::EncodePhraseBlob(" a b ")),
+ "a b");
+ const auto cleared = ImCodec::EncodePhraseBlob({});
+ EXPECT_FALSE(cleared.isEmpty());
+ EXPECT_TRUE(ImCodec::DecodePhraseBlob(cleared).isEmpty());
+ EXPECT_TRUE(ImCodec::DecodePhraseBlob({}).isEmpty());
+ EXPECT_EQ(ImCodec::DecodePhraseBlob(QByteArray("\x01") + "legacy"),
+ "legacy");
+}
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({}));
+}
diff --git a/src/m_im/test/im_core_fixtures.txt b/src/m_im/test/im_core_fixtures.txt
new file mode 100644
index 0000000..c74305a
--- /dev/null
+++ b/src/m_im/test/im_core_fixtures.txt
@@ -0,0 +1,6 @@
+payload_hex=8430557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4d9fe23486d92b7dc01264b7095badf04294e7398bde2072c51769bc0e50a2f54799ec3e80d32577ca1c6eb10355a7fa4c9ee13385d82a7ccf1163b6085aacff4193e6388add2f71c41668bb0d5fa1f44698eb3d8fd22476c91b6db00254a6f94b9de03284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9ce
+default_token=amKP728R77F45TnSYGQXLBccLfzhmJK57iEBodH6K93JcfC4aaMV8RDQet2wxMa65o5iduagAeEzBSK5H1DGzQyFvkpBfKzyqn22mhtbiQmc8Fg2EpoayTETXQ3xhm48mj17FvzJKARWziu55UZNkgRGXy4pBzFUqRRL1Cj2QwJ45JmQKQr9NyGvZQYoUoVfRDW2kFvwSozkbBPMRiAMoUHUzL4meTMqqVfR5KkL31w2RskYHG75TpmKyrW2fy9cHzF8W3ppUoCytsHzfDLeeHJ9HzLhHaQESmpLadTyr4RdZJ3RT8jHbsgRE63U2fQfotX4KrJczaoyg1fb58CPNQLDyJT55tUkG67N6U2HpkR4HSvF9P2kngzhTndKPssD9xCrqBreDHMKVHg6NFycpdPeitAywga9owDDZ64oZkHWvcNGJTNp5Lx4jAvZy9GX7Qx2M3zczr413Wjkdetu5tabM5W
+phrase=correct horse battery staple
+phrase_token=WBdvhtwfNxtyRamQPNSLaMToEnnLEZr9BWP4qFZSyMvUgK6JD2iw65f6MjsfjNt1jgsdr5KxQishCJLNtVNroFE8qx9G8Z2VBu4Tg6oP7SkuwE72TUbJ14GyhtmeZxYefcX5FEXg7kegwygdAWjhZyq1ZrE7kvXJxVeFpuCPznQ3APfREBa5YAdWNw7BwW28kRdZGz1A1GbZXjW18uNVsQEFZc87GQo9F3WPaDNGmQRijRaYB4GQZyBvZCWwZ3pUwrgYsEEjP2MH91ZCUkbAMEUoYxgAZTo5M3Ki295jfkmBwZz88uMDWn8Vtau4o2bWFYRwLQhCwhoxU44kWEG4fHAEkDXGAseoBLfxZfxPZECq45E92ewXkxQmawsUTNJPFFNveqCGemtYvN6PaFBK5pjpaUQR4TALwG8JPbyrYDh3VR5sFP8dJckyueW8UYPvh569oniHPorouWPqY6dD1kLw4vk
+phrase_fingerprint=17A9-23F6
+default_fingerprint=9138-57AC
diff --git a/src/m_im/ts/ModuleIM.de_DE.ts b/src/m_im/ts/ModuleIM.de_DE.ts
new file mode 100644
index 0000000..7f8866b
--- /dev/null
+++ b/src/m_im/ts/ModuleIM.de_DE.ts
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Run the update_translations CMake target to populate the source strings in this file.
+-->
+<!DOCTYPE TS>
+<TS version="2.1"/>
diff --git a/src/m_im/ts/ModuleIM.en_US.ts b/src/m_im/ts/ModuleIM.en_US.ts
new file mode 100644
index 0000000..7f8866b
--- /dev/null
+++ b/src/m_im/ts/ModuleIM.en_US.ts
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Run the update_translations CMake target to populate the source strings in this file.
+-->
+<!DOCTYPE TS>
+<TS version="2.1"/>
diff --git a/src/m_im/ts/ModuleIM.es_ES.ts b/src/m_im/ts/ModuleIM.es_ES.ts
new file mode 100644
index 0000000..7f8866b
--- /dev/null
+++ b/src/m_im/ts/ModuleIM.es_ES.ts
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Run the update_translations CMake target to populate the source strings in this file.
+-->
+<!DOCTYPE TS>
+<TS version="2.1"/>
diff --git a/src/m_im/ts/ModuleIM.fr_FR.ts b/src/m_im/ts/ModuleIM.fr_FR.ts
new file mode 100644
index 0000000..7f8866b
--- /dev/null
+++ b/src/m_im/ts/ModuleIM.fr_FR.ts
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Run the update_translations CMake target to populate the source strings in this file.
+-->
+<!DOCTYPE TS>
+<TS version="2.1"/>
diff --git a/src/m_im/ts/ModuleIM.it_IT.ts b/src/m_im/ts/ModuleIM.it_IT.ts
new file mode 100644
index 0000000..7f8866b
--- /dev/null
+++ b/src/m_im/ts/ModuleIM.it_IT.ts
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Run the update_translations CMake target to populate the source strings in this file.
+-->
+<!DOCTYPE TS>
+<TS version="2.1"/>
diff --git a/src/m_im/ts/ModuleIM.pl_PL.ts b/src/m_im/ts/ModuleIM.pl_PL.ts
new file mode 100644
index 0000000..7f8866b
--- /dev/null
+++ b/src/m_im/ts/ModuleIM.pl_PL.ts
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Run the update_translations CMake target to populate the source strings in this file.
+-->
+<!DOCTYPE TS>
+<TS version="2.1"/>
diff --git a/src/m_im/ts/ModuleIM.ru_RU.ts b/src/m_im/ts/ModuleIM.ru_RU.ts
new file mode 100644
index 0000000..7f8866b
--- /dev/null
+++ b/src/m_im/ts/ModuleIM.ru_RU.ts
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Run the update_translations CMake target to populate the source strings in this file.
+-->
+<!DOCTYPE TS>
+<TS version="2.1"/>
diff --git a/src/m_im/ts/ModuleIM.zh_CN.ts b/src/m_im/ts/ModuleIM.zh_CN.ts
new file mode 100644
index 0000000..7f8866b
--- /dev/null
+++ b/src/m_im/ts/ModuleIM.zh_CN.ts
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Run the update_translations CMake target to populate the source strings in this file.
+-->
+<!DOCTYPE TS>
+<TS version="2.1"/>
diff --git a/src/m_im/ts/ModuleIM.zh_TW.ts b/src/m_im/ts/ModuleIM.zh_TW.ts
new file mode 100644
index 0000000..7f8866b
--- /dev/null
+++ b/src/m_im/ts/ModuleIM.zh_TW.ts
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Run the update_translations CMake target to populate the source strings in this file.
+-->
+<!DOCTYPE TS>
+<TS version="2.1"/>
diff --git a/src/m_im/ui/main.lua b/src/m_im/ui/main.lua
new file mode 100644
index 0000000..2ce4636
--- /dev/null
+++ b/src/m_im/ui/main.lua
@@ -0,0 +1,31 @@
+-- Instant messaging: where it is offered.
+--
+-- Decrypting a token needs no entry at all: the module's decoder is offered
+-- every text the user decrypts. These are the two ways to make one, and the
+-- page to set the shared phrase.
+
+local encrypt = commands.get("com.bktus.gpgfrontend.module.im.encrypt")
+local encrypt_sign = commands.get("com.bktus.gpgfrontend.module.im.encrypt_sign")
+
+ui.mount {
+ id = "settings",
+ anchor = ui.anchor.settings { section = "features" },
+ widget = native.widget("settings"),
+}
+
+-- A token only means something when the editor's text IS the document.
+-- Whether an encryption applies right now is the commands' own state, which
+-- asks the Host's operations each time the menu is shown.
+local function for_plain_text(ctx)
+ if not ctx.document or ctx.document.type ~= "text" then
+ return { enabled = false }
+ end
+ return { args = { target = ctx.document } }
+end
+
+ui.action { id = "encrypt", anchor = ui.anchor("main.menu.operations"),
+ command = encrypt, icon = ":/icons/email.png",
+ shortcut = "Ctrl+M", update = for_plain_text }
+ui.action { id = "encrypt_sign", anchor = ui.anchor("main.menu.operations"),
+ command = encrypt_sign, icon = ":/icons/email-check.png",
+ shortcut = "Ctrl+Shift+M", update = for_plain_text }