aboutsummaryrefslogtreecommitdiffstats
path: root/src/m_email/EMailSecurityView.cpp
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-09-12 10:26:03 +0200
committersaturneric <[email protected]>2026-09-12 10:26:03 +0200
commit0203289d99ee376c98842e80b821a2c554cb01a8 (patch)
treeb99514c3d0d042fc133b481a6c10637095484b1d /src/m_email/EMailSecurityView.cpp
parentfeat(email): add OpenPGP message analysis model (diff)
downloadmodules-0203289d99ee376c98842e80b821a2c554cb01a8.tar.gz
modules-0203289d99ee376c98842e80b821a2c554cb01a8.zip
feat(email): add secure message inspection views
- add formatted body rendering with blocked remote content and safe links - add MIME structure, header, and security inspection tabs - show signatures, recipients, keys, findings, and part digests - preserve raw headers and message bytes during inspection - add forensic read-only mode with secure content wiping - support reply, reply-all, forwarding, and drag-and-drop attachments - track Bcc recipients separately from serialized message headers - refresh signature verification when the keyring changes - centralize email view styling and theme-aware controls
Diffstat (limited to 'src/m_email/EMailSecurityView.cpp')
-rw-r--r--src/m_email/EMailSecurityView.cpp443
1 files changed, 443 insertions, 0 deletions
diff --git a/src/m_email/EMailSecurityView.cpp b/src/m_email/EMailSecurityView.cpp
new file mode 100644
index 0000000..b003a75
--- /dev/null
+++ b/src/m_email/EMailSecurityView.cpp
@@ -0,0 +1,443 @@
+/**
+ * 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 "EMailSecurityView.h"
+
+#include <GFSDKGpg.h>
+
+#include <QApplication>
+#include <QHeaderView>
+#include <QLabel>
+#include <QLocale>
+#include <QTreeWidget>
+#include <QVBoxLayout>
+
+#include "EMailHelper.h"
+#include "EMailViewStyle.h"
+
+namespace {
+
+constexpr int kColItem = 0;
+constexpr int kColValue = 1;
+
+// Mirrors GpgFrontend::GpgKeyStatus, which GFGpgKeyBrief::usability reports.
+enum KeyUsability : int {
+ kUSABLE = 0,
+ kEXPIRING_SOON = 1,
+ kEXPIRED = 2,
+ kREVOKED = 3,
+ kDISABLED = 4,
+};
+
+auto DescribeUsability(int usability) -> QString {
+ switch (usability) {
+ case kUSABLE:
+ return QApplication::translate("EMailSecurityView", "usable");
+ case kEXPIRING_SOON:
+ return QApplication::translate("EMailSecurityView", "expiring soon");
+ case kEXPIRED:
+ return QApplication::translate("EMailSecurityView", "expired");
+ case kREVOKED:
+ return QApplication::translate("EMailSecurityView", "revoked");
+ case kDISABLED:
+ return QApplication::translate("EMailSecurityView", "disabled");
+ default:
+ return QApplication::translate("EMailSecurityView", "unknown");
+ }
+}
+
+// Mirrors GpgFrontend::GpgSigValidity.
+auto DescribeValidity(int validity) -> QString {
+ switch (validity) {
+ case 0:
+ return QApplication::translate("EMailSecurityView", "valid");
+ case 1:
+ return QApplication::translate("EMailSecurityView", "valid, with issues");
+ case 2:
+ return QApplication::translate("EMailSecurityView",
+ "valid, key not fully trusted");
+ case 3:
+ return QApplication::translate("EMailSecurityView", "invalid");
+ case 4:
+ return QApplication::translate("EMailSecurityView", "public key missing");
+ case 5:
+ return QApplication::translate("EMailSecurityView",
+ "signing key revoked");
+ case 6:
+ return QApplication::translate("EMailSecurityView", "signature expired");
+ case 7:
+ return QApplication::translate("EMailSecurityView",
+ "signing key expired");
+ default:
+ return QApplication::translate("EMailSecurityView", "unknown");
+ }
+}
+
+auto ValidityIsGood(int validity) -> bool { return validity <= 2; }
+
+} // namespace
+
+EMailSecurityView::EMailSecurityView(QWidget* parent) : QWidget(parent) {
+ build_ui();
+}
+
+void EMailSecurityView::build_ui() {
+ auto* layout = new QVBoxLayout(this);
+ layout->setContentsMargins(8, 6, 8, 6);
+ layout->setSpacing(6);
+
+ headline_ = new QLabel(this);
+ headline_->setWordWrap(true);
+ layout->addWidget(headline_);
+
+ tree_ = new QTreeWidget(this);
+ tree_->setUniformRowHeights(true);
+ tree_->setAlternatingRowColors(true);
+ tree_->setHeaderLabels({tr("Item"), tr("Detail")});
+ tree_->header()->setStretchLastSection(true);
+ tree_->header()->setSectionResizeMode(kColItem,
+ QHeaderView::ResizeToContents);
+ layout->addWidget(tree_, 1);
+}
+
+void EMailSecurityView::Clear() {
+ tree_->clear();
+ headline_->clear();
+}
+
+auto EMailSecurityView::add_group(const QString& title) -> QTreeWidgetItem* {
+ auto* group = new QTreeWidgetItem(tree_);
+ group->setText(kColItem, title);
+ auto font = group->font(kColItem);
+ font.setBold(true);
+ group->setFont(kColItem, font);
+ group->setFirstColumnSpanned(true);
+ return group;
+}
+
+void EMailSecurityView::add_signature_section(
+ const QList<EMailSignatureRegion>& regions,
+ const QList<EMailSignatureResult>& results) {
+ if (regions.isEmpty()) return;
+
+ auto* group = add_group(tr("Signatures"));
+ const auto muted = EMailMutedColor(this);
+
+ for (const auto& region : regions) {
+ auto* region_item = new QTreeWidgetItem(group);
+ region_item->setText(kColItem,
+ tr("Signature %1").arg(region.region_id + 1));
+ region_item->setText(
+ kColValue, region.nesting_depth == 0
+ ? tr("covers %1").arg(EMailHumanSize(region.raw_length))
+ : tr("covers %1, nested %2 deep")
+ .arg(EMailHumanSize(region.raw_length))
+ .arg(region.nesting_depth));
+
+ // Results are gathered by region id. Position in either list means
+ // nothing: a region can produce several signatures, or none at all.
+ QList<EMailSignatureResult> mine;
+ for (const auto& result : results) {
+ if (result.region_id == region.region_id) mine.append(result);
+ }
+
+ if (mine.isEmpty()) {
+ auto* none = new QTreeWidgetItem(region_item);
+ none->setText(kColItem, tr("Result"));
+ // Said plainly rather than left blank: a signed range that nothing has
+ // vouched for is a fact about the message, not a gap in the display.
+ none->setText(kColValue,
+ tr("this section is signed, but nothing has verified it "
+ "yet"));
+ none->setForeground(kColValue, muted);
+ }
+
+ for (const auto& result : mine) {
+ auto* item = new QTreeWidgetItem(region_item);
+ item->setText(kColItem,
+ result.uid.isEmpty() ? tr("Unknown signer") : result.uid);
+ item->setText(kColValue, DescribeValidity(result.validity));
+ item->setForeground(kColValue, ValidityIsGood(result.validity)
+ ? EMailAccentColor(this, true)
+ : EMailWarningColor(this));
+
+ const auto detail = [&](const QString& name, const QString& value) {
+ if (value.isEmpty()) return;
+ auto* row = new QTreeWidgetItem(item);
+ row->setText(kColItem, name);
+ row->setText(kColValue, value);
+ row->setForeground(kColItem, muted);
+ };
+
+ detail(tr("Fingerprint"), result.fingerprint);
+ detail(tr("Public key algorithm"), result.pubkey_algo);
+ detail(tr("Signed at"), result.sign_time.isValid()
+ ? QLocale().toString(result.sign_time)
+ : QString());
+
+ // The two hash values side by side, each labelled for what it is: one
+ // is the sender's claim, the other is what was actually used.
+ detail(tr("Declared hash (micalg)"), region.declared_micalg);
+ detail(tr("Signature hash"), result.hash_algo);
+
+ if (result.micalg_mismatch) {
+ auto* warn = new QTreeWidgetItem(item);
+ warn->setText(kColItem, tr("Hash mismatch"));
+ warn->setText(kColValue,
+ tr("the message declares %1 but the signature used %2")
+ .arg(region.declared_micalg, result.hash_algo));
+ warn->setForeground(kColValue, EMailWarningColor(this));
+ }
+
+ for (const auto& warning : result.warnings) {
+ auto* warn = new QTreeWidgetItem(item);
+ warn->setText(kColItem, tr("Warning"));
+ warn->setText(kColValue, warning);
+ warn->setForeground(kColValue, EMailWarningColor(this));
+ }
+ }
+ }
+}
+
+void EMailSecurityView::add_recipient_section(
+ const QList<EMailRecipientRow>& recipients) {
+ if (recipients.isEmpty()) return;
+
+ auto* group = add_group(tr("Recipients"));
+ const auto muted = EMailMutedColor(this);
+
+ for (const auto& row : recipients) {
+ auto* item = new QTreeWidgetItem(group);
+
+ switch (row.match) {
+ case RecipientMatch::kMATCHED:
+ item->setText(kColItem, row.address);
+ item->setText(kColValue, tr("addressed and encrypted to"));
+ item->setForeground(kColValue, EMailAccentColor(this, true));
+ break;
+
+ case RecipientMatch::kADDRESSED_NOT_ENCRYPTED:
+ // The only genuine warning in this section: this person was told the
+ // message is for them and cannot open it.
+ item->setText(kColItem, row.address);
+ item->setText(kColValue,
+ tr("in %1, but NOT encrypted to - they cannot read this")
+ .arg(row.header_field));
+ item->setForeground(kColValue, EMailWarningColor(this));
+ break;
+
+ case RecipientMatch::kENCRYPTED_NOT_ADDRESSED:
+ // Ordinary and usually correct: a blind copy, an archive key, or the
+ // sender's own key. Reported, not flagged.
+ item->setText(kColItem, row.info.uid.isEmpty()
+ ? tr("Key %1").arg(row.info.key_id)
+ : row.info.uid);
+ item->setText(kColValue,
+ tr("encrypted to, but not in the visible headers"));
+ item->setForeground(kColValue, muted);
+ break;
+
+ case RecipientMatch::kHIDDEN_RECIPIENT:
+ item->setText(kColItem, tr("Hidden recipient"));
+ item->setText(kColValue,
+ tr("the sender chose not to record who this is"));
+ item->setForeground(kColValue, muted);
+ break;
+ }
+
+ if (!row.info.fingerprint.isEmpty()) {
+ auto* fpr = new QTreeWidgetItem(item);
+ fpr->setText(kColItem, tr("Fingerprint"));
+ fpr->setText(kColValue, row.info.fingerprint);
+ fpr->setForeground(kColItem, muted);
+ }
+
+ if (row.info.algo_is_primary_key) {
+ auto* note = new QTreeWidgetItem(item);
+ note->setText(kColItem, tr("Note"));
+ note->setText(kColValue,
+ tr("the engine reported the primary key rather than the "
+ "encryption subkey actually used"));
+ note->setForeground(kColValue, muted);
+ }
+ }
+}
+
+void EMailSecurityView::add_key_section(const QStringList& addresses,
+ int channel) {
+ if (addresses.isEmpty()) return;
+
+ auto* group = add_group(tr("Keys for these addresses"));
+ const auto muted = EMailMutedColor(this);
+
+ QStringList seen;
+ for (const auto& address : addresses) {
+ const auto email = AddressOfUid(address);
+ if (email.isEmpty() || seen.contains(email)) continue;
+ seen.append(email);
+
+ GFGpgKeyBrief* briefs = nullptr;
+ int count = 0;
+ GFGpgFindKeysByEmail(channel, email.toUtf8().constData(), &briefs, &count);
+
+ auto* item = new QTreeWidgetItem(group);
+ item->setText(kColItem, email);
+
+ if (count == 0) {
+ item->setText(kColValue, tr("no key found"));
+ item->setForeground(kColValue, EMailWarningColor(this));
+ GFGpgFreeKeyBriefs(briefs, count);
+ continue;
+ }
+
+ item->setText(kColValue,
+ count == 1 ? tr("1 key") : tr("%1 keys").arg(count));
+
+ for (int i = 0; i < count; ++i) {
+ const auto& brief = briefs[i];
+
+ auto* key_item = new QTreeWidgetItem(item);
+ key_item->setText(kColItem, QString::fromUtf8(brief.uid));
+
+ // Usability. Says whether the key can be used at all -- and nothing
+ // whatsoever about whose key it is.
+ key_item->setText(
+ kColValue, tr("key is %1").arg(DescribeUsability(brief.usability)));
+ key_item->setForeground(kColValue, brief.usability == kUSABLE
+ ? EMailAccentColor(this, true)
+ : EMailWarningColor(this));
+
+ auto* fpr = new QTreeWidgetItem(key_item);
+ fpr->setText(kColItem, tr("Fingerprint"));
+ fpr->setText(kColValue, QString::fromUtf8(brief.fingerprint));
+ fpr->setForeground(kColItem, muted);
+
+ // Identity binding, kept as its own row. A usable key carrying this
+ // address only on a secondary or revoked UID is exactly the case a
+ // single combined verdict would bury.
+ auto* identity = new QTreeWidgetItem(key_item);
+ identity->setText(kColItem, tr("Identity"));
+ if (brief.matched_uid_revoked != 0) {
+ identity->setText(
+ kColValue, tr("this address is on a REVOKED user ID of the key"));
+ identity->setForeground(kColValue, EMailWarningColor(this));
+ } else if (brief.matched_uid_is_primary != 0) {
+ identity->setText(kColValue, tr("this address is the key's primary "
+ "user ID"));
+ identity->setForeground(kColValue, EMailAccentColor(this, true));
+ } else {
+ identity->setText(kColValue,
+ tr("this address is a secondary user ID of the key"));
+ identity->setForeground(kColValue, muted);
+ }
+
+ if (brief.can_encrypt == 0) {
+ auto* note = new QTreeWidgetItem(key_item);
+ note->setText(kColItem, tr("Note"));
+ note->setText(kColValue, tr("this key cannot be used for encryption"));
+ note->setForeground(kColValue, EMailWarningColor(this));
+ }
+ }
+
+ GFGpgFreeKeyBriefs(briefs, count);
+ }
+}
+
+void EMailSecurityView::add_findings_section(
+ const QList<EMailFinding>& findings) {
+ if (findings.isEmpty()) return;
+
+ auto* group = add_group(tr("What stands out"));
+
+ for (const auto& finding : findings) {
+ auto* item = new QTreeWidgetItem(group);
+ item->setText(kColItem, finding.title);
+ item->setText(kColValue, finding.detail);
+
+ switch (finding.level) {
+ case EMailFindingLevel::kRISK:
+ // The only level painted as danger. Reserved for things that are
+ // actually wrong or built to deceive.
+ item->setForeground(kColItem, EMailThemeColor(this, &GFUIDangerColor));
+ break;
+ case EMailFindingLevel::kWARN:
+ item->setForeground(kColItem, EMailWarningColor(this));
+ break;
+ case EMailFindingLevel::kNOTE:
+ item->setForeground(kColItem, EMailMutedColor(this));
+ break;
+ }
+ }
+}
+
+void EMailSecurityView::SetMessage(EMailSecurityState state,
+ const QList<EMailSignatureRegion>& regions,
+ const QList<EMailSignatureResult>& results,
+ const QList<EMailRecipientRow>& recipients,
+ const QStringList& addresses, int channel,
+ const QList<EMailFinding>& findings) {
+ tree_->clear();
+
+ switch (state) {
+ case EMailSecurityState::kPLAIN:
+ headline_->setText(tr("This message is not signed or encrypted."));
+ break;
+ case EMailSecurityState::kSIGNED:
+ headline_->setText(tr("This message carries a signature."));
+ break;
+ case EMailSecurityState::kENCRYPTED:
+ headline_->setText(tr("This message is encrypted."));
+ break;
+ case EMailSecurityState::kSIGNED_ENCRYPTED:
+ headline_->setText(
+ tr("This message is encrypted and carries a "
+ "signature."));
+ break;
+ case EMailSecurityState::kMALFORMED_PGP:
+ headline_->setText(
+ tr("This message claims to use OpenPGP but its "
+ "structure does not follow RFC 3156."));
+ break;
+ }
+
+ auto palette = headline_->palette();
+ palette.setColor(QPalette::WindowText,
+ state == EMailSecurityState::kMALFORMED_PGP
+ ? EMailWarningColor(this)
+ : EMailMutedColor(this));
+ headline_->setPalette(palette);
+
+ // Findings first: if something about this message is deceptive, that is the
+ // thing to read before any of the detail below it.
+ add_findings_section(findings);
+ add_signature_section(regions, results);
+ add_recipient_section(recipients);
+ add_key_section(addresses, channel);
+
+ tree_->expandAll();
+}