Line data Source code
1 : // SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements what both backends share: the environment the store's
6 : * processes run in, the refusals for links and paths outside the store, `.gpg-
7 : * id` lookup, and Pass::finished(), which routes each finished process to its
8 : * signal.
9 : */
10 : #include "pass.h"
11 :
12 : #include "gpgidgeneration.h"
13 : #include "gpgidsigner.h"
14 : #include "gpgkeystate.h"
15 : #include "pathvalidator.h"
16 : #include "processinfo.h"
17 : #include "util.h"
18 : #include <QCoreApplication>
19 : #include <QDebug>
20 : #include <QDir>
21 : #include <QFile>
22 : #include <QFileInfo>
23 : #include <QHash>
24 : #include <QLocale>
25 : #include <QProcess>
26 : #include <QRandomGenerator>
27 : #include <QRegularExpression>
28 : #include <QTextStream>
29 : #include <array>
30 : #include <optional>
31 : #include <utility>
32 :
33 : #include "qtpasslogging.h"
34 :
35 : using Enums::GIT_INIT;
36 : using Enums::GIT_PULL;
37 : using Enums::GIT_PUSH;
38 : using Enums::GPG_GENKEYS;
39 : using Enums::PASS_COPY;
40 : using Enums::PASS_INIT;
41 : using Enums::PASS_INSERT;
42 : using Enums::PASS_MOVE;
43 : using Enums::PASS_REMOVE;
44 : using Enums::PASS_SHOW;
45 :
46 : namespace {
47 : auto fallbackCharset(const QString &input, const QString &fallback) -> QString {
48 1520 : return input.isEmpty() ? fallback : input;
49 : }
50 :
51 313 : auto effectiveCharset(const PasswordConfiguration &passConfig) -> QString {
52 313 : int sel = passConfig.selected;
53 313 : if (sel < 0 || sel >= PasswordConfiguration::CHARSETS_COUNT)
54 : sel = PasswordConfiguration::ALLCHARS;
55 : return fallbackCharset(
56 313 : passConfig.Characters[sel],
57 313 : passConfig.Characters[PasswordConfiguration::ALLCHARS]);
58 : }
59 : } // namespace
60 :
61 327 : Pass::Pass() : env(QProcessEnvironment::systemEnvironment()) {
62 327 : connect(&exec, &Executor::finished, this, &Pass::finished);
63 327 : connect(&exec, &Executor::error, this, &Pass::finished);
64 :
65 327 : connect(&exec, &Executor::starting, this, &Pass::startingExecuteWrapper);
66 327 : connect(&exec, &Executor::drained, this, [this] {
67 : // Idle means the queue is empty and nothing runs, so no insert is in
68 : // flight: force the pending count to zero. A report that never arrived
69 : // (the "No such transaction" path in ImitatePass::finished(), or any
70 : // later change that drops an insert without reporting) would otherwise
71 : // leave the count one too high for the rest of the session, and every
72 : // later dialog would take its own report for an earlier one and wait for
73 : // ever. Idle cannot fall between a dialog's insertsPending() read and its
74 : // own report, since that insert keeps the executor busy.
75 72 : m_insertsPending = 0;
76 72 : emit idle();
77 : });
78 : // Each queued process starts in the environment as the store is then:
79 : // the Git pin is judged at that moment (dequeuing() comes just before the
80 : // start, on this thread).
81 327 : connect(&exec, &Executor::dequeuing, this, [this](int, bool completes) {
82 177 : if (completes) {
83 172 : exec.setEnvironment(withCurrentGitPin(env));
84 : }
85 177 : });
86 : // Merge our vars into WSLENV rather than blindly appending a duplicate
87 : // entry. WSLENV flags: /p translates the path, /u forwards from Windows
88 : // into WSL only; /w would be the other direction, from WSL out, and leaves
89 : // pass inside WSL without the value. Everything updateEnv() sets for pass
90 : // has to cross, the signing key above all: without it pass does not check
91 : // the .gpg-id signature.
92 : const QStringList wslenvVars = {
93 654 : QStringLiteral("PASSWORD_STORE_DIR/p"),
94 327 : QStringLiteral("PASSWORD_STORE_SIGNING_KEY/u"),
95 327 : QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH/u"),
96 327 : QStringLiteral("PASSWORD_STORE_CHARACTER_SET/u"),
97 327 : QStringLiteral("PASSWORD_STORE_GPG_OPTS/u"),
98 327 : QStringLiteral("GIT_CEILING_DIRECTORIES/p"),
99 2616 : QStringLiteral("GIT_LITERAL_PATHSPECS/u")};
100 654 : const QString existing = env.value(QStringLiteral("WSLENV"));
101 327 : if (existing.isEmpty()) {
102 652 : env.insert(QStringLiteral("WSLENV"), wslenvVars.join(':'));
103 : } else {
104 1 : QStringList parts = existing.split(':', Qt::SkipEmptyParts);
105 8 : for (const QString &v : wslenvVars) {
106 7 : if (!parts.contains(v))
107 : parts.append(v);
108 : }
109 2 : env.insert(QStringLiteral("WSLENV"), parts.join(':'));
110 : }
111 327 : }
112 :
113 2 : void Pass::executeWrapper(PROCESS id, const QString &app,
114 : const QStringList &args, bool readStdout,
115 : bool readStderr) {
116 2 : executeWrapper(id, app, args, QString(), readStdout, readStderr);
117 2 : }
118 :
119 177 : void Pass::executeWrapper(PROCESS id, const QString &app,
120 : const QStringList &args, QString input,
121 : bool readStdout, bool readStderr) {
122 177 : beforeExecute(id);
123 177 : qCDebug(lcQtPass) << app << loggableArgs(args);
124 177 : exec.execute(id, m_settings.passStore, app, args, std::move(input),
125 : readStdout, readStderr);
126 177 : }
127 :
128 4 : auto Pass::loggableArgs(const QStringList &args) -> QStringList {
129 : // Secrets travel on stdin (Insert's content, KeygenDialog's batch file),
130 : // never in argv; this keeps the log honest should that ever change.
131 : static const QStringList secretOptions{
132 13 : QStringLiteral("--passphrase"), QStringLiteral("--override-session-key")};
133 4 : QStringList out;
134 : bool hide = false;
135 12 : for (const QString &arg : args) {
136 8 : if (hide) {
137 1 : out << QStringLiteral("<redacted>");
138 : hide = false;
139 1 : continue;
140 : }
141 14 : if (arg.startsWith(QStringLiteral("otpauth://"))) {
142 1 : out << QStringLiteral("<redacted>");
143 1 : continue;
144 : }
145 12 : if (arg.startsWith(QStringLiteral("--passphrase=")) ||
146 11 : arg.startsWith(QStringLiteral("--override-session-key="))) {
147 2 : out << arg.left(arg.indexOf(u'=') + 1) + QStringLiteral("<redacted>");
148 1 : continue;
149 : }
150 : hide = secretOptions.contains(arg);
151 : out << arg;
152 : }
153 4 : return out;
154 : }
155 :
156 32 : void Pass::beforeExecute(PROCESS /*id*/) {}
157 :
158 609 : void Pass::init(const AppSettings &settings) {
159 609 : m_settings = settings;
160 : #ifdef __APPLE__
161 : // If it exists, prepend gpgtools to PATH
162 : if (QFile(QStringLiteral("/usr/local/MacGPG2/bin")).exists())
163 : env.insert(QStringLiteral("PATH"),
164 : QStringLiteral("/usr/local/MacGPG2/bin:") +
165 : env.value(QStringLiteral("PATH")));
166 : // Add missing /usr/local/bin (exact component match, no leading colon)
167 : const QString currentPath = env.value(QStringLiteral("PATH"));
168 : if (!currentPath.split(':', Qt::SkipEmptyParts)
169 : .contains(QStringLiteral("/usr/local/bin"))) {
170 : env.insert(QStringLiteral("PATH"),
171 : currentPath.isEmpty()
172 : ? QStringLiteral("/usr/local/bin")
173 : : QStringLiteral("/usr/local/bin:") + currentPath);
174 : }
175 : #endif
176 :
177 : // The configured gpgHome wins over GNUPGHOME only when it exists: a stale
178 : // one (#1711) fails every gpg call with "No secret key". Clearing the
179 : // setting restores the inherited value rather than keeping the old path.
180 1218 : const QString inheritedHome = QProcessEnvironment::systemEnvironment().value(
181 1218 : QStringLiteral("GNUPGHOME"));
182 569 : const auto useInheritedHome = [this, &inheritedHome]() {
183 569 : if (inheritedHome.isEmpty()) {
184 790 : env.remove(QStringLiteral("GNUPGHOME"));
185 : } else {
186 348 : env.insert(QStringLiteral("GNUPGHOME"), inheritedHome);
187 : }
188 1178 : };
189 609 : if (m_settings.gpgHome.isEmpty()) {
190 567 : useInheritedHome();
191 : } else {
192 42 : QDir absHome(m_settings.gpgHome);
193 42 : absHome.makeAbsolute();
194 42 : if (absHome.exists()) {
195 80 : env.insert(QStringLiteral("GNUPGHOME"), absHome.path());
196 : } else {
197 2 : if (inheritedHome.isEmpty()) {
198 2 : qCWarning(lcQtPass) << "gpgHome" << absHome.path()
199 1 : << "does not exist; using the default GnuPG home";
200 1 : emit statusMsg(tr("Configured GPG home %1 does not exist, using the "
201 : "default keyring")
202 2 : .arg(absHome.path()),
203 : 5000);
204 : } else {
205 2 : qCWarning(lcQtPass)
206 2 : << "gpgHome" << absHome.path() << "does not exist; using GNUPGHOME"
207 1 : << inheritedHome << "from the environment";
208 1 : emit statusMsg(tr("Configured GPG home %1 does not exist, using "
209 : "GNUPGHOME %2 from the environment")
210 2 : .arg(absHome.path(), inheritedHome),
211 : 5000);
212 : }
213 2 : useInheritedHome();
214 : }
215 42 : }
216 609 : }
217 :
218 1211 : auto Pass::generatePassword(unsigned int length, const QString &charset)
219 : -> QString {
220 1211 : if (length == 0) {
221 2 : emit critical(tr("Invalid password length"),
222 1 : tr("Can't generate password with zero length."));
223 1 : return {};
224 : }
225 1210 : QString passwd;
226 1210 : if (m_settings.usePwgen) {
227 : // --secure goes first as it overrides --no-* otherwise
228 3 : QStringList args;
229 3 : args.append("-1");
230 3 : if (!m_settings.lessRandom) {
231 4 : args.append("--secure");
232 : }
233 5 : args.append(m_settings.avoidCapitals ? "--no-capitalize" : "--capitalize");
234 5 : args.append(m_settings.avoidNumbers ? "--no-numerals" : "--numerals");
235 3 : if (m_settings.useSymbols) {
236 2 : args.append("--symbols");
237 : }
238 3 : args.append(QString::number(length));
239 3 : if (Executor::executeBlocking(m_settings.pwgenExecutable, args, &passwd) ==
240 : 0) {
241 3 : static const QRegularExpression literalNewLines{"[\\n\\r]"};
242 2 : passwd.remove(literalNewLines);
243 : } else {
244 1 : passwd.clear();
245 1 : qCDebug(lcQtPass) << "pwgen fail";
246 : // Report it, as the other generation failures do: otherwise Generate
247 : // does nothing silently and OK then saves an empty (or, on Edit, the
248 : // old) password.
249 1 : emit critical(
250 1 : tr("Password generation failed"),
251 1 : tr("Could not run pwgen (\"%1\"). Check the pwgen program in the "
252 : "settings, or turn \"Use pwgen\" off.")
253 2 : .arg(m_settings.pwgenExecutable));
254 : }
255 : } else {
256 : // An empty CUSTOM charset falls back to ALLCHARS, not a weak password
257 : // (#780).
258 : const QString cs = fallbackCharset(
259 : charset, m_settings.passwordConfiguration
260 1207 : .Characters[PasswordConfiguration::ALLCHARS]);
261 1207 : if (cs.length() > 0) {
262 2412 : passwd = generateRandomPassword(cs, length);
263 : } else {
264 1 : emit critical(
265 1 : tr("No characters chosen"),
266 1 : tr("Can't generate password, there are no characters to choose from "
267 : "set in the configuration!"));
268 : }
269 : }
270 : return passwd;
271 : }
272 :
273 33 : bool Pass::gpgSupportsEd25519(const QString &gpgExecutable) {
274 : const QString exe =
275 33 : gpgExecutable.isEmpty() ? QStringLiteral("gpg") : gpgExecutable;
276 33 : QString out, err;
277 132 : if (Executor::executeBlocking(exe, {"--version"}, &out, &err) != 0) {
278 : return false;
279 : }
280 31 : QRegularExpression versionRegex(R"(gpg \(GnuPG\) (\d+)\.(\d+))");
281 31 : QRegularExpressionMatch match = versionRegex.match(out);
282 31 : if (!match.hasMatch()) {
283 : return false;
284 : }
285 30 : int major = match.captured(1).toInt();
286 30 : int minor = match.captured(2).toInt();
287 30 : return major > 2 || (major == 2 && minor >= 1);
288 31 : }
289 :
290 : // ed25519 (GnuPG 2.1+) generates much faster than RSA.
291 29 : QString Pass::getDefaultKeyTemplate(const QString &gpgExecutable) {
292 29 : if (gpgSupportsEd25519(gpgExecutable)) {
293 26 : return QStringLiteral("%echo Generating a default key\n"
294 : "Key-Type: EdDSA\n"
295 : "Key-Curve: Ed25519\n"
296 : "Subkey-Type: ECDH\n"
297 : "Subkey-Curve: Curve25519\n"
298 : "Name-Real: \n"
299 : "Name-Comment: QtPass\n"
300 : "Name-Email: \n"
301 : "Expire-Date: 0\n"
302 : "%no-protection\n"
303 : "%commit\n"
304 : "%echo done");
305 : }
306 3 : return QStringLiteral("%echo Generating a default key\n"
307 : "Key-Type: RSA\n"
308 : "Subkey-Type: RSA\n"
309 : "Name-Real: \n"
310 : "Name-Comment: QtPass\n"
311 : "Name-Email: \n"
312 : "Expire-Date: 0\n"
313 : "%no-protection\n"
314 : "%commit\n"
315 : "%echo done");
316 : }
317 :
318 : namespace {
319 8 : auto resolveWslGpgconfPath(const QString &lastPart) -> QString {
320 8 : qsizetype lastSep = lastPart.lastIndexOf('/');
321 8 : if (lastSep < 0) {
322 5 : lastSep = lastPart.lastIndexOf('\\');
323 : }
324 5 : if (lastSep >= 0) {
325 6 : return lastPart.left(lastSep + 1) + "gpgconf";
326 : }
327 5 : return QStringLiteral("gpgconf");
328 : }
329 :
330 8 : QString findGpgconfInGpgDir(const QString &gpgPath) {
331 8 : QFileInfo gpgInfo(gpgPath);
332 8 : if (!gpgInfo.isAbsolute()) {
333 1 : return {};
334 : }
335 :
336 7 : QDir dir(gpgInfo.absolutePath());
337 :
338 : #ifdef Q_OS_WIN
339 : QFileInfo candidateExe(dir.filePath("gpgconf.exe"));
340 : if (candidateExe.isExecutable()) {
341 : return candidateExe.filePath();
342 : }
343 : #endif
344 :
345 7 : QFileInfo candidate(dir.filePath("gpgconf"));
346 7 : if (candidate.isExecutable()) {
347 5 : return candidate.filePath();
348 : }
349 2 : return {};
350 8 : }
351 :
352 : } // namespace
353 :
354 22 : auto Pass::resolveGpgconfCommand(const QString &gpgPath)
355 : -> ResolvedGpgconfCommand {
356 22 : if (gpgPath.trimmed().isEmpty()) {
357 : return {"gpgconf", {}};
358 : }
359 :
360 : // WSL: gpgconf next to the configured gpg, same distribution, via --exec
361 : // not the shell. An unparsed form (`wsl sh -c ...`, bare `wsl`) falls back
362 : // to gpgconf on the Windows PATH.
363 21 : if (const auto wsl = Executor::parseWslCommand(gpgPath)) {
364 18 : if (!QFileInfo(wsl->command).fileName().startsWith("gpg")) {
365 : return {"gpgconf", {}};
366 : }
367 16 : const auto gpgconf = wsl->with(resolveWslGpgconfPath(wsl->command));
368 0 : return {gpgconf.launcher, gpgconf.argv({})};
369 8 : }
370 :
371 12 : const QStringList parts = QProcess::splitCommand(gpgPath);
372 12 : if (parts.isEmpty()) {
373 : return {"gpgconf", {}};
374 : }
375 : const QString first = parts.first();
376 33 : if (QFileInfo(first).fileName().compare("wsl", Qt::CaseInsensitive) == 0 ||
377 30 : QFileInfo(first).fileName().compare("wsl.exe", Qt::CaseInsensitive) ==
378 : 0) {
379 : return {"gpgconf", {}};
380 : }
381 :
382 9 : if (!first.contains('/') && !first.contains('\\')) {
383 : return {"gpgconf", {}};
384 : }
385 :
386 8 : QString gpgconfPath = findGpgconfInGpgDir(first);
387 8 : if (!gpgconfPath.isEmpty()) {
388 5 : return {gpgconfPath, {}};
389 : }
390 :
391 : return {"gpgconf", {}};
392 22 : }
393 :
394 11 : void Pass::GenerateGPGKeys(QString batch) {
395 : const QString gpgPath = m_settings.gpgExecutable;
396 11 : if (gpgPath.isEmpty()) {
397 : // The executor would fail an empty executable with a generic process
398 : // error; the keygen dialog waits for generateGPGKeysFailed and a reason
399 : // it can show. Queued so we do not re-enter KeygenDialog::done(), which
400 : // drives key generation synchronously.
401 9 : QMetaObject::invokeMethod(
402 : this,
403 9 : [this]() {
404 5 : emit generateGPGKeysFailed(tr("No GPG executable configured"));
405 5 : emit processErrorExit(1, tr("No GPG executable configured"));
406 5 : },
407 : Qt::QueuedConnection);
408 : return;
409 : }
410 :
411 : // A stale gpg-agent holding the key database lock causes "database locked"
412 : // timeouts during key generation.
413 2 : ResolvedGpgconfCommand resolvedGpgconf = resolveGpgconfCommand(gpgPath);
414 : QStringList killArgs = resolvedGpgconf.arguments;
415 2 : killArgs << "--kill";
416 2 : killArgs << "gpg-agent";
417 : // Use same environment as key generation to target correct gpg-agent
418 2 : if (Executor::executeBlocking(env, resolvedGpgconf.program, killArgs) != 0) {
419 2 : qCWarning(lcQtPass) << "Failed to kill gpg-agent";
420 : }
421 :
422 16 : executeWrapper(GPG_GENKEYS, gpgPath, {"--gen-key", "--no-tty", "--batch"},
423 : std::move(batch), true, true);
424 : }
425 :
426 134 : auto Pass::listKeys(QStringList keystrings, bool secret) -> QList<UserInfo> {
427 938 : QStringList args = {"--no-tty", "--with-colons", "--with-fingerprint"};
428 358 : args.append(secret ? "--list-secret-keys" : "--list-keys");
429 :
430 298 : for (const QString &keystring : std::as_const(keystrings)) {
431 164 : if (!keystring.isEmpty()) {
432 : args.append(keystring);
433 : }
434 : }
435 134 : QString p_out;
436 134 : if (Executor::executeBlocking(m_settings.gpgExecutable, args, &p_out) != 0) {
437 10 : return {};
438 : }
439 124 : return parseGpgColonOutput(p_out, secret);
440 : }
441 :
442 111 : auto Pass::listKeys(const QString &keystring, bool secret) -> QList<UserInfo> {
443 222 : return listKeys(QStringList(keystring), secret);
444 : }
445 :
446 : namespace {
447 :
448 94 : auto containsAny(const QString &str, const QStringList &patterns) -> bool {
449 263 : for (const QString &p : patterns) {
450 180 : if (str.contains(p)) {
451 : return true;
452 : }
453 : }
454 : return false;
455 : }
456 :
457 : // @p patterns must already be lowercase; only @p str is lowered.
458 102 : auto containsAnyCaseInsensitive(const QString &str, const QStringList &patterns)
459 : -> bool {
460 : const QString lower = str.toLower();
461 252 : for (const QString &p : patterns) {
462 161 : if (lower.contains(p)) {
463 : return true;
464 : }
465 : }
466 : return false;
467 : }
468 :
469 : } // namespace
470 :
471 36 : auto gpgErrorMessage(const QString &err) -> QString {
472 : // Machine-readable status tokens added by --status-fd 2
473 180 : if (containsAny(err, {QStringLiteral("[GNUPG:] KEYEXPIRED"),
474 36 : QStringLiteral("[GNUPG:] INV_RECP 5 ")}))
475 : return QCoreApplication::translate(
476 : "Pass", "Encryption failed: GPG key has expired. Please renew or "
477 5 : "replace it.");
478 155 : if (containsAny(err, {QStringLiteral("[GNUPG:] KEYREVOKED"),
479 31 : QStringLiteral("[GNUPG:] INV_RECP 4 ")}))
480 : return QCoreApplication::translate(
481 4 : "Pass", "Encryption failed: GPG key has been revoked.");
482 135 : if (containsAny(err, {QStringLiteral("[GNUPG:] NO_PUBKEY"),
483 27 : QStringLiteral("[GNUPG:] INV_RECP")}))
484 : return QCoreApplication::translate(
485 : "Pass", "Encryption failed: recipient GPG key not found or invalid. "
486 2 : "Check that the key ID in .gpg-id is correct and imported.");
487 25 : if (err.contains(QStringLiteral("[GNUPG:] FAILURE")))
488 : return QCoreApplication::translate(
489 1 : "Pass", "Encryption failed. Check that your GPG key is valid.");
490 :
491 : // Locale-dependent fallbacks
492 96 : if (containsAnyCaseInsensitive(err, {QLatin1String("key has expired"),
493 : QLatin1String("key expired")}))
494 : return QCoreApplication::translate(
495 : "Pass", "Encryption failed: GPG key has expired. Please renew or "
496 1 : "replace it.");
497 92 : if (containsAnyCaseInsensitive(err, {QLatin1String("key has been revoked"),
498 : QLatin1String("revoked")}))
499 : return QCoreApplication::translate(
500 1 : "Pass", "Encryption failed: GPG key has been revoked.");
501 : // pass passes no --status-fd, so this is all gpg says about a key that is
502 : // present but cannot encrypt: expired, revoked, without an encryption
503 : // subkey, or not trusted ("There is no assurance this key belongs to the
504 : // named user"). "Not found ... imported" would send the user the wrong
505 : // way; gpg's own lines, shown below this, say which it is.
506 66 : if (containsAnyCaseInsensitive(err, {QLatin1String("unusable public key")}))
507 : return QCoreApplication::translate(
508 : "Pass", "Encryption failed: a recipient's GPG key cannot be used. It "
509 : "may have expired or been revoked, have no encryption subkey, "
510 3 : "or not be trusted; gpg's message below says which.");
511 76 : if (containsAnyCaseInsensitive(err, {QLatin1String("no public key"),
512 : QLatin1String("no secret key")}))
513 : return QCoreApplication::translate(
514 : "Pass", "Encryption failed: recipient GPG key not found or invalid. "
515 5 : "Check that the key ID in .gpg-id is correct and imported.");
516 42 : if (containsAnyCaseInsensitive(err, {QLatin1String("encryption failed")}))
517 : return QCoreApplication::translate(
518 1 : "Pass", "Encryption failed. Check that your GPG key is valid.");
519 :
520 13 : return {};
521 0 : }
522 :
523 183 : void Pass::finished(int id, int exitCode, const QString &out,
524 : const QString &err) {
525 183 : auto pid = static_cast<PROCESS>(id);
526 :
527 : // Pop the Show() this completion belongs to whether it worked or not, or
528 : // the next result would be attributed to the wrong file.
529 183 : QString shownFile;
530 183 : if (pid == PASS_SHOW && !m_pendingShows.isEmpty()) {
531 48 : shownFile = m_pendingShows.dequeue();
532 : }
533 : // Count this insert off, success or failure, before its result goes out, so
534 : // insertsPending() tracks the inserts still awaiting a report. Each insert
535 : // reaches here once as PASS_INSERT.
536 183 : if (pid == PASS_INSERT && m_insertsPending > 0) {
537 77 : --m_insertsPending;
538 : }
539 :
540 183 : if (exitCode != 0) {
541 39 : handleProcessError(pid, exitCode, out, err);
542 : return;
543 : }
544 :
545 144 : if (pid == PASS_SHOW) {
546 34 : emit finishedShow(out, shownFile);
547 : return;
548 : }
549 110 : emitProcessFinishedSignal(pid, out, err);
550 : }
551 :
552 39 : void Pass::handleProcessError(PROCESS pid, int exitCode, const QString &out,
553 : const QString &err) {
554 : Q_UNUSED(out);
555 :
556 39 : if (pid == PASS_INSERT) {
557 18 : const QString friendly = gpgErrorMessage(err);
558 : const QString why =
559 18 : friendly.isEmpty() ? err : formatInsertError(friendly, err);
560 18 : const bool written = insertWritten(err);
561 18 : emit insertFailed(why, written);
562 18 : if (written) {
563 : // The entry is in the store; a git step after it failed (a commit
564 : // that could not be signed, with pass.signcommits, say). The dialog
565 : // closes on this, so say it here rather than only in the output
566 : // panel.
567 8 : emit critical(tr("Saved, not committed"),
568 4 : tr("The entry is saved in the store, but recording it "
569 : "in git failed. What was reported:\n\n%1")
570 8 : .arg(err.trimmed()));
571 : }
572 18 : emit processErrorExit(exitCode, why);
573 : return;
574 : }
575 :
576 21 : if (pid == GPG_GENKEYS) {
577 1 : emit generateGPGKeysFailed(err);
578 : }
579 21 : emit processErrorExit(exitCode, err);
580 : }
581 :
582 6 : auto Pass::formatInsertError(const QString &friendly, const QString &err)
583 : -> QString {
584 6 : QStringList humanLines;
585 27 : for (const QString &line : err.split('\n')) {
586 : QString cleanedLine = line;
587 15 : cleanedLine.remove('\r');
588 15 : if (!cleanedLine.startsWith(QLatin1String("[GNUPG:]")))
589 : humanLines.append(cleanedLine);
590 : }
591 12 : const QString humanErr = humanLines.join('\n').trimmed();
592 18 : return humanErr.isEmpty() ? friendly : friendly + "\n\n" + humanErr;
593 : }
594 :
595 110 : void Pass::emitProcessFinishedSignal(PROCESS pid, const QString &out,
596 : const QString &err) {
597 : // Only output that cannot contain a secret reaches the generic listeners
598 : // (the output panel among them); Enums::processInfo is the one table.
599 110 : if (pid >= 0 && pid < Enums::PROCESS_COUNT &&
600 108 : !Enums::processInfo(pid).secret) {
601 42 : emit finishedAnyWithPid(out, err, pid);
602 : }
603 :
604 : // The kind-specific signal. PASS_SHOW is emitted by finished(), which
605 : // knows the file. Content search runs in-process (NativeGrep), not
606 : // through `pass grep`, and emits finishedGrep itself.
607 : using Signal = void (Pass::*)(const QString &, const QString &);
608 : static const QHash<int, Signal> kSignals{
609 : {GIT_INIT, &Pass::finishedGitInit},
610 : {GIT_PULL, &Pass::finishedGitPull},
611 : {GIT_PUSH, &Pass::finishedGitPush},
612 : {PASS_INSERT, &Pass::finishedInsert},
613 : {PASS_REMOVE, &Pass::finishedRemove},
614 : {PASS_INIT, &Pass::finishedInit},
615 : {PASS_MOVE, &Pass::finishedMove},
616 : {PASS_COPY, &Pass::finishedCopy},
617 : {GPG_GENKEYS, &Pass::finishedGenerateGPGKeys},
618 116 : };
619 110 : if (const Signal signal = kSignals.value(pid, nullptr)) {
620 106 : (this->*signal)(out, err);
621 : }
622 110 : }
623 :
624 2827 : void Pass::setEnvVar(const QString &name, const QString &value) {
625 2827 : if (value.isEmpty())
626 939 : env.remove(name);
627 : else
628 1888 : env.insert(name, value);
629 2827 : }
630 :
631 313 : void Pass::updateEnv() {
632 626 : setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY"),
633 313 : m_settings.passSigningKey);
634 626 : setEnvVar(QStringLiteral("PASSWORD_STORE_DIR"), m_settings.passStore);
635 : // Every path QtPass hands git names one file or folder: an entry called
636 : // "a*.gpg" must not make `git rm -f -- a*.gpg` remove every entry that
637 : // matches. Also reaches the git that pass runs, which passes names the
638 : // same way.
639 626 : setEnvVar(QStringLiteral("GIT_LITERAL_PATHSPECS"), QStringLiteral("1"));
640 :
641 313 : const PasswordConfiguration &passConfig = m_settings.passwordConfiguration;
642 626 : setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH"),
643 313 : QString::number(passConfig.length));
644 :
645 626 : setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET"),
646 313 : effectiveCharset(passConfig));
647 :
648 : // pass hands PASSWORD_STORE_GPG_OPTS to every gpg it runs: the user's own
649 : // options (split as pass's shell splits them: spaces, tabs, newlines),
650 : // then the output limit ImitatePass passes directly.
651 313 : QStringList gpgOpts = QProcessEnvironment::systemEnvironment()
652 626 : .value(QStringLiteral("PASSWORD_STORE_GPG_OPTS"))
653 626 : .split(QRegularExpression(QStringLiteral("\\s+")),
654 626 : Qt::SkipEmptyParts);
655 626 : gpgOpts << maxOutputArgs();
656 626 : setEnvVar(QStringLiteral("PASSWORD_STORE_GPG_OPTS"),
657 313 : gpgOpts.join(QLatin1Char(' ')));
658 :
659 : // As pass does: git never looks for a repository above the store's own
660 : // parent. A store that is not a repository itself but lies inside one (a
661 : // dotfiles repository in the home folder) would otherwise have its entries
662 : // committed and pushed there by native git mode. Only in the environment
663 : // of the processes QtPass runs for the store, never QtPass's own, which a
664 : // file manager or terminal it opens would inherit. pass sets its own
665 : // ceiling whatever this says. A store kept inside a larger repository on
666 : // purpose opts out (gitUseParentRepository), and then, as without a
667 : // store, whatever ceiling QtPass was started with applies.
668 : // A ceiling git cannot take as one entry (the parent's path holds the
669 : // list separator) is backed by GIT_DIR and GIT_WORK_TREE naming the
670 : // store's own repository (Util::gitStoreVariables()); pass unsets those
671 : // for itself and keeps its own ceiling.
672 626 : setEnvVar(QStringLiteral("GIT_DIR"), QString());
673 626 : setEnvVar(QStringLiteral("GIT_WORK_TREE"), QString());
674 313 : if (m_settings.gitUseParentRepository || m_settings.passStore.isEmpty()) {
675 34 : setEnvVar(QStringLiteral("GIT_CEILING_DIRECTORIES"),
676 34 : qEnvironmentVariable("GIT_CEILING_DIRECTORIES"));
677 : } else {
678 : for (const auto &[name, value] : Util::gitStoreVariables(
679 892 : m_settings.passStore, m_settings.gitExecutable)) {
680 300 : setEnvVar(name, value);
681 : }
682 : }
683 :
684 313 : exec.setEnvironment(env);
685 313 : }
686 :
687 835 : auto Pass::withCurrentGitPin(QProcessEnvironment base) const
688 : -> QProcessEnvironment {
689 835 : if (m_settings.gitUseParentRepository || m_settings.passStore.isEmpty()) {
690 0 : return base;
691 : }
692 1670 : base.remove(QStringLiteral("GIT_DIR"));
693 1670 : base.remove(QStringLiteral("GIT_WORK_TREE"));
694 : for (const auto &[name, value] : Util::gitStoreVariables(
695 2515 : m_settings.passStore, m_settings.gitExecutable)) {
696 845 : base.insert(name, value);
697 : }
698 835 : return base;
699 : }
700 :
701 904 : auto Pass::maxOutputArgs() -> QStringList {
702 2712 : return {QStringLiteral("--max-output"), QString::number(kMaxPlaintextBytes)};
703 904 : }
704 :
705 : namespace {
706 : /// File names compare the way the platform's file system compares them:
707 : /// "C:/Store" and "c:/store" are the same directory on Windows.
708 : #ifdef Q_OS_WIN
709 : constexpr auto kPathCase = Qt::CaseInsensitive;
710 : #else
711 : constexpr auto kPathCase = Qt::CaseSensitive;
712 : #endif
713 :
714 : /// Whether @p path is @p dir or lies under it.
715 660 : auto isAtOrUnder(const QString &path, const QString &dir,
716 : const QString &dirPrefix) -> bool {
717 1059 : return path.compare(dir, kPathCase) == 0 ||
718 399 : path.startsWith(dirPrefix, kPathCase);
719 : }
720 :
721 : /// Where the lookup of a .gpg-id starts and the store that bounds it.
722 : struct GpgIdLookup {
723 : QDir start;
724 : QString storeDir;
725 : QString storePrefix;
726 : };
727 :
728 : /// The folder whose list @p for_file wants: its own for a folder, its
729 : /// folder's for an entry.
730 325 : auto gpgIdLookup(const QString &for_file, const QString &passStore)
731 : -> GpgIdLookup {
732 325 : const QString normalizedFile = QDir::fromNativeSeparators(for_file);
733 : // Inside the store means at a path boundary: "/store-other/x" is not
734 : // under "/store", however the prefix compares.
735 : const QString storeDir =
736 325 : QDir::cleanPath(QDir::fromNativeSeparators(passStore));
737 325 : const QString storePrefix = storeDir.endsWith(QLatin1Char('/'))
738 325 : ? storeDir
739 325 : : storeDir + QLatin1Char('/');
740 325 : const QString cleanFile = QDir::cleanPath(normalizedFile);
741 325 : const bool insideStore = isAtOrUnder(cleanFile, storeDir, storePrefix);
742 : // A relative name is one inside the store; an absolute path outside it
743 : // stays what it is and the walk stops at once.
744 15 : const QString fullPath = insideStore || QDir::isAbsolutePath(cleanFile)
745 325 : ? cleanFile
746 10 : : storePrefix + cleanFile;
747 : // A folder (trailing separator, which cleanPath() dropped, or an existing
748 : // directory) wants its own list; an entry wants its folder's.
749 : const bool isFolder =
750 325 : normalizedFile.endsWith(QLatin1Char('/')) || QFileInfo(fullPath).isDir();
751 : // A folder that does not exist yet (an entry saved into a new folder, a
752 : // move to one) starts the walk at its nearest existing ancestor in the
753 : // store, as pass's set_gpg_recipients does: otherwise the walk ended at
754 : // once and the store root's list was taken over the one in between.
755 325 : QString start = isFolder ? fullPath : QFileInfo(fullPath).absolutePath();
756 337 : while (!QFileInfo(start).exists() &&
757 668 : isAtOrUnder(QDir::cleanPath(start), storeDir, storePrefix) &&
758 337 : QDir::cleanPath(start) != storeDir) {
759 12 : start = QFileInfo(start).absolutePath();
760 : }
761 325 : return {QDir(start), storeDir, storePrefix};
762 325 : }
763 :
764 : /// Whether @p candidate can be its folder's .gpg-id. A link under the name
765 : /// is not: whoever planted it chose the recipients elsewhere.
766 356 : auto isOwnGpgId(const QFileInfo &candidate) -> bool {
767 597 : return candidate.exists() && !candidate.isSymLink() &&
768 241 : !candidate.isJunction();
769 : }
770 :
771 : /// Walk up from the lookup's start to the first folder inside the store
772 : /// that has its own .gpg-id; nothing when none does. A link under the name
773 : /// is skipped, so the parent's list applies instead.
774 248 : auto nearestGpgId(const GpgIdLookup &lookup) -> std::optional<QString> {
775 248 : QDir dir = lookup.start;
776 1316 : while (dir.exists() && isAtOrUnder(QDir::cleanPath(dir.absolutePath()),
777 329 : lookup.storeDir, lookup.storePrefix)) {
778 304 : const QFileInfo candidate(dir.absoluteFilePath(".gpg-id"));
779 304 : if (isOwnGpgId(candidate)) {
780 223 : return candidate.absoluteFilePath();
781 : }
782 81 : if (!dir.cdUp()) {
783 : break;
784 : }
785 304 : }
786 25 : return std::nullopt;
787 248 : }
788 :
789 : } // namespace
790 :
791 437 : auto Pass::refuseLinkedPath(const QString &path, LinkScope scope,
792 : PathSpelling spelling) -> bool {
793 : const QString full = QDir::isAbsolutePath(path)
794 437 : ? path
795 83 : : QDir(m_settings.passStore).filePath(path);
796 : // The link check below walks only under the store as configured: a path
797 : // spelled through the root's link target would get no more than the
798 : // entry itself judged. No caller makes one; say so if one starts to.
799 729 : if (spelling == PathSpelling::Configured &&
800 292 : PathValidator::isTargetSpelled(m_settings.passStore, full)) {
801 4 : qCWarning(lcQtPass)
802 : << "Path spelled through the store root's link target, not as the "
803 : "store is configured; Pass operations expect the configured "
804 2 : "spelling:"
805 2 : << full;
806 : }
807 437 : if (!Util::isUnderLink(full, m_settings.passStore,
808 : scope == LinkScope::EntryAndParents)) {
809 : return false;
810 : }
811 : const QString why =
812 0 : tr("%1 is, or lies behind, a symbolic link or junction. What that "
813 : "points to is not part of the password store and is left alone.")
814 54 : .arg(QDir::toNativeSeparators(QDir::cleanPath(full)));
815 27 : refuseAsNotPartOfStore(why);
816 : return true;
817 : }
818 :
819 46 : auto Pass::refuseFolderOutsideStore(const QString &folder, QString *relative)
820 : -> bool {
821 46 : if (const std::optional<QString> inStore =
822 46 : PathValidator::storeRelativePath(m_settings.passStore, folder)) {
823 41 : if (relative != nullptr) {
824 41 : *relative = *inStore;
825 : }
826 : return false;
827 : }
828 5 : refuseAsNotPartOfStore(
829 5 : tr("%1 is not inside the password store. Recipients are set for the "
830 : "store or a folder in it.")
831 10 : .arg(QDir::toNativeSeparators(QDir::cleanPath(folder))));
832 5 : return true;
833 : }
834 :
835 486 : auto Pass::unfinishedGitOperationReason() -> QString {
836 486 : if (!m_settings.useGit) {
837 384 : return {};
838 : }
839 : // One git call names the path of each marker file or directory, whether or
840 : // not it exists; the backend runs it in the store (ImitatePass with
841 : // `-C <store>`, RealPass through `pass git`). The markers, paired with the
842 : // operation each one means, in the order git prints them back.
843 : const std::array<std::pair<QLatin1String, QString>, 6> markers{{
844 102 : {QLatin1String("rebase-merge"), tr("rebase")},
845 102 : {QLatin1String("rebase-apply"), tr("rebase")},
846 102 : {QLatin1String("MERGE_HEAD"), tr("merge")},
847 102 : {QLatin1String("CHERRY_PICK_HEAD"), tr("cherry-pick")},
848 102 : {QLatin1String("REVERT_HEAD"), tr("revert")},
849 : // A multi-commit cherry-pick or revert keeps its sequencer state after a
850 : // conflict resolution is committed, when CHERRY_PICK_HEAD/REVERT_HEAD is
851 : // already gone but `--continue` has more commits to apply. Checked last,
852 : // so the specific head above names the operation when it is still there.
853 102 : {QLatin1String("sequencer"), tr("cherry-pick or revert")},
854 0 : }};
855 204 : QStringList args{QStringLiteral("rev-parse")};
856 714 : for (const auto &marker : markers) {
857 612 : args << QStringLiteral("--git-path") << marker.first;
858 : }
859 102 : QString out;
860 102 : if (!storeGit(args, &out)) {
861 : // Git is off, the store is not a repository, or git could not be asked:
862 : // nothing is refused, as the operation would run anyway.
863 14 : return {};
864 : }
865 88 : const QStringList paths = out.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
866 228 : for (int i = 0; i < paths.size() && i < static_cast<int>(markers.size());
867 : ++i) {
868 : const QString path = paths.at(i).trimmed();
869 : const QString full = QDir::isAbsolutePath(path)
870 146 : ? path
871 146 : : QDir(m_settings.passStore).filePath(path);
872 146 : if (!QFileInfo::exists(full)) {
873 : continue;
874 : }
875 6 : return tr("The store's Git repository is in the middle of a %1. Finish or "
876 : "abort it with git before changing the store, or what you change "
877 : "may be lost.")
878 6 : .arg(markers.at(static_cast<size_t>(i)).second);
879 : }
880 82 : return {};
881 : }
882 :
883 68 : auto Pass::refusePendingPull() -> bool {
884 84 : if (!m_settings.useGit || !exec.hasQueued(Enums::GIT_PULL)) {
885 : return false;
886 : }
887 : const QString why =
888 : tr("The password store is being updated (a Git pull is in progress). "
889 : "Wait until it has finished, then try again.");
890 : // Like refuseAsNotPartOfStore(): the interface arms when an operation starts
891 : // and is released by processErrorExit(); a refusal starts nothing.
892 2 : emit startingExecuteWrapper();
893 2 : emit critical(tr("Update in progress"), why);
894 2 : emit processErrorExit(1, why);
895 : return true;
896 : }
897 :
898 344 : auto Pass::refuseUnfinishedGitOperation() -> bool {
899 344 : const QString why = unfinishedGitOperationReason();
900 344 : if (why.isEmpty()) {
901 : return false;
902 : }
903 : // Like refuseAsNotPartOfStore(): the interface arms when an operation starts
904 : // and is released by finished()/processErrorExit(); a refusal starts
905 : // nothing, so it emits both ends.
906 4 : emit startingExecuteWrapper();
907 4 : emit critical(tr("Unfinished Git operation"), why);
908 4 : emit processErrorExit(1, why);
909 : return true;
910 : }
911 :
912 32 : void Pass::refuseAsNotPartOfStore(const QString &why) {
913 : // The interface arms itself when an operation starts and is released by
914 : // finished or processErrorExit; a refusal starts nothing, so emits both.
915 32 : emit startingExecuteWrapper();
916 32 : emit critical(tr("Not part of the store"), why);
917 32 : emit processErrorExit(1, why);
918 32 : }
919 :
920 248 : auto Pass::getGpgIdPath(const QString &for_file, const QString &passStore)
921 : -> QString {
922 496 : return nearestGpgId(gpgIdLookup(for_file, passStore))
923 : .value_or(
924 992 : QDir(QDir::fromNativeSeparators(passStore)).filePath(".gpg-id"));
925 : }
926 :
927 158 : auto Pass::refuseSkippedGpgId(const QString &file, bool signing,
928 : const QString &except) -> bool {
929 : // Without a signing key the parent's list applies, as it does for pass.
930 158 : if (!signing) {
931 : return false;
932 : }
933 56 : QString recordError;
934 : const std::optional<QString> skipped =
935 56 : skippedGpgId(file, m_settings.passStore, &recordError);
936 56 : if (!skipped || (!except.isEmpty() &&
937 58 : QDir::cleanPath(*skipped) == QDir::cleanPath(except))) {
938 : return false;
939 : }
940 : // The strings keep the context they were translated in.
941 5 : if (!recordError.isEmpty()) {
942 0 : emit critical(QCoreApplication::translate(
943 : "ImitatePass", "Cannot check the recipient list"),
944 0 : QCoreApplication::translate(
945 : "ImitatePass", "%1 Whether %2 was removed cannot be "
946 : "told, so nothing was encrypted.")
947 0 : .arg(recordError, QDir::toNativeSeparators(*skipped)));
948 0 : return true;
949 : }
950 5 : emit critical(
951 10 : QCoreApplication::translate("ImitatePass", "Recipient list missing"),
952 5 : QCoreApplication::translate(
953 : "ImitatePass",
954 : "The signed recipient list %1 is missing or is not a regular file. "
955 : "QtPass does not fall back to a parent folder's list for it: that "
956 : "list was not signed for this folder. If a link is under that name, "
957 : "remove it. Then open Users for that folder and save its "
958 : "recipients, which writes and signs a fresh list.")
959 10 : .arg(QDir::toNativeSeparators(*skipped)));
960 5 : return true;
961 : }
962 :
963 44 : auto Pass::verifySignedList(const GpgIdSigner &signer, const QString &gpgIdFile,
964 : QByteArray *contents, QString *why) const -> bool {
965 44 : if (!signer.verifyFile(gpgIdFile, contents)) {
966 : return false;
967 : }
968 : // Authentic and unmodified is not the same as current: an older signed
969 : // list put back into the store is refused once this device has seen a
970 : // newer one, and one signed for another folder is refused here.
971 43 : QString reason;
972 43 : if (GpgIdGeneration::accept(gpgIdFile, *contents, m_settings.passStore,
973 : &reason) != GpgIdGeneration::Verdict::Accepted) {
974 16 : if (why != nullptr) {
975 16 : *why = reason;
976 : }
977 16 : return false;
978 : }
979 : return true;
980 : }
981 :
982 77 : auto Pass::skippedGpgId(const QString &forFile, const QString &passStore,
983 : QString *recordError) -> std::optional<QString> {
984 77 : const GpgIdLookup lookup = gpgIdLookup(forFile, passStore);
985 77 : QDir dir = lookup.start;
986 : // The same walk as nearestGpgId(), stopping at the list it finds, and
987 : // short of the store root: the root's own list has no parent to fall
988 : // back to, and the signature check refuses it when it is missing.
989 300 : while (dir.exists() && QDir::cleanPath(dir.absolutePath())
990 100 : .startsWith(lookup.storePrefix, kPathCase)) {
991 52 : const QFileInfo candidate(dir.absoluteFilePath(".gpg-id"));
992 52 : if (isOwnGpgId(candidate)) {
993 18 : return std::nullopt;
994 : }
995 : // A link under the name (dangling or not), or nothing under it where
996 : // this device accepted a list before: both are the folder's list gone,
997 : // not a folder that never had one.
998 34 : if (candidate.isSymLink() || candidate.isJunction()) {
999 12 : return candidate.absoluteFilePath();
1000 : }
1001 : // An unreadable record is not "never seen": whether a list was accepted
1002 : // here cannot be told, so this folder is refused too (fail closed), with
1003 : // the record's own reason for the caller to show.
1004 28 : QString why;
1005 : const std::optional<qint64> seen =
1006 56 : GpgIdGeneration::remembered(candidate.absoluteFilePath(), &why);
1007 28 : if (!seen) {
1008 1 : if (recordError != nullptr) {
1009 : *recordError =
1010 : why.isEmpty()
1011 2 : ? tr("The generation record of the recipient lists cannot "
1012 : "be read.")
1013 : : why;
1014 : }
1015 2 : return candidate.absoluteFilePath();
1016 : }
1017 27 : if (*seen > 0) {
1018 8 : return candidate.absoluteFilePath();
1019 : }
1020 23 : if (!dir.cdUp()) {
1021 : break;
1022 : }
1023 52 : }
1024 48 : return std::nullopt;
1025 77 : }
1026 :
1027 : namespace {
1028 :
1029 : /// What the Users dialog may preselect for a verified list, given what the
1030 : /// generation record says about it.
1031 7 : auto judgedRecipients(GpgIdGeneration::Verdict verdict,
1032 : const QByteArray &contents, const QString &gpgIdPath,
1033 : const QString &why) -> Pass::RecipientsForEditing {
1034 : using RecipientsForEditing = Pass::RecipientsForEditing;
1035 7 : RecipientsForEditing result;
1036 7 : switch (verdict) {
1037 1 : case GpgIdGeneration::Verdict::Accepted:
1038 1 : result.state = RecipientsForEditing::State::Verified;
1039 1 : result.recipients = Pass::parseRecipients(contents, gpgIdPath);
1040 1 : break;
1041 1 : case GpgIdGeneration::Verdict::Rollback:
1042 : // Authentic and this folder's, only older: the recovery goes through
1043 : // this dialog, so it is preselected, with the reason in view.
1044 1 : result.state = RecipientsForEditing::State::VerifiedRollback;
1045 1 : result.recipients = Pass::parseRecipients(contents, gpgIdPath);
1046 1 : result.warning = why;
1047 1 : break;
1048 5 : case GpgIdGeneration::Verdict::Unbound:
1049 : case GpgIdGeneration::Verdict::Conflict:
1050 : case GpgIdGeneration::Verdict::WrongFolder:
1051 : case GpgIdGeneration::Verdict::WrongStore:
1052 : case GpgIdGeneration::Verdict::Malformed:
1053 : case GpgIdGeneration::Verdict::RecordUnavailable:
1054 : // Signed, but not shown to be this folder's list, not a list to trust,
1055 : // or nothing to judge it by: preselecting it would sign it in.
1056 5 : result.state = RecipientsForEditing::State::Rejected;
1057 : result.warning =
1058 5 : Pass::tr("%1 Nothing is preselected: saving would sign whatever is "
1059 : "in it. Select the recipients yourself.")
1060 10 : .arg(why);
1061 5 : break;
1062 : }
1063 7 : return result;
1064 : }
1065 :
1066 : } // namespace
1067 :
1068 44 : auto Pass::recipientsForEditing(const QString &dir, const QString &passStore)
1069 : -> RecipientsForEditing {
1070 44 : RecipientsForEditing result;
1071 44 : const QString folder = dir.isEmpty() ? QString() : dir;
1072 44 : const QString gpgIdPath = getGpgIdPath(folder, passStore);
1073 : const GpgIdSigner signer(
1074 : m_settings.gpgExecutable,
1075 132 : GpgIdSigner::keysFromSetting(m_settings.passSigningKey));
1076 44 : if (!signer.enabled()) {
1077 : // A list that is there but cannot be read as one (too large, not a
1078 : // regular file, unreadable) is not an empty list: preselecting nothing
1079 : // without a word would let OK write a new list over it, and later
1080 : // re-encryptions drop whoever it named.
1081 : // Read once and parsed from those bytes: a second read could find
1082 : // something else under the name. A link counts as there, dangling too.
1083 34 : const QFileInfo info(gpgIdPath);
1084 34 : if (!info.exists() && !info.isSymLink()) {
1085 : result.state = RecipientsForEditing::State::Unsigned;
1086 13 : return result;
1087 : }
1088 : const std::optional<QByteArray> bytes =
1089 21 : Util::readRegularFile(gpgIdPath, Util::kMaxGpgIdBytes);
1090 21 : if (!bytes) {
1091 2 : result.state = RecipientsForEditing::State::Rejected;
1092 : result.warning =
1093 2 : tr("%1 cannot be read as a recipient list (a regular file of at "
1094 : "most %2), so nothing is preselected. Check it before choosing "
1095 : "recipients here: OK writes a new list over it.")
1096 2 : .arg(gpgIdPath,
1097 4 : QLocale().formattedDataSize(Util::kMaxGpgIdBytes));
1098 2 : return result;
1099 : }
1100 : result.state = RecipientsForEditing::State::Unsigned;
1101 19 : result.recipients = parseRecipients(*bytes, gpgIdPath);
1102 19 : return result;
1103 34 : }
1104 10 : QString recordError;
1105 10 : if (const std::optional<QString> skipped =
1106 10 : skippedGpgId(folder, passStore, &recordError)) {
1107 : // The folder's own list was removed or replaced by a link (or the record
1108 : // that would tell cannot be read): the list the walk found further up
1109 : // was not signed for this folder, so preselecting it would sign it in
1110 : // here with one click.
1111 1 : result.state = RecipientsForEditing::State::Rejected;
1112 : result.warning =
1113 : recordError.isEmpty()
1114 2 : ? tr("The recipient list %1 is missing or is not a regular file, "
1115 : "so nothing is preselected: the list of a parent folder was "
1116 : "not signed for this one. Select this folder's recipients "
1117 : "yourself; OK writes and signs a fresh list.")
1118 : .arg(*skipped)
1119 1 : : tr("%1 Nothing is preselected: whether %2 was removed cannot "
1120 : "be told.")
1121 : .arg(recordError, *skipped);
1122 1 : return result;
1123 : }
1124 9 : if (!QFileInfo::exists(gpgIdPath)) {
1125 : // A folder without a list yet (new store, new profile): nothing to
1126 : // verify and nothing to preselect.
1127 1 : result.state = RecipientsForEditing::State::Verified;
1128 1 : return result;
1129 : }
1130 8 : QByteArray contents;
1131 8 : if (!signer.verifyFile(gpgIdPath, &contents)) {
1132 1 : result.state = RecipientsForEditing::State::Rejected;
1133 : result.warning =
1134 1 : tr("The recipient list %1 does not verify against the signing key, "
1135 : "so nothing is preselected: saving would sign whatever is in it. "
1136 : "Select the recipients yourself; OK writes and signs a fresh list.")
1137 2 : .arg(gpgIdPath);
1138 1 : return result;
1139 : }
1140 7 : QString why;
1141 : const GpgIdGeneration::Verdict verdict =
1142 7 : GpgIdGeneration::accept(gpgIdPath, contents, passStore, &why);
1143 7 : return judgedRecipients(verdict, contents, gpgIdPath, why);
1144 44 : }
1145 :
1146 18 : auto Pass::getRecipientList(const QString &for_file, const QString &passStore)
1147 : -> QStringList {
1148 18 : const QString gpgIdPath = getGpgIdPath(for_file, passStore);
1149 : // The root .gpg-id comes back whatever it is; a link is not a list.
1150 18 : if (Util::isLinkedFolder(gpgIdPath)) {
1151 1 : return {};
1152 : }
1153 : const std::optional<QByteArray> bytes =
1154 17 : Util::readRegularFile(gpgIdPath, Util::kMaxGpgIdBytes);
1155 17 : if (!bytes) {
1156 1 : return {};
1157 : }
1158 16 : return parseRecipients(*bytes, gpgIdPath);
1159 : }
1160 :
1161 154 : auto Pass::parseRecipients(const QByteArray &contents,
1162 : const QString &sourceName) -> QStringList {
1163 154 : QStringList recipients;
1164 : const QString text = QString::fromUtf8(contents);
1165 565 : for (const QString &line : text.split(QLatin1Char('\n'))) {
1166 822 : QString recipient = line.split(QLatin1Char('#')).first().trimmed();
1167 411 : if (recipient.isEmpty()) {
1168 213 : continue;
1169 : }
1170 198 : if (!Util::isValidKeyId(recipient)) {
1171 : // Never drop a recipient silently: the list is written back verbatim
1172 : // by UsersDialog, so a skipped line disappears from .gpg-id.
1173 4 : qCWarning(lcQtPass) << "Skipping unusable recipient in" << sourceName
1174 2 : << ":" << recipient;
1175 2 : continue;
1176 2 : }
1177 : recipients += recipient;
1178 : }
1179 154 : return recipients;
1180 : }
1181 :
1182 8 : auto Pass::seedGpgIdFile(const QString &newDir, const QString &passStore)
1183 : -> bool {
1184 16 : const QString gpgIdFile = QDir(newDir).absoluteFilePath(".gpg-id");
1185 8 : if (QFileInfo::exists(gpgIdFile)) {
1186 : return false;
1187 : }
1188 : // Resolving from the file to be created finds the parent's .gpg-id whether
1189 : // or not newDir has a trailing separator.
1190 7 : const QStringList recipients = getRecipientList(gpgIdFile, passStore);
1191 7 : if (recipients.isEmpty()) {
1192 : return false;
1193 : }
1194 : // Only reached without a signing key, so no generation header: a plain
1195 : // list stays readable for clients that take a comment for a recipient.
1196 : // Staged and replacing nothing: a link planted after the exists() above
1197 : // (a dangling one passes it) fails the seed instead of being written through.
1198 4 : return Util::writeFileReplacing(
1199 : gpgIdFile,
1200 8 : (recipients.join(QLatin1Char('\n')) + QLatin1Char('\n')).toUtf8(), false);
1201 : }
1202 :
1203 : /* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
1204 : */
1205 :
1206 7620 : auto Pass::boundedRandom(quint32 bound) -> quint32 {
1207 7620 : if (bound < 2) {
1208 : return 0;
1209 : }
1210 :
1211 : quint32 randval;
1212 : // arc4random_uniform-style rejection against modulo bias: drop values
1213 : // below 2^32 % bound, which (1 + ~bound) % bound computes in quint32.
1214 7616 : const quint32 rejectionThreshold = (1 + ~bound) % bound;
1215 :
1216 : do {
1217 : randval = QRandomGenerator::system()->generate();
1218 7616 : } while (randval < rejectionThreshold);
1219 :
1220 7616 : return randval % bound;
1221 : }
1222 :
1223 1209 : auto Pass::generateRandomPassword(const QString &charset, unsigned int length)
1224 : -> QString {
1225 1209 : if (charset.isEmpty() || length == 0U) {
1226 2 : return {};
1227 : }
1228 1207 : QString out;
1229 8827 : for (unsigned int i = 0; i < length; ++i) {
1230 7620 : out.append(charset.at(static_cast<int>(
1231 7620 : boundedRandom(static_cast<quint32>(charset.length())))));
1232 : }
1233 : return out;
1234 : }
|