QtPass Developer documentation
QtPass test coverage
Current view: top level - src/usersdialog.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 98.4 % 246 242
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 27 27

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2015 Anne Jan Brouwer
       2              : // SPDX-License-Identifier: GPL-3.0-or-later
       3              : /**
       4              :  * @file
       5              :  * @brief Implements UsersDialog; selectRecipients() matches the `.gpg-id`
       6              :  * lines against the keyring and lists the ones no key answers to.
       7              :  */
       8              : #include "usersdialog.h"
       9              : #include "importkeydialog.h"
      10              : #include "pass.h"
      11              : #include "qtpasssettings.h"
      12              : #include "ui_usersdialog.h"
      13              : #include "windowstatestore.h"
      14              : #include <QApplication>
      15              : #include <QDateTime>
      16              : #include <QDialogButtonBox>
      17              : #include <QFont>
      18              : #include <QHash>
      19              : #include <QKeyEvent>
      20              : #include <QLabel>
      21              : #include <QLineEdit>
      22              : #include <QListWidget>
      23              : #include <QMessageBox>
      24              : #include <QPalette>
      25              : #include <QPushButton>
      26              : #include <QRegularExpression>
      27              : #include <QSet>
      28              : #include <QSignalBlocker>
      29              : #include <QWidget>
      30              : #include <algorithm>
      31              : #include <utility>
      32              : 
      33              : #include "qtpasslogging.h"
      34           39 : UsersDialog::UsersDialog(Pass *pass, const AppSettings &s, QString dir,
      35           39 :                          QWidget *parent)
      36           78 :     : UsersDialog(pass, s, std::move(dir), Preselect::FromList, parent) {}
      37              : 
      38           42 : UsersDialog::UsersDialog(Pass *pass, const AppSettings &s, QString dir,
      39           42 :                          Preselect preselect, QWidget *parent)
      40           42 :     : QDialog(parent), ui(new Ui::UsersDialog), m_pass(pass),
      41              :       m_passStore(s.passStore), m_gpgExe(s.gpgExecutable),
      42           84 :       m_dir(std::move(dir)), m_preselect(preselect) {
      43              :   Q_ASSERT(pass);
      44           42 :   ui->setupUi(this);
      45              : 
      46           42 :   restoreDialogState();
      47           42 :   if (!loadGpgKeys()) {
      48            1 :     connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
      49            1 :     return;
      50              :   }
      51              : 
      52           41 :   loadRecipients();
      53           41 :   populateList();
      54              : 
      55           41 :   connectSignals();
      56            0 : }
      57              : 
      58           41 : void UsersDialog::connectSignals() {
      59           41 :   connect(ui->buttonBox, &QDialogButtonBox::accepted, this,
      60           41 :           &UsersDialog::accept);
      61           41 :   connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
      62           41 :   connect(ui->listWidget, &QListWidget::itemChanged, this,
      63           82 :           &UsersDialog::itemChange);
      64              :   // importKeyButton is wired via Qt's automatic on_<name>_<signal> mechanism
      65              :   // already triggered by setupUi().
      66              : 
      67           41 :   ui->lineEdit->setClearButtonEnabled(true);
      68           41 : }
      69              : 
      70           42 : void UsersDialog::restoreDialogState() {
      71           84 :   WindowStateStore::attach(*this, QStringLiteral("usersDialog"));
      72           42 : }
      73              : 
      74           43 : auto UsersDialog::loadGpgKeys() -> bool {
      75           43 :   QList<UserInfo> users = m_pass->listKeys();
      76           43 :   if (users.isEmpty()) {
      77            3 :     QMessageBox::critical(parentWidget(), tr("Keylist missing"),
      78            0 :                           tr("Could not fetch list of available GPG keys"));
      79            1 :     reject();
      80              :     return false;
      81              :   }
      82              : 
      83           42 :   markSecretKeys(users);
      84              : 
      85              :   m_userList = users;
      86           42 :   return true;
      87              : }
      88              : 
      89           42 : void UsersDialog::markSecretKeys(QList<UserInfo> &users) {
      90           84 :   QList<UserInfo> secret_keys = m_pass->listKeys("", true);
      91              :   QSet<QString> secretKeyIds;
      92           47 :   for (const UserInfo &sec : secret_keys) {
      93            5 :     secretKeyIds.insert(sec.key_id);
      94              :   }
      95          125 :   for (auto &user : users) {
      96           83 :     if (secretKeyIds.contains(user.key_id)) {
      97            5 :       user.have_secret = true;
      98              :     }
      99              :   }
     100           42 : }
     101              : 
     102            7 : void UsersDialog::showRecipientWarning(const QString &text) {
     103              :   // Plain text before the text is set: it can quote the store's recipient
     104              :   // list, which as rich text could make Qt load a file (<img src=...>).
     105            7 :   auto *banner = new QLabel(this);
     106            7 :   banner->setTextFormat(Qt::PlainText);
     107            7 :   banner->setText(text);
     108           14 :   banner->setObjectName(QStringLiteral("recipientWarning"));
     109            7 :   banner->setWordWrap(true);
     110              :   // Palette, not a stylesheet colour, so it reads on dark themes too; red as
     111              :   // ProcessOutputPanel paints error lines.
     112            7 :   QFont bold = banner->font();
     113              :   bold.setBold(true);
     114            7 :   banner->setFont(bold);
     115            7 :   QPalette palette = banner->palette();
     116            7 :   palette.setColor(QPalette::WindowText, QColor(Qt::red));
     117            7 :   banner->setPalette(palette);
     118            7 :   banner->setContentsMargins(4, 4, 4, 4);
     119            7 :   ui->verticalLayout->insertWidget(1, banner);
     120            7 : }
     121              : 
     122              : namespace {
     123              : 
     124              : /// A key ID as written anywhere: without a 0x prefix, upper case.
     125           60 : auto normalisedKeyId(const QString &id) -> QString {
     126              :   const QString trimmed = id.trimmed();
     127           60 :   return (trimmed.startsWith(QLatin1String("0x"), Qt::CaseInsensitive)
     128          120 :               ? trimmed.mid(2)
     129              :               : trimmed)
     130           60 :       .toUpper();
     131              : }
     132              : 
     133              : /// Whether @p recipient, as a .gpg-id lists it, names @p key the way gpg's
     134              : /// -r resolves it: a key ID in any length gpg accepts (the key carries its
     135              : /// fingerprint, the list may carry a long or short ID, a suffix of it, or
     136              : /// the fingerprint; eight characters is the shortest), or else a name,
     137              : /// matched case-insensitively as a substring of the UID after gpg's
     138              : /// "=", "<...>", "@" and "*" markers.
     139           35 : auto answersTo(const UserInfo &key, const QString &recipient) -> bool {
     140              :   static const QRegularExpression hexId(
     141           38 :       QStringLiteral("^(0x)?[0-9A-Fa-f]{8,}$"));
     142           70 :   if (hexId.match(recipient.trimmed()).hasMatch()) {
     143           30 :     const QString id = normalisedKeyId(recipient);
     144           30 :     const QString keyId = normalisedKeyId(key.key_id);
     145           30 :     return keyId.endsWith(id) || (keyId.size() >= 8 && id.endsWith(keyId));
     146              :   }
     147              :   QString term = recipient.trimmed();
     148            5 :   if (term.startsWith(u'=') || term.startsWith(u'@') || term.startsWith(u'*')) {
     149            0 :     term.remove(0, 1);
     150            5 :   } else if (term.startsWith(u'<') && term.endsWith(u'>')) {
     151            2 :     term = term.mid(1, term.size() - 2);
     152              :   }
     153            5 :   return !term.isEmpty() && key.name.contains(term, Qt::CaseInsensitive);
     154              : }
     155              : 
     156              : } // namespace
     157              : 
     158           20 : void UsersDialog::selectRecipients(const QStringList &recipients) {
     159              :   // Each per-line gpg lookup is a blocking call on the dialog's way up, so a
     160              :   // store with many keys missing from the keyring (a fresh clone, a team
     161              :   // store) is capped at this many across both the fallback below and the
     162              :   // missing-key check; the rest resolve by the batched call or stay unchecked.
     163              :   constexpr int kMaxLookups = 10;
     164              :   int lookups = 0;
     165              :   // Remembers the per-line lookups the fallback already ran, so the
     166              :   // missing-key loop below reuses them instead of asking gpg a second time.
     167           20 :   QHash<QString, bool> perLineHasKey;
     168              : 
     169              :   // One gpg call resolves every recipient, by key ID or by email/UID.
     170           40 :   QList<UserInfo> resolved = m_pass->listKeys(recipients);
     171           20 :   if (resolved.isEmpty() && recipients.size() > 1) {
     172              :     // One line gpg cannot read as a key search ("0x", a fingerprint one
     173              :     // digit short) aborts that whole call with exit code 2, so nothing is
     174              :     // listed for the other lines, whose keys are in the keyring all the same.
     175              :     // Asked for one by one they still resolve; only the unreadable line is
     176              :     // reported missing by the loop below, as an unknown key already is.
     177           25 :     for (const QString &recipient : recipients) {
     178           22 :       if (lookups++ >= kMaxLookups) {
     179              :         break;
     180              :       }
     181           21 :       const QList<UserInfo> keys = m_pass->listKeys(recipient);
     182           21 :       perLineHasKey.insert(recipient, !keys.isEmpty());
     183              :       resolved += keys;
     184              :     }
     185              :   }
     186           59 :   for (UserInfo &user : m_userList) {
     187           39 :     if (std::any_of(resolved.cbegin(), resolved.cend(),
     188              :                     [&user](const UserInfo &key) {
     189           49 :                       return key.key_id == user.key_id;
     190              :                     })) {
     191           29 :       user.enabled = true;
     192              :     }
     193              :   }
     194              :   // Only what gpg itself cannot find is reported missing. The fallback's
     195              :   // per-line answer, when it has one, is gpg's own verdict for that line and
     196              :   // decides it outright. Otherwise the heuristic match is a fast path (a
     197              :   // secondary UID or a subkey ID resolves in gpg without showing in what it
     198              :   // lists), and a per-line gpg call settles anything it does not catch. Taking
     199              :   // the cached answer first also keeps a genuinely unreadable line ("0x")
     200              :   // reported missing even when a resolved key's UID happens to contain it, so
     201              :   // the heuristic answersTo() would otherwise match.
     202           70 :   for (const QString &recipient : recipients) {
     203              :     bool found = false;
     204           50 :     const auto cached = perLineHasKey.constFind(recipient);
     205              :     if (cached != perLineHasKey.constEnd()) {
     206           21 :       found = cached.value();
     207              :     } else {
     208           29 :       found = std::any_of(resolved.cbegin(), resolved.cend(),
     209              :                           [&recipient](const UserInfo &key) {
     210           35 :                             return answersTo(key, recipient);
     211              :                           });
     212           29 :       if (!found && lookups++ < kMaxLookups) {
     213            5 :         found = !m_pass->listKeys(recipient).isEmpty();
     214              :       }
     215              :     }
     216           50 :     if (!found) {
     217           26 :       UserInfo missing;
     218           26 :       missing.enabled = true;
     219           26 :       missing.key_id = recipient;
     220           52 :       missing.name = " ?? " + tr("Key not found in keyring");
     221              :       m_userList.append(missing);
     222           26 :     }
     223              :   }
     224           20 : }
     225              : 
     226           41 : void UsersDialog::loadRecipients() {
     227              :   // A store that had no list when it was chosen (a new profile): a list
     228              :   // that appeared since is nobody's choice, and was read under the active
     229              :   // backend's signing policy, not the new profile's.
     230           41 :   if (m_preselect == Preselect::Nothing) {
     231            3 :     return;
     232              :   }
     233              :   // Through the backend: with a signing key only a verified list may be
     234              :   // preselected, since OK signs whatever is selected.
     235              :   const Pass::RecipientsForEditing loaded =
     236           38 :       m_pass->recipientsForEditing(m_dir, m_passStore);
     237           38 :   if (!loaded.warning.isEmpty()) {
     238            7 :     showRecipientWarning(loaded.warning);
     239              :   }
     240              :   // A folder without .gpg-id (new store, new profile) has no recipients yet;
     241              :   // listKeys() with no filter would return the whole keyring, preselected.
     242           38 :   if (loaded.state != Pass::RecipientsForEditing::State::Rejected &&
     243              :       !loaded.recipients.isEmpty()) {
     244           20 :     selectRecipients(loaded.recipients);
     245              :   }
     246              : }
     247              : 
     248           84 : UsersDialog::~UsersDialog() = default;
     249              : 
     250            8 : void UsersDialog::accept() {
     251            8 :   if (!hasSelection()) {
     252              :     // Nothing to encrypt to: an empty .gpg-id would make every insert fail
     253              :     // and the wizard never offers this dialog again once the file exists.
     254              :     return;
     255              :   }
     256            6 :   if (m_initOnAccept) {
     257            6 :     m_pass->Init(m_dir, m_userList);
     258              :   }
     259              : 
     260            6 :   QDialog::accept();
     261              : }
     262              : 
     263          122 : auto UsersDialog::hasSelection() const -> bool {
     264              :   return std::any_of(m_userList.cbegin(), m_userList.cend(),
     265          189 :                      [](const UserInfo &user) { return user.enabled; });
     266              : }
     267              : 
     268          111 : void UsersDialog::updateOkButton() {
     269          111 :   if (auto *ok = ui->buttonBox->button(QDialogButtonBox::Ok)) {
     270          111 :     ok->setEnabled(hasSelection());
     271              :   }
     272          111 : }
     273              : 
     274            1 : void UsersDialog::keyPressEvent(QKeyEvent *event) {
     275            1 :   switch (event->key()) {
     276            1 :   case Qt::Key_Escape:
     277            1 :     ui->lineEdit->clear();
     278            1 :     break;
     279              :   default:
     280              :     break;
     281              :   }
     282            1 : }
     283              : 
     284          123 : void UsersDialog::itemChange(QListWidgetItem *item) {
     285          123 :   if (!item) {
     286           74 :     return;
     287              :   }
     288          123 :   bool ok = false;
     289          123 :   const int index = item->data(Qt::UserRole).toInt(&ok);
     290          123 :   if (!ok) {
     291          146 :     qCWarning(lcQtPass)
     292           73 :         << "UsersDialog::itemChange: invalid user index data for item";
     293           73 :     return;
     294              :   }
     295           50 :   if (index < 0 || index >= m_userList.size()) {
     296            2 :     qCWarning(lcQtPass) << "UsersDialog::itemChange: user index out of range:"
     297            1 :                         << index << "valid range is [0,"
     298            1 :                         << (m_userList.size() - 1) << "]";
     299            1 :     return;
     300              :   }
     301           49 :   m_userList[index].enabled = item->checkState() == Qt::Checked;
     302           49 :   updateOkButton();
     303              : }
     304              : 
     305           62 : void UsersDialog::populateList(const QString &filter) {
     306              :   // Invalidate cached datetime so expiry checks use fresh current time
     307           62 :   m_cachedDateTimeValid = false;
     308              : 
     309           62 :   QString patternString = "*" + filter + "*";
     310           62 :   if (m_cachedPatternString != patternString) {
     311              :     QRegularExpression re(
     312          104 :         QRegularExpression::wildcardToRegularExpression(patternString),
     313           52 :         QRegularExpression::CaseInsensitiveOption);
     314           52 :     if (re.isValid()) {
     315           52 :       m_cachedNameFilter = re;
     316           52 :       m_cachedPatternString = patternString;
     317              :     }
     318           52 :   }
     319           62 :   const QRegularExpression &nameFilter = m_cachedNameFilter;
     320           62 :   ui->listWidget->clear();
     321              : 
     322          223 :   for (int i = 0; i < m_userList.size(); ++i) {
     323              :     const auto &user = m_userList.at(i);
     324          161 :     if (!passesFilter(user, filter, nameFilter)) {
     325           23 :       continue;
     326              :     }
     327              : 
     328          138 :     auto *item = new QListWidgetItem(buildUserText(user), ui->listWidget);
     329          138 :     applyUserStyling(item, user);
     330          204 :     item->setCheckState(user.enabled ? Qt::Checked : Qt::Unchecked);
     331          138 :     item->setData(Qt::UserRole, QVariant::fromValue(i));
     332          138 :     ui->listWidget->addItem(item);
     333              :   }
     334           62 :   updateOkButton();
     335           62 : }
     336              : 
     337          161 : bool UsersDialog::passesFilter(const UserInfo &user, const QString &filter,
     338              :                                const QRegularExpression &nameFilter) const {
     339          171 :   if (!filter.isEmpty() && !nameFilter.match(user.name).hasMatch()) {
     340              :     return false;
     341              :   }
     342              :   // A ticked key is one the folder is encrypted to: it is shown whatever
     343              :   // its state, or an expired or missing recipient stays in .gpg-id unseen and
     344              :   // cannot be unticked. "Show unusable keys" is about keys to add.
     345          151 :   if (user.enabled || ui->checkBox->isChecked()) {
     346           81 :     return true;
     347              :   }
     348           62 :   return user.isValid() && !isUserExpired(user);
     349              : }
     350              : 
     351          200 : auto UsersDialog::isUserExpired(const UserInfo &user) const -> bool {
     352          200 :   if (!m_cachedDateTimeValid) {
     353           60 :     m_cachedCurrentDateTime = QDateTime::currentDateTime();
     354           60 :     m_cachedDateTimeValid = true;
     355              :   }
     356              :   // gpg marks a key it already judged expired with validity 'e'.
     357          398 :   return user.validity == 'e' ||
     358          207 :          (user.expiry.isValid() && m_cachedCurrentDateTime > user.expiry);
     359              : }
     360              : 
     361          138 : QString UsersDialog::buildUserText(const UserInfo &user) const {
     362          138 :   QString text = user.name + "\n" + user.key_id;
     363          138 :   if (user.created.isValid()) {
     364          222 :     text += " " + tr("created") + " " +
     365          333 :             QLocale::system().toString(user.created, QLocale::ShortFormat);
     366              :   }
     367          138 :   if (user.expiry.isValid()) {
     368           12 :     text += " " + tr("expires") + " " +
     369           18 :             QLocale::system().toString(user.expiry, QLocale::ShortFormat);
     370              :   }
     371          138 :   return text;
     372              : }
     373              : 
     374          138 : void UsersDialog::applyUserStyling(QListWidgetItem *item,
     375              :                                    const UserInfo &user) const {
     376          138 :   const QString originalText = item->text();
     377              :   // Status badge first: an own key can be expired too, and then the marker
     378              :   // matters more than the "you can decrypt with this" colour.
     379              :   bool badged = true;
     380              :   // Expired before invalid: gpg gives an expired key validity 'e', which is
     381              :   // not a valid one, and "expired" says what to do about it.
     382          138 :   if (isUserExpired(user)) {
     383              :     // A lone dark-red foreground is unreadable on dark themes, and gpg
     384              :     // refuses to encrypt to expired keys anyway.
     385            6 :     item->setBackground(Qt::darkRed);
     386            6 :     item->setForeground(Qt::white);
     387           12 :     item->setText(tr("[EXPIRED] ") + originalText);
     388              :   } else if (!user.isValid()) {
     389           30 :     item->setBackground(Qt::darkRed);
     390           30 :     item->setForeground(Qt::white);
     391           60 :     item->setText(tr("[INVALID] ") + originalText);
     392          102 :   } else if (!user.fullyValid()) {
     393            7 :     item->setBackground(Qt::darkYellow);
     394            7 :     item->setForeground(Qt::white);
     395           14 :     item->setText(tr("[PARTIAL] ") + originalText);
     396              :   } else {
     397           95 :     item->setText(originalText);
     398              :     badged = false;
     399              :   }
     400          138 :   if (user.have_secret) {
     401            7 :     if (!badged) {
     402           14 :       item->setForeground(QApplication::palette().color(QPalette::Link));
     403              :     }
     404            7 :     QFont font = item->font();
     405              :     font.setBold(true);
     406            7 :     item->setFont(font);
     407            7 :   }
     408          138 : }
     409              : 
     410           10 : void UsersDialog::on_lineEdit_textChanged(const QString &filter) {
     411           10 :   populateList(filter);
     412           10 : }
     413              : 
     414           20 : void UsersDialog::on_checkBox_clicked() { populateList(ui->lineEdit->text()); }
     415              : 
     416            1 : void UsersDialog::selectImportedKey(const QString &importedKey) {
     417              :   // Suffixes shorter than 16 characters would give false positives.
     418            2 :   const auto sameKey = [&importedKey](const QString &keyId) {
     419            2 :     return (keyId.length() >= 16 &&
     420            3 :             importedKey.endsWith(keyId, Qt::CaseInsensitive)) ||
     421            2 :            (importedKey.length() >= 16 &&
     422            1 :             keyId.endsWith(importedKey, Qt::CaseInsensitive));
     423            1 :   };
     424              :   // The stored key_id, not the item text.
     425            2 :   for (int i = 0; i < ui->listWidget->count(); ++i) {
     426            2 :     QListWidgetItem *item = ui->listWidget->item(i);
     427            2 :     bool ok = false;
     428            2 :     const int idx = item != nullptr ? item->data(Qt::UserRole).toInt(&ok) : -1;
     429            4 :     if (ok && idx >= 0 && idx < m_userList.size() &&
     430            4 :         !m_userList[idx].key_id.isEmpty() && sameKey(m_userList[idx].key_id)) {
     431            1 :       ui->listWidget->setCurrentItem(item);
     432            1 :       return;
     433              :     }
     434              :   }
     435              : }
     436              : 
     437            2 : void UsersDialog::on_importKeyButton_clicked() {
     438            2 :   ImportKeyDialog dialog(m_gpgExe, this);
     439            2 :   if (dialog.exec() != QDialog::Accepted) {
     440              :     return;
     441              :   }
     442              : 
     443              :   // Non-empty after Accepted, but an empty id would make the endsWith()
     444              :   // below match the first listed key.
     445            1 :   const QString importedKey = dialog.importedKeyId();
     446            1 :   if (importedKey.isEmpty()) {
     447              :     return;
     448              :   }
     449              : 
     450              :   // loadGpgKeys() rebuilds the key list from the keyring and drops every tick.
     451              :   // Remember which recipients were selected (the .gpg-id list and the user's
     452              :   // own ticks, including any "key not found" rows) and re-apply them after, so
     453              :   // importing a key does not silently remove the other recipients.
     454            1 :   QStringList selected;
     455            2 :   for (const UserInfo &user : std::as_const(m_userList)) {
     456            1 :     if (user.enabled) {
     457            1 :       selected.append(user.key_id);
     458              :     }
     459              :   }
     460              : 
     461            1 :   if (!loadGpgKeys()) {
     462              :     return;
     463              :   }
     464              : 
     465              :   // Re-apply the remembered selection. Most saved identifiers are a key ID that
     466              :   // is in the rebuilt list, so match those exactly first (no gpg lookup). An
     467              :   // identifier that does not match a row is one that was listed as "key not
     468              :   // found" - a short ID or email that gpg could not resolve, or whose key has
     469              :   // just been imported and now shows under its fingerprint. Hand those to
     470              :   // selectRecipients(), which resolves each against the refreshed keyring,
     471              :   // ticks the matching row, and re-adds a "key not found" row for any that
     472              :   // still does not resolve.
     473            1 :   QStringList unresolved;
     474            2 :   for (const QString &keyId : std::as_const(selected)) {
     475              :     bool matched = false;
     476            3 :     for (UserInfo &user : m_userList) {
     477            2 :       if (user.key_id == keyId) {
     478            1 :         user.enabled = true;
     479              :         matched = true;
     480              :       }
     481              :     }
     482            1 :     if (!matched) {
     483              :       unresolved.append(keyId);
     484              :     }
     485              :   }
     486            1 :   if (!unresolved.isEmpty()) {
     487            0 :     selectRecipients(unresolved);
     488              :   }
     489              : 
     490              :   // Clear the filter so the just-imported key is visible. setText("") still
     491              :   // emits textChanged once, so don't double-populate.
     492              :   {
     493            1 :     const QSignalBlocker blocker(ui->lineEdit);
     494            1 :     ui->lineEdit->clear();
     495            1 :   }
     496            1 :   populateList(QString());
     497              : 
     498            1 :   selectImportedKey(importedKey);
     499            2 : }
        

Generated by: LCOV version 2.0-1