• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IJHack / QtPass / 35198163954

17 Sep 2026 08:09AM UTC coverage: 74.517% (+0.7%) from 73.769%
35198163954

push

github

web-flow
KeygenDialog drives key generation itself; ConfigDialog no longer knows MainWindow (#1796)

* KeygenDialog drives key generation itself; ConfigDialog no longer knows MainWindow

The result of gpg --gen-key travelled KeygenDialog -> ConfigDialog::genKey
-> MainWindow::generateKeyPair (a QPointer kept only to relay) ->
generateGPGKeyPair signal -> QtPass lambda -> Pass::GenerateGPGKeys, and
back via QtPass::onKeyGenerationComplete -> MainWindow::cleanKeygenDialog
-> close() -> reject(). So a successful keygen made checkSecretKeys()'s
exec() return Rejected and the first-run wizard gave up; the QPointer
existed because of a use-after-free (#1603) on that path.

KeygenDialog takes the Pass, connects to finishedGenerateGPGKeys and
processErrorExit for the duration of one run, accepts on success and on
failure shows the reason and re-enables the form. The relay members,
signal and lambda are gone; ConfigDialog takes a plain QWidget parent and
drops mainwindow.h.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

* KeygenDialog listens to a keygen-specific failure signal; cancel test keeps the dialog alive

Pass::processErrorExit fires for any command that fails while gpg is
generating (a queued git push, say) and would have taken the dialog down
mid-run. Pass now also emits generateGPGKeysFailed(err) for GPG_GENKEYS
and the no-gpg case; the dialog connects to that. The cancel test emits
the late signals at a still-living dialog so the explicit disconnects are
what is being tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

* KeygenDialog: a retry restarts the progress indicator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

41 of 46 new or added lines in 4 files covered. (89.13%)

39 existing lines in 2 files now uncovered.

5790 of 7770 relevant lines covered (74.52%)

65.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.91
/src/pass.cpp
1
// SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "pass.h"
4
#include "gpgkeystate.h"
5
#include "util.h"
6
#include <QCoreApplication>
7
#include <QDebug>
8
#include <QDir>
9
#include <QFile>
10
#include <QFileInfo>
11
#include <QProcess>
12
#include <QRandomGenerator>
13
#include <QRegularExpression>
14
#include <QSaveFile>
15
#include <QTextStream>
16
#include <utility>
17

18
#include "qtpasslogging.h"
19

20
using Enums::GIT_INIT;
21
using Enums::GIT_PULL;
22
using Enums::GIT_PUSH;
23
using Enums::GPG_GENKEYS;
24
using Enums::PASS_COPY;
25
using Enums::PASS_GREP;
26
using Enums::PASS_INIT;
27
using Enums::PASS_INSERT;
28
using Enums::PASS_MOVE;
29
using Enums::PASS_REMOVE;
30
using Enums::PASS_SHOW;
31

32
namespace {
33
/**
34
 * @brief Returns a non-empty charset value, using a fallback when needed.
35
 * @param input Preferred charset value.
36
 * @param fallback Charset to use when @p input is empty.
37
 * @return @p input if it is not empty; otherwise @p fallback.
38
 */
39
auto fallbackCharset(const QString &input, const QString &fallback) -> QString {
40
  return input.isEmpty() ? fallback : input;
1,293 ✔
41
}
42

43
/**
44
 * @brief Resolve the effective password character set from configuration.
45
 *
46
 * Uses the selected charset index from @p passConfig when it is within range;
47
 * otherwise falls back to the ALLCHARS entry. If the resolved charset string
48
 * is empty, falls back again to the ALLCHARS value.
49
 *
50
 * @param passConfig Password generation configuration.
51
 * @return Non-empty charset string to use for password generation.
52
 */
53
auto effectiveCharset(const PasswordConfiguration &passConfig) -> QString {
89 ✔
54
  int sel = passConfig.selected;
89 ✔
55
  if (sel < 0 || sel >= PasswordConfiguration::CHARSETS_COUNT)
89 ✔
56
    sel = PasswordConfiguration::ALLCHARS;
57
  return fallbackCharset(
58
      passConfig.Characters[sel],
89 ✔
59
      passConfig.Characters[PasswordConfiguration::ALLCHARS]);
89 ✔
60
}
61
} // namespace
62

63
/**
64
 * @brief Pass::Pass wrapper for using either pass or the pass imitation
65
 */
66
Pass::Pass() : env(QProcessEnvironment::systemEnvironment()) {
110 ✔
67
  connect(&exec, &Executor::finished, this, &Pass::finished);
110 ✔
68
  connect(&exec, &Executor::error, this, &Pass::finished);
110 ✔
69

70
  connect(&exec, &Executor::starting, this, &Pass::startingExecuteWrapper);
110 ✔
71
  // Merge our vars into WSLENV rather than blindly appending a duplicate entry
72
  const QStringList wslenvVars = {
73
      QStringLiteral("PASSWORD_STORE_DIR/p"),
220 ✔
74
      QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH/w"),
110 ✔
75
      QStringLiteral("PASSWORD_STORE_CHARACTER_SET/w")};
440 ✔
76
  const QString existing = env.value(QStringLiteral("WSLENV"));
220 ✔
77
  if (existing.isEmpty()) {
110 ✔
78
    env.insert(QStringLiteral("WSLENV"), wslenvVars.join(':'));
220 ✔
79
  } else {
80
    QStringList parts = existing.split(':', Qt::SkipEmptyParts);
×
81
    for (const QString &v : wslenvVars) {
×
82
      if (!parts.contains(v))
×
83
        parts.append(v);
84
    }
85
    env.insert(QStringLiteral("WSLENV"), parts.join(':'));
×
86
  }
87
}
110 ✔
88

89
/**
90
 * @brief Executes a wrapper command.
91
 * @param id Process ID
92
 * @param app Application to execute
93
 * @param args Arguments
94
 * @param readStdout Whether to read stdout
95
 * @param readStderr Whether to read stderr
96
 */
97
void Pass::executeWrapper(PROCESS id, const QString &app,
×
98
                          const QStringList &args, bool readStdout,
99
                          bool readStderr) {
100
  executeWrapper(id, app, args, QString(), readStdout, readStderr);
×
101
}
×
102

103
void Pass::executeWrapper(PROCESS id, const QString &app,
84 ✔
104
                          const QStringList &args, QString input,
105
                          bool readStdout, bool readStderr) {
106
  beforeExecute(id);
84 ✔
107
  qCDebug(lcQtPass) << app << args;
84 ✔
108
  exec.execute(id, m_settings.passStore, app, args, std::move(input),
84 ✔
109
               readStdout, readStderr);
110
}
84 ✔
111

112
void Pass::beforeExecute(PROCESS /*id*/) {}
14 ✔
113

114
/**
115
 * @brief Initializes the pass wrapper with a settings snapshot.
116
 * @param settings Application settings to use for this backend lifetime.
117
 */
118
void Pass::init(const AppSettings &settings) {
142 ✔
119
  m_settings = settings;
142 ✔
120
#ifdef __APPLE__
121
  // If it exists, prepend gpgtools to PATH
122
  if (QFile(QStringLiteral("/usr/local/MacGPG2/bin")).exists())
123
    env.insert(QStringLiteral("PATH"),
124
               QStringLiteral("/usr/local/MacGPG2/bin:") +
125
                   env.value(QStringLiteral("PATH")));
126
  // Add missing /usr/local/bin (exact component match, no leading colon)
127
  const QString currentPath = env.value(QStringLiteral("PATH"));
128
  if (!currentPath.split(':', Qt::SkipEmptyParts)
129
           .contains(QStringLiteral("/usr/local/bin"))) {
130
    env.insert(QStringLiteral("PATH"),
131
               currentPath.isEmpty()
132
                   ? QStringLiteral("/usr/local/bin")
133
                   : QStringLiteral("/usr/local/bin:") + currentPath);
134
  }
135
#endif
136

137
  // GNUPGHOME: the configured gpgHome wins over the inherited environment,
138
  // but only when it exists. A gpgHome that is gone (the 1.7.0 test suite
139
  // left its temporary keyring path in the live QtPass.conf, #1711) would
140
  // make every gpg call fail with "No secret key"; fall back to whatever the
141
  // environment says and tell the user. Clearing the setting at runtime
142
  // restores the inherited value as well instead of keeping the old path.
143
  const QString inheritedHome = QProcessEnvironment::systemEnvironment().value(
142 ✔
144
      QStringLiteral("GNUPGHOME"));
284 ✔
145
  const auto useInheritedHome = [this, &inheritedHome]() {
102 ✔
146
    if (inheritedHome.isEmpty()) {
102 ✔
147
      env.remove(QStringLiteral("GNUPGHOME"));
204 ✔
148
    } else {
149
      env.insert(QStringLiteral("GNUPGHOME"), inheritedHome);
×
150
    }
151
  };
244 ✔
152
  if (m_settings.gpgHome.isEmpty()) {
142 ✔
153
    useInheritedHome();
101 ✔
154
  } else {
155
    QDir absHome(m_settings.gpgHome);
41 ✔
156
    absHome.makeAbsolute();
41 ✔
157
    if (absHome.exists()) {
41 ✔
158
      env.insert(QStringLiteral("GNUPGHOME"), absHome.path());
80 ✔
159
    } else {
160
      if (inheritedHome.isEmpty()) {
1 ✔
161
        qCWarning(lcQtPass) << "gpgHome" << absHome.path()
2 ✔
162
                            << "does not exist; using the default GnuPG home";
1 ✔
163
        emit statusMsg(tr("Configured GPG home %1 does not exist, using the "
1 ✔
164
                          "default keyring")
165
                           .arg(absHome.path()),
2 ✔
166
                       5000);
167
      } else {
168
        qCWarning(lcQtPass)
×
169
            << "gpgHome" << absHome.path() << "does not exist; using GNUPGHOME"
×
170
            << inheritedHome << "from the environment";
×
171
        emit statusMsg(tr("Configured GPG home %1 does not exist, using "
×
172
                          "GNUPGHOME %2 from the environment")
173
                           .arg(absHome.path(), inheritedHome),
×
174
                       5000);
175
      }
176
      useInheritedHome();
1 ✔
177
    }
178
  }
41 ✔
179
}
142 ✔
180

181
/**
182
 * @brief Pass::Generate use either pwgen or internal password
183
 * generator
184
 * @param length of the desired password
185
 * @param charset to use for generation
186
 * @return the password
187
 */
188
auto Pass::generatePassword(unsigned int length, const QString &charset)
1,205 ✔
189
    -> QString {
190
  if (length == 0) {
1,205 ✔
191
    emit critical(tr("Invalid password length"),
2 ✔
192
                  tr("Can't generate password with zero length."));
1 ✔
193
    return {};
194
  }
195
  QString passwd;
1,204 ✔
196
  if (m_settings.usePwgen) {
1,204 ✔
197
    // --secure goes first as it overrides --no-* otherwise
198
    QStringList args;
×
199
    args.append("-1");
×
200
    if (!m_settings.lessRandom) {
×
201
      args.append("--secure");
×
202
    }
203
    args.append(m_settings.avoidCapitals ? "--no-capitalize" : "--capitalize");
×
204
    args.append(m_settings.avoidNumbers ? "--no-numerals" : "--numerals");
×
205
    if (m_settings.useSymbols) {
×
206
      args.append("--symbols");
×
207
    }
208
    args.append(QString::number(length));
×
209
    // executeBlocking returns 0 on success, non-zero on failure
210
    if (Executor::executeBlocking(m_settings.pwgenExecutable, args, &passwd) ==
×
211
        0) {
212
      static const QRegularExpression literalNewLines{"[\\n\\r]"};
×
213
      passwd.remove(literalNewLines);
×
214
    } else {
215
      passwd.clear();
×
216
      qCDebug(lcQtPass) << "pwgen fail";
×
217
      // Error is already handled by clearing passwd; no need for critical
218
      // signal here
219
    }
220
  } else {
221
    // Validate charset - if CUSTOM is selected but chars are empty,
222
    // fall back to ALLCHARS to prevent weak passwords (issue #780)
223
    const QString cs = fallbackCharset(
224
        charset, m_settings.passwordConfiguration
225
                     .Characters[PasswordConfiguration::ALLCHARS]);
1,204 ✔
226
    if (cs.length() > 0) {
1,204 ✔
227
      passwd = generateRandomPassword(cs, length);
2,408 ✔
228
    } else {
229
      emit critical(
×
230
          tr("No characters chosen"),
×
231
          tr("Can't generate password, there are no characters to choose from "
×
232
             "set in the configuration!"));
233
    }
234
  }
235
  return passwd;
236
}
237

238
/**
239
 * @brief Pass::gpgSupportsEd25519 check if GPG supports ed25519 (ECC)
240
 * GPG 2.1+ supports ed25519 which is much faster for key generation
241
 * @return true if ed25519 is supported
242
 */
243
bool Pass::gpgSupportsEd25519(const QString &gpgExecutable) {
24 ✔
244
  const QString exe =
245
      gpgExecutable.isEmpty() ? QStringLiteral("gpg") : gpgExecutable;
24 ✔
246
  QString out, err;
24 ✔
247
  if (Executor::executeBlocking(exe, {"--version"}, &out, &err) != 0) {
72 ✔
248
    return false;
249
  }
250
  QRegularExpression versionRegex(R"(gpg \(GnuPG\) (\d+)\.(\d+))");
48 ✔
251
  QRegularExpressionMatch match = versionRegex.match(out);
24 ✔
252
  if (!match.hasMatch()) {
24 ✔
253
    return false;
254
  }
255
  int major = match.captured(1).toInt();
24 ✔
256
  int minor = match.captured(2).toInt();
24 ✔
257
  return major > 2 || (major == 2 && minor >= 1);
24 ✔
258
}
48 ✔
259

260
/**
261
 * @brief Pass::getDefaultKeyTemplate return default key generation template
262
 * Uses ed25519 if supported, otherwise falls back to RSA
263
 * @return GPG batch template string
264
 */
265
QString Pass::getDefaultKeyTemplate(const QString &gpgExecutable) {
23 ✔
266
  if (gpgSupportsEd25519(gpgExecutable)) {
23 ✔
267
    return QStringLiteral("%echo Generating a default key\n"
23 ✔
268
                          "Key-Type: EdDSA\n"
269
                          "Key-Curve: Ed25519\n"
270
                          "Subkey-Type: ECDH\n"
271
                          "Subkey-Curve: Curve25519\n"
272
                          "Name-Real: \n"
273
                          "Name-Comment: QtPass\n"
274
                          "Name-Email: \n"
275
                          "Expire-Date: 0\n"
276
                          "%no-protection\n"
277
                          "%commit\n"
278
                          "%echo done");
279
  }
280
  return QStringLiteral("%echo Generating a default key\n"
×
281
                        "Key-Type: RSA\n"
282
                        "Subkey-Type: RSA\n"
283
                        "Name-Real: \n"
284
                        "Name-Comment: QtPass\n"
285
                        "Name-Email: \n"
286
                        "Expire-Date: 0\n"
287
                        "%no-protection\n"
288
                        "%commit\n"
289
                        "%echo done");
290
}
291

292
namespace {
293
/**
294
 * @brief Resolve a candidate gpgconf path from the trailing WSL path segment.
295
 *
296
 * Takes the directory portion of @p lastPart (separated by '/' or '\\') and
297
 * appends "gpgconf"; if no separator is present, returns the bare executable
298
 * name "gpgconf".
299
 *
300
 * @param lastPart Path fragment that may contain a directory and executable.
301
 * @return Full path ending in "gpgconf", or "gpgconf" as a fallback.
302
 */
303
auto resolveWslGpgconfPath(const QString &lastPart) -> QString {
5 ✔
304
  qsizetype lastSep = lastPart.lastIndexOf('/');
5 ✔
305
  if (lastSep < 0) {
5 ✔
306
    lastSep = lastPart.lastIndexOf('\\');
4 ✔
307
  }
308
  if (lastSep >= 0) {
4 ✔
309
    return lastPart.left(lastSep + 1) + "gpgconf";
2 ✔
310
  }
311
  return QStringLiteral("gpgconf");
4 ✔
312
}
313

314
/**
315
 * @brief Finds the path to the gpgconf executable in the same directory as the
316
 * given GPG path.
317
 * @example
318
 * QString result = findGpgconfInGpgDir(gpgPath);
319
 * std::cout << result.toStdString() << std::endl; // Expected output: path to
320
 * gpgconf or empty string
321
 *
322
 * @param gpgPath - Absolute path to a GPG executable or related file used to
323
 * locate gpgconf.
324
 * @return QString - The full path to gpgconf if found and executable; otherwise
325
 * an empty QString.
326
 */
327
QString findGpgconfInGpgDir(const QString &gpgPath) {
1 ✔
328
  QFileInfo gpgInfo(gpgPath);
1 ✔
329
  if (!gpgInfo.isAbsolute()) {
1 ✔
330
    return {};
331
  }
332

333
  QDir dir(gpgInfo.absolutePath());
1 ✔
334

335
#ifdef Q_OS_WIN
336
  QFileInfo candidateExe(dir.filePath("gpgconf.exe"));
337
  if (candidateExe.isExecutable()) {
338
    return candidateExe.filePath();
339
  }
340
#endif
341

342
  QFileInfo candidate(dir.filePath("gpgconf"));
1 ✔
343
  if (candidate.isExecutable()) {
1 ✔
344
    return candidate.filePath();
×
345
  }
346
  return {};
347
}
1 ✔
348

349
} // namespace
350

351
/**
352
 * @brief Resolves the appropriate gpgconf command from a given GPG executable
353
 * path or command string.
354
 * @example
355
 * ResolvedGpgconfCommand result = Pass::resolveGpgconfCommand("wsl.exe
356
 * /usr/bin/gpg"); std::cout << result.first.toStdString() << std::endl; //
357
 * Expected output sample
358
 *
359
 * @param const QString &gpgPath - Path or command string pointing to the GPG
360
 * executable.
361
 * @return ResolvedGpgconfCommand - A pair containing the resolved gpgconf
362
 * command and its arguments.
363
 */
364
auto Pass::resolveGpgconfCommand(const QString &gpgPath)
10 ✔
365
    -> ResolvedGpgconfCommand {
366
  if (gpgPath.trimmed().isEmpty()) {
10 ✔
367
    return {"gpgconf", {}};
368
  }
369

370
  QStringList parts = QProcess::splitCommand(gpgPath);
9 ✔
371

372
  if (parts.isEmpty()) {
9 ✔
373
    return {"gpgconf", {}};
374
  }
375

376
  const QString first = parts.first();
377
  if (first.compare("wsl", Qt::CaseInsensitive) == 0 ||
20 ✔
378
      first.compare("wsl.exe", Qt::CaseInsensitive) == 0) {
11 ✔
379
    if (parts.size() >= 2 && parts.at(1).startsWith("sh")) {
13 ✔
380
      return {"gpgconf", {}};
381
    }
382
    if (parts.size() >= 2 &&
6 ✔
383
        QFileInfo(parts.last()).fileName().startsWith("gpg")) {
16 ✔
384
      QString wslGpgconf = resolveWslGpgconfPath(parts.last());
5 ✔
385
      parts.removeLast();
5 ✔
386
      // Run gpgconf directly rather than through the distribution's default
387
      // shell, which would word-split and expand the arguments. Keep any
388
      // --exec/-e the user already put in the command.
389
      if (!parts.contains("--exec") && !parts.contains("-e")) {
9 ✔
390
        parts.append("--exec");
6 ✔
391
      }
392
      parts.append(wslGpgconf);
393
      return {parts.first(), parts.mid(1)};
394
    }
395
    return {"gpgconf", {}};
396
  }
397

398
  if (!first.contains('/') && !first.contains('\\')) {
2 ✔
399
    return {"gpgconf", {}};
400
  }
401

402
  QString gpgconfPath = findGpgconfInGpgDir(first);
1 ✔
403
  if (!gpgconfPath.isEmpty()) {
1 ✔
404
    return {gpgconfPath, {}};
×
405
  }
406

407
  return {"gpgconf", {}};
408
}
10 ✔
409

410
/**
411
 * @brief Pass::GenerateGPGKeys internal gpg keypair generator . .
412
 * @param batch GnuPG style configuration string
413
 */
414
void Pass::GenerateGPGKeys(QString batch) {
7 ✔
415
  const QString gpgPath = m_settings.gpgExecutable;
416
  if (gpgPath.isEmpty()) {
7 ✔
417
    // No gpg configured: executeWrapper would hand an empty executable to the
418
    // Executor, which silently drops it (see Executor::execute), leaving the
419
    // keygen dialog spinning with no feedback. Surface the misconfiguration
420
    // instead. Deferred via a queued call so we do not re-enter
421
    // KeygenDialog::done(), which drives key generation synchronously.
422
    QMetaObject::invokeMethod(
7 ✔
423
        this,
424
        [this]() {
7 ✔
425
          emit generateGPGKeysFailed(tr("No GPG executable configured"));
4 ✔
426
          emit processErrorExit(1, tr("No GPG executable configured"));
4 ✔
427
        },
4 ✔
428
        Qt::QueuedConnection);
429
    return;
430
  }
431

432
  // Kill any stale GPG agents that might be holding locks on the key database.
433
  // This helps avoid "database locked" timeouts during key generation.
434
  ResolvedGpgconfCommand resolvedGpgconf = resolveGpgconfCommand(gpgPath);
×
435
  QStringList killArgs = resolvedGpgconf.arguments;
436
  killArgs << "--kill";
×
437
  killArgs << "gpg-agent";
×
438
  // Use same environment as key generation to target correct gpg-agent
439
  if (Executor::executeBlocking(env, resolvedGpgconf.program, killArgs) != 0) {
×
440
    qCWarning(lcQtPass) << "Failed to kill gpg-agent";
×
441
  }
442

443
  executeWrapper(GPG_GENKEYS, gpgPath, {"--gen-key", "--no-tty", "--batch"},
×
444
                 std::move(batch), true, true);
445
}
×
446

447
/**
448
 * @brief Pass::listKeys list users
449
 * @param keystrings
450
 * @param secret list private keys
451
 * @return QList<UserInfo> users
452
 */
453
auto Pass::listKeys(QStringList keystrings, bool secret) -> QList<UserInfo> {
27 ✔
454
  QStringList args = {"--no-tty", "--with-colons", "--with-fingerprint"};
108 ✔
455
  args.append(secret ? "--list-secret-keys" : "--list-keys");
67 ✔
456

457
  for (const QString &keystring : std::as_const(keystrings)) {
54 ✔
458
    if (!keystring.isEmpty()) {
27 ✔
459
      args.append(keystring);
460
    }
461
  }
462
  QString p_out;
27 ✔
463
  if (Executor::executeBlocking(m_settings.gpgExecutable, args, &p_out) != 0) {
27 ✔
464
    return {};
×
465
  }
466
  return parseGpgColonOutput(p_out, secret);
27 ✔
467
}
27 ✔
468

469
/**
470
 * @brief Pass::listKeys list users
471
 * @param keystring
472
 * @param secret list private keys
473
 * @return QList<UserInfo> users
474
 */
475
auto Pass::listKeys(const QString &keystring, bool secret) -> QList<UserInfo> {
24 ✔
476
  return listKeys(QStringList(keystring), secret);
48 ✔
477
}
478

479
/**
480
 * @brief Maps GPG stderr (which may include --status-fd 2 tokens) to a
481
 * user-friendly encryption error string.
482
 *
483
 * Checked in order: machine-readable [GNUPG:] status tokens first (locale-
484
 * independent), then case-insensitive substring fallbacks for GPG builds that
485
 * don't emit status tokens.
486
 *
487
 * @param err Raw stderr from GPG
488
 * @return Translated human-readable error, or empty string if not recognised
489
 */
490
namespace {
491

492
/**
493
 * @brief Checks if @p str contains any of the @p patterns (case-sensitive).
494
 * @param str String to search in.
495
 * @param patterns Patterns to search for.
496
 * @return true if any pattern is found, false otherwise.
497
 */
498
auto containsAny(const QString &str, const QStringList &patterns) -> bool {
31 ✔
499
  for (const QString &p : patterns) {
82 ✔
500
    if (str.contains(p)) {
58 ✔
501
      return true;
502
    }
503
  }
504
  return false;
505
}
506

507
/**
508
 * @brief Checks if str contains any of the patterns (case-insensitive).
509
 * @param str String to search in (will be lowercased once).
510
 * @param patterns List of patterns to search for (must be lowercase; caller
511
 * should convert patterns to lowercase before calling).
512
 * @return true if any pattern is found.
513
 */
514
auto containsAnyCaseInsensitive(const QString &str, const QStringList &patterns)
13 ✔
515
    -> bool {
516
  const QString lower = str.toLower();
517
  for (const QString &p : patterns) {
32 ✔
518
    if (lower.contains(p)) {
23 ✔
519
      return true;
520
    }
521
  }
522
  return false;
523
}
524

525
} // namespace
526

527
auto gpgErrorMessage(const QString &err) -> QString {
13 ✔
528
  // Machine-readable status tokens added by --status-fd 2
529
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYEXPIRED"),
65 ✔
530
                        QStringLiteral("[GNUPG:] INV_RECP 5 ")}))
13 ✔
531
    return QCoreApplication::translate(
532
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
533
                "replace it.");
3 ✔
534
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYREVOKED"),
50 ✔
535
                        QStringLiteral("[GNUPG:] INV_RECP 4 ")}))
10 ✔
536
    return QCoreApplication::translate(
537
        "Pass", "Encryption failed: GPG key has been revoked.");
2 ✔
538
  if (containsAny(err, {QStringLiteral("[GNUPG:] NO_PUBKEY"),
40 ✔
539
                        QStringLiteral("[GNUPG:] INV_RECP")}))
8 ✔
540
    return QCoreApplication::translate(
541
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
542
                "Check that the key ID in .gpg-id is correct and imported.");
2 ✔
543
  if (err.contains(QStringLiteral("[GNUPG:] FAILURE")))
6 ✔
544
    return QCoreApplication::translate(
545
        "Pass", "Encryption failed. Check that your GPG key is valid.");
1 ✔
546

547
  // Locale-dependent fallbacks
548
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has expired"),
20 ✔
549
                                       QLatin1String("key expired")}))
550
    return QCoreApplication::translate(
551
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
552
                "replace it.");
1 ✔
553
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has been revoked"),
16 ✔
554
                                       QLatin1String("revoked")}))
555
    return QCoreApplication::translate(
556
        "Pass", "Encryption failed: GPG key has been revoked.");
1 ✔
557
  if (containsAnyCaseInsensitive(err, {QLatin1String("no public key"),
15 ✔
558
                                       QLatin1String("unusable public key"),
559
                                       QLatin1String("no secret key")}))
560
    return QCoreApplication::translate(
561
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
562
                "Check that the key ID in .gpg-id is correct and imported.");
2 ✔
563
  if (containsAnyCaseInsensitive(err, {QLatin1String("encryption failed")}))
3 ✔
564
    return QCoreApplication::translate(
565
        "Pass", "Encryption failed. Check that your GPG key is valid.");
×
566

567
  return {};
568
}
×
569

570
namespace {
571
/**
572
 * @brief Determine whether a line from `pass grep` output is an entry header.
573
 *
574
 * Detects the ANSI blue escape (\x1B[94m) emitted by `pass grep`; as a
575
 * plain-text fallback, treats a non-indented line ending in ':' as a header.
576
 *
577
 * @param rawLine Original unmodified output line (with any ANSI codes).
578
 * @param trimmedLine The line after surrounding whitespace has been stripped.
579
 * @return true if the line is an entry header; otherwise false.
580
 */
581
auto isGrepHeaderLine(const QString &rawLine, const QString &trimmedLine)
46 ✔
582
    -> bool {
583
  return rawLine.startsWith(QStringLiteral("\x1B[94m")) ||
126 ✔
584
         (!rawLine.startsWith(' ') && !rawLine.startsWith('\t') &&
94 ✔
585
          trimmedLine.endsWith(':'));
122 ✔
586
}
587
} // namespace
588

589
/**
590
 * @brief Parses 'pass grep' raw output into (entry, matches) pairs.
591
 *
592
 * pass grep emits ANSI blue color (\x1B[94m) at the start of each entry
593
 * header line. This is checked before stripping ANSI so headers are detected
594
 * reliably regardless of locale.
595
 */
596
auto parseGrepOutput(const QString &rawOut)
13 ✔
597
    -> QList<QPair<QString, QStringList>> {
598
  static const QRegularExpression ansi(
599
      QStringLiteral(R"(\x1B\[[0-9;]*[a-zA-Z])"));
15 ✔
600
  QList<QPair<QString, QStringList>> results;
13 ✔
601
  QString currentEntry;
13 ✔
602
  QStringList currentMatches;
13 ✔
603
  for (const QString &rawLine : rawOut.split('\n')) {
72 ✔
604
    QString line = rawLine;
605
    line.remove('\r');
46 ✔
606
    line.remove(ansi);
46 ✔
607
    line = line.trimmed();
46 ✔
608
    const bool isHeader = isGrepHeaderLine(rawLine, line);
46 ✔
609
    if (isHeader) {
46 ✔
610
      if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
14 ✔
611
        results.append({currentEntry, currentMatches});
3 ✔
612
      currentEntry = line.endsWith(':') ? line.chopped(1) : line;
14 ✔
613
      currentMatches.clear();
14 ✔
614
    } else if (!currentEntry.isEmpty()) {
32 ✔
615
      if (!line.isEmpty())
29 ✔
616
        currentMatches << line;
617
    }
618
  }
619
  if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
13 ✔
620
    results.append({currentEntry, currentMatches});
11 ✔
621
  return results;
13 ✔
622
}
623

624
/**
625
 * @brief Pass::processFinished reemits specific signal based on what process
626
 * has finished
627
 * @param id    id of Pass process that was scheduled and finished
628
 * @param exitCode  return code of a process
629
 * @param out   output generated by process(if capturing was requested, empty
630
 *              otherwise)
631
 * @param err   error output generated by process(if capturing was requested,
632
 *              or error occurred)
633
 */
634
void Pass::finished(int id, int exitCode, const QString &out,
83 ✔
635
                    const QString &err) {
636
  auto pid = static_cast<PROCESS>(id);
83 ✔
637

638
  if (exitCode != 0) {
83 ✔
639
    handleProcessError(pid, exitCode, out, err);
3 ✔
640
    return;
3 ✔
641
  }
642

643
  emitProcessFinishedSignal(pid, out, err);
80 ✔
644
}
645

646
void Pass::handleProcessError(PROCESS pid, int exitCode, const QString &out,
3 ✔
647
                              const QString &err) {
648
  Q_UNUSED(out);
649

650
  if (pid == PASS_GREP) {
3 ✔
651
    handleGrepError(exitCode, err);
2 ✔
652
    return;
2 ✔
653
  }
654

655
  if (pid == PASS_INSERT) {
1 ✔
656
    const QString friendly = gpgErrorMessage(err);
×
657
    if (!friendly.isEmpty()) {
×
658
      emit processErrorExit(exitCode, formatInsertError(friendly, err));
×
659
      return;
660
    }
661
  }
662

663
  if (pid == GPG_GENKEYS) {
1 ✔
NEW
664
    emit generateGPGKeysFailed(err);
×
665
  }
666
  emit processErrorExit(exitCode, err);
1 ✔
667
}
668

669
void Pass::handleGrepError(int exitCode, const QString &err) {
2 ✔
670
  if (exitCode == 1) {
2 ✔
671
    emit finishedGrep({});
2 ✔
672
  } else {
673
    emit processErrorExit(exitCode, err);
1 ✔
674
    emit finishedGrep({});
2 ✔
675
  }
676
}
2 ✔
677

678
auto Pass::formatInsertError(const QString &friendly, const QString &err)
×
679
    -> QString {
680
  QStringList humanLines;
×
681
  for (const QString &line : err.split('\n')) {
×
682
    QString cleanedLine = line;
683
    cleanedLine.remove('\r');
×
684
    if (!cleanedLine.startsWith(QLatin1String("[GNUPG:]")))
×
685
      humanLines.append(cleanedLine);
686
  }
687
  const QString humanErr = humanLines.join('\n').trimmed();
×
688
  return humanErr.isEmpty() ? friendly : friendly + "\n\n" + humanErr;
×
689
}
690

691
/**
692
 * @brief Emit the appropriate finished signal for a completed subprocess.
693
 *
694
 * Emits a specific Qt signal corresponding to the given process identifier; for
695
 * grep results the stdout is parsed into a list of matches before emitting.
696
 *
697
 * @param pid The process identifier indicating which finished signal to emit.
698
 * @param out Standard output produced by the process.
699
 * @param err Standard error produced by the process.
700
 */
701
void Pass::emitProcessFinishedSignal(PROCESS pid, const QString &out,
80 ✔
702
                                     const QString &err) {
703
  /**
704
   * @brief Filter sensitive commands to prevent password leakage.
705
   *
706
   * Sensitive commands (PASS_SHOW, etc.) output plaintext passwords or
707
   * searchable content that should not be exposed to any UI listener.
708
   *
709
   * Using a default branch: if new PASS_* values are added, they
710
   * default to NOT leaking (safe by default). Making this
711
   * exhaustive would require updating here for every new
712
   * command and risk silent password leakage if forgotten.
713
   */
714
  switch (pid) {
80 ✔
715
  case PASS_SHOW:
716
  case PASS_GREP:
717
  case PASS_INSERT:
718
    break;
719
  default:
13 ✔
720
    emit finishedAnyWithPid(out, err, pid);
13 ✔
721
    break;
13 ✔
722
  }
723

724
  switch (pid) {
80 ✔
725
  case GIT_INIT:
1 ✔
726
    emit finishedGitInit(out, err);
1 ✔
727
    break;
1 ✔
728
  case GIT_PULL:
1 ✔
729
    emit finishedGitPull(out, err);
1 ✔
730
    break;
1 ✔
731
  case GIT_PUSH:
2 ✔
732
    emit finishedGitPush(out, err);
2 ✔
733
    break;
2 ✔
734
  case PASS_SHOW:
20 ✔
735
    emit finishedShow(out);
20 ✔
736
    break;
20 ✔
737
  case PASS_INSERT:
45 ✔
738
    emit finishedInsert(out, err);
45 ✔
739
    break;
45 ✔
740
  case PASS_REMOVE:
2 ✔
741
    emit finishedRemove(out, err);
2 ✔
742
    break;
2 ✔
743
  case PASS_INIT:
3 ✔
744
    emit finishedInit(out, err);
3 ✔
745
    break;
3 ✔
746
  case PASS_MOVE:
2 ✔
747
    emit finishedMove(out, err);
2 ✔
748
    break;
2 ✔
749
  case PASS_COPY:
2 ✔
750
    emit finishedCopy(out, err);
2 ✔
751
    break;
2 ✔
752
  case GPG_GENKEYS:
×
753
    emit finishedGenerateGPGKeys(out, err);
×
754
    break;
×
755
  case PASS_GREP:
2 ✔
756
    emit finishedGrep(parseGrepOutput(out));
2 ✔
757
    break;
2 ✔
758
  default:
×
759
    qCDebug(lcQtPass) << "Unhandled process type" << pid;
×
760
    break;
×
761
  }
762
}
80 ✔
763

764
/**
765
 * @brief Set or remove a single environment variable.
766
 *
767
 * @param name Variable name, without a trailing '=' (e.g.
768
 * "PASSWORD_STORE_DIR").
769
 * @param value New value; an empty string removes the variable entirely.
770
 */
771
void Pass::setEnvVar(const QString &name, const QString &value) {
362 ✔
772
  if (value.isEmpty())
362 ✔
773
    env.remove(name);
98 ✔
774
  else
775
    env.insert(name, value);
264 ✔
776
}
362 ✔
777

778
/**
779
 * @brief Update the process environment used for executing external commands.
780
 *
781
 * Updates environment entries for PASSWORD_STORE_SIGNING_KEY,
782
 * PASSWORD_STORE_DIR, PASSWORD_STORE_GENERATED_LENGTH, and
783
 * PASSWORD_STORE_CHARACTER_SET based on current settings, then applies the
784
 * environment to the internal executor.
785
 */
786
void Pass::updateEnv() {
89 ✔
787
  setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY"),
178 ✔
788
            m_settings.passSigningKey);
89 ✔
789
  setEnvVar(QStringLiteral("PASSWORD_STORE_DIR"), m_settings.passStore);
178 ✔
790

791
  const PasswordConfiguration &passConfig = m_settings.passwordConfiguration;
89 ✔
792
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH"),
178 ✔
793
            QString::number(passConfig.length));
89 ✔
794

795
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET"),
178 ✔
796
            effectiveCharset(passConfig));
89 ✔
797

798
  exec.setEnvironment(env);
89 ✔
799
}
89 ✔
800

801
/**
802
 * @brief Pass::getGpgIdPath return gpgid file path for some file (folder).
803
 * @param for_file which file (folder) would you like the gpgid file path for.
804
 * @return path to the gpgid file.
805
 */
806
auto Pass::getGpgIdPath(const QString &for_file, const QString &passStore)
152 ✔
807
    -> QString {
808
  QString normalizedStore = QDir::fromNativeSeparators(passStore);
152 ✔
809
  QString normalizedFile = QDir::fromNativeSeparators(for_file);
152 ✔
810
  QString fullPath = normalizedFile.startsWith(normalizedStore)
152 ✔
811
                         ? normalizedFile
152 ✔
812
                         : normalizedStore + "/" + normalizedFile;
82 ✔
813
  QDir gpgIdDir(QFileInfo(fullPath).absoluteDir());
152 ✔
814
  // QDir::cleanPath() always normalises to forward slashes, so use '/'
815
  // here rather than QDir::separator() (which returns '\\' on Windows).
816
  QString cleanPassStore = QDir::cleanPath(normalizedStore);
152 ✔
817
  bool found = false;
818
  while (gpgIdDir.exists()) {
189 ✔
819
    QString currentPath = QDir::cleanPath(gpgIdDir.absolutePath());
374 ✔
820
    const QString prefix =
821
        cleanPassStore.endsWith('/') ? cleanPassStore : cleanPassStore + "/";
187 ✔
822
    if (currentPath != cleanPassStore && !currentPath.startsWith(prefix)) {
187 ✔
823
      break;
824
    }
825
    if (QFile(gpgIdDir.absoluteFilePath(".gpg-id")).exists()) {
342 ✔
826
      found = true;
827
      break;
828
    }
829
    if (!gpgIdDir.cdUp()) {
37 ✔
830
      break;
831
    }
832
  }
833
  return found ? gpgIdDir.absoluteFilePath(".gpg-id")
152 ✔
834
               : QDir(normalizedStore).filePath(".gpg-id");
456 ✔
835
}
152 ✔
836

837
/**
838
 * @brief Pass::getRecipientList return list of gpg-id's to encrypt for
839
 * @param for_file which file (folder) would you like recipients for
840
 * @return recipients gpg-id contents
841
 */
842
auto Pass::getRecipientList(const QString &for_file, const QString &passStore)
85 ✔
843
    -> QStringList {
844
  QFile gpgId(getGpgIdPath(for_file, passStore));
85 ✔
845
  if (!gpgId.open(QIODevice::ReadOnly | QIODevice::Text)) {
85 ✔
846
    return {};
8 ✔
847
  }
848
  QStringList recipients;
77 ✔
849
  while (!gpgId.atEnd()) {
175 ✔
850
    QString recipient(gpgId.readLine());
196 ✔
851
    recipient = recipient.split("#")[0].trimmed();
196 ✔
852
    if (recipient.isEmpty()) {
98 ✔
853
      continue;
7 ✔
854
    }
855
    if (!Util::isValidKeyId(recipient)) {
91 ✔
856
      // Never drop a recipient silently: the list is written back verbatim
857
      // by UsersDialog, so a skipped line disappears from .gpg-id.
858
      qCWarning(lcQtPass) << "Skipping unusable recipient in"
4 ✔
859
                          << gpgId.fileName() << ":" << recipient;
4 ✔
860
      continue;
2 ✔
861
    }
2 ✔
862
    recipients += recipient;
863
  }
864
  return recipients;
865
}
85 ✔
866

867
/**
868
 * @brief Pass::seedGpgIdFile write the inherited recipients into a new
869
 * folder's .gpg-id
870
 * @param newDir absolute path of the freshly created folder
871
 * @param passStore root directory of the password store
872
 * @return true when newDir/.gpg-id was written
873
 */
874
auto Pass::seedGpgIdFile(const QString &newDir, const QString &passStore)
5 ✔
875
    -> bool {
876
  const QString gpgIdFile = QDir(newDir).absoluteFilePath(".gpg-id");
10 ✔
877
  if (QFileInfo::exists(gpgIdFile)) {
5 ✔
878
    return false;
879
  }
880
  // Resolve from the file we are about to create: getGpgIdPath walks up from
881
  // its directory, so this yields the parent's .gpg-id whether or not newDir
882
  // carries a trailing separator.
883
  const QStringList recipients = getRecipientList(gpgIdFile, passStore);
4 ✔
884
  if (recipients.isEmpty()) {
4 ✔
885
    return false;
886
  }
887
  QSaveFile gpgId(gpgIdFile);
2 ✔
888
  if (!gpgId.open(QIODevice::WriteOnly)) {
2 ✔
889
    return false;
890
  }
891
  QTextStream out(&gpgId);
2 ✔
892
  for (const QString &recipient : recipients) {
5 ✔
893
    out << recipient << '\n';
3 ✔
894
  }
895
  out.flush();
2 ✔
896
  if (out.status() != QTextStream::Ok || !gpgId.commit()) {
2 ✔
897
    return false;
×
898
  }
899
  // Lock to owner-only access; see ImitatePass::writeGpgIdFile for the
900
  // rationale (NFS / USB / unusual umask). Best-effort where setPermissions
901
  // is a no-op.
902
  QFile::setPermissions(gpgIdFile, QFile::ReadOwner | QFile::WriteOwner);
2 ✔
903
  return true;
904
}
2 ✔
905

906
/* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
907
 */
908

909
/**
910
 * @brief Generates a random number bounded by the given value.
911
 * @param bound Upper bound (exclusive)
912
 * @return Random number in range [0, bound)
913
 */
914
auto Pass::boundedRandom(quint32 bound) -> quint32 {
7,592 ✔
915
  if (bound < 2) {
7,592 ✔
916
    return 0;
917
  }
918

919
  quint32 randval;
920
  // Rejection-sampling threshold to avoid modulo bias.
921
  // This follows the well-known "arc4random_uniform"-style approach:
922
  // reject values in the low range [0, min), where
923
  //   min = 2^32 % bound
924
  // so that the remaining range size is an exact multiple of `bound`.
925
  //
926
  // In quint32 arithmetic, (1 + ~bound) wraps to (2^32 - bound), therefore
927
  //   (1 + ~bound) % bound == 2^32 % bound.
928
  const quint32 rejectionThreshold = (1 + ~bound) % bound;
7,592 ✔
929

930
  do {
931
    randval = QRandomGenerator::system()->generate();
932
  } while (randval < rejectionThreshold);
7,592 ✔
933

934
  return randval % bound;
7,592 ✔
935
}
936

937
/**
938
 * @brief Generates a random password from the given charset.
939
 * @param charset Characters to use in the password
940
 * @param length Desired password length
941
 * @return Generated password string
942
 */
943
auto Pass::generateRandomPassword(const QString &charset, unsigned int length)
1,204 ✔
944
    -> QString {
945
  if (charset.isEmpty() || length == 0U) {
1,204 ✔
946
    return {};
947
  }
948
  QString out;
1,204 ✔
949
  for (unsigned int i = 0; i < length; ++i) {
8,796 ✔
950
    out.append(charset.at(static_cast<int>(
7,592 ✔
951
        boundedRandom(static_cast<quint32>(charset.length())))));
7,592 ✔
952
  }
953
  return out;
954
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc