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 : }
|