QtPass Developer documentation
QtPass test coverage
Current view: top level - src/pass.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 98.3 % 541 532
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 51 51

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

Generated by: LCOV version 2.0-1