aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-09-13 17:50:22 +0200
committersaturneric <[email protected]>2026-09-13 17:50:22 +0200
commitbf79d46bd0e8eddd1ad0976cfe158c2574813474 (patch)
tree32eb298fe8c62e6929f5099c609c79edcf0445d8 /src
parentfeat(email): unify signature verification (diff)
downloadmodules-bf79d46bd0e8eddd1ad0976cfe158c2574813474.tar.gz
modules-bf79d46bd0e8eddd1ad0976cfe158c2574813474.zip
refactor(email): centralize verification result handling
- route verification requests through the host-owned crypto operation - cache and validate one result for badges, attachments, and security details - avoid automatic verification when opening tabs or refreshing keyrings - invalidate pending and cached results when message data changes - distinguish inconclusive checks from bad or unchecked signatures - prevent removing locked message parts after selection changes
Diffstat (limited to 'src')
-rw-r--r--src/m_email/EMailPageView.cpp171
-rw-r--r--src/m_email/EMailPageView.h54
2 files changed, 164 insertions, 61 deletions
diff --git a/src/m_email/EMailPageView.cpp b/src/m_email/EMailPageView.cpp
index d44538e..df349a0 100644
--- a/src/m_email/EMailPageView.cpp
+++ b/src/m_email/EMailPageView.cpp
@@ -78,6 +78,7 @@
#include "EMailSecurityView.h"
#include "EMailSendDialog.h"
#include "EMailStructureView.h"
+#include "EMailVerificationPayload.h"
#include "EMailViewLayout.h"
#include "EMailViewStyle.h"
#include "GFModuleCommonUtils.hpp"
@@ -383,12 +384,10 @@ void EMailPageView::build_details_dialog() {
details_tabs_->addTab(header_view_, tr("Headers"));
// The security view can ask for the two things it used to only describe.
+ // Asking is all it does: the answer arrives at ApplyVerificationResult()
+ // and repaints everything, whichever of the ways to ask was used.
connect(security_view_, &EMailSecurityView::SignalVerifyAgainRequested, this,
- [this]() {
- run_verification();
- refresh_security();
- refresh_attachments();
- });
+ [this]() { request_verification(); });
connect(security_view_, &EMailSecurityView::SignalImportMessageKeysRequested,
this, &EMailPageView::import_message_keys);
@@ -1011,7 +1010,12 @@ void EMailPageView::sync_details() {
true, details_tabs_->currentWidget() == security_view_)) {
return;
}
- ensure_regions_verified();
+
+ // Shows what is known; it does not go and find out. Opening a tab used to
+ // verify the message, which meant looking at it started a crypto operation
+ // -- a wait dialog and a status report the user never asked for -- and gave
+ // this view a verification of its own, disagreeing with the host's. Until
+ // the user asks, the honest answer is that nobody has checked.
refresh_security();
}
@@ -1392,9 +1396,8 @@ void EMailPageView::refresh_structure() {
// Results belong to the document that produced them. A new load has not
// verified anything yet, and carrying the previous message's verdicts over
// would be worse than showing none.
- signature_results_.clear();
+ discard_verification();
recipient_rows_.clear();
- verify_state_ = EMailVerifyState::kNOT_ATTEMPTED;
vmime::shared_ptr<vmime::message> parsed;
if (!CheckIfEMLMessage(last_source_, parsed)) {
@@ -1573,18 +1576,23 @@ void EMailNotifyKeyringChanged() {
void EMailPageView::NotifyKeyringChanged() {
if (regions_.isEmpty()) return;
- // The previous answers were about a keyring that no longer exists.
- verify_state_ = EMailVerifyState::kNOT_ATTEMPTED;
- signature_results_.clear();
+ // The previous answer was about a keyring that no longer exists: a signature
+ // reported as unverifiable because the key was missing becomes verifiable
+ // the moment that key is imported, and showing the old answer afterwards is
+ // showing something that is no longer true.
+ //
+ // DISCARDED, not redone. Importing a key is not asking to verify, and a
+ // keyring refresh reaching every open message must not set a crypto
+ // operation running behind each of them. The surface goes back to saying
+ // nobody has checked, which is now the case, and the user can ask.
+ discard_verification();
+ refresh_security_button();
+ refresh_attachments();
- // Only redo the work now if it is being looked at; otherwise the next visit
- // to the Security tab picks it up, which is where the verification is
- // normally triggered anyway.
if (EMailNeedsSecurityRefresh(
details_visible(),
details_tabs_ != nullptr &&
details_tabs_->currentWidget() == security_view_)) {
- ensure_regions_verified();
refresh_security();
}
}
@@ -1635,34 +1643,61 @@ void EMailPageView::sync_inspection() {
apply_content_lock();
}
-void EMailPageView::ensure_regions_verified() {
- if (verify_state_ != EMailVerifyState::kNOT_ATTEMPTED || regions_.isEmpty() ||
- last_source_.isEmpty()) {
- return;
- }
+void EMailPageView::discard_verification() {
+ cached_verification_ = {};
- // Once per load, unless asked again. A verification that produced nothing is
- // still an answer, and repeating it on every focus would re-run the engine
- // for no new information -- but it is an answer the user is now able to tell
- // apart from "not tried yet", and so is able to ask to have taken again.
- run_verification();
+ // Whatever was in flight was about the document as it was. Cleared with the
+ // answer it would have filled, so a request that is now meaningless cannot
+ // leave this page unable to make another one.
+ verification_pending_ = false;
}
-void EMailPageView::run_verification() {
- // Can wait on the agent, on this thread.
- const EMailBusyCursor busy;
+void EMailPageView::request_verification() {
+ if (verification_pending_) return;
+ if (regions_.isEmpty() || last_source_.isEmpty()) return;
+
+ // Asked for through the host, like every other crypto operation this view
+ // offers. There is ONE verification in this application and the host owns
+ // starting it: a second one here, over this view's own copy of the bytes,
+ // is what let the status report and this surface describe the same message
+ // differently.
+ verification_pending_ = true;
+ emit SignalCryptoOperationRequested("verify");
+}
+
+void EMailPageView::ApplyVerificationResult(const QByteArray& payload) {
+ // Terminal either way: whatever happens below, this page is free to ask
+ // again afterwards. A request that could never be answered must not leave
+ // the view permanently silent.
+ verification_pending_ = false;
+
+ if (payload.isEmpty()) {
+ // The request ended without an answer -- refused before it reached the
+ // engine, or cancelled. Nothing to render; what matters is that the page
+ // has been released to ask again, which happened above.
+ MLogDebug("a verification request ended without a result");
+ return;
+ }
+
+ EMailVerificationResult result;
+ if (!DecodeVerificationPayload(payload, result)) {
+ // A payload from a build that means something different by it, or one that
+ // did not survive the trip. Half of a verdict is worse than none: it would
+ // be rendered as though it were the whole answer.
+ MLogWarn("discarding a verification result that could not be read");
+ return;
+ }
- signature_results_.clear();
- VerifyEMLRegions(GFGpgCurrentGpgContextChannel(), last_source_, tree_root_,
- regions_, signature_results_);
+ // An answer about bytes that are no longer here. The document can be edited,
+ // or the tab switched, between asking and being answered, and painting the
+ // old verdict onto the new bytes tells the user something about a message
+ // that is not in front of them.
+ if (!VerificationMatchesSource(result, last_source_)) {
+ MLogWarn("discarding a verification result for a different document");
+ return;
+ }
- // The distinction the Security tab needs: a signed message with no results
- // after a verification has been attempted is a different thing from one
- // nothing has looked at yet, and saying "nothing has verified it yet" for
- // both left the user with no way to know which they were reading.
- verify_state_ = signature_results_.isEmpty()
- ? EMailVerifyState::kATTEMPTED_EMPTY
- : EMailVerifyState::kVERIFIED;
+ cached_verification_ = result;
// What the message surface is entitled to say has just changed. Without
// this the badge kept the structural wording it was given at load time, so
@@ -1670,6 +1705,7 @@ void EMailPageView::run_verification() {
// long as the tab stayed open.
refresh_security_button();
refresh_attachments();
+ refresh_security();
}
void EMailPageView::slot_derive_message(int mode) {
@@ -2103,12 +2139,14 @@ void EMailPageView::refresh_security() {
const auto findings =
tree_root_.content_type.isEmpty()
? QList<EMailFinding>{}
- : InspectMessage(message_, tree_root_, regions_, last_source_);
+ : InspectMessage(message_, tree_root_, regions_,
+ cached_verification_.verdicts);
- security_view_->SetMessage(security_state_, regions_, signature_results_,
- recipient_rows_, addresses, message_.from,
- GFGpgCurrentGpgContextChannel(), findings,
- verify_state_, !message_key_parts().isEmpty());
+ security_view_->SetMessage(
+ security_state_, regions_, cached_verification_.signatures,
+ recipient_rows_, addresses, message_.from,
+ GFGpgCurrentGpgContextChannel(), findings, cached_verification_.state,
+ !message_key_parts().isEmpty());
}
/**
@@ -2264,8 +2302,19 @@ auto EMailPageView::security_badge() const -> EMailBadgeState {
// alone, so a multipart/signed whose signature part held forty bytes of
// garbage read as "Signed", in the same accent colour as a verified one,
// and nothing on the message surface ever contradicted it.
- return DeriveSecurityBadge(security_state_, verify_state_, signature_results_,
- message_.from);
+ //
+ // Quoted, never recomputed: a verification produced this, and the status
+ // report quotes the same one. Deriving a second opinion here is exactly how
+ // this surface came to call a message forged while the report called it
+ // verified.
+ if (cached_verification_.state != EMailVerifyState::kNOT_ATTEMPTED) {
+ return cached_verification_.overall;
+ }
+
+ // Nothing has verified this document. Structure is all that can honestly be
+ // said about it, which is what this answers with an empty result.
+ return DeriveSecurityBadge(security_state_, EMailVerifyState::kNOT_ATTEMPTED,
+ {}, message_.from);
}
void EMailPageView::paint_security_button() {
@@ -2334,6 +2383,13 @@ void EMailPageView::refresh_security_button() {
text = tr("Bad signature");
icon = ":/icons/warning.png";
break;
+ case EMailBadgeState::kSIGNED_ERROR:
+ // Not the same claim as a bad signature, and not the same as one nobody
+ // has looked at. The check was made and could not produce an answer,
+ // which is the one outcome trying again might change.
+ text = tr("Signature could not be checked");
+ icon = ":/icons/warning.png";
+ break;
case EMailBadgeState::kMALFORMED:
text = tr("Malformed OpenPGP structure");
icon = ":/icons/warning.png";
@@ -2429,6 +2485,10 @@ void EMailPageView::rebuild_security_menu() {
open_details(section);
};
const auto request = [this](const QString& op) {
+ // Verify goes through the one place that tracks a request in flight, so
+ // that the badge's own menu, the Security tab's button and the toolbar are
+ // all the same single request rather than three ways to start three.
+ if (op == "verify") return request_verification();
emit SignalCryptoOperationRequested(op);
};
@@ -2662,7 +2722,10 @@ void EMailPageView::refresh_attachments() {
// badge bug -- a part inside a subtree whose signature is forged, or
// whose key is missing, was labelled exactly like a checked one.
//
- // So the claim is only as strong as what has actually been checked.
+ // So the claim is only as strong as what has actually been checked --
+ // and it is the SAME answer the badge and the status report quote,
+ // because all three read the one verification rather than each working
+ // something out for itself.
const auto badge = security_badge();
if (badge == EMailBadgeState::kSIGNED_GOOD) {
item->setText(kColSigned, tr("signed"));
@@ -2670,6 +2733,11 @@ void EMailPageView::refresh_attachments() {
} else if (badge == EMailBadgeState::kSIGNED_UNVERIFIED) {
item->setText(kColSigned, tr("signed, not checked"));
EMailSetCellTone(item, kColSigned, EMailTone::kMUTED, this);
+ } else if (badge == EMailBadgeState::kSIGNED_ERROR) {
+ // The check ran and could not answer. Saying "not good" here would
+ // report an inconclusive check as evidence against the part.
+ item->setText(kColSigned, tr("signed, could not be checked"));
+ EMailSetCellTone(item, kColSigned, EMailTone::kWARN, this);
} else {
// The signature covering it came back bad, unknown or mismatched.
// Whatever the Security tab says about that signature is what this
@@ -2893,9 +2961,8 @@ void EMailPageView::WipeContent() {
wipe_tree(tree_root_);
tree_root_ = EMailPart{};
regions_.clear();
- signature_results_.clear();
+ discard_verification();
recipient_rows_.clear();
- verify_state_ = EMailVerifyState::kNOT_ATTEMPTED;
inspection_stale_ = false;
security_state_ = EMailSecurityState::kPLAIN;
view_state_ = EMailViewState{};
@@ -2937,7 +3004,13 @@ void EMailPageView::slot_selection_changed() {
const auto selected = attachment_list_->selectedItems().size();
const auto total = attachment_list_->topLevelItemCount();
- remove_button_->setEnabled(selected > 0);
+ // Selection alone is not enough to remove a part: a signed or encrypted
+ // message does not change, and this runs AFTER apply_content_lock() on every
+ // refresh, so without the lock here it handed the button straight back.
+ // Saving a part out does not change the message, so those two only follow
+ // the selection.
+ const auto locked = content_lock() != EMailLockReason::kNONE;
+ remove_button_->setEnabled(selected > 0 && !locked);
save_button_->setEnabled(selected > 0);
save_all_button_->setEnabled(total > 0);
diff --git a/src/m_email/EMailPageView.h b/src/m_email/EMailPageView.h
index 4c8d1c3..72810d5 100644
--- a/src/m_email/EMailPageView.h
+++ b/src/m_email/EMailPageView.h
@@ -126,6 +126,25 @@ class EMailPageView : public QWidget {
void NotifyKeyringChanged(); // NOLINT
/**
+ * @brief Takes a verification the host performed as this message's answer.
+ *
+ * The consuming half of the one-verification contract: the badge, the
+ * attachment list and the Security section all render what arrives here,
+ * and this view never asks an engine anything itself. Two verifiers over
+ * two byte sequences is how the status report came to call a message
+ * verified while this surface called it forged.
+ *
+ * Refuses rather than guesses. A payload this build cannot fully read, or
+ * one produced for bytes this document no longer holds -- it may have been
+ * edited while the answer was in flight -- is dropped, leaving the surface
+ * saying what it honestly knows instead of showing a verdict about a
+ * different message.
+ *
+ * @param payload the wire form; see EMailVerificationPayload.h
+ */
+ void ApplyVerificationResult(const QByteArray& payload); // NOLINT
+
+ /**
* @brief Takes the host's raw document editor and presents it as a mode.
*
* Optional half of the page/view contract: a page that finds this member
@@ -451,17 +470,23 @@ class EMailPageView : public QWidget {
/// operation have established. Read-only: it never writes the document, so
/// opening the section cannot change the bytes.
void refresh_security();
- /// Verifies each signature region, once, the first time the Security section
- /// is actually looked at. Deferred rather than done on load because a message
- /// may carry several signatures and most are never opened; verifying
- /// reads the message and writes nothing, so it is safe to run here.
/// What the security surface may claim, from structure AND verification.
+ /// Quotes the cached result rather than deriving a second opinion.
[[nodiscard]] auto security_badge() const -> EMailBadgeState;
- void ensure_regions_verified();
- /// Verifies every signed region now, whatever has been tried before, and
- /// records which of EMailVerifyState the outcome was. Runs on this thread.
- void run_verification();
+ /// Asks the HOST to verify this message. Nothing here performs the
+ /// verification: there is one verification in this application and the host
+ /// owns starting it, so that the status report and this surface can never
+ /// end up describing the same message differently.
+ ///
+ /// Only ever in response to the user asking. Looking at a message is not
+ /// asking for a crypto operation.
+ void request_verification();
+
+ /// Forgets the cached answer, and any request waiting to fill it. For when
+ /// the document or the keyring beneath it has changed and the answer is
+ /// about neither of them any more.
+ void discard_verification();
/// Chooses between the formatted and plain renderings of the body, and
/// shows the toggle only when the message actually offers both.
void refresh_body_view();
@@ -485,11 +510,16 @@ class EMailPageView : public QWidget {
EMailPart tree_root_;
QList<EMailSignatureRegion> regions_;
EMailSecurityState security_state_{EMailSecurityState::kPLAIN};
- /// Verification results, each already stamped with the region whose bytes
- /// produced it. Empty until an operation has actually run.
- QList<EMailSignatureResult> signature_results_;
+ /// The one verification answer this view renders from -- the badge, the
+ /// attachment list and the Security section all read it, and none of them
+ /// works anything out for itself. Produced by the host, not here. Empty
+ /// until a verification the user asked for has come back.
+ EMailVerificationResult cached_verification_;
+ /// A request is out and its answer has not arrived. Guards against asking
+ /// twice for one document; cleared on EVERY way the request can end,
+ /// including the ones where no answer ever comes.
+ bool verification_pending_{false};
QList<EMailRecipientRow> recipient_rows_;
- EMailVerifyState verify_state_{EMailVerifyState::kNOT_ATTEMPTED};
/// Whether edits have left the inspection views describing an older document
/// than the one the editor now holds.
bool inspection_stale_{false};