diff options
author | saturneric <[email protected]> | 2024-11-19 15:00:56 +0000 |
---|---|---|
committer | saturneric <[email protected]> | 2024-11-19 15:00:56 +0000 |
commit | 5b883eebb6992e00a8979000a64e72ff1aae9432 (patch) | |
tree | 4a52c8620e3cb02d2ca3d4b2112c553239ada5bb /src | |
parent | feat: add delete subkey function (diff) | |
download | GpgFrontend-5b883eebb6992e00a8979000a64e72ff1aae9432.tar.gz GpgFrontend-5b883eebb6992e00a8979000a64e72ff1aae9432.zip |
feat: add revoke subkey function
Diffstat (limited to 'src')
-rw-r--r-- | src/core/function/gpg/GpgKeyManager.cpp | 237 | ||||
-rw-r--r-- | src/core/function/gpg/GpgKeyManager.h | 13 | ||||
-rw-r--r-- | src/test/core/GpgCoreTestKeyManagement.cpp | 38 | ||||
-rw-r--r-- | src/ui/dialog/keypair_details/KeyPairSubkeyTab.cpp | 103 | ||||
-rw-r--r-- | src/ui/dialog/keypair_details/KeyPairSubkeyTab.h | 6 |
5 files changed, 330 insertions, 67 deletions
diff --git a/src/core/function/gpg/GpgKeyManager.cpp b/src/core/function/gpg/GpgKeyManager.cpp index bd52c341..753b668f 100644 --- a/src/core/function/gpg/GpgKeyManager.cpp +++ b/src/core/function/gpg/GpgKeyManager.cpp @@ -36,6 +36,64 @@ GpgFrontend::GpgKeyManager::GpgKeyManager(int channel) : SingletonFunctionObject<GpgKeyManager>(channel) {} +auto GpgFrontend::GpgKeyManager::interactor_cb_fnc(void* handle, + const char* status, + const char* args, + int fd) -> gpgme_error_t { + auto* handle_struct = static_cast<AutomatonHandelStruct*>(handle); + QString status_s = status; + QString args_s = args; + + if (status_s == "KEY_CONSIDERED") { + auto tokens = QString(args).split(' '); + + if (tokens.empty() || tokens[0] != handle_struct->KeyFpr()) { + LOG_W() << "handle struct key fpr " << handle_struct->KeyFpr() + << "mismatch token: " << tokens[0] << ", exit..."; + + return -1; + } + + return 0; + } + + if (status_s == "GOT_IT" || status_s.isEmpty()) { + FLOG_D("gpg reply is GOT_IT, continue..."); + return 0; + } + + LOG_D() << "current state" << handle_struct->CurrentStatus() + << "gpg status: " << status_s << ", args: " << args_s; + + AutomatonState next_state = handle_struct->NextState(status_s, args_s); + if (next_state == AS_ERROR) { + FLOG_D("handle struct next state caught error, abort..."); + return -1; + } + LOG_D() << "next state" << next_state; + + if (next_state == AS_SAVE) { + handle_struct->SetSuccess(true); + } + + // set state and preform action + handle_struct->SetStatus(next_state); + Command cmd = handle_struct->Action(); + + LOG_D() << "next action, cmd:" << cmd; + + if (!cmd.isEmpty()) { + auto btye_array = cmd.toUtf8(); + gpgme_io_write(fd, btye_array, btye_array.size()); + gpgme_io_write(fd, "\n", 1); + } else if (status_s == "GET_LINE") { + // avoid trapping in this state + return GPG_ERR_FALSE; + } + + return 0; +} + auto GpgFrontend::GpgKeyManager::SignKey( const GpgFrontend::GpgKey& target, GpgFrontend::KeyArgsList& keys, const QString& uid, const std::unique_ptr<QDateTime>& expires) -> bool { @@ -179,69 +237,110 @@ auto GpgFrontend::GpgKeyManager::SetOwnerTrustLevel(const GpgKey& key, return CheckGpgError(err) == GPG_ERR_NO_ERROR && handel_struct.Success(); } -auto GpgFrontend::GpgKeyManager::interactor_cb_fnc(void* handle, - const char* status, - const char* args, - int fd) -> gpgme_error_t { - auto* handle_struct = static_cast<AutomatonHandelStruct*>(handle); - QString status_s = status; - QString args_s = args; - - if (status_s == "KEY_CONSIDERED") { - auto tokens = QString(args).split(' '); - - if (tokens.empty() || tokens[0] != handle_struct->KeyFpr()) { - LOG_W() << "handle struct key fpr " << handle_struct->KeyFpr() - << "mismatch token: " << tokens[0] << ", exit..."; - - return -1; - } - - return 0; - } - - if (status_s == "GOT_IT" || status_s.isEmpty()) { - FLOG_D("status GOT_IT, continue..."); - return 0; +auto GpgFrontend::GpgKeyManager::DeleteSubkey(const GpgKey& key, + int subkey_index) -> bool { + if (subkey_index < 0 || subkey_index >= key.GetSubKeys()->size()) { + LOG_W() << "illegal subkey index: " << subkey_index; } - LOG_D() << "current state" << handle_struct->CurrentStatus() - << "gpg status: " << status_s << ", args: " << args_s; - - AutomatonState next_state = handle_struct->NextState(status_s, args_s); - if (next_state == AS_ERROR) { - FLOG_D("handle struct next state caught error, abort..."); - return -1; - } - LOG_D() << "next state" << next_state; + AutomatonNextStateHandler next_state_handler = + [](AutomatonState state, QString status, QString args) { + auto tokens = args.split(' '); - if (next_state == AS_SAVE) { - handle_struct->SetSuccess(true); - } + switch (state) { + case AS_START: + if (status == "GET_LINE" && args == "keyedit.prompt") { + return AS_SELECT; + } + return AS_ERROR; + case AS_SELECT: + if (status == "GET_LINE" && args == "keyedit.prompt") { + return AS_COMMAND; + } + return AS_ERROR; + case AS_COMMAND: + if (status == "GET_LINE" && args == "keyedit.prompt") { + return AS_QUIT; + } else if (status == "GET_BOOL" && + args == "keyedit.remove.subkey.okay") { + return AS_REALLY_ULTIMATE; + } + return AS_ERROR; + case AS_REALLY_ULTIMATE: + if (status == "GET_LINE" && args == "keyedit.prompt") { + return AS_QUIT; + } + return AS_ERROR; + case AS_QUIT: + if (status == "GET_BOOL" && args == "keyedit.save.okay") { + return AS_SAVE; + } + return AS_ERROR; + case AS_ERROR: + if (status == "GET_LINE" && args == "keyedit.prompt") { + return AS_QUIT; + } + return AS_ERROR; + default: + return AS_ERROR; + }; + }; - // set state and preform action - handle_struct->SetStatus(next_state); - Command cmd = handle_struct->Action(); + AutomatonActionHandler action_handler = + [subkey_index](AutomatonHandelStruct& handler, AutomatonState state) { + switch (state) { + case AS_SELECT: + return QString("key %1").arg(subkey_index); + case AS_COMMAND: + return QString("delkey"); + case AS_REALLY_ULTIMATE: + handler.SetSuccess(true); + return QString("Y"); + case AS_QUIT: + return QString("quit"); + case AS_SAVE: + handler.SetSuccess(true); + return QString("Y"); + case AS_START: + case AS_ERROR: + return QString(""); + default: + return QString(""); + } + return QString(""); + }; - LOG_D() << "next action, cmd:" << cmd; + auto key_fpr = key.GetFingerprint(); + AutomatonHandelStruct handel_struct(key_fpr); + handel_struct.SetHandler(next_state_handler, action_handler); - if (!cmd.isEmpty()) { - auto btye_array = cmd.toUtf8(); - gpgme_io_write(fd, btye_array, btye_array.size()); - gpgme_io_write(fd, "\n", 1); - } else if (status_s == "GET_LINE") { - // avoid trapping in this state - return GPG_ERR_FALSE; - } + GpgData data_out; - return 0; + auto err = + gpgme_op_interact(ctx_.DefaultContext(), static_cast<gpgme_key_t>(key), 0, + GpgKeyManager::interactor_cb_fnc, + static_cast<void*>(&handel_struct), data_out); + return CheckGpgError(err) == GPG_ERR_NO_ERROR && handel_struct.Success(); } -auto GpgFrontend::GpgKeyManager::DeleteSubkey(const GpgKey& key, - int subkey_index) -> bool { + +auto GpgFrontend::GpgKeyManager::RevokeSubkey( + const GpgKey& key, int subkey_index, int reason_code, + const QString& reason_text) -> bool { if (subkey_index < 0 || subkey_index >= key.GetSubKeys()->size()) { LOG_W() << "illegal subkey index: " << subkey_index; } + // dealing with reason text +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 4) + auto reason_text_lines = + GpgFrontend::SecureCreateSharedObject<QList<QString>>( + reason_text.split('\n', Qt::SkipEmptyParts).toVector()); +#else + auto reason_text_lines = + GpgFrontend::SecureCreateSharedObject<QVector<QString>>( + reason_text.split('\n', Qt::SkipEmptyParts).toVector()); +#endif + AutomatonNextStateHandler next_state_handler = [](AutomatonState state, QString status, QString args) { auto tokens = args.split(' '); @@ -261,13 +360,35 @@ auto GpgFrontend::GpgKeyManager::DeleteSubkey(const GpgKey& key, if (status == "GET_LINE" && args == "keyedit.prompt") { return AS_QUIT; } else if (status == "GET_BOOL" && - args == "keyedit.remove.subkey.okay") { + args == "keyedit.revoke.subkey.okay") { + return AS_REALLY_ULTIMATE; + } + return AS_ERROR; + case AS_REASON_CODE: + if (status == "GET_LINE" && args == "keyedit.prompt") { + return AS_QUIT; + } else if (status == "GET_LINE" && + args == "ask_revocation_reason.text") { + return AS_REASON_TEXT; + } + return AS_ERROR; + case AS_REASON_TEXT: + if (status == "GET_LINE" && args == "keyedit.prompt") { + return AS_QUIT; + } else if (status == "GET_LINE" && + args == "ask_revocation_reason.text") { + return AS_REASON_TEXT; + } else if (status == "GET_BOOL" && + args == "ask_revocation_reason.okay") { return AS_REALLY_ULTIMATE; } return AS_ERROR; case AS_REALLY_ULTIMATE: if (status == "GET_LINE" && args == "keyedit.prompt") { return AS_QUIT; + } else if (status == "GET_LINE" && + args == "ask_revocation_reason.code") { + return AS_REASON_CODE; } return AS_ERROR; case AS_QUIT: @@ -286,14 +407,20 @@ auto GpgFrontend::GpgKeyManager::DeleteSubkey(const GpgKey& key, }; AutomatonActionHandler action_handler = - [subkey_index](AutomatonHandelStruct& handler, AutomatonState state) { + [subkey_index, reason_code, reason_text_lines]( + AutomatonHandelStruct& handler, AutomatonState state) { switch (state) { case AS_SELECT: return QString("key %1").arg(subkey_index); case AS_COMMAND: - return QString("delkey"); + return QString("revkey"); + case AS_REASON_CODE: + return QString::number(reason_code); + case AS_REASON_TEXT: + return reason_text_lines->isEmpty() + ? QString("") + : QString(reason_text_lines->takeFirst().toUtf8()); case AS_REALLY_ULTIMATE: - handler.SetSuccess(true); return QString("Y"); case AS_QUIT: return QString("quit"); diff --git a/src/core/function/gpg/GpgKeyManager.h b/src/core/function/gpg/GpgKeyManager.h index 83a38d05..986e835f 100644 --- a/src/core/function/gpg/GpgKeyManager.h +++ b/src/core/function/gpg/GpgKeyManager.h @@ -101,6 +101,17 @@ class GPGFRONTEND_CORE_EXPORT GpgKeyManager */ auto DeleteSubkey(const GpgKey& key, int subkey_index) -> bool; + /** + * @brief + * + * @param key + * @param subkey_index + * @return true + * @return false + */ + auto RevokeSubkey(const GpgKey& key, int subkey_index, int reason_code, + const QString& reason_text) -> bool; + private: static auto interactor_cb_fnc(void* handle, const char* status, const char* args, int fd) -> gpgme_error_t; @@ -111,6 +122,8 @@ class GPGFRONTEND_CORE_EXPORT GpgKeyManager AS_SELECT, AS_COMMAND, AS_VALUE, + AS_REASON_CODE, + AS_REASON_TEXT, AS_REALLY_ULTIMATE, AS_SAVE, AS_ERROR, diff --git a/src/test/core/GpgCoreTestKeyManagement.cpp b/src/test/core/GpgCoreTestKeyManagement.cpp index 114c237a..135a7e51 100644 --- a/src/test/core/GpgCoreTestKeyManagement.cpp +++ b/src/test/core/GpgCoreTestKeyManagement.cpp @@ -107,6 +107,7 @@ cBEIUb80jrN959lF8eobqrVouY5GyvZXVZFGoXS4OTkFAwlEZxWBxJw= )"; namespace GpgFrontend::Test { + TEST_F(GpgCoreTest, CoreDeleteSubkeyTestA) { auto info = GpgKeyImportExporter::GetInstance().ImportKey( GFBuffer(QString::fromLatin1(TEST_PRIVATE_KEY_DATA))); @@ -127,6 +128,7 @@ TEST_F(GpgCoreTest, CoreDeleteSubkeyTestA) { ASSERT_TRUE(res); + GpgKeyGetter::GetInstance().FlushKeyCache(); key = GpgKeyGetter::GetInstance(kGpgFrontendDefaultChannel) .GetKey("822D7E13F5B85D7D"); ASSERT_TRUE(key.IsGood()); @@ -213,4 +215,40 @@ TEST_F(GpgCoreTest, CoreSetOwnerTrustA) { GpgKeyOpera::GetInstance().DeleteKey(key.GetId()); } +TEST_F(GpgCoreTest, CoreRevokeSubkeyTestA) { + auto info = GpgKeyImportExporter::GetInstance().ImportKey( + GFBuffer(QString::fromLatin1(TEST_PRIVATE_KEY_DATA))); + + ASSERT_EQ(info->not_imported, 0); + ASSERT_EQ(info->imported, 1); + + auto key = GpgKeyGetter::GetInstance(kGpgFrontendDefaultChannel) + .GetKey("822D7E13F5B85D7D"); + ASSERT_TRUE(key.IsGood()); + + auto subkeys = key.GetSubKeys(); + + ASSERT_EQ(subkeys->size(), 5); + ASSERT_EQ((*subkeys)[2].GetID(), "2D1F9FC59B568A8C"); + + auto res = GpgKeyManager::GetInstance().RevokeSubkey( + key, 2, 2, QString("H\nE\nLL\nO\n\n")); + + ASSERT_TRUE(res); + + GpgKeyGetter::GetInstance().FlushKeyCache(); + key = GpgKeyGetter::GetInstance(kGpgFrontendDefaultChannel) + .GetKey("822D7E13F5B85D7D"); + ASSERT_TRUE(key.IsGood()); + + subkeys = key.GetSubKeys(); + + ASSERT_EQ(subkeys->size(), 5); + ASSERT_EQ((*subkeys)[2].GetID(), "2D1F9FC59B568A8C"); + + ASSERT_TRUE((*subkeys)[2].IsRevoked()); + + GpgKeyOpera::GetInstance().DeleteKey(key.GetId()); +} + } // namespace GpgFrontend::Test
\ No newline at end of file diff --git a/src/ui/dialog/keypair_details/KeyPairSubkeyTab.cpp b/src/ui/dialog/keypair_details/KeyPairSubkeyTab.cpp index 335f9c53..ded630e7 100644 --- a/src/ui/dialog/keypair_details/KeyPairSubkeyTab.cpp +++ b/src/ui/dialog/keypair_details/KeyPairSubkeyTab.cpp @@ -37,6 +37,7 @@ #include "core/utils/IOUtils.h" #include "ui/UISignalStation.h" #include "ui/UserInterfaceUtils.h" +#include "ui/dialog/RevocationOptionsDialog.h" namespace GpgFrontend::UI { @@ -85,14 +86,16 @@ KeyPairSubkeyTab::KeyPairSubkeyTab(int channel, const QString& key_id, subkey_detail_layout->addWidget( new QLabel(tr("Create Date (Local Time)") + ": "), 7, 0); subkey_detail_layout->addWidget(new QLabel(tr("Existence") + ": "), 8, 0); - subkey_detail_layout->addWidget(new QLabel(tr("Key in Smart Card") + ": "), 9, - 0); - subkey_detail_layout->addWidget(new QLabel(tr("Fingerprint") + ": "), 10, 0); + subkey_detail_layout->addWidget(new QLabel(tr("Revoked") + ": "), 9, 0); + subkey_detail_layout->addWidget(new QLabel(tr("Key in Smart Card") + ": "), + 10, 0); + subkey_detail_layout->addWidget(new QLabel(tr("Fingerprint") + ": "), 11, 0); key_type_var_label_ = new QLabel(this); key_id_var_label_ = new QLabel(this); key_size_var_label_ = new QLabel(this); expire_var_label_ = new QLabel(this); + revoke_var_label_ = new QLabel(this); algorithm_var_label_ = new QLabel(this); algorithm_detail_var_label_ = new QLabel(this); created_var_label_ = new QLabel(this); @@ -114,8 +117,9 @@ KeyPairSubkeyTab::KeyPairSubkeyTab(int channel, const QString& key_id, subkey_detail_layout->addWidget(expire_var_label_, 6, 1, 1, 2); subkey_detail_layout->addWidget(created_var_label_, 7, 1, 1, 2); subkey_detail_layout->addWidget(master_key_exist_var_label_, 8, 1, 1, 2); - subkey_detail_layout->addWidget(card_key_label_, 9, 1, 1, 2); - subkey_detail_layout->addWidget(fingerprint_var_label_, 10, 1, 1, 2); + subkey_detail_layout->addWidget(revoke_var_label_, 9, 1, 1, 2); + subkey_detail_layout->addWidget(card_key_label_, 10, 1, 1, 2); + subkey_detail_layout->addWidget(fingerprint_var_label_, 11, 1, 1, 2); export_subkey_button_ = new QPushButton(tr("Export Subkey")); export_subkey_button_->setFlat(true); @@ -191,7 +195,6 @@ void KeyPairSubkeyTab::slot_refresh_subkey_list() { this->buffered_subkeys_.clear(); auto sub_keys = key_.GetSubKeys(); for (auto& sub_key : *sub_keys) { - if (sub_key.IsDisabled() || sub_key.IsRevoked()) continue; this->buffered_subkeys_.push_back(std::move(sub_key)); } @@ -232,12 +235,20 @@ void KeyPairSubkeyTab::slot_refresh_subkey_list() { tmp6->setTextAlignment(Qt::AlignCenter); subkey_list_->setItem(row, 6, tmp6); - if (!row) { + if (row == 0) { for (auto i = 0; i < subkey_list_->columnCount(); i++) { subkey_list_->item(row, i)->setForeground(QColor(65, 105, 255)); } } + if (subkey.IsExpired() || subkey.IsRevoked()) { + for (auto i = 0; i < subkey_list_->columnCount(); i++) { + auto font = subkey_list_->item(row, i)->font(); + font.setStrikeOut(true); + subkey_list_->item(row, i)->setFont(font); + } + } + row++; } @@ -332,6 +343,17 @@ void KeyPairSubkeyTab::slot_refresh_subkey_detail() { key_type_var_label_->setText( subkey.IsHasCertificationCapability() ? tr("Primary Key") : tr("Subkey")); + + revoke_var_label_->setText(subkey.IsRevoked() ? tr("Yes") : tr("No")); + if (!subkey.IsRevoked()) { + auto palette_expired = revoke_var_label_->palette(); + palette_expired.setColor(revoke_var_label_->foregroundRole(), Qt::red); + revoke_var_label_->setPalette(palette_expired); + } else { + auto palette_valid = revoke_var_label_->palette(); + palette_valid.setColor(revoke_var_label_->foregroundRole(), Qt::darkGreen); + revoke_var_label_->setPalette(palette_valid); + } } void KeyPairSubkeyTab::create_subkey_opera_menu() { @@ -348,8 +370,13 @@ void KeyPairSubkeyTab::create_subkey_opera_menu() { connect(delete_subkey_act_, &QAction::triggered, this, &KeyPairSubkeyTab::slot_delete_subkey); + revoke_subkey_act_ = new QAction(tr("Revoke")); + connect(revoke_subkey_act_, &QAction::triggered, this, + &KeyPairSubkeyTab::slot_revoke_subkey); + subkey_opera_menu_->addAction(export_subkey_act_); subkey_opera_menu_->addAction(edit_subkey_act_); + subkey_opera_menu_->addAction(revoke_subkey_act_); subkey_opera_menu_->addAction(delete_subkey_act_); } @@ -360,10 +387,9 @@ void KeyPairSubkeyTab::slot_edit_subkey() { dialog->show(); } -void KeyPairSubkeyTab::slot_revoke_subkey() {} - void KeyPairSubkeyTab::contextMenuEvent(QContextMenuEvent* event) { - if (key_.IsPrivateKey() && !subkey_list_->selectedItems().isEmpty()) { + // must have primary key before do any actions on subkey + if (key_.IsHasMasterKey() && !subkey_list_->selectedItems().isEmpty()) { const auto& subkey = get_selected_subkey(); if (subkey.IsHasCertificationCapability()) return; @@ -371,6 +397,8 @@ void KeyPairSubkeyTab::contextMenuEvent(QContextMenuEvent* event) { export_subkey_act_->setDisabled(!subkey.IsSecretKey()); edit_subkey_act_->setDisabled(!subkey.IsSecretKey()); delete_subkey_act_->setDisabled(!subkey.IsSecretKey()); + revoke_subkey_act_->setDisabled(!subkey.IsSecretKey() || + subkey.IsRevoked()); subkey_opera_menu_->exec(event->globalPos()); } @@ -486,4 +514,59 @@ void KeyPairSubkeyTab::slot_delete_subkey() { emit SignalKeyDatabaseRefresh(); } + +void KeyPairSubkeyTab::slot_revoke_subkey() { + const auto& subkey = get_selected_subkey(); + const auto subkeys = key_.GetSubKeys(); + + QString message = tr("<h3>Revoke Subkey Confirmation</h3><br />" + "<b>KeyID:</b> %1<br />2<br />" + "Revoking a subkey will make it permanently unusable. " + "This action is <b>irreversible</b>.<br />" + "Are you sure you want to revoke this subkey?") + .arg(subkey.GetID()); + + int ret = QMessageBox::warning(this, tr("Revoke Subkey"), message, + QMessageBox::Cancel | QMessageBox::Yes, + QMessageBox::Cancel); + + if (ret != QMessageBox::Yes) return; + + int index = 0; + for (const auto& sk : *subkeys) { + if (sk.GetFingerprint() == subkey.GetFingerprint()) { + break; + } + index++; + } + + if (index == 0) { + QMessageBox::critical( + this, tr("Illegal Operation"), + tr("Cannot revoke the primary key or an invalid subkey.")); + return; + } + + auto* revocation_options_dialog = new RevocationOptionsDialog(this); + + connect(revocation_options_dialog, + &RevocationOptionsDialog::SignalRevokeOptionAccepted, this, + [key = key_, index, channel = current_gpg_context_channel_, this]( + int code, const QString& text) { + auto res = GpgKeyManager::GetInstance(channel).RevokeSubkey( + key, index, code, text); + if (!res) { + QMessageBox::critical( + nullptr, tr("Revocation Failed"), + tr("Failed to revoke the subkey. Please try again.")); + } else { + QMessageBox::information( + nullptr, tr("Revocation Successful"), + tr("The subkey has been successfully revoked.")); + emit SignalKeyDatabaseRefresh(); + } + }); + + revocation_options_dialog->show(); +} } // namespace GpgFrontend::UI diff --git a/src/ui/dialog/keypair_details/KeyPairSubkeyTab.h b/src/ui/dialog/keypair_details/KeyPairSubkeyTab.h index 1222b77d..9c8daeb9 100644 --- a/src/ui/dialog/keypair_details/KeyPairSubkeyTab.h +++ b/src/ui/dialog/keypair_details/KeyPairSubkeyTab.h @@ -78,8 +78,9 @@ class KeyPairSubkeyTab : public QWidget { QMenu* subkey_opera_menu_{}; ///< QLabel* key_type_var_label_; - QLabel* key_size_var_label_; ///< Label containing the keys key size - QLabel* expire_var_label_; ///< Label containing the keys expiration date + QLabel* key_size_var_label_; ///< Label containing the keys key size + QLabel* expire_var_label_; ///< Label containing the keys expiration date + QLabel* revoke_var_label_; QLabel* created_var_label_; ///< Label containing the keys creation date QLabel* algorithm_var_label_; ///< Label containing the keys algorithm QLabel* algorithm_detail_var_label_; ///< @@ -94,6 +95,7 @@ class KeyPairSubkeyTab : public QWidget { QAction* edit_subkey_act_; QAction* delete_subkey_act_; + QAction* revoke_subkey_act_; private slots: |