QtPass 2.0.0
Developer documentation
Loading...
Searching...
No Matches
usersdialog.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2015 Anne Jan Brouwer
2// SPDX-License-Identifier: GPL-3.0-or-later
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"
34UsersDialog::UsersDialog(Pass *pass, const AppSettings &s, QString dir,
35 QWidget *parent)
36 : UsersDialog(pass, s, std::move(dir), Preselect::FromList, parent) {}
37
38UsersDialog::UsersDialog(Pass *pass, const AppSettings &s, QString dir,
39 Preselect preselect, QWidget *parent)
40 : QDialog(parent), ui(new Ui::UsersDialog), m_pass(pass),
41 m_passStore(s.passStore), m_gpgExe(s.gpgExecutable),
42 m_dir(std::move(dir)), m_preselect(preselect) {
43 Q_ASSERT(pass);
44 ui->setupUi(this);
45
47 if (!loadGpgKeys()) {
48 connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
49 return;
50 }
51
54
56}
57
59 connect(ui->buttonBox, &QDialogButtonBox::accepted, this,
61 connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
62 connect(ui->listWidget, &QListWidget::itemChanged, this,
64 // importKeyButton is wired via Qt's automatic on_<name>_<signal> mechanism
65 // already triggered by setupUi().
66
67 ui->lineEdit->setClearButtonEnabled(true);
68}
69
71 WindowStateStore::attach(*this, QStringLiteral("usersDialog"));
72}
73
75 QList<UserInfo> users = m_pass->listKeys();
76 if (users.isEmpty()) {
77 QMessageBox::critical(parentWidget(), tr("Keylist missing"),
78 tr("Could not fetch list of available GPG keys"));
79 reject();
80 return false;
81 }
82
83 markSecretKeys(users);
84
85 m_userList = users;
86 return true;
87}
88
89void UsersDialog::markSecretKeys(QList<UserInfo> &users) {
90 QList<UserInfo> secret_keys = m_pass->listKeys("", true);
91 QSet<QString> secretKeyIds;
92 for (const UserInfo &sec : secret_keys) {
93 secretKeyIds.insert(sec.key_id);
94 }
95 for (auto &user : users) {
96 if (secretKeyIds.contains(user.key_id)) {
97 user.have_secret = true;
98 }
99 }
100}
101
102void 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 auto *banner = new QLabel(this);
106 banner->setTextFormat(Qt::PlainText);
107 banner->setText(text);
108 banner->setObjectName(QStringLiteral("recipientWarning"));
109 banner->setWordWrap(true);
110 // Palette, not a stylesheet colour, so it reads on dark themes too; red as
111 // ProcessOutputPanel paints error lines.
112 QFont bold = banner->font();
113 bold.setBold(true);
114 banner->setFont(bold);
115 QPalette palette = banner->palette();
116 palette.setColor(QPalette::WindowText, QColor(Qt::red));
117 banner->setPalette(palette);
118 banner->setContentsMargins(4, 4, 4, 4);
119 ui->verticalLayout->insertWidget(1, banner);
120}
121
122namespace {
123
125auto normalisedKeyId(const QString &id) -> QString {
126 const QString trimmed = id.trimmed();
127 return (trimmed.startsWith(QLatin1String("0x"), Qt::CaseInsensitive)
128 ? trimmed.mid(2)
129 : trimmed)
130 .toUpper();
131}
132
139auto answersTo(const UserInfo &key, const QString &recipient) -> bool {
140 static const QRegularExpression hexId(
141 QStringLiteral("^(0x)?[0-9A-Fa-f]{8,}$"));
142 if (hexId.match(recipient.trimmed()).hasMatch()) {
143 const QString id = normalisedKeyId(recipient);
144 const QString keyId = normalisedKeyId(key.key_id);
145 return keyId.endsWith(id) || (keyId.size() >= 8 && id.endsWith(keyId));
146 }
147 QString term = recipient.trimmed();
148 if (term.startsWith(u'=') || term.startsWith(u'@') || term.startsWith(u'*')) {
149 term.remove(0, 1);
150 } else if (term.startsWith(u'<') && term.endsWith(u'>')) {
151 term = term.mid(1, term.size() - 2);
152 }
153 return !term.isEmpty() && key.name.contains(term, Qt::CaseInsensitive);
154}
155
156} // namespace
157
158void 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 QHash<QString, bool> perLineHasKey;
168
169 // One gpg call resolves every recipient, by key ID or by email/UID.
170 QList<UserInfo> resolved = m_pass->listKeys(recipients);
171 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 for (const QString &recipient : recipients) {
178 if (lookups++ >= kMaxLookups) {
179 break;
180 }
181 const QList<UserInfo> keys = m_pass->listKeys(recipient);
182 perLineHasKey.insert(recipient, !keys.isEmpty());
183 resolved += keys;
184 }
185 }
186 for (UserInfo &user : m_userList) {
187 if (std::any_of(resolved.cbegin(), resolved.cend(),
188 [&user](const UserInfo &key) {
189 return key.key_id == user.key_id;
190 })) {
191 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 for (const QString &recipient : recipients) {
203 bool found = false;
204 const auto cached = perLineHasKey.constFind(recipient);
205 if (cached != perLineHasKey.constEnd()) {
206 found = cached.value();
207 } else {
208 found = std::any_of(resolved.cbegin(), resolved.cend(),
209 [&recipient](const UserInfo &key) {
210 return answersTo(key, recipient);
211 });
212 if (!found && lookups++ < kMaxLookups) {
213 found = !m_pass->listKeys(recipient).isEmpty();
214 }
215 }
216 if (!found) {
217 UserInfo missing;
218 missing.enabled = true;
219 missing.key_id = recipient;
220 missing.name = " ?? " + tr("Key not found in keyring");
221 m_userList.append(missing);
222 }
223 }
224}
225
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.
231 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 =
237 if (!loaded.warning.isEmpty()) {
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.
243 !loaded.recipients.isEmpty()) {
245 }
246}
247
248UsersDialog::~UsersDialog() = default;
249
251 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 if (m_initOnAccept) {
258 }
259
260 QDialog::accept();
261}
262
263auto UsersDialog::hasSelection() const -> bool {
264 return std::any_of(m_userList.cbegin(), m_userList.cend(),
265 [](const UserInfo &user) { return user.enabled; });
266}
267
269 if (auto *ok = ui->buttonBox->button(QDialogButtonBox::Ok)) {
270 ok->setEnabled(hasSelection());
271 }
272}
273
274void UsersDialog::keyPressEvent(QKeyEvent *event) {
275 switch (event->key()) {
276 case Qt::Key_Escape:
277 ui->lineEdit->clear();
278 break;
279 default:
280 break;
281 }
282}
283
284void UsersDialog::itemChange(QListWidgetItem *item) {
285 if (!item) {
286 return;
287 }
288 bool ok = false;
289 const int index = item->data(Qt::UserRole).toInt(&ok);
290 if (!ok) {
291 qCWarning(lcQtPass)
292 << "UsersDialog::itemChange: invalid user index data for item";
293 return;
294 }
295 if (index < 0 || index >= m_userList.size()) {
296 qCWarning(lcQtPass) << "UsersDialog::itemChange: user index out of range:"
297 << index << "valid range is [0,"
298 << (m_userList.size() - 1) << "]";
299 return;
300 }
301 m_userList[index].enabled = item->checkState() == Qt::Checked;
303}
304
305void UsersDialog::populateList(const QString &filter) {
306 // Invalidate cached datetime so expiry checks use fresh current time
307 m_cachedDateTimeValid = false;
308
309 QString patternString = "*" + filter + "*";
310 if (m_cachedPatternString != patternString) {
311 QRegularExpression re(
312 QRegularExpression::wildcardToRegularExpression(patternString),
313 QRegularExpression::CaseInsensitiveOption);
314 if (re.isValid()) {
316 m_cachedPatternString = patternString;
317 }
318 }
319 const QRegularExpression &nameFilter = m_cachedNameFilter;
320 ui->listWidget->clear();
321
322 for (int i = 0; i < m_userList.size(); ++i) {
323 const auto &user = m_userList.at(i);
324 if (!passesFilter(user, filter, nameFilter)) {
325 continue;
326 }
327
328 auto *item = new QListWidgetItem(buildUserText(user), ui->listWidget);
329 applyUserStyling(item, user);
330 item->setCheckState(user.enabled ? Qt::Checked : Qt::Unchecked);
331 item->setData(Qt::UserRole, QVariant::fromValue(i));
332 ui->listWidget->addItem(item);
333 }
335}
336
337bool UsersDialog::passesFilter(const UserInfo &user, const QString &filter,
338 const QRegularExpression &nameFilter) const {
339 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 if (user.enabled || ui->checkBox->isChecked()) {
346 return true;
347 }
348 return user.isValid() && !isUserExpired(user);
349}
350
351auto UsersDialog::isUserExpired(const UserInfo &user) const -> bool {
353 m_cachedCurrentDateTime = QDateTime::currentDateTime();
355 }
356 // gpg marks a key it already judged expired with validity 'e'.
357 return user.validity == 'e' ||
358 (user.expiry.isValid() && m_cachedCurrentDateTime > user.expiry);
359}
360
361QString UsersDialog::buildUserText(const UserInfo &user) const {
362 QString text = user.name + "\n" + user.key_id;
363 if (user.created.isValid()) {
364 text += " " + tr("created") + " " +
365 QLocale::system().toString(user.created, QLocale::ShortFormat);
366 }
367 if (user.expiry.isValid()) {
368 text += " " + tr("expires") + " " +
369 QLocale::system().toString(user.expiry, QLocale::ShortFormat);
370 }
371 return text;
372}
373
374void UsersDialog::applyUserStyling(QListWidgetItem *item,
375 const UserInfo &user) const {
376 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 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 item->setBackground(Qt::darkRed);
386 item->setForeground(Qt::white);
387 item->setText(tr("[EXPIRED] ") + originalText);
388 } else if (!user.isValid()) {
389 item->setBackground(Qt::darkRed);
390 item->setForeground(Qt::white);
391 item->setText(tr("[INVALID] ") + originalText);
392 } else if (!user.fullyValid()) {
393 item->setBackground(Qt::darkYellow);
394 item->setForeground(Qt::white);
395 item->setText(tr("[PARTIAL] ") + originalText);
396 } else {
397 item->setText(originalText);
398 badged = false;
399 }
400 if (user.have_secret) {
401 if (!badged) {
402 item->setForeground(QApplication::palette().color(QPalette::Link));
403 }
404 QFont font = item->font();
405 font.setBold(true);
406 item->setFont(font);
407 }
408}
409
410void UsersDialog::on_lineEdit_textChanged(const QString &filter) {
411 populateList(filter);
412}
413
415
416void UsersDialog::selectImportedKey(const QString &importedKey) {
417 // Suffixes shorter than 16 characters would give false positives.
418 const auto sameKey = [&importedKey](const QString &keyId) {
419 return (keyId.length() >= 16 &&
420 importedKey.endsWith(keyId, Qt::CaseInsensitive)) ||
421 (importedKey.length() >= 16 &&
422 keyId.endsWith(importedKey, Qt::CaseInsensitive));
423 };
424 // The stored key_id, not the item text.
425 for (int i = 0; i < ui->listWidget->count(); ++i) {
426 QListWidgetItem *item = ui->listWidget->item(i);
427 bool ok = false;
428 const int idx = item != nullptr ? item->data(Qt::UserRole).toInt(&ok) : -1;
429 if (ok && idx >= 0 && idx < m_userList.size() &&
430 !m_userList[idx].key_id.isEmpty() && sameKey(m_userList[idx].key_id)) {
431 ui->listWidget->setCurrentItem(item);
432 return;
433 }
434 }
435}
436
438 ImportKeyDialog dialog(m_gpgExe, this);
439 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 const QString importedKey = dialog.importedKeyId();
446 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 QStringList selected;
455 for (const UserInfo &user : std::as_const(m_userList)) {
456 if (user.enabled) {
457 selected.append(user.key_id);
458 }
459 }
460
461 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 QStringList unresolved;
474 for (const QString &keyId : std::as_const(selected)) {
475 bool matched = false;
476 for (UserInfo &user : m_userList) {
477 if (user.key_id == keyId) {
478 user.enabled = true;
479 matched = true;
480 }
481 }
482 if (!matched) {
483 unresolved.append(keyId);
484 }
485 }
486 if (!unresolved.isEmpty()) {
487 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 const QSignalBlocker blocker(ui->lineEdit);
494 ui->lineEdit->clear();
495 }
496 populateList(QString());
497
498 selectImportedKey(importedKey);
499}
Dialog for importing GPG keys from file or clipboard.
auto importedKeyId() const -> QString
Get the imported key id on success.
Abstract base class for password store operations.
Definition pass.h:75
auto listKeys(QStringList keystrings, bool secret=false) -> QList< UserInfo >
List GPG keys matching patterns.
Definition pass.cpp:426
auto recipientsForEditing(const QString &dir, const QString &passStore) -> RecipientsForEditing
The recipients a dialog may preselect for editing the list of dir.
Definition pass.cpp:1068
virtual void Init(QString path, const QList< UserInfo > &users)=0
Initialize new password store.
QString m_cachedPatternString
~UsersDialog() override
Destroys the dialog and its generated form.
void updateOkButton()
Enable OK only while at least one key is ticked (hasSelection()).
bool m_initOnAccept
void itemChange(QListWidgetItem *item)
Handle user selection change.
QRegularExpression m_cachedNameFilter
void on_importKeyButton_clicked()
Import a GPG key and refresh the list.
void on_lineEdit_textChanged(const QString &filter)
Filter user list.
void showRecipientWarning(const QString &text)
Show text above the key list in bold red, for a recipient list that could not be verified.
auto isUserExpired(const UserInfo &user) const -> bool
Check if GPG key is expired.
void keyPressEvent(QKeyEvent *event) override
Handle key press.
void applyUserStyling(QListWidgetItem *item, const UserInfo &user) const
Apply visual styling to user item.
QString m_dir
Preselect
Whether the recipients on disk are ticked when it opens.
Definition usersdialog.h:40
@ Nothing
None: for a store that had no list when it was chosen.
Definition usersdialog.h:42
QList< UserInfo > m_userList
void connectSignals()
Connect dialog signals.
QString m_passStore
QDateTime m_cachedCurrentDateTime
void restoreDialogState()
QString buildUserText(const UserInfo &user) const
Build display text for user.
void populateList(const QString &filter=QString())
Populate user list.
void selectImportedKey(const QString &importedKey)
Make the key gpg just imported the current row. gpg reports a 16-character long ID (IMPORTED) or a 40...
void selectRecipients(const QStringList &recipients)
Tick the keys the folder is encrypted to, and list recipients no key in the keyring answers to,...
void loadRecipients()
Load recipients and handle missing keys.
void accept() override
Handle dialog acceptance.
bool m_cachedDateTimeValid
QString m_gpgExe
UsersDialog(Pass *pass, const AppSettings &s, QString dir, QWidget *parent=nullptr)
Construct users dialog.
void markSecretKeys(QList< UserInfo > &users)
Mark which keys have secret counterparts.
Preselect m_preselect
bool passesFilter(const UserInfo &user, const QString &filter, const QRegularExpression &nameFilter) const
Check if user passes filter.
auto hasSelection() const -> bool
Whether at least one key is ticked.
void on_checkBox_clicked()
Handle select all checkbox.
QScopedPointer< Ui::UsersDialog > ui
The widgets from usersdialog.ui.
auto loadGpgKeys() -> bool
Load GPG keys and determine secret key status.
Pass * m_pass
Dialog that imports an ASCII-armored GPG public key from a file or the clipboard by running gpg --imp...
void attach(QDialog &dialog, const QString &key)
restore() now, save() whenever the dialog finishes.
Pass, the abstract password-store backend that RealPass and ImitatePass implement,...
The "qtpass" logging category.
QtPassSettings, the QSettings singleton behind every saved setting, the profiles and the Pass backend...
Plain value-object holding all flat QtPass configuration.
Definition appsettings.h:36
What a dialog may do with the list of a folder (recipientsForEditing()).
Definition pass.h:340
QStringList recipients
The recipients to preselect; empty when Rejected.
Definition pass.h:363
State state
How the list on disk stood up.
Definition pass.h:361
QString warning
What the dialog should show, if anything.
Definition pass.h:365
@ Rejected
Not to be used: signature invalid, written for another folder or for no folder in particular while a ...
Definition pass.h:358
Stores GPG key info including validity, creation date, and more.
Definition userinfo.h:19
bool have_secret
Whether secret key is available (can decrypt with this key).
Definition userinfo.h:61
bool enabled
Whether this user/key is enabled for normal use.
Definition userinfo.h:65
QString key_id
Hexadecimal representation of the GnuPG key identifier.
Definition userinfo.h:52
auto fullyValid() const -> bool
Check full validity per GnuPG validity codes.
Definition userinfo.h:30
auto isValid() const -> bool
Check whether the key has any usable validity level.
Definition userinfo.h:43
QDateTime created
Date/time when key was created.
Definition userinfo.h:73
QString name
GPG user ID / full name.
Definition userinfo.h:48
QDateTime expiry
Date/time when key expires.
Definition userinfo.h:69
UsersDialog, the recipient picker for a folder's .gpg-id.
WindowStateStore: saves and restores top-level window geometry through QtPassSettings,...