diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ include(ECMQtDeclareLoggingCategory) include(ECMAddTests) -set(PIM_VERSION "5.3.40") +set(PIM_VERSION "5.3.41") set(LIBKLEO_LIB_VERSION ${PIM_VERSION}) set(QT_REQUIRED_VERSION "5.5.0") set(GPGMEPP_LIB_VERSION "5.2.40") diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -99,6 +99,7 @@ ui/keyselectiondialog.cpp ui/keyrequester.cpp ui/keyapprovaldialog.cpp + ui/keyselectioncombo.cpp ) ki18n_wrap_ui(libkleo_ui_common_SRCS diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -17,3 +17,4 @@ add_kleo_test(test_keylister.cpp) add_kleo_test(test_auditlog.cpp) add_kleo_test(test_keyformailbox.cpp) +add_kleo_test(test_keyselectioncombo.cpp) diff --git a/src/tests/test_keyselectioncombo.cpp b/src/tests/test_keyselectioncombo.cpp new file mode 100644 --- /dev/null +++ b/src/tests/test_keyselectioncombo.cpp @@ -0,0 +1,70 @@ +/* + This file is part of libkleopatra's test suite. + Copyright (c) 2016 Klarälvdalens Datakonsult AB + + Libkleopatra is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License, + version 2, as published by the Free Software Foundation. + + Libkleopatra 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 this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "ui/keyselectioncombo.h" +#include + +#include +#include + +#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + KAboutData aboutData(QStringLiteral("test_keyselectioncombo"), i18n("KeySelectionCombo Test"), QStringLiteral("0.1")); + QCommandLineParser parser; + QCommandLineOption openpgpOption(QStringLiteral("openpgp"), i18n("Show OpenPGP keys")); + parser.addOption(openpgpOption); + QCommandLineOption smimeOption(QStringLiteral("smime"), i18n("Show S/MIME keys")); + parser.addOption(smimeOption); + + KAboutData::setApplicationData(aboutData); + parser.addVersionOption(); + parser.addHelpOption(); + aboutData.setupCommandLine(&parser); + parser.process(app); + aboutData.processCommandLine(&parser); + + QWidget window; + QVBoxLayout layout(&window); + + Kleo::KeySelectionCombo combo(parser.isSet(smimeOption) ? GpgME::CMS : GpgME::OpenPGP, Kleo::KeySelectionCombo::AnyKeys); + combo.setIdentity(QString(), QString()); + layout.addWidget(&combo); + + window.show(); + + /* + if (dlg.exec() == QDialog::Accepted) { + qDebug() << "accepted; selected key:" << (dlg.selectedKey().userID(0).id() ? dlg.selectedKey().userID(0).id() : "") << "\nselected _keys_:"; + for (std::vector::const_iterator it = dlg.selectedKeys().begin(); it != dlg.selectedKeys().end(); ++it) { + qDebug() << (it->userID(0).id() ? it->userID(0).id() : ""); + } + } else { + qDebug() << "rejected"; + } + */ + + return app.exec(); +} + diff --git a/src/ui/keyselectioncombo.h b/src/ui/keyselectioncombo.h new file mode 100644 --- /dev/null +++ b/src/ui/keyselectioncombo.h @@ -0,0 +1,67 @@ +/* This file is part of Kleopatra, the KDE keymanager + Copyright (c) 2016 Klarälvdalens Datakonsult AB + + Kleopatra 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 2 of the License, or + (at your option) any later version. + + Kleopatra 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 this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef KLEO_KEYSELECTIONCOMBO_H +#define KLEO_KEYSELECTIONCOMBO_H + +#include + +#include + +#include +#include "kleo/enum.h" + +namespace GpgME +{ +class Key; +} + +namespace Kleo +{ + +class KeySelectionComboPrivate; +class KLEO_EXPORT KeySelectionCombo : public QComboBox +{ + Q_OBJECT + +public: + enum KeyPurpose { + SigningKeys = 0, + EncryptionKeys = 1, + AnyKeys = SigningKeys | EncryptionKeys + }; + + explicit KeySelectionCombo(GpgME::Protocol protocol, + KeyPurpose keyPurpose, + QWidget *parent = Q_NULLPTR); + virtual ~KeySelectionCombo(); + + void setIdentity(const QString &name, const QString &email); + + GpgME::Key currentKey() const; + void setCurrentKey(const GpgME::Key &key); + +Q_SIGNALS: + void currentKeyChanged(const GpgME::Key &key); + +private: + KeySelectionComboPrivate * const d; +}; + +} +#endif diff --git a/src/ui/keyselectioncombo.cpp b/src/ui/keyselectioncombo.cpp new file mode 100644 --- /dev/null +++ b/src/ui/keyselectioncombo.cpp @@ -0,0 +1,489 @@ +/* This file is part of Kleopatra, the KDE keymanager + Copyright (c) 2016 Klarälvdalens Datakonsult AB + + Kleopatra 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 2 of the License, or + (at your option) any later version. + + Kleopatra 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 this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "keyselectioncombo.h" +#include + +#include "kleo/cryptobackendfactory.h" +#include "kleo/keylistjob.h" +#include "kleo/keygenerationjob.h" +#include "kleo/dn.h" +#include "progressbar.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +Q_DECLARE_METATYPE(GpgME::UserID) + +namespace +{ +static QString iconPath(const QString &name) +{ + return QStandardPaths::locate(QStandardPaths::GenericDataLocation, "libkleopatra/pics/" + name + ".png"); +} + +class KeyGenerationDialog : public QDialog +{ + Q_OBJECT +public: + explicit KeyGenerationDialog(const QString &name, + const QString &email, + const Kleo::CryptoBackend::Protocol *backend, + QWidget *parent = Q_NULLPTR) + : QDialog(parent) + , mBackend(backend) + , mOrganizationEdit(Q_NULLPTR) + , mCountryCodeEdit(Q_NULLPTR) + { + setWindowTitle(i18n("Generate a new Key Pair")); + auto layout = new QVBoxLayout(this); + mStack = new QStackedWidget(this); + layout->addWidget(mStack); + + { + auto w = new QWidget; + auto wLayout = new QFormLayout(); + w->setLayout(wLayout); + + mNameEdit = new QLineEdit(this); + mNameEdit->setText(name); + connect(mNameEdit, &QLineEdit::textChanged, + this, &KeyGenerationDialog::validateIdentity); + wLayout->addRow(i18n("Name:"), mNameEdit); + + mEmailEdit = new QLineEdit(this); + mEmailEdit->setText(email); + connect(mEmailEdit, &QLineEdit::textChanged, + this, &KeyGenerationDialog::validateIdentity); + wLayout->addRow(i18n("Email:"), mEmailEdit); + + if (backend->name() == Kleo::CryptoBackend::SMIME) { + mOrganizationEdit = new QLineEdit(this); + connect(mOrganizationEdit, &QLineEdit::textChanged, + this, &KeyGenerationDialog::validateIdentity); + wLayout->addRow(i18n("Organization:"), mOrganizationEdit); + + mCountryCodeEdit = new QLineEdit(this); + connect(mCountryCodeEdit, &QLineEdit::textChanged, + this, &KeyGenerationDialog::validateIdentity); + wLayout->addRow(i18n("Country code:"), mCountryCodeEdit); + } + + mStack->addWidget(w); + } + { + auto w = new QWidget; + auto wLayout = new QVBoxLayout(); + w->setLayout(wLayout); + + auto progressbar = new Kleo::ProgressBar(w); + progressbar->setRange(0, 0); + wLayout->addStretch(2); + wLayout->addWidget(progressbar, 1, Qt::AlignVCenter | Qt::AlignHCenter); + wLayout->addStretch(2); + + mStack->addWidget(w); + } + + mButtonBox = new QDialogButtonBox(this); + mButtonBox->addButton(QDialogButtonBox::Ok); + mButtonBox->addButton(QDialogButtonBox::Cancel); + connect(mButtonBox, &QDialogButtonBox::accepted, + this, &KeyGenerationDialog::okClicked); + connect(mButtonBox, &QDialogButtonBox::rejected, + this, &KeyGenerationDialog::reject); + layout->addWidget(mButtonBox); + } + + ~KeyGenerationDialog() + { + } + + GpgME::Key key() const + { + return mKey; + } + +private Q_SLOTS: + void okClicked() + { + if (mStack->currentIndex() == 0) { + mButtonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + mStack->setCurrentIndex(1); + // Encode email + QString email = mEmailEdit->text(); + const int pos = email.lastIndexOf('@'); + if (pos > -1) { + email = email.left(pos + 1) + QString::fromLatin1(QUrl::toAce(email.mid(pos + 1))); + } + + QString args; + // FIXME: :-( Isn't the purpose of libkleo to abstract away this madness? + // + // The hardcoded key types and lengths are based on the default + // values in Kleopatra's New Certificate Wizard. It would be nice if + // we could invoke the wizard through "Advanced" button here, because + // I don't want to duplicate the whole UI for that here, but there's + // no API for that. Would be nice to have a unified key generation UI + // provided by libkleo... + if (mBackend->name() == Kleo::CryptoBackend::OpenPGP) { + args = QStringLiteral("\n" + "%ask-passphrase\n" + "key-type: RSA\n" + "key-length: 2048\n" + "key-usage: sign\n" + "subkey-type: RSA\n" + "subkey-length: 2048\n" + "subkey-usage: encrypt\n" + "name-email: %1\n" + "name-real: %2\n" + "").arg(email, + mNameEdit->text()); + } else { + args = QStringLiteral("\n" + "key-type: RSA\n" + "key-length: 2048\n" + "key-usage: sign encrypt\n" + "name-email: %1\n" + "name-dn: CN=%2,O=%3,C=%4\n" + "").arg(email, + mNameEdit->text(), + mOrganizationEdit->text(), + mCountryCodeEdit->text()); + } + qCDebug(KLEO_UI_LOG) << args << "\n"; + + auto job = mBackend->keyGenerationJob(); + connect(job, &Kleo::KeyGenerationJob::result, + this, &KeyGenerationDialog::keyGenerated); + connect(this, &KeyGenerationDialog::rejected, + job, &Kleo::KeyGenerationJob::slotCancel); + job->start(args); + } + } + + void keyGenerated(const GpgME::KeyGenerationResult &result) + { + if (result.error().code()) { + if (!result.error().isCanceled()) { + KMessageBox::error(this, i18n("An error occurred when generating a new key pair: %1", + result.error().asString()), + i18n("Error")); + } + reject(); + return; + } + + // FIXME: No backend->getKey()? + QScopedPointer context(GpgME::Context::createForProtocol(GpgME::OpenPGP)); + GpgME::Error e; + mKey = context->key(result.fingerprint(), e); + if (e && !e.isCanceled()) { + KMessageBox::error(this, i18n("An error occurred when retrieving the new key: %1", e.asString()), + i18n("Error")); + reject(); + return; + } + + accept(); + } + + void validateIdentity() + { + if (mStack->currentIndex() == 0) { + mButtonBox->button(QDialogButtonBox::Ok)->setEnabled( + !mNameEdit->text().isEmpty() && + !mEmailEdit->text().isEmpty() && + (!mOrganizationEdit || !mOrganizationEdit->text().isEmpty()) && + (!mCountryCodeEdit || !mCountryCodeEdit->text().isEmpty())); + } + } + +private: + GpgME::Key mKey; + const Kleo::CryptoBackend::Protocol *mBackend; + QLineEdit *mNameEdit; + QLineEdit *mEmailEdit; + QLineEdit *mOrganizationEdit; + QLineEdit *mCountryCodeEdit; + QStackedWidget *mStack; + QDialogButtonBox *mButtonBox; +}; + +class KeySortModel : public QSortFilterProxyModel +{ + Q_OBJECT +public: + KeySortModel(QObject *parent = Q_NULLPTR) + : QSortFilterProxyModel(parent) + { + } + + bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const Q_DECL_OVERRIDE + { + const GpgME::UserID uidLeft = source_left.data(Qt::UserRole).value(); + const GpgME::UserID uidRight = source_right.data(Qt::UserRole).value(); + + if (uidLeft.isNull()) { + return uidRight.isNull(); + } + if (uidRight.isNull()) { + return !uidLeft.isNull(); + } + + // More trusted keys go to the top + if (uidLeft.validity() > uidRight.validity()) { + return true; + } + + // Group equally trusted keys by ID + int cmp = qstrcmp(uidLeft.parent().shortKeyID(), uidRight.parent().shortKeyID()); + if (cmp != 0) { + return cmp < 0; + } + + // Group equally trusted keys with the same ID by user's name + cmp = QString::localeAwareCompare(uidLeft.name(), uidRight.name()); + if (cmp != 0) { + return cmp < 0; + } + + // Group equally trusted keys with the same ID and name by email address + return qstrcmp(uidLeft.email(), uidRight.email()) < 0; + } +}; + +} + +namespace Kleo +{ +class KeySelectionComboPrivate +{ +public: + KeySelectionComboPrivate(KeySelectionCombo *parent) + : q(parent) + { + } + + QIcon iconForValidity(GpgME::UserID::Validity validity) const; + + void keysReceived(const GpgME::KeyListResult &result, + const std::vector &keys); + + void addKey(const GpgME::Key &key); + void createNewKeyPair(); + void currentIndexChanged(); + + QString name; + QString email; + const CryptoBackend::Protocol *backend; + QStandardItemModel *model; + KeySortModel *sortModel; + KeyGenerationDialog *generationDialog; + GpgME::Protocol protocol; + KeySelectionCombo::KeyPurpose keyPurpose; + +private: + KeySelectionCombo * const q; +}; + +} + +using namespace Kleo; + +void KeySelectionComboPrivate::keysReceived(const GpgME::KeyListResult &result, + const std::vector &keys) +{ + model->clear(); + + if (result.error().code()) { + qCWarning(KLEO_UI_LOG) << result.error().asString(); + return; + } + + qCDebug(KLEO_UI_LOG) << "Received" << keys.size() << "keys"; + for (const auto &key : keys) { + addKey(key); + } + sortModel->sort(0); + + auto item = new QStandardItem(QIcon::fromTheme(QStringLiteral("document-new")), + i18n("Create a new key pair")); + item->setData(QStringLiteral("create-new-key-pair"), Qt::UserRole); + model->appendRow(item); +} + +void Kleo::KeySelectionComboPrivate::addKey(const GpgME::Key& key) +{ + qCDebug(KLEO_UI_LOG) << "Received key" << key.keyID(); + if (keyPurpose & KeySelectionCombo::EncryptionKeys && !key.canEncrypt()) { + qCDebug(KLEO_UI_LOG) << "\tUnsuitable: cannot encrypt"; + return; + } + if (keyPurpose & KeySelectionCombo::SigningKeys && !key.canSign()) { + qCDebug(KLEO_UI_LOG) << "\tUnsuitable: cannot sign"; + return; + } + + for (const auto &userId : key.userIDs()) { + QString name, email; + if (protocol == GpgME::OpenPGP) { + name = userId.name(); + email = userId.email(); + } else { + Kleo::DN dn(userId.id()); + name = dn[QStringLiteral("CN")]; + email = dn[QStringLiteral("EMAIL")]; + if (name.isEmpty() || email.isEmpty()) { + continue; + } + } + + const QIcon icon = iconForValidity(userId.validity()); + const QString keyName = i18nc("Name (key ID)", "%1 <%2> (%3)", + name, email, key.shortKeyID()); + auto item = new QStandardItem(icon, keyName); + item->setData(QVariant::fromValue(userId), Qt::UserRole); + model->appendRow(item); + } +} + + +void KeySelectionComboPrivate::currentIndexChanged() +{ + const auto uid = q->currentData(Qt::UserRole).value(); + if (uid.isNull()) { + if (q->currentData(Qt::UserRole).toString() == QLatin1String("create-new-key-pair")) { + QTimer::singleShot(0, q, [this]() { createNewKeyPair(); }); + return; + } else { + // Invalid key? + } + } else { + Q_EMIT q->currentKeyChanged(uid.parent()); + } +} + +void KeySelectionComboPrivate::createNewKeyPair() +{ + QScopedPointer dlg(new KeyGenerationDialog(name, email, backend, q)); + if (dlg->exec()) { + const bool blocked = q->blockSignals(true); + addKey(dlg->key()); + q->blockSignals(!blocked); + q->setCurrentKey(dlg->key()); + } +} + +QIcon KeySelectionComboPrivate::iconForValidity(GpgME::UserID::Validity validity) const +{ + switch (validity) { + default: + case GpgME::UserID::Unknown: + case GpgME::UserID::Undefined: + return QIcon(iconPath(QStringLiteral("key_unknown"))); + case GpgME::UserID::Never: + return QIcon(iconPath(QStringLiteral("key"))); + case GpgME::UserID::Marginal: + case GpgME::UserID::Full: + case GpgME::UserID::Ultimate: + return QIcon(iconPath(QStringLiteral("key_ok"))); + } +} + +KeySelectionCombo::KeySelectionCombo(GpgME::Protocol proto, + KeyPurpose purpose, + QWidget* parent) + : QComboBox(parent) + , d(new KeySelectionComboPrivate(this)) +{ + d->protocol = proto; + d->backend = (proto == GpgME::OpenPGP) ? + CryptoBackendFactory::instance()->openpgp() : + CryptoBackendFactory::instance()->smime(); + d->keyPurpose = purpose; + + d->model = new QStandardItemModel(this); + d->sortModel = new KeySortModel(this); + d->sortModel->setSourceModel(d->model); + setModel(d->sortModel); + + connect(this, static_cast(&QComboBox::currentIndexChanged), + this, [this]() { d->currentIndexChanged(); }); +} + +KeySelectionCombo::~KeySelectionCombo() +{ + delete d; +} + +void KeySelectionCombo::setIdentity(const QString &name, const QString &email) +{ + d->name = name; + d->email = email; + + auto job = d->backend->keyListJob(false, false, true); + connect(job, &KeyListJob::result, + this, [this](const GpgME::KeyListResult &result, + const std::vector &keys) { + d->keysReceived(result, keys); + }); + job->start({ name, email }, true); +} + +void KeySelectionCombo::setCurrentKey(const GpgME::Key &key) +{ + for (int i = 0; i < count(); ++i) { + const auto uid = itemData(i, Qt::UserRole).value(); + if (uid.isNull()) { + continue; + } + + if (qstrcmp(uid.parent().keyID(), key.keyID()) == 0) { + setCurrentIndex(i); + return; + } + } +} + +GpgME::Key KeySelectionCombo::currentKey() const +{ + const auto uid = currentData(Qt::UserRole).value(); + return uid.parent(); +} + + +#include "keyselectioncombo.moc"