diff --git a/doc/encrypted_attachments.md b/doc/encrypted_attachments.md index 01392230..37fb6c4a 100644 --- a/doc/encrypted_attachments.md +++ b/doc/encrypted_attachments.md @@ -1,9 +1,10 @@ # Зашифрованные вложения группового чата -В desktop chatgui кнопка замка рядом со скрепкой включает шифрование следующего -вложения или голосового сообщения. Выбор фиксируется вместе с адресатом до -подготовки; переход в другую беседу сбрасывает переключатель. В DM используется -существующее автоматическое E2E-шифрование лички. +В desktop chatgui после выбора файла или перетаскивания в чат рядом с его именем +появляется флажок «Зашифровать вложение». Он применяется к выбранному файлу, +изображению или видео. Удаление, замена файла и переход в другую беседу сбрасывают +флажок. Выбор фиксируется вместе с адресатом при отправке, до подготовки. +В DM флажок скрыт: используется существующее автоматическое E2E-шифрование лички. Автор генерирует независимые случайные crypto_UUID и ключ AES-256 на каждую подготовку. Ключ записывается в локальную SQLite до шифрования. Готовый контейнер diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index e225c957..47cc637a 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -112,7 +113,16 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par m_attachPreviewLabel = new QLabel(m_attachPreview); m_attachPreviewLabel->setStyleSheet("background: palette(light); border-radius: 6px; padding: 2px 8px;"); prevLay->addWidget(m_attachPreviewLabel, 1); + m_encryptCheck = new QCheckBox(QCoreApplication::translate("ChatGui", "Encrypt attachment"), m_attachPreview); + m_encryptCheck->setObjectName("encryptAttachmentCheckBox"); + m_encryptCheck->setToolTip(QCoreApplication::translate("ChatGui", "Encrypt attachment; approve each recipient's key request")); + prevLay->addWidget(m_encryptCheck); + connect(m_encryptCheck, &QCheckBox::toggled, this, [this](bool checked) { + GUI_DEBUG("attachment encryption choice: target=%s source=%s encrypted=%d", + qPrintable(m_channelIdForRecord), qPrintable(m_pendingAttachSrc), checked); + }); m_attachClearBtn = new QPushButton(QString::fromUtf8("\xE2\x9C\x95"), m_attachPreview); + m_attachClearBtn->setObjectName("clearAttachmentButton"); m_attachClearBtn->setFixedSize(20, 20); m_attachClearBtn->setCursor(Qt::PointingHandCursor); m_attachClearBtn->setStyleSheet("QPushButton { border: none; background: transparent; color: palette(text); }" @@ -133,13 +143,6 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par QCoreApplication::translate("ChatGui", "Attach"), this); connect(m_attachBtn, &QPushButton::clicked, this, &InputBar::onAttachClicked); layout->addWidget(wrapBottomBtn(m_attachBtn, 3), 0, Qt::AlignBottom); - m_encryptBtn = new QPushButton(QString::fromUtf8("🔒"), this); - m_encryptBtn->setObjectName("encryptAttachmentButton"); - m_encryptBtn->setCheckable(true); - m_encryptBtn->setFixedSize(32, 32); - m_encryptBtn->setToolTip(QCoreApplication::translate("ChatGui", "Encrypt attachment; approve each recipient's key request")); - m_encryptBtn->setAccessibleName(QCoreApplication::translate("ChatGui", "Encrypt attachment")); - layout->addWidget(wrapBottomBtn(m_encryptBtn, 3), 0, Qt::AlignBottom); m_input->setAcceptRichText(false); m_input->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); @@ -413,7 +416,7 @@ void InputBar::onSendClicked() { if (hasAttach) { const quint64 requestId = beginReplySend(reply, m_replyGeneration, m_channelIdForRecord, {}, m_pendingAttachSrc); - const bool encrypted = !m_dmMode && m_encryptBtn->isChecked(); + const bool encrypted = !m_dmMode && m_encryptCheck->isChecked(); if (encrypted) { auto* request = (struct attachment_send_req*)u_calloc(1, sizeof(struct attachment_send_req)); QByteArray source = m_pendingAttachSrc.toUtf8(), name = m_pendingAttachName.toUtf8(); @@ -434,7 +437,6 @@ void InputBar::onSendClicked() { if (reply.size() == 32) memcpy(request->reply_to, reply.constData(), 32); request->request_id = requestId; emit submitAttachment(m_channelIdForRecord, request); - m_encryptBtn->setChecked(false); } else if (m_pendingAttachAsImage) { GUI_INFO("send: image=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); emit sendImageAttachment(m_channelIdForRecord, m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName, reply, requestId); @@ -471,7 +473,6 @@ void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& c if (m_channelIdForRecord != channelId || m_mediaDirBase != mediaDirBase) { clearReply(); ++m_attachmentContext; - m_encryptBtn->setChecked(false); clearPendingAttachment(); if (m_dropHint) m_dropHint->hide(); } @@ -482,14 +483,14 @@ void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& c void InputBar::setDmMode(bool enabled) { m_dmMode = enabled; - m_encryptBtn->setVisible(!enabled); - if (enabled) m_encryptBtn->setChecked(false); + m_encryptCheck->setVisible(!enabled); + if (enabled) m_encryptCheck->setChecked(false); m_pttBtn->setEnabled(true); m_attachBtn->setEnabled(true); } void InputBar::setMediaEnabled(bool enabled) { - if (m_encryptBtn) m_encryptBtn->setEnabled(enabled); + if (m_encryptCheck) m_encryptCheck->setEnabled(enabled); if (m_pttBtn) m_pttBtn->setEnabled(enabled); if (m_attachBtn) m_attachBtn->setEnabled(enabled); if (!enabled && m_dropHint) m_dropHint->hide(); @@ -502,7 +503,6 @@ void InputBar::onPttPressed() { m_pttActive = m_recorder->isRecording(); if (!m_pttActive) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "voice recording did not start; composer remains idle"); return; } m_recordTarget = m_channelIdForRecord; - m_recordEncrypted = !m_dmMode && m_encryptBtn->isChecked(); m_recordReply = m_replyTo; m_recordReplyGeneration = m_replyGeneration; m_replyFailed = false; m_pttBtn->setStyleSheet(m_pttBtn->styleSheet() + "QPushButton { background: #e74c3c; border-radius: 4px; }"); @@ -525,12 +525,10 @@ void InputBar::onPttReleased() { auto* request = (struct attachment_send_req*)u_calloc(1, sizeof(struct attachment_send_req)); if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "InputBar: voice request allocation failed"); return; } request->info.kind = ATTACHMENT_VOICE; request->preset = VoiceEncoder::preset(); - request->encrypted = m_recordEncrypted; if (!m_recorder->takeRecording(request)) { u_free(request); return; } if (m_recordReply.size() == 32) memcpy(request->reply_to, m_recordReply.constData(), 32); request->request_id = beginReplySend(m_recordReply, m_recordReplyGeneration, m_recordTarget); emit submitAttachment(m_recordTarget, request); - if (m_recordTarget == m_channelIdForRecord) m_encryptBtn->setChecked(false); } void InputBar::onAttachClicked() { @@ -595,6 +593,7 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) { .arg(fi.suffix().isEmpty() ? "bin" : fi.suffix()); } + m_encryptCheck->setChecked(false); m_pendingAttachSrc = srcPath; ++m_attachmentRevision; m_pendingAttachDest = destPath; @@ -615,6 +614,7 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) { } void InputBar::clearPendingAttachment() { + m_encryptCheck->setChecked(false); ++m_attachmentRevision; m_pendingAttachSrc.clear(); m_pendingAttachDest.clear(); diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index 2eefaad9..29bec7ee 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -10,6 +10,7 @@ class QTextEdit; class QPushButton; +class QCheckBox; class QLabel; class QProgressBar; class QTimer; @@ -83,8 +84,7 @@ private: QPushButton *m_emojiBtn; QPushButton *m_pttBtn; QPushButton *m_attachBtn = nullptr; - QPushButton *m_encryptBtn = nullptr; - bool m_recordEncrypted = false; + QCheckBox *m_encryptCheck = nullptr; EmojiPanel *m_emojiPanel; AudioRecorder *m_recorder; QWidget *m_recordOverlay = nullptr; diff --git a/tools/chatgui/tests/test_file_drop.cpp b/tools/chatgui/tests/test_file_drop.cpp index c375c2da..1b19b33e 100644 --- a/tools/chatgui/tests/test_file_drop.cpp +++ b/tools/chatgui/tests/test_file_drop.cpp @@ -22,6 +22,7 @@ extern "C" { #include #include #include +#include static QString makeFile(const QTemporaryDir& dir, const QString& name, const QByteArray& contents = "test") { const QString path = dir.filePath(name); @@ -47,12 +48,19 @@ static bool dropUrls(QWidget* target, const QList& urls, bool process = tr class FileDropTest : public QObject { Q_OBJECT private slots: + void encryptedAttachmentCapturesTargetAndResetsChoice_data() { + QTest::addColumn("picker"); + QTest::newRow("drop") << false; QTest::newRow("file-picker") << true; + } void encryptedAttachmentCapturesTargetAndResetsChoice() { + QFETCH(bool, picker); QTemporaryDir dir; QVERIFY(dir.isValid()); const QString file = makeFile(dir, "private.txt", "private bytes"); InputBar bar(nullptr, nullptr); bar.show(); bar.setRecordingContext(dir.filePath("media"), "123"); auto* editor = bar.findChild(); QVERIFY(editor); - auto* encrypt = bar.findChild("encryptAttachmentButton"); QVERIFY(encrypt); + auto* encrypt = bar.findChild("encryptAttachmentCheckBox"); QVERIFY(encrypt); + auto* clear = bar.findChild("clearAttachmentButton"); QVERIFY(clear); + QVERIFY(!encrypt->isVisible()); QVERIFY(!encrypt->isChecked()); QSignalSpy ordinary(&bar, &InputBar::sendFileAttachment); int encrypted = 0; connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString& target, attachment_send_req* request) { @@ -60,13 +68,35 @@ private slots: QCOMPARE(QString::fromUtf8(request->source), file); QCOMPARE(QString::fromUtf8(request->info.name), QString("private.txt")); ++encrypted; u_free(request); }); - QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(file)})); + QTimer timer; timer.setInterval(1); + if (picker) { + connect(&timer, &QTimer::timeout, &bar, [&] { + auto* dialog = qobject_cast(QApplication::activeModalWidget()); + if (!dialog) return; + timer.stop(); dialog->selectFile(file); + QMetaObject::invokeMethod(dialog, "accept", Qt::DirectConnection); + }); + timer.start(); QVERIFY(QMetaObject::invokeMethod(&bar, "onAttachClicked", Qt::DirectConnection)); + } else QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(file)})); + QVERIFY(encrypt->isVisible()); QVERIFY(!encrypt->isChecked()); QTest::mouseClick(encrypt, Qt::LeftButton); QVERIFY(encrypt->isChecked()); QTest::keyClick(editor, Qt::Key_Return); QCOMPARE(encrypted, 1); QCOMPARE(ordinary.size(), 0); QVERIFY(!encrypt->isChecked()); QVERIFY(QFile::exists(file)); + QVERIFY(!encrypt->isVisible()); + QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(file)})); QTest::mouseClick(encrypt, Qt::LeftButton); QVERIFY(encrypt->isChecked()); - bar.setRecordingContext(dir.filePath("media"), "456"); QVERIFY(!encrypt->isChecked()); - bar.setDmMode(true); QVERIFY(encrypt->isHidden()); QVERIFY(!encrypt->isChecked()); + QTest::mouseClick(clear, Qt::LeftButton); QVERIFY(!encrypt->isVisible()); QVERIFY(!encrypt->isChecked()); + QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(file)})); + QTest::mouseClick(encrypt, Qt::LeftButton); QVERIFY(encrypt->isChecked()); + QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(dir.filePath("missing.txt"))})); + QVERIFY(encrypt->isChecked()); + QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(makeFile(dir, "second.txt"))})); + QVERIFY(encrypt->isVisible()); QVERIFY(!encrypt->isChecked()); + QTest::mouseClick(encrypt, Qt::LeftButton); QVERIFY(encrypt->isChecked()); + bar.setRecordingContext(dir.filePath("media"), "456"); QVERIFY(!encrypt->isVisible()); QVERIFY(!encrypt->isChecked()); + bar.setDmMode(true); + QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(file)})); + QVERIFY(!encrypt->isVisible()); QVERIFY(!encrypt->isChecked()); } void replyDraftAndLateResults() {