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

IJHack / QtPass / 35151583099

16 Sep 2026 09:17PM UTC coverage: 72.406% (+0.06%) from 72.346%
35151583099

push

github

web-flow
Merge pull request #1780 from IJHack/fix/2.0-configdialog-layout

ConfigDialog: scroll areas per tab, group boxes for the sections, wrapped checkbox rows

5395 of 7451 relevant lines covered (72.41%)

61.37 hits per line

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

79.6
/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
#ifdef QT_DEBUG
19
#include "debughelper.h"
20
#endif
21

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

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

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

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

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

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

105
void Pass::executeWrapper(PROCESS id, const QString &app,
70 ✔
106
                          const QStringList &args, QString input,
107
                          bool readStdout, bool readStderr) {
108
  beforeExecute(id);
70 ✔
109
#ifdef QT_DEBUG
110
  dbg() << app << args;
111
#endif
112
  exec.execute(id, m_settings.passStore, app, args, std::move(input),
70 ✔
113
               readStdout, readStderr);
114
}
70 ✔
115

116
void Pass::beforeExecute(PROCESS /*id*/) {}
×
117

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

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

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

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

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

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

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

340
  QDir dir(gpgInfo.absolutePath());
1 ✔
341

342
#ifdef Q_OS_WIN
343
  QFileInfo candidateExe(dir.filePath("gpgconf.exe"));
344
  if (candidateExe.isExecutable()) {
345
    return candidateExe.filePath();
346
  }
347
#endif
348

349
  QFileInfo candidate(dir.filePath("gpgconf"));
1 ✔
350
  if (candidate.isExecutable()) {
1 ✔
351
    return candidate.filePath();
×
352
  }
353
  return {};
354
}
1 ✔
355

356
} // namespace
357

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

377
  QStringList parts = QProcess::splitCommand(gpgPath);
9 ✔
378

379
  if (parts.isEmpty()) {
9 ✔
380
    return {"gpgconf", {}};
381
  }
382

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

405
  if (!first.contains('/') && !first.contains('\\')) {
2 ✔
406
    return {"gpgconf", {}};
407
  }
408

409
  QString gpgconfPath = findGpgconfInGpgDir(first);
1 ✔
410
  if (!gpgconfPath.isEmpty()) {
1 ✔
411
    return {gpgconfPath, {}};
×
412
  }
413

414
  return {"gpgconf", {}};
415
}
10 ✔
416

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

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

449
  executeWrapper(GPG_GENKEYS, gpgPath, {"--gen-key", "--no-tty", "--batch"},
×
450
                 std::move(batch), true, true);
451
}
×
452

453
/**
454
 * @brief Pass::listKeys list users
455
 * @param keystrings
456
 * @param secret list private keys
457
 * @return QList<UserInfo> users
458
 */
459
auto Pass::listKeys(QStringList keystrings, bool secret) -> QList<UserInfo> {
7 ✔
460
  QStringList args = {"--no-tty", "--with-colons", "--with-fingerprint"};
28 ✔
461
  args.append(secret ? "--list-secret-keys" : "--list-keys");
16 ✔
462

463
  for (const QString &keystring : std::as_const(keystrings)) {
14 ✔
464
    if (!keystring.isEmpty()) {
7 ✔
465
      args.append(keystring);
466
    }
467
  }
468
  QString p_out;
7 ✔
469
  if (Executor::executeBlocking(m_settings.gpgExecutable, args, &p_out) != 0) {
7 ✔
470
    return {};
×
471
  }
472
  return parseGpgColonOutput(p_out, secret);
7 ✔
473
}
7 ✔
474

475
/**
476
 * @brief Pass::listKeys list users
477
 * @param keystring
478
 * @param secret list private keys
479
 * @return QList<UserInfo> users
480
 */
481
auto Pass::listKeys(const QString &keystring, bool secret) -> QList<UserInfo> {
6 ✔
482
  return listKeys(QStringList(keystring), secret);
12 ✔
483
}
484

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

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

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

531
} // namespace
532

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

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

573
  return {};
574
}
×
575

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

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

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

644
  if (exitCode != 0) {
69 ✔
645
    handleProcessError(pid, exitCode, out, err);
2 ✔
646
    return;
2 ✔
647
  }
648

649
  emitProcessFinishedSignal(pid, out, err);
67 ✔
650
}
651

652
void Pass::handleProcessError(PROCESS pid, int exitCode, const QString &out,
2 ✔
653
                              const QString &err) {
654
  Q_UNUSED(out);
655

656
  if (pid == PASS_GREP) {
2 ✔
657
    handleGrepError(exitCode, err);
2 ✔
658
    return;
2 ✔
659
  }
660

661
  if (pid == PASS_INSERT) {
×
662
    const QString friendly = gpgErrorMessage(err);
×
663
    if (!friendly.isEmpty()) {
×
664
      emit processErrorExit(exitCode, formatInsertError(friendly, err));
×
665
      return;
666
    }
667
  }
668

669
  emit processErrorExit(exitCode, err);
×
670
}
671

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

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

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

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

769
/**
770
 * @brief Set or remove a single environment variable.
771
 *
772
 * @param name Variable name, without a trailing '=' (e.g.
773
 * "PASSWORD_STORE_DIR").
774
 * @param value New value; an empty string removes the variable entirely.
775
 */
776
void Pass::setEnvVar(const QString &name, const QString &value) {
334 ✔
777
  if (value.isEmpty())
334 ✔
778
    env.remove(name);
91 ✔
779
  else
780
    env.insert(name, value);
243 ✔
781
}
334 ✔
782

783
/**
784
 * @brief Update the process environment used for executing external commands.
785
 *
786
 * Updates environment entries for PASSWORD_STORE_SIGNING_KEY,
787
 * PASSWORD_STORE_DIR, PASSWORD_STORE_GENERATED_LENGTH, and
788
 * PASSWORD_STORE_CHARACTER_SET based on current settings, then applies the
789
 * environment to the internal executor.
790
 */
791
void Pass::updateEnv() {
82 ✔
792
  setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY"),
164 ✔
793
            m_settings.passSigningKey);
82 ✔
794
  setEnvVar(QStringLiteral("PASSWORD_STORE_DIR"), m_settings.passStore);
164 ✔
795

796
  const PasswordConfiguration &passConfig = m_settings.passwordConfiguration;
82 ✔
797
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH"),
164 ✔
798
            QString::number(passConfig.length));
82 ✔
799

800
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET"),
164 ✔
801
            effectiveCharset(passConfig));
82 ✔
802

803
  exec.setEnvironment(env);
82 ✔
804
}
82 ✔
805

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

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

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

911
/* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
912
 */
913

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

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

935
  do {
936
    randval = QRandomGenerator::system()->generate();
937
  } while (randval < rejectionThreshold);
7,592 ✔
938

939
  return randval % bound;
7,592 ✔
940
}
941

942
/**
943
 * @brief Generates a random password from the given charset.
944
 * @param charset Characters to use in the password
945
 * @param length Desired password length
946
 * @return Generated password string
947
 */
948
auto Pass::generateRandomPassword(const QString &charset, unsigned int length)
1,204 ✔
949
    -> QString {
950
  if (charset.isEmpty() || length == 0U) {
1,204 ✔
951
    return {};
952
  }
953
  QString out;
1,204 ✔
954
  for (unsigned int i = 0; i < length; ++i) {
8,796 ✔
955
    out.append(charset.at(static_cast<int>(
7,592 ✔
956
        boundedRandom(static_cast<quint32>(charset.length())))));
7,592 ✔
957
  }
958
  return out;
959
}
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