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 PasswordDialog: field rows built from templates and entry
6 : * content, round-trip-safe serialisation in getPassword(), OTP validation and
7 : * the locked-while-saving insert protocol.
8 : */
9 : #include "passworddialog.h"
10 : #include "fieldlabel.h"
11 : #include "filecontent.h"
12 : #include "pass.h"
13 : #include "passwordconfiguration.h"
14 : #include "pathvalidator.h"
15 : #include "qtpasssettings.h"
16 : #include "secretlineedit.h"
17 : #include "secretplaintextedit.h"
18 : #include "templateio.h"
19 : #include "totp.h"
20 : #include "ui_passworddialog.h"
21 : #include "util.h"
22 : #include <algorithm>
23 : #include <limits>
24 :
25 : #include <QAction>
26 : #include <QCheckBox>
27 : #include <QComboBox>
28 : #include <QDialogButtonBox>
29 : #include <QDir>
30 : #include <QFileInfo>
31 : #include <QFormLayout>
32 : #include <QHash>
33 : #include <QIcon>
34 : #include <QLabel>
35 : #include <QLineEdit>
36 : #include <QMessageBox>
37 : #include <QPushButton>
38 : #include <QShortcut>
39 : #include <QSignalBlocker>
40 : #include <QTextDocument>
41 : #include <utility>
42 :
43 : #include "qtpasslogging.h"
44 :
45 : namespace {
46 : /// Dynamic property markOtpFieldEdited() sets on the line edit the user typed
47 : /// in; normalizeOtpField() reads it from the field that is the OTP one.
48 : const char kOtpEditedProperty[] = "qtpassOtpEdited";
49 : /// Dynamic property holding a field row's verbatim source line, so
50 : /// getPassword() can write an untouched field back exactly as it was read.
51 : const char kOriginalLineProperty[] = "qtpassOriginalLine";
52 : } // namespace
53 :
54 24 : PasswordDialog::PasswordDialog(PasswordConfiguration passConfig,
55 24 : QWidget *parent)
56 24 : : QDialog(parent), ui(new Ui::PasswordDialog),
57 24 : m_passConfig(std::move(passConfig)), m_pass(QtPassSettings::getPass()) {
58 : // Only reached from tests; production uses the overload taking an
59 : // explicit Pass* with a caller-controlled lifetime.
60 : m_templating = false;
61 : m_isNew = false;
62 :
63 24 : ui->setupUi(this);
64 24 : connect(ui->checkBoxShow, &QCheckBox::toggled, this,
65 48 : &PasswordDialog::setPasswordVisible);
66 24 : reportCopies(ui->lineEditPassword);
67 24 : connect(ui->plainTextEdit, &SecretPlainTextEdit::copied, this,
68 24 : &PasswordDialog::copied);
69 24 : setupTemplateBox();
70 24 : setLength(m_passConfig.length);
71 24 : setPasswordCharTemplate(m_passConfig.selected);
72 :
73 24 : connect(m_pass, &Pass::finishedShow, this, &PasswordDialog::setPass);
74 24 : connect(m_pass, &Pass::finishedInsert, this,
75 24 : &PasswordDialog::onInsertFinished);
76 24 : connect(m_pass, &Pass::insertFailed, this, &PasswordDialog::onInsertFailed);
77 24 : }
78 :
79 66 : PasswordDialog::PasswordDialog(Pass *pass, const AppSettings &s, QString file,
80 66 : const bool &isNew, QWidget *parent)
81 66 : : QDialog(parent), ui(new Ui::PasswordDialog), m_pass(pass),
82 66 : m_file(std::move(file)), m_isNew(isNew) {
83 :
84 66 : ui->setupUi(this);
85 66 : connect(ui->checkBoxShow, &QCheckBox::toggled, this,
86 132 : &PasswordDialog::setPasswordVisible);
87 66 : reportCopies(ui->lineEditPassword);
88 66 : connect(ui->plainTextEdit, &SecretPlainTextEdit::copied, this,
89 66 : &PasswordDialog::copied);
90 66 : setupTemplateBox();
91 : // The name row only appears once setNewEntryLocation() says where a new
92 : // entry may go; an existing entry keeps its name.
93 66 : ui->nameRow->hide();
94 :
95 92 : setWindowTitle(isNew && m_file.isEmpty()
96 117 : ? tr("New password")
97 168 : : this->windowTitle() + " " + m_file);
98 66 : m_passConfig = s.passwordConfiguration;
99 66 : usePwgen(s.usePwgen);
100 66 : setTemplate(s.passTemplate, s.useTemplate);
101 66 : templateAll(s.templateAllFields);
102 :
103 66 : setLength(m_passConfig.length);
104 66 : setPasswordCharTemplate(m_passConfig.selected);
105 :
106 66 : connect(m_pass, &Pass::finishedShow, this, &PasswordDialog::setPass);
107 66 : connect(m_pass, &Pass::processErrorExit, this, &PasswordDialog::onShowError);
108 66 : connect(m_pass, &Pass::finishedInsert, this,
109 66 : &PasswordDialog::onInsertFinished);
110 66 : connect(m_pass, &Pass::insertFailed, this, &PasswordDialog::onInsertFailed);
111 66 : connect(this, &PasswordDialog::rejected, this, &PasswordDialog::on_rejected);
112 :
113 66 : if (!isNew) {
114 : // Show() is asynchronous: until finishedShow lands the fields are empty,
115 : // so an early Ok would write nothing and the late setPass() would clobber
116 : // what was typed. Lock the editor and Ok until the decrypt arrives.
117 40 : ui->statusLabel->setText(tr("Decrypting…"));
118 40 : setEditorEnabled(false);
119 40 : ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
120 80 : m_pass->Show(m_file);
121 : }
122 66 : }
123 :
124 116 : PasswordDialog::~PasswordDialog() {
125 : // QDialog's destructor hides the window after this body, and losing focus
126 : // emits editingFinished() into normalizeOtpField() or renameField(), slots
127 : // of a PasswordDialog already gone. Hide here, while the object is whole.
128 90 : hide();
129 206 : }
130 :
131 5 : void PasswordDialog::setPasswordVisible(bool show) {
132 5 : if (show) {
133 3 : ui->lineEditPassword->setEchoMode(QLineEdit::Normal);
134 : } else {
135 2 : ui->lineEditPassword->setEchoMode(QLineEdit::Password);
136 : }
137 5 : }
138 :
139 3 : void PasswordDialog::on_createPasswordButton_clicked() {
140 3 : ui->widget->setEnabled(false);
141 3 : const int currentIndex = ui->passwordTemplateSwitch->currentIndex();
142 3 : if (currentIndex < 0 ||
143 : currentIndex >= static_cast<int>(PasswordConfiguration::CHARSETS_COUNT)) {
144 1 : ui->widget->setEnabled(true);
145 1 : return;
146 : }
147 :
148 2 : QString newPass = m_pass->generatePassword(
149 2 : static_cast<unsigned int>(ui->spinBox_pwdLength->value()),
150 : m_passConfig.Characters[static_cast<PasswordConfiguration::CharacterSet>(
151 2 : currentIndex)]);
152 2 : if (!newPass.isEmpty()) {
153 2 : ui->lineEditPassword->setText(newPass);
154 : }
155 2 : ui->widget->setEnabled(true);
156 : }
157 :
158 24 : void PasswordDialog::save() {
159 : // Show is asynchronous: refuse to save an existing entry before its
160 : // content loads. getPassword() always returns at least a newline, so an
161 : // isEmpty() check cannot stand in for this.
162 24 : if (!m_isNew && !m_contentLoaded) {
163 1 : return;
164 : }
165 :
166 : // Canonicalise before serialising: getPassword() stays a dumb serializer.
167 23 : normalizeOtpField();
168 :
169 23 : QString newValue = getPassword();
170 46 : if (newValue.right(1) != "\n") {
171 2 : newValue += "\n";
172 : }
173 :
174 23 : setSaving(true);
175 : // A refusal (the name is taken, the .gpg-id is unusable, a linked path)
176 : // comes through critical() before Insert() returns, with nothing started
177 : // that could still report.
178 23 : QString refusal;
179 : const QMetaObject::Connection refused =
180 23 : connect(m_pass, &Pass::critical, this,
181 0 : [&refusal](const QString &, const QString &message) {
182 1 : refusal = message;
183 23 : });
184 : // Reports from inserts already queued (an earlier dialog that a hanging
185 : // queue let close while its save was still pending) must pass before one is
186 : // taken as this dialog's own. The count is read before our own Insert()
187 : // queues, so it does not include it.
188 23 : m_insertsAhead = m_pass->insertsPending();
189 69 : m_pass->Insert(m_file, newValue, !m_isNew);
190 23 : disconnect(refused);
191 23 : if (m_saving && !refusal.isEmpty()) {
192 1 : setSaving(false);
193 1 : showNotSaved(refusal);
194 : }
195 23 : }
196 :
197 45 : void PasswordDialog::setSaving(bool saving) {
198 45 : m_saving = saving;
199 45 : setEditorEnabled(!saving);
200 45 : ui->nameRow->setEnabled(!saving);
201 : // Another template would rebuild the field rows under a pending insert.
202 45 : ui->templateBox->setEnabled(!saving);
203 : // Cancel stays: a save that never reports (an unanswered PIN prompt, a
204 : // queue held up by a hanging pull) must not leave quitting as the only
205 : // way out. reject() asks first.
206 45 : ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(!saving);
207 68 : ui->statusLabel->setText(saving ? tr("Saving…") : QString());
208 45 : }
209 :
210 25 : auto PasswordDialog::isOwnInsertReport() -> bool {
211 25 : if (!m_saving) {
212 : return false;
213 : }
214 : // Since a hanging queue can let a dialog close while its insert is still
215 : // queued (#1965), more than one insert can be in flight, and neither
216 : // finishedInsert nor insertFailed says which it is. The Executor runs the
217 : // queue in order, so the first m_insertsAhead reports belong to inserts
218 : // queued before this dialog's; let them pass before taking one as our own.
219 23 : if (m_insertsAhead > 0) {
220 2 : --m_insertsAhead;
221 2 : return false;
222 : }
223 : return true;
224 : }
225 :
226 18 : void PasswordDialog::onInsertFinished() {
227 18 : if (!isOwnInsertReport()) {
228 : return;
229 : }
230 16 : setSaving(false);
231 16 : QDialog::accept();
232 : }
233 :
234 7 : void PasswordDialog::onInsertFailed(const QString &err, bool written) {
235 7 : if (!isOwnInsertReport()) {
236 : return;
237 : }
238 5 : setSaving(false);
239 : // The entry is in the store and a later step (git add or commit) failed,
240 : // which the backend reports ("Saved, not committed"). Saved is saved, so
241 : // the dialog closes, also for an existing entry: "Not saved" would be
242 : // wrong, and a new entry could only be tried again under a name it now
243 : // has.
244 5 : if (written) {
245 2 : QDialog::accept();
246 2 : return;
247 : }
248 3 : showNotSaved(err);
249 : }
250 :
251 9 : void PasswordDialog::reject() {
252 9 : if (m_saving) {
253 6 : const QMessageBox::StandardButton answer = QMessageBox::question(
254 3 : this, tr("Still saving"),
255 3 : tr("The entry is still being saved. Close the dialog anyway? If "
256 : "saving then fails, what you typed is lost."),
257 : QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
258 : // The insert may have reported while the question was up.
259 3 : if (answer != QMessageBox::Yes || !m_saving) {
260 : return;
261 : }
262 : // Whatever the insert reports later, the main window shows it.
263 1 : m_saving = false;
264 : }
265 7 : QDialog::reject();
266 : }
267 :
268 4 : void PasswordDialog::showNotSaved(const QString &err) {
269 4 : if (!m_storeRoot.isEmpty()) {
270 4 : validateNewEntry(); // gates OK on the name again
271 : }
272 8 : ui->statusLabel->setText(tr("Not saved: %1").arg(err.trimmed()));
273 4 : }
274 :
275 14 : void PasswordDialog::on_rejected() { setPassword(QString()); }
276 :
277 15 : void PasswordDialog::setNewEntryLocation(const QString &storeRoot,
278 : const QStringList &folders,
279 : const QString ¤tFolder) {
280 30 : m_storeRoot = QDir::cleanPath(storeRoot);
281 15 : ui->folderBox->clear();
282 : int current = 0;
283 46 : for (const QString &folder : folders) {
284 62 : const QString rel = QDir::cleanPath(folder) == QStringLiteral(".")
285 31 : ? QString()
286 31 : : QDir::cleanPath(folder);
287 62 : if (rel == QDir::cleanPath(currentFolder) ||
288 : (rel.isEmpty() &&
289 37 : QDir::cleanPath(currentFolder) == QStringLiteral("."))) {
290 15 : current = ui->folderBox->count();
291 : }
292 77 : ui->folderBox->addItem(rel.isEmpty() ? QStringLiteral("/") : rel + u'/',
293 62 : rel);
294 : }
295 15 : ui->folderBox->setCurrentIndex(current);
296 30 : m_newEntryFolder = ui->folderBox->currentData().toString();
297 15 : ui->nameRow->show();
298 15 : connect(ui->nameEdit, &QLineEdit::textChanged, this,
299 15 : &PasswordDialog::validateNewEntry);
300 15 : connect(ui->folderBox, &QComboBox::currentIndexChanged, this,
301 15 : &PasswordDialog::validateNewEntry);
302 15 : validateNewEntry();
303 15 : ui->nameEdit->setFocus();
304 15 : }
305 :
306 54 : auto PasswordDialog::resolveNewEntry(QString *problem) const -> QString {
307 162 : QString name = QDir::fromNativeSeparators(ui->nameEdit->text().trimmed());
308 : // The suffix belongs to the file, not the entry: Insert() appends it, so a
309 : // typed "github.gpg" would otherwise become github.gpg.gpg.
310 108 : if (name.endsWith(QStringLiteral(".gpg"), Qt::CaseInsensitive)) {
311 3 : name.chop(4);
312 : }
313 54 : if (name.isEmpty()) {
314 17 : *problem = tr("Give the entry a name.");
315 17 : return {};
316 : }
317 37 : if (name.endsWith(u'/')) {
318 1 : *problem = tr("A name cannot end in /.");
319 1 : return {};
320 : }
321 36 : const QString folder = ui->folderBox->currentData().toString();
322 : const QString rel =
323 100 : QDir::cleanPath(folder.isEmpty() ? name : folder + u'/' + name);
324 36 : const QString absolute = QDir(m_storeRoot).filePath(rel);
325 108 : if (rel.isEmpty() || rel == QStringLiteral(".") ||
326 36 : !PathValidator::isPathInStore(m_storeRoot, absolute)) {
327 2 : *problem = tr("That name would resolve outside the password store.");
328 2 : return {};
329 : }
330 68 : if (QFileInfo::exists(absolute + QStringLiteral(".gpg"))) {
331 6 : *problem = tr("An entry called %1 already exists.").arg(rel);
332 3 : return {};
333 : }
334 31 : if (QFileInfo(absolute).isDir()) {
335 2 : *problem = tr("%1 is a folder.").arg(rel);
336 1 : return {};
337 : }
338 30 : problem->clear();
339 : return rel;
340 : }
341 :
342 : // Re-checked on every keystroke; gates OK.
343 40 : void PasswordDialog::validateNewEntry() {
344 40 : QString problem;
345 40 : const QString rel = resolveNewEntry(&problem);
346 40 : ui->statusLabel->setText(problem);
347 40 : ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(!rel.isEmpty());
348 40 : }
349 :
350 27 : void PasswordDialog::accept() {
351 27 : if (m_saving) {
352 : return;
353 : }
354 26 : if (!m_storeRoot.isEmpty()) {
355 14 : QString problem;
356 14 : const QString rel = resolveNewEntry(&problem);
357 14 : if (rel.isEmpty()) {
358 1 : ui->statusLabel->setText(problem);
359 : return;
360 : }
361 : // A name like work/vpn may need its folder; gpg does not create it.
362 26 : const QString parent = QFileInfo(QDir(m_storeRoot).filePath(rel)).path();
363 25 : if (!QDir(parent).exists() && !Util::mkpathForStore(parent)) {
364 2 : ui->statusLabel->setText(
365 2 : tr("Could not create the folder %1.").arg(parent));
366 : return;
367 : }
368 12 : m_file = rel;
369 24 : setWindowTitle(tr("Password") + QStringLiteral(" ") + m_file);
370 : }
371 24 : save();
372 : }
373 :
374 60 : void PasswordDialog::setPassword(const QString &password) {
375 : // Which lines become fields is the user's choice: forcing allFields
376 : // (#1138, for #132) made every `key: value` line a label-locked widget,
377 : // even with templates off (#1766). MainWindow renders by the same rule.
378 : FileContent fileContent =
379 84 : FileContent::parse(password, m_templating ? m_fields : QStringList(),
380 120 : m_templating && m_allFields);
381 120 : ui->lineEditPassword->setText(fileContent.getPassword());
382 :
383 60 : QWidget *previous = ui->checkBoxShow;
384 60 : NamedValues namedValues = fileContent.getNamedValues();
385 78 : for (QLineEdit *line : std::as_const(m_templateLines)) {
386 18 : const NamedValue nv = namedValues.takeNamedValue(line->objectName());
387 18 : line->setText(nv.value);
388 36 : line->setProperty(kOriginalLineProperty, nv.originalLine);
389 : // The value is the entry's again, whatever the user typed before this
390 : // reload: not a value normalizeOtpField() may rewrite.
391 18 : line->setProperty(kOtpEditedProperty, QVariant());
392 : previous = line;
393 18 : }
394 : // Remove previously created dynamic widgets to prevent duplicates and leaks
395 62 : for (QLineEdit *line : std::as_const(m_otherLines)) {
396 2 : ui->formLayout->removeRow(line);
397 : }
398 60 : m_otherLines.clear();
399 93 : for (const NamedValue &nv : std::as_const(namedValues)) {
400 33 : auto *line = new SecretLineEdit();
401 33 : reportCopies(line);
402 33 : line->setObjectName(nv.name);
403 33 : line->setText(nv.value);
404 66 : line->setProperty(kOriginalLineProperty, nv.originalLine);
405 33 : auto *label = new FieldLabel(nv.name);
406 33 : label->setBuddy(line);
407 33 : ui->formLayout->addRow(label, line);
408 33 : setTabOrder(previous, line);
409 33 : m_otherLines.append(line);
410 : previous = line;
411 : // Both widgets are rows of the form; removeRow() deletes them together,
412 : // so a rename or removal that arrives afterwards finds no receiver.
413 33 : connect(label, &FieldLabel::renamed, this,
414 33 : [this, line, label](const QString &, const QString &to) {
415 7 : renameField(line, label, to);
416 : });
417 33 : connect(label, &FieldLabel::removeRequested, this,
418 67 : [this, line] { removeField(line); });
419 : // The visible way to remove the field; the context menu is the other.
420 33 : auto *remove = line->addAction(
421 99 : QIcon::fromTheme(QStringLiteral("edit-delete"),
422 99 : QIcon(QStringLiteral(":/icons/edit-delete.svg"))),
423 : QLineEdit::TrailingPosition);
424 66 : remove->setObjectName(QStringLiteral("removeFieldAction"));
425 33 : remove->setToolTip(tr("Remove field"));
426 33 : connect(remove, &QAction::triggered, this,
427 69 : [this, line] { removeField(line); });
428 : }
429 :
430 : // setPlainText, not insertPlainText: re-populating must replace the body,
431 : // not save back a duplicated copy.
432 60 : ui->plainTextEdit->setPlainText(fileContent.getRemainingData());
433 :
434 : // m_otherLines was just rebuilt, so the OTP field may be a new widget.
435 60 : hookOtpField();
436 60 : }
437 :
438 7 : void PasswordDialog::renameField(QLineEdit *line, FieldLabel *label,
439 : const QString &to) {
440 : QList<QLineEdit *> allLines(m_templateLines);
441 7 : allLines.append(m_otherLines);
442 18 : for (QLineEdit *other : std::as_const(allLines)) {
443 17 : if (other != line && other->objectName() == to) {
444 2 : ui->statusLabel->setText(tr("A field called %1 already exists.").arg(to));
445 : return;
446 : }
447 : }
448 6 : ui->statusLabel->clear();
449 6 : line->setObjectName(to);
450 6 : label->setText(to);
451 : // The field may have become, or stopped being, the OTP one.
452 6 : hookOtpField();
453 : }
454 :
455 4 : void PasswordDialog::removeField(QLineEdit *line) {
456 4 : m_otherLines.removeAll(line);
457 : // takeRow(), not removeRow(): removeRow() deletes the widgets while the
458 : // label's contextMenuEvent() or the action's triggered() is still on the
459 : // stack. They are deleted once the stack has unwound.
460 4 : const QFormLayout::TakeRowResult row = ui->formLayout->takeRow(line);
461 : // hide() takes the focus from a focused line, and that emits
462 : // editingFinished(): not into normalizeOtpField() for a field that is
463 : // being removed.
464 4 : disconnect(line, nullptr, this, nullptr);
465 4 : if (m_otpLine == line) {
466 : m_otpLine = nullptr;
467 : }
468 12 : for (QLayoutItem *item : {row.labelItem, row.fieldItem}) {
469 8 : if (item == nullptr) {
470 0 : continue;
471 : }
472 8 : if (QWidget *widget = item->widget()) {
473 8 : widget->hide();
474 8 : widget->deleteLater();
475 : }
476 8 : delete item;
477 : }
478 4 : hookOtpField();
479 4 : }
480 :
481 164 : auto PasswordDialog::otpLineEdit() const -> QLineEdit * {
482 : QList<QLineEdit *> allLines(m_templateLines);
483 164 : allLines.append(m_otherLines);
484 : QLineEdit *firstMatch = nullptr;
485 250 : for (QLineEdit *line : std::as_const(allLines)) {
486 200 : if (!FileContent::isOtpFieldName(line->objectName())) {
487 73 : continue;
488 : }
489 : // Prefer a populated field: the default template's empty `OTP` widget
490 : // would otherwise shadow a `TOTP:` value in m_otherLines, leaving the
491 : // real secret unvalidated and unnormalised.
492 54 : if (!line->text().trimmed().isEmpty()) {
493 : return line;
494 : }
495 13 : if (firstMatch == nullptr) {
496 : firstMatch = line;
497 : }
498 : }
499 : return firstMatch;
500 : }
501 :
502 164 : void PasswordDialog::hookOtpField() {
503 : // Only m_otherLines widgets are deleted by removeRow(); a template widget
504 : // keeps its action, and the next validate would add a second icon.
505 : // m_otpWarning is a QPointer: null when setPassword() deleted its owner.
506 164 : if (!m_otpWarning.isNull()) {
507 : if (auto *owner = qobject_cast<QWidget *>(m_otpWarning->parent())) {
508 3 : owner->removeAction(m_otpWarning);
509 : }
510 3 : delete m_otpWarning;
511 : }
512 : m_otpWarning = nullptr;
513 :
514 164 : QLineEdit *otp = otpLineEdit();
515 : // The field that was the OTP one may not be any more (renamed away, or
516 : // another one now comes first): it stops being validated and normalised
517 : // here, so its OTP hooks go with the role. Its copies are still reported.
518 176 : if (!m_otpLine.isNull() && m_otpLine != otp) {
519 5 : disconnect(m_otpLine, &QLineEdit::textChanged, this,
520 : &PasswordDialog::validateOtpField);
521 5 : disconnect(m_otpLine, &QLineEdit::textEdited, this,
522 : &PasswordDialog::markOtpFieldEdited);
523 5 : disconnect(m_otpLine, &QLineEdit::editingFinished, this,
524 : &PasswordDialog::normalizeOtpField);
525 : }
526 : m_otpLine = otp;
527 164 : if (otp == nullptr) {
528 : return;
529 : }
530 25 : otp->setPlaceholderText(tr("otpauth:// URI or base32 secret"));
531 25 : connect(otp, &QLineEdit::textChanged, this, &PasswordDialog::validateOtpField,
532 : Qt::UniqueConnection);
533 : // textEdited, not textChanged: it fires only for user input, so programmatic
534 : // population does not count as the user having touched the field.
535 25 : connect(otp, &QLineEdit::textEdited, this,
536 25 : &PasswordDialog::markOtpFieldEdited, Qt::UniqueConnection);
537 : // Canonicalise as soon as the user leaves the field, so the value they end
538 : // up saving is the value they were shown.
539 25 : connect(otp, &QLineEdit::editingFinished, this,
540 25 : &PasswordDialog::normalizeOtpField, Qt::UniqueConnection);
541 25 : validateOtpField();
542 : }
543 :
544 90 : void PasswordDialog::markOtpFieldEdited() {
545 90 : if (QObject *line = sender()) {
546 180 : line->setProperty(kOtpEditedProperty, true);
547 : }
548 90 : }
549 :
550 135 : void PasswordDialog::validateOtpField() {
551 : // m_otpLine, not otpLineEdit(): the signals that bring us here and the
552 : // edit mark are that line's. The OTP role is settled when fields change.
553 : QLineEdit *otp = m_otpLine;
554 135 : if (otp == nullptr) {
555 0 : return;
556 : }
557 :
558 270 : const QString text = otp->text().trimmed();
559 135 : const bool bad = !text.isEmpty() && !Totp::isValid(text);
560 : if (bad) {
561 60 : if (m_otpWarning.isNull()) {
562 : // Theme-aware indicator: no hardcoded colours and no extra layout row.
563 : m_otpWarning =
564 102 : otp->addAction(QIcon::fromTheme(QStringLiteral("dialog-warning")),
565 34 : QLineEdit::TrailingPosition);
566 : }
567 120 : otp->setToolTip(tr("Invalid OTP secret"));
568 : } else {
569 75 : if (!m_otpWarning.isNull()) {
570 29 : otp->removeAction(m_otpWarning);
571 29 : delete m_otpWarning;
572 : m_otpWarning = nullptr;
573 : }
574 150 : otp->setToolTip(QString());
575 : }
576 : }
577 :
578 : // Only an otpauth:// URI or a value typed into this field is rewritten.
579 : // Base32::sanitizeInput() maps 1 to L and 8 to B, so a backup code like
580 : // `12345678` passes as base32 and was silently rewritten on any OK. A bare
581 : // secret left alone still works: Totp::parse() accepts one.
582 34 : void PasswordDialog::normalizeOtpField() {
583 : QLineEdit *otp = m_otpLine;
584 34 : if (otp == nullptr) {
585 24 : return;
586 : }
587 36 : const QString text = otp->text().trimmed();
588 18 : if (text.isEmpty()) {
589 : return;
590 : }
591 35 : if (!otp->property(kOtpEditedProperty).toBool() &&
592 8 : !FileContent::isOtpUriValue(text)) {
593 : return;
594 : }
595 : // fileName(), not completeBaseName(): the latter truncates at the last dot,
596 : // turning an entry called github.com into a label of "github". m_file already
597 : // has its .gpg suffix stripped by MainWindow::getFile().
598 20 : const QString canonical = Totp::normalize(text, QFileInfo(m_file).fileName());
599 10 : if (!canonical.isEmpty()) {
600 8 : otp->setText(canonical);
601 : }
602 : }
603 :
604 54 : auto PasswordDialog::getPassword() -> QString {
605 108 : QString passFile = ui->lineEditPassword->text() + "\n";
606 : QList<QLineEdit *> allLines(m_templateLines);
607 54 : allLines.append(m_otherLines);
608 97 : for (QLineEdit *line : std::as_const(allLines)) {
609 43 : const QString text = line->text();
610 43 : const QString name = line->objectName();
611 43 : const QString original = line->property(kOriginalLineProperty).toString();
612 : // A field whose name and value are still exactly what was parsed keeps its
613 : // source line verbatim: reserialising would turn `key:value` into
614 : // `key: value`, trim whitespace the value carried, and drop a field with an
615 : // empty value (`recovery:`) entirely. The widget holds trimmed name/value,
616 : // so compare against the original line split and trimmed the same way.
617 43 : if (!original.isEmpty()) {
618 39 : const qsizetype colon = original.indexOf(QLatin1Char(':'));
619 156 : if (colon >= 0 && original.left(colon).trimmed() == name &&
620 105 : original.right(original.length() - colon - 1).trimmed() == text) {
621 31 : passFile += original + "\n";
622 31 : continue;
623 : }
624 : }
625 12 : if (text.isEmpty()) {
626 1 : continue;
627 : }
628 22 : passFile += name + ": " + text + "\n";
629 : }
630 : // Not toPlainText(): it saves a blank for every no-break space (U+00A0) in
631 : // the notes, so any OK rewrites even lines the user never touched and a
632 : // passphrase or recovery code with no-break spaces is corrupted. The raw
633 : // document text keeps the no-break spaces; only the block and line
634 : // separators it uses are normalised back to '\n'.
635 54 : QString notes = ui->plainTextEdit->document()->toRawText();
636 54 : notes.replace(QChar(QChar::ParagraphSeparator), QLatin1Char('\n'));
637 54 : notes.replace(QChar(QChar::LineSeparator), QLatin1Char('\n'));
638 : passFile += notes;
639 54 : return passFile;
640 : }
641 :
642 94 : void PasswordDialog::setTemplate(const QString &rawFields, bool useTemplate) {
643 94 : m_fields = TemplateIO::fieldsIn(rawFields);
644 94 : m_templating = useTemplate;
645 :
646 100 : for (QLineEdit *line : std::as_const(m_templateLines)) {
647 6 : ui->formLayout->removeRow(line);
648 : }
649 94 : m_templateLines.clear();
650 :
651 : // Defensively remove all rows tracked in m_otherLines to prevent accumulation
652 : // when cycling templates or applying new templates after setPassword
653 95 : for (QLineEdit *line : std::as_const(m_otherLines)) {
654 1 : ui->formLayout->removeRow(line);
655 : }
656 94 : m_otherLines.clear();
657 :
658 94 : if (m_templating) {
659 49 : QWidget *previous = ui->checkBoxShow;
660 104 : for (const QString &field : std::as_const(m_fields)) {
661 55 : if (field.isEmpty()) {
662 18 : continue;
663 : }
664 37 : auto *line = new SecretLineEdit();
665 37 : reportCopies(line);
666 : // Plain text before the name is set: the template comes from the
667 : // store, and as rich text <img src="/dev/zero"> has Qt read that file
668 : // without end.
669 37 : auto *label = new QLabel();
670 37 : label->setTextFormat(Qt::PlainText);
671 37 : label->setText(field);
672 37 : line->setObjectName(field);
673 37 : ui->formLayout->addRow(label, line);
674 37 : setTabOrder(previous, line);
675 37 : m_templateLines.append(line);
676 : previous = line;
677 : }
678 : }
679 :
680 94 : hookOtpField();
681 94 : }
682 :
683 160 : void PasswordDialog::reportCopies(SecretLineEdit *line) {
684 160 : connect(line, &SecretLineEdit::copied, this, &PasswordDialog::copied);
685 160 : }
686 :
687 72 : void PasswordDialog::templateAll(bool templateAll) {
688 72 : m_allFields = templateAll;
689 72 : }
690 :
691 93 : void PasswordDialog::setLength(int length) {
692 93 : ui->spinBox_pwdLength->setValue(length);
693 93 : }
694 :
695 94 : void PasswordDialog::setPasswordCharTemplate(int templateIndex) {
696 94 : ui->passwordTemplateSwitch->setCurrentIndex(templateIndex);
697 94 : }
698 :
699 68 : void PasswordDialog::usePwgen(bool usePwgen) {
700 68 : m_usePwgen = usePwgen;
701 68 : ui->passwordTemplateSwitch->setDisabled(usePwgen);
702 68 : ui->label_characterset->setDisabled(usePwgen);
703 68 : }
704 :
705 : // The template row stays hidden until setAvailableTemplates() fills it.
706 90 : void PasswordDialog::setupTemplateBox() {
707 90 : ui->label_template->hide();
708 90 : ui->templateBox->hide();
709 90 : connect(ui->templateBox, &QComboBox::currentTextChanged, this,
710 90 : [this](const QString &name) {
711 : // applyTemplate() rebuilds the field rows, so only act on a real
712 : // change; the box is also updated from applyTemplate() itself.
713 2 : if (name != m_currentTemplateName) {
714 2 : applyTemplate(name);
715 : }
716 2 : });
717 90 : }
718 :
719 9 : void PasswordDialog::setAvailableTemplates(
720 : const QHash<QString, QStringList> &templates,
721 : const QString &defaultTemplate) {
722 9 : m_availableTemplates = templates;
723 9 : QStringList templateNames = templates.keys();
724 9 : if (templateNames.isEmpty()) {
725 : return;
726 : }
727 18 : std::sort(templateNames.begin(), templateNames.end());
728 : {
729 9 : const QSignalBlocker blocker(ui->templateBox);
730 9 : ui->templateBox->clear();
731 9 : ui->templateBox->addItems(templateNames);
732 9 : }
733 9 : ui->label_template->show();
734 9 : ui->templateBox->show();
735 9 : if (!m_templateShortcut) {
736 9 : m_templateShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_T), this,
737 11 : this, [this] { cycleTemplate(); });
738 : }
739 : QString selected = defaultTemplate;
740 9 : if (!templateNames.contains(selected)) {
741 1 : selected = templateNames.first();
742 : }
743 9 : applyTemplate(selected);
744 : }
745 :
746 15 : void PasswordDialog::applyTemplate(const QString &templateName) {
747 15 : if (m_saving) {
748 1 : return;
749 : }
750 14 : auto it = m_availableTemplates.constFind(templateName);
751 : if (it != m_availableTemplates.constEnd()) {
752 14 : m_currentTemplateName = templateName;
753 14 : const QSignalBlocker blocker(ui->templateBox);
754 14 : ui->templateBox->setCurrentText(templateName);
755 14 : QString fields = it.value().join("\n");
756 14 : setTemplate(fields, true);
757 14 : }
758 : }
759 :
760 6 : void PasswordDialog::cycleTemplate() {
761 : if (m_availableTemplates.isEmpty()) {
762 2 : return;
763 : }
764 4 : QStringList names = m_availableTemplates.keys();
765 8 : std::sort(names.begin(), names.end());
766 :
767 : qsizetype currentIdx = names.indexOf(m_currentTemplateName);
768 : qsizetype nextIdx;
769 4 : if (currentIdx < 0) {
770 : nextIdx = 0;
771 : } else {
772 4 : nextIdx = (currentIdx + 1) % names.size();
773 : }
774 4 : applyTemplate(names.at(nextIdx));
775 : }
776 :
777 55 : void PasswordDialog::setPass(const QString &output, const QString &file) {
778 : // finishedShow names its file: a decrypt of any other entry (a tree click
779 : // that was still queued when the dialog opened) is not ours.
780 55 : if (!file.isEmpty() && file != m_file) {
781 : return;
782 : }
783 : // An editor block per line: an entry of millions of lines (a small,
784 : // compressed one in a shared store can be one) would take QtPass down.
785 : // Not loaded at all, so nothing can be saved over it from here either.
786 : // Counted as an editor of the whole entry would hold it: the final
787 : // newline pass and QtPass write ends the last line, it starts no new one.
788 : // Password and fields count too, so this is stricter than the display's
789 : // limit on the remaining text alone.
790 54 : if (const qsizetype lines =
791 54 : output.count(QLatin1Char('\n')) +
792 101 : (output.isEmpty() || output.endsWith(QLatin1Char('\n')) ? 0 : 1);
793 : lines > FileContent::kMaxDisplayLines) {
794 : // The count in a variable first, as in FileContent: older lupdate
795 : // versions lose a tr() with template arguments among its arguments.
796 : const int n = static_cast<int>(
797 1 : std::min<qsizetype>(lines, std::numeric_limits<int>::max()));
798 1 : ui->statusLabel->setText(
799 1 : tr("This entry has %n line(s), too many to edit here.", nullptr, n));
800 : return;
801 : }
802 53 : setPassword(output);
803 53 : m_contentLoaded = true;
804 : // Drop the "Decrypting…" note so it cannot be mistaken for a warning.
805 53 : ui->statusLabel->clear();
806 53 : setEditorEnabled(true);
807 53 : ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
808 : }
809 :
810 2 : void PasswordDialog::onShowError(int /* exitCode */, const QString &err) {
811 : // React only while the dialog is still waiting for its own decrypt.
812 : // processErrorExit carries no request identity, but for an existing entry
813 : // the only Pass operation in flight is the Show() that opened this dialog.
814 2 : if (m_isNew || m_contentLoaded) {
815 : return;
816 : }
817 1 : ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
818 1 : setEditorEnabled(false);
819 1 : ui->statusLabel->setText(err);
820 : }
821 :
822 139 : void PasswordDialog::setEditorEnabled(bool enabled) {
823 139 : ui->lineEditPassword->setEnabled(enabled);
824 139 : ui->createPasswordButton->setEnabled(enabled);
825 139 : ui->checkBoxShow->setEnabled(enabled);
826 139 : ui->passwordTemplateSwitch->setEnabled(enabled);
827 139 : ui->label_characterset->setEnabled(enabled);
828 139 : ui->spinBox_pwdLength->setEnabled(enabled);
829 139 : ui->plainTextEdit->setEnabled(enabled);
830 177 : for (QLineEdit *line : std::as_const(m_templateLines)) {
831 38 : line->setEnabled(enabled);
832 : }
833 180 : for (QLineEdit *line : std::as_const(m_otherLines)) {
834 41 : line->setEnabled(enabled);
835 : }
836 : // Re-apply the pwgen policy on the way back up, or the two widgets usePwgen()
837 : // locks would be spuriously re-enabled with the rest of the editor.
838 139 : if (enabled && m_usePwgen) {
839 0 : usePwgen(true);
840 : }
841 139 : }
|