diff options
| author | saturneric <[email protected]> | 2026-09-24 22:36:31 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-09-24 22:36:31 +0200 |
| commit | cd318335f9700c5740166fba519a0e1f118c6a36 (patch) | |
| tree | ca06efa18ef4eeaffb978f82c5f1a236bb5d8727 /src | |
| parent | feat(im): add IM module to sandbox build (diff) | |
| download | modules-cd318335f9700c5740166fba519a0e1f118c6a36.tar.gz modules-cd318335f9700c5740166fba519a0e1f118c6a36.zip | |
feat(im): add instant messaging token module
- add encrypted Base58 token encoding with shared book phrases
- provide IM encrypt and encrypt-and-sign editor commands
- add settings UI for phrase management and fingerprints
- register the module and its SDK-free codec tests
- register existing email module tests with CTest
Diffstat (limited to 'src')
| -rw-r--r-- | src/m_email/test/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | src/m_im/CMakeLists.txt | 40 | ||||
| -rw-r--r-- | src/m_im/ImCodec.cpp | 159 | ||||
| -rw-r--r-- | src/m_im/ImCodec.h | 80 | ||||
| -rw-r--r-- | src/m_im/ImModule.cpp | 273 | ||||
| -rw-r--r-- | src/m_im/ImSettingsPage.cpp | 300 | ||||
| -rw-r--r-- | src/m_im/ImSettingsPage.h | 81 | ||||
| -rw-r--r-- | src/m_im/ImToken.cpp | 796 | ||||
| -rw-r--r-- | src/m_im/ImToken.h | 143 | ||||
| -rw-r--r-- | src/m_im/module.json | 23 | ||||
| -rw-r--r-- | src/m_im/test/CMakeLists.txt | 58 | ||||
| -rw-r--r-- | src/m_im/test/ImCodecTest.cpp | 132 | ||||
| -rw-r--r-- | src/m_im/test/ImTokenTest.cpp | 332 | ||||
| -rw-r--r-- | src/m_im/test/im_core_fixtures.txt | 6 | ||||
| -rw-r--r-- | src/m_im/ts/ModuleIM.de_DE.ts | 6 | ||||
| -rw-r--r-- | src/m_im/ts/ModuleIM.en_US.ts | 6 | ||||
| -rw-r--r-- | src/m_im/ts/ModuleIM.es_ES.ts | 6 | ||||
| -rw-r--r-- | src/m_im/ts/ModuleIM.fr_FR.ts | 6 | ||||
| -rw-r--r-- | src/m_im/ts/ModuleIM.it_IT.ts | 6 | ||||
| -rw-r--r-- | src/m_im/ts/ModuleIM.pl_PL.ts | 6 | ||||
| -rw-r--r-- | src/m_im/ts/ModuleIM.ru_RU.ts | 6 | ||||
| -rw-r--r-- | src/m_im/ts/ModuleIM.zh_CN.ts | 6 | ||||
| -rw-r--r-- | src/m_im/ts/ModuleIM.zh_TW.ts | 6 | ||||
| -rw-r--r-- | src/m_im/ui/main.lua | 31 |
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 } |
