Line data Source code
1 : // SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements FirstRunWizard and its pages; the store is created and
6 : * initialised in FirstRunWizard::initialiseStore() on Finish.
7 : */
8 : #include "firstrunwizard.h"
9 : #include "executor.h"
10 : #include "gpgkeystate.h"
11 : #include "imitatepass.h"
12 : #include "keygendialog.h"
13 : #include "passbackendfactory.h"
14 : #include "profileinit.h"
15 : #include "qtpasssettings.h"
16 : #include "util.h"
17 : #include <QCheckBox>
18 : #include <QDateTime>
19 : #include <QDir>
20 : #include <QFile>
21 : #include <QFileDialog>
22 : #include <QFileInfo>
23 : #include <QFormLayout>
24 : #include <QHBoxLayout>
25 : #include <QLabel>
26 : #include <QLineEdit>
27 : #include <QListWidget>
28 : #include <QMessageBox>
29 : #include <QPushButton>
30 : #include <QSystemTrayIcon>
31 : #include <QToolButton>
32 : #include <QVBoxLayout>
33 : #include <functional>
34 : #ifdef Q_OS_WIN
35 : #include <windows.h>
36 : #endif
37 :
38 : namespace {
39 :
40 : /// A line edit with a "…" button that opens a file or folder picker.
41 92 : auto pathRow(QLineEdit *edit, QWidget *parent,
42 : const std::function<QString()> &pick) -> QWidget * {
43 92 : auto *row = new QWidget(parent);
44 92 : auto *layout = new QHBoxLayout(row);
45 92 : layout->setContentsMargins(0, 0, 0, 0);
46 92 : layout->addWidget(edit);
47 92 : auto *browse = new QToolButton(row);
48 184 : browse->setText(QStringLiteral("…"));
49 92 : browse->setToolTip(FirstRunWizard::tr("Browse"));
50 92 : layout->addWidget(browse);
51 184 : QObject::connect(browse, &QToolButton::clicked, edit, [edit, pick]() {
52 3 : const QString chosen = pick();
53 3 : if (!chosen.isEmpty()) {
54 2 : edit->setText(chosen);
55 2 : emit edit->textEdited(chosen);
56 : }
57 3 : });
58 92 : return row;
59 : }
60 :
61 184 : auto runnable(const QString &exe) -> bool {
62 184 : return Executor::parseWslCommand(exe).has_value() ||
63 184 : (!exe.isEmpty() && QFileInfo(exe).isExecutable());
64 : }
65 :
66 : } // namespace
67 :
68 : // --------------------------------------------------------------------------
69 :
70 23 : FirstRunWizard::FirstRunWizard(QWidget *parent)
71 23 : : QWizard(parent), m_settings(QtPassSettings::load()) {
72 23 : setWindowTitle(tr("Welcome to QtPass"));
73 23 : setWizardStyle(QWizard::ModernStyle);
74 23 : setOption(QWizard::NoBackButtonOnStartPage);
75 23 : setOption(QWizard::HaveHelpButton, false);
76 :
77 23 : auto *intro = new QWizardPage(this);
78 23 : intro->setTitle(tr("Welcome to QtPass"));
79 23 : auto *introLayout = new QVBoxLayout(intro);
80 : auto *introText = new QLabel(
81 23 : tr("QtPass is a graphical front-end for <i>pass</i>, the standard Unix "
82 : "password manager: every password is a file encrypted with GnuPG, "
83 : "kept in a folder you can put under Git.<br><br>The next pages find "
84 : "GnuPG, make sure you have a key to encrypt to and pick the folder "
85 : "your passwords live in. Nothing is written until you press "
86 : "Finish."),
87 23 : intro);
88 23 : introText->setWordWrap(true);
89 23 : introText->setTextFormat(Qt::RichText);
90 23 : introLayout->addWidget(introText);
91 23 : setPage(IntroPage, intro);
92 23 : setPage(ProgramsPage, new ProgramsWizardPage(this));
93 23 : setPage(KeyPage, new KeyWizardPage(this));
94 23 : setPage(StorePage, new StoreWizardPage(this));
95 23 : setPage(DonePage, new DoneWizardPage(this));
96 23 : setStartId(IntroPage);
97 : resize(640, 480);
98 23 : }
99 :
100 20 : auto FirstRunWizard::secretKeys(const QString &gpgExecutable)
101 : -> QList<UserInfo> {
102 20 : if (!runnable(gpgExecutable)) {
103 2 : return {};
104 : }
105 18 : QString out;
106 108 : const int rc = Executor::executeBlocking(
107 : gpgExecutable,
108 18 : {QStringLiteral("--no-tty"), QStringLiteral("--with-colons"),
109 18 : QStringLiteral("--with-fingerprint"),
110 18 : QStringLiteral("--list-secret-keys")},
111 : &out);
112 18 : if (rc != 0) {
113 2 : return {};
114 : }
115 16 : return parseGpgColonOutput(out, true);
116 : }
117 :
118 155 : auto FirstRunWizard::isStore(const QString &path) -> bool {
119 155 : return !path.isEmpty() &&
120 465 : QFile::exists(QDir(path).filePath(QStringLiteral(".gpg-id")));
121 : }
122 :
123 5 : auto FirstRunWizard::initialiseStore(const QString &store) -> bool {
124 5 : QList<UserInfo> recipients;
125 10 : for (const UserInfo &key : std::as_const(m_keys)) {
126 5 : if (key.enabled) {
127 : recipients.append(key);
128 : }
129 : }
130 5 : const bool created = !QDir(store).exists();
131 5 : if (created && !Util::mkpathForStore(store)) {
132 2 : QMessageBox::warning(
133 1 : this, tr("Error"),
134 2 : tr("Failed to create password-store at: %1").arg(store));
135 1 : return false;
136 : }
137 : #ifdef Q_OS_WIN
138 : // Only a folder made here is hidden, like ~/.password-store on Unix; a
139 : // folder the user picked keeps its attributes.
140 : if (created) {
141 : SetFileAttributes(store.toStdWString().c_str(), FILE_ATTRIBUTE_HIDDEN);
142 : }
143 : #endif
144 4 : QString note;
145 4 : if (!ProfileInit::initialise(store, recipients, m_settings, m_settings.useGit,
146 : ¬e)) {
147 : // ProfileInit writes .gpg-id before it signs and commits; take it back so
148 : // a second Finish runs the whole initialisation again instead of finding
149 : // a store that looks finished.
150 2 : QFile::remove(QDir(store).filePath(QStringLiteral(".gpg-id")));
151 2 : QFile::remove(QDir(store).filePath(QStringLiteral(".gpg-id.sig")));
152 1 : QMessageBox::warning(this, tr("Password store not initialised"), note);
153 1 : return false;
154 : }
155 3 : if (!note.isEmpty()) {
156 : // ProfileInit's own note talks of switching profiles; on the first run
157 : // the folder simply becomes the store.
158 2 : QMessageBox::information(
159 1 : this, tr("Password store"),
160 1 : tr("%1 already contains encrypted files; they were not "
161 : "re-encrypted to the ticked keys. Open Users after the start "
162 : "to do that.")
163 2 : .arg(QDir::toNativeSeparators(store)));
164 : }
165 : return true;
166 : }
167 :
168 2 : auto FirstRunWizard::initialiseGit(const QString &store) -> bool {
169 2 : QString note;
170 2 : if (ProfileInit::initGit(store, m_settings, ¬e)) {
171 : return true;
172 : }
173 : // Take the repository back too, so a second Finish retries the whole thing
174 : // instead of skipping a .git that never got its first commit.
175 2 : QDir(QDir(store).filePath(QStringLiteral(".git"))).removeRecursively();
176 1 : QMessageBox::warning(this, tr("Password store not initialised"), note);
177 1 : return false;
178 : }
179 :
180 8 : void FirstRunWizard::accept() {
181 : // The Finish button validates the page before calling this; a programmatic
182 : // accept() gets the same treatment so the last page's choices are in.
183 8 : if (currentPage() != nullptr && !currentPage()->validatePage()) {
184 3 : return;
185 : }
186 8 : const QString store = QDir::cleanPath(m_settings.passStore);
187 : const bool needsGit =
188 11 : m_settings.useGit && !QDir(store).exists(QStringLiteral(".git"));
189 8 : const bool ready = !isStore(store) ? initialiseStore(store)
190 3 : : needsGit ? initialiseGit(store)
191 : : true;
192 7 : if (!ready) {
193 : return;
194 : }
195 5 : m_settings.passStore = Util::normalizeFolderPath(store);
196 5 : QtPassSettings::save(m_settings);
197 : // The backend cached the old paths at construction.
198 5 : PassBackendFactory::invalidate();
199 5 : QWizard::accept();
200 : }
201 :
202 : // --------------------------------------------------------------------------
203 :
204 23 : ProgramsWizardPage::ProgramsWizardPage(FirstRunWizard *wizard)
205 23 : : QWizardPage(wizard), m_wizard(wizard), m_gpg(new QLineEdit(this)),
206 23 : m_git(new QLineEdit(this)), m_pass(new QLineEdit(this)),
207 46 : m_usePass(new QCheckBox(tr("Use the pass command-line tool"), this)),
208 46 : m_gpgStatus(new QLabel(this)) {
209 46 : setTitle(tr("Programs"));
210 23 : setSubTitle(tr("GnuPG does the encrypting. pass and Git are optional; "
211 : "QtPass can do their work itself."));
212 23 : auto *layout = new QFormLayout(this);
213 2 : auto pickExe = [this]() {
214 2 : return QFileDialog::getOpenFileName(this, tr("Select executable"),
215 4 : QString(), tr("All files (*)"));
216 : };
217 69 : layout->addRow(tr("GnuPG"), pathRow(m_gpg, this, pickExe));
218 23 : m_gpgStatus->setWordWrap(true);
219 46 : layout->addRow(QString(), m_gpgStatus);
220 69 : layout->addRow(tr("Git"), pathRow(m_git, this, pickExe));
221 69 : layout->addRow(tr("pass"), pathRow(m_pass, this, pickExe));
222 23 : m_usePass->setToolTip(tr("Run the pass script for every operation instead "
223 : "of calling gpg and git directly"));
224 23 : layout->addRow(QString(), m_usePass);
225 92 : for (QLineEdit *edit : {m_gpg, m_git, m_pass}) {
226 69 : connect(edit, &QLineEdit::textEdited, this,
227 138 : &ProgramsWizardPage::updateStatus);
228 : }
229 23 : }
230 :
231 12 : void ProgramsWizardPage::initializePage() {
232 12 : AppSettings &s = m_wizard->m_settings;
233 : // QtPassSettings::initExecutables() fills empty paths from PATH only; a
234 : // stored path whose binary has moved would be shown as an error here, so
235 : // look it up again in that case.
236 48 : auto detect = [](QString &stored, const QStringList &names) {
237 48 : if (runnable(stored)) {
238 : return;
239 : }
240 70 : for (const QString &name : names) {
241 37 : const QString found = Util::findBinaryInPath(name);
242 37 : if (!found.isEmpty()) {
243 1 : stored = found;
244 : return;
245 : }
246 : }
247 : };
248 48 : detect(s.gpgExecutable, {QStringLiteral("gpg2"), QStringLiteral("gpg")});
249 36 : detect(s.gitExecutable, {QStringLiteral("git")});
250 36 : detect(s.passExecutable, {QStringLiteral("pass")});
251 36 : detect(s.pwgenExecutable, {QStringLiteral("pwgen")});
252 12 : m_gpg->setText(s.gpgExecutable);
253 12 : m_git->setText(s.gitExecutable);
254 12 : m_pass->setText(s.passExecutable);
255 12 : m_usePass->setChecked(s.usePass || runnable(s.passExecutable));
256 12 : updateStatus();
257 12 : }
258 :
259 15 : void ProgramsWizardPage::updateStatus() {
260 30 : const QString gpg = m_gpg->text().trimmed();
261 15 : if (gpg.isEmpty()) {
262 4 : m_gpgStatus->setText(tr("GnuPG was not found. Install it (gpg or gpg2) "
263 : "and enter its location here."));
264 13 : } else if (!runnable(gpg)) {
265 2 : m_gpgStatus->setText(tr("%1 is not an executable file.").arg(gpg));
266 : } else {
267 24 : m_gpgStatus->setText(QString());
268 : }
269 30 : m_usePass->setEnabled(runnable(m_pass->text().trimmed()));
270 15 : if (!m_usePass->isEnabled()) {
271 15 : m_usePass->setChecked(false);
272 : }
273 15 : emit completeChanged();
274 15 : }
275 :
276 58 : auto ProgramsWizardPage::isComplete() const -> bool {
277 174 : return runnable(m_gpg->text().trimmed());
278 : }
279 :
280 11 : auto ProgramsWizardPage::validatePage() -> bool {
281 11 : AppSettings &s = m_wizard->m_settings;
282 11 : s.gpgExecutable = m_gpg->text().trimmed();
283 11 : s.gitExecutable = m_git->text().trimmed();
284 11 : s.passExecutable = m_pass->text().trimmed();
285 11 : s.usePass = m_usePass->isChecked();
286 11 : return true;
287 : }
288 :
289 : // --------------------------------------------------------------------------
290 :
291 23 : KeyWizardPage::KeyWizardPage(FirstRunWizard *wizard)
292 23 : : QWizardPage(wizard), m_wizard(wizard), m_list(new QListWidget(this)),
293 46 : m_generate(new QPushButton(tr("Generate a new key pair…"), this)),
294 46 : m_status(new QLabel(this)) {
295 46 : setTitle(tr("Your key"));
296 23 : setSubTitle(tr("Passwords are encrypted to GnuPG keys. Tick the keys that "
297 : "should be able to open a new store; you need at least one "
298 : "with its secret half on this machine."));
299 23 : auto *layout = new QVBoxLayout(this);
300 23 : layout->addWidget(m_list);
301 23 : m_status->setWordWrap(true);
302 23 : layout->addWidget(m_status);
303 23 : auto *buttons = new QHBoxLayout;
304 23 : buttons->addStretch();
305 23 : buttons->addWidget(m_generate);
306 23 : layout->addLayout(buttons);
307 23 : connect(m_generate, &QPushButton::clicked, this, &KeyWizardPage::generate);
308 23 : connect(m_list, &QListWidget::itemChanged, this,
309 60 : [this]() { emit completeChanged(); });
310 23 : }
311 :
312 15 : void KeyWizardPage::initializePage() { reload(); }
313 :
314 16 : void KeyWizardPage::reload() {
315 16 : m_wizard->m_keys =
316 16 : FirstRunWizard::secretKeys(m_wizard->m_settings.gpgExecutable);
317 16 : m_list->clear();
318 16 : const QDateTime now = QDateTime::currentDateTime();
319 49 : for (UserInfo &key : m_wizard->m_keys) {
320 : // An expired or otherwise unusable key would make every encryption to
321 : // the new store fail; list it, but do not tick it.
322 17 : QString why;
323 18 : if (key.expiry.isValid() && key.expiry < now) {
324 1 : why = tr("expired");
325 : } else if (!key.isValid()) {
326 1 : why = tr("not usable");
327 : }
328 17 : key.enabled = why.isEmpty();
329 34 : QString label = QStringLiteral("%1\n%2").arg(key.name, key.key_id);
330 17 : if (!why.isEmpty()) {
331 4 : label += QStringLiteral(" (%1)").arg(why);
332 : }
333 17 : auto *item = new QListWidgetItem(label, m_list);
334 34 : item->setFlags(key.enabled ? (item->flags() | Qt::ItemIsUserCheckable)
335 : : (item->flags() & ~Qt::ItemIsEnabled));
336 19 : item->setCheckState(key.enabled ? Qt::Checked : Qt::Unchecked);
337 : }
338 32 : m_status->setText(m_wizard->m_keys.isEmpty()
339 16 : ? tr("GnuPG has no secret key yet. Generate one here, "
340 : "or import your existing key with gpg first.")
341 : : QString());
342 16 : emit completeChanged();
343 16 : }
344 :
345 1 : void KeyWizardPage::generate() {
346 : // A backend of the wizard's own, set up from its copy of the settings, so
347 : // the chosen gpg is used and nothing is written before Finish. Each Pass
348 : // owns its Executor; the shared backend is not involved.
349 1 : ImitatePass pass;
350 1 : pass.init(m_wizard->m_settings);
351 1 : KeygenDialog d(m_wizard->m_settings.gpgExecutable, &pass, this);
352 1 : d.exec();
353 1 : reload();
354 1 : }
355 :
356 132 : auto KeyWizardPage::isComplete() const -> bool { return true; }
357 :
358 16 : auto KeyWizardPage::validatePage() -> bool {
359 38 : for (int i = 0; i < m_list->count() && i < m_wizard->m_keys.size(); ++i) {
360 22 : m_wizard->m_keys[i].enabled = m_list->item(i)->checkState() == Qt::Checked;
361 : }
362 16 : return true;
363 : }
364 :
365 : // --------------------------------------------------------------------------
366 :
367 23 : StoreWizardPage::StoreWizardPage(FirstRunWizard *wizard)
368 23 : : QWizardPage(wizard), m_wizard(wizard), m_path(new QLineEdit(this)),
369 46 : m_useGit(new QCheckBox(tr("Keep the store under Git"), this)),
370 46 : m_status(new QLabel(this)) {
371 46 : setTitle(tr("Password store"));
372 23 : setSubTitle(tr("The folder your passwords live in. An existing store is "
373 : "used as it is; an empty or missing folder is set up for "
374 : "the keys you ticked."));
375 23 : auto *layout = new QFormLayout(this);
376 69 : layout->addRow(tr("Folder"), pathRow(m_path, this, [this]() {
377 : return QFileDialog::getExistingDirectory(
378 1 : this, tr("Choose the password store folder"),
379 3 : m_path->text());
380 : }));
381 23 : m_status->setWordWrap(true);
382 23 : layout->addRow(QString(), m_status);
383 23 : layout->addRow(QString(), m_useGit);
384 23 : connect(m_path, &QLineEdit::textEdited, this, &StoreWizardPage::updateStatus);
385 23 : }
386 :
387 18 : void StoreWizardPage::initializePage() {
388 18 : QString path = m_wizard->m_settings.passStore;
389 18 : if (path.isEmpty()) {
390 10 : path = Util::findPasswordStore();
391 : }
392 18 : m_path->setText(QDir::toNativeSeparators(QDir::cleanPath(path)));
393 18 : const QString clean = QDir::cleanPath(path);
394 36 : const bool repository = QDir(clean).exists(QStringLiteral(".git"));
395 18 : bool haveGit = runnable(m_wizard->m_settings.gitExecutable);
396 : // A first commit needs a name and an e-mail; without them Finish would
397 : // fail at the very end. Ask git now and say what to do instead.
398 : const bool identity =
399 26 : haveGit && (repository || ProfileInit::gitIdentityConfigured(
400 8 : QDir::homePath(), m_wizard->m_settings));
401 18 : m_useGit->setToolTip(
402 18 : haveGit && !identity
403 18 : ? tr("Git has no name and e-mail to commit with yet. Run\n"
404 : "git config --global user.name \"Your Name\"\n"
405 : "git config --global user.email you@example.org\n"
406 : "and turn Git on in Settings afterwards.")
407 : : tr("Every change becomes a commit; a folder that is no "
408 : "repository yet gets one"));
409 18 : haveGit = haveGit && identity;
410 18 : m_useGit->setEnabled(haveGit);
411 : // On for a repository or a store still to be made, off for an existing
412 : // store that is no repository: turning Git on there would make every
413 : // remove fail in a folder git knows nothing about.
414 40 : m_useGit->setChecked(haveGit && (m_wizard->m_settings.useGit || repository ||
415 4 : !FirstRunWizard::isStore(clean)));
416 18 : updateStatus();
417 18 : }
418 :
419 82 : auto StoreWizardPage::recipients() const -> int {
420 : int ticked = 0;
421 190 : for (const UserInfo &key : std::as_const(m_wizard->m_keys)) {
422 158 : ticked += key.enabled ? 1 : 0;
423 : }
424 82 : return ticked;
425 : }
426 :
427 26 : void StoreWizardPage::updateStatus() {
428 52 : const QString path = QDir::cleanPath(m_path->text().trimmed());
429 26 : QString text;
430 26 : if (path.isEmpty()) {
431 1 : text = tr("Enter a folder.");
432 25 : } else if (FirstRunWizard::isStore(path)) {
433 : const int entries = static_cast<int>(
434 18 : Util::regularFilesUnder(path, {QStringLiteral("*.gpg")}).size());
435 6 : text = tr("An existing password store with %n entries.", nullptr, entries);
436 19 : } else if (QDir(path).exists()) {
437 6 : text = QDir(path).isEmpty()
438 6 : ? tr("An empty folder; it will be set up as a password store.")
439 : : tr("This folder is not a password store yet; a .gpg-id for "
440 : "the ticked keys will be written into it.");
441 : } else {
442 16 : text = tr("The folder does not exist yet; it will be created.");
443 : }
444 26 : if (!path.isEmpty() && !FirstRunWizard::isStore(path) && recipients() == 0) {
445 6 : text += QLatin1Char(' ') +
446 12 : tr("Go back and tick at least one key to encrypt it to.");
447 : }
448 26 : m_status->setText(text);
449 26 : emit completeChanged();
450 26 : }
451 :
452 87 : auto StoreWizardPage::isComplete() const -> bool {
453 261 : const QString path = QDir::cleanPath(m_path->text().trimmed());
454 87 : if (path.isEmpty()) {
455 : return false;
456 : }
457 84 : return FirstRunWizard::isStore(path) || recipients() > 0;
458 : }
459 :
460 10 : auto StoreWizardPage::validatePage() -> bool {
461 10 : m_wizard->m_settings.passStore =
462 20 : QDir::fromNativeSeparators(m_path->text().trimmed());
463 10 : m_wizard->m_settings.useGit = m_useGit->isEnabled() && m_useGit->isChecked();
464 10 : return true;
465 : }
466 :
467 : // --------------------------------------------------------------------------
468 :
469 23 : DoneWizardPage::DoneWizardPage(FirstRunWizard *wizard)
470 23 : : QWizardPage(wizard), m_wizard(wizard), m_summary(new QLabel(this)),
471 46 : m_hidePassword(new QCheckBox(tr("Hide passwords until asked"), this)),
472 69 : m_trayIcon(new QCheckBox(tr("Show an icon in the system tray"), this)) {
473 46 : setTitle(tr("Ready"));
474 23 : setSubTitle(tr("Everything else can be changed later in Settings."));
475 23 : auto *layout = new QVBoxLayout(this);
476 23 : m_summary->setWordWrap(true);
477 23 : m_summary->setTextFormat(Qt::RichText);
478 23 : layout->addWidget(m_summary);
479 23 : layout->addSpacing(12);
480 23 : m_hidePassword->setToolTip(
481 23 : tr("Show the password line as dots; Show password reveals it"));
482 23 : layout->addWidget(m_hidePassword);
483 23 : layout->addWidget(m_trayIcon);
484 23 : layout->addStretch();
485 23 : setFinalPage(true);
486 23 : }
487 :
488 9 : void DoneWizardPage::initializePage() {
489 9 : const AppSettings &s = m_wizard->m_settings;
490 9 : const QString store = QDir::toNativeSeparators(QDir::cleanPath(s.passStore));
491 9 : QStringList lines;
492 18 : lines << tr("Store: %1").arg(store.toHtmlEscaped());
493 9 : if (FirstRunWizard::isStore(s.passStore)) {
494 3 : lines << tr("It is already a password store and is used as it is.");
495 6 : } else if (s.useGit) {
496 2 : lines << tr("It will be set up for the ticked keys and put under Git.");
497 : } else {
498 4 : lines << tr("It will be set up for the ticked keys.");
499 : }
500 18 : lines << tr("GnuPG: %1").arg(s.gpgExecutable.toHtmlEscaped());
501 9 : lines << (s.usePass ? tr("Operations run through pass.")
502 : : tr("Operations run through gpg and git directly."));
503 18 : m_summary->setText(lines.join(QStringLiteral("<br>")));
504 9 : m_hidePassword->setChecked(true);
505 9 : const bool tray = QSystemTrayIcon::isSystemTrayAvailable();
506 9 : m_trayIcon->setVisible(tray);
507 9 : m_trayIcon->setChecked(tray && s.useTrayIcon);
508 9 : }
509 :
510 13 : auto DoneWizardPage::validatePage() -> bool {
511 13 : m_wizard->m_settings.hidePassword = m_hidePassword->isChecked();
512 13 : m_wizard->m_settings.useTrayIcon =
513 13 : m_trayIcon->isVisible() && m_trayIcon->isChecked();
514 13 : return true;
515 : }
|