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

IJHack / QtPass / 34832253743

14 Sep 2026 10:15AM UTC coverage: 65.229% (+0.1%) from 65.09%
34832253743

push

github

web-flow
Backport: seed new-folder .gpg-id from parent recipients (#1709) (#1714)

* fix: seed new-folder .gpg-id from parent recipients (#1682) (#1688)

(cherry picked from commit a82adcde2)

* Follow-up to #1688: never seed an unsigned .gpg-id, test the seeding (#1695)

#1688 made "Add folder" copy the parent's recipients into the new
folder's .gpg-id, but it did so unconditionally. With passSigningKey
configured that file has no .gpg-id.sig, so ImitatePass::Insert
(verifyGpgIdFile) and real pass (PASSWORD_STORE_SIGNING_KEY) refuse
every insert into the folder until UsersDialog re-inits it. The write
was also inlined in a private slot behind QInputDialog, so #1682 had no
regression test.

Move the write into Pass::seedGpgIdFile(newDir, passStore): it resolves
the inherited recipients from newDir/.gpg-id (so a trailing separator
on newDir is harmless), refuses to overwrite an existing file, writes
nothing when no recipient is inherited, and keeps the QSaveFile +
owner-only permissions from #1688. MainWindow::addFolder calls it only
when no signing key is configured; with one, the folder is left without
its own .gpg-id and inherits the parent's signed list, which encrypts to
the same recipients. Add tst_util cases for the copy (comment lines and
an email recipient in the parent), the trailing separator, the
no-recipients refusal and the no-overwrite guard.

Backport to main (#1709): also drop the <QTextStream> include #1688 added
to mainwindow.cpp, which this commit leaves unused (2.0-nazomer removed
it later; it was also mis-sorted for clang-format).

Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit a8bed36bc)

---------

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

14 of 18 new or added lines in 2 files covered. (77.78%)

1 existing line in 1 file now uncovered.

4735 of 7259 relevant lines covered (65.23%)

61.05 hits per line

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

77.94
/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_OTP_GENERATE;
32
using Enums::PASS_REMOVE;
33
using Enums::PASS_SHOW;
34

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

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

66
/**
67
 * @brief Pass::Pass wrapper for using either pass or the pass imitation
68
 */
69
Pass::Pass() : env(QProcessEnvironment::systemEnvironment()) {
52 ✔
70
  connect(&exec,
52 ✔
71
          static_cast<void (Executor::*)(int, int, const QString &,
72
                                         const QString &)>(&Executor::finished),
73
          this, &Pass::finished);
52 ✔
74
  connect(&exec, &Executor::error, this, &Pass::finished);
52 ✔
75

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

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

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

120
void Pass::beforeExecute(PROCESS /*id*/) {}
×
121

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

145
  if (!m_settings.gpgHome.isEmpty()) {
66 ✔
146
    QDir absHome(m_settings.gpgHome);
27 ✔
147
    absHome.makeAbsolute();
27 ✔
148
    env.insert(QStringLiteral("GNUPGHOME"), absHome.path());
54 ✔
149
  }
27 ✔
150
}
66 ✔
151

152
/**
153
 * @brief Pass::Generate use either pwgen or internal password
154
 * generator
155
 * @param length of the desired password
156
 * @param charset to use for generation
157
 * @return the password
158
 */
159
auto Pass::generatePassword(unsigned int length, const QString &charset)
1,205 ✔
160
    -> QString {
161
  if (length == 0) {
1,205 ✔
162
    emit critical(tr("Invalid password length"),
2 ✔
163
                  tr("Can't generate password with zero length."));
1 ✔
164
    return {};
165
  }
166
  QString passwd;
1,204 ✔
167
  if (m_settings.usePwgen) {
1,204 ✔
168
    // --secure goes first as it overrides --no-* otherwise
169
    QStringList args;
×
170
    args.append("-1");
×
171
    if (!m_settings.lessRandom) {
×
172
      args.append("--secure");
×
173
    }
174
    args.append(m_settings.avoidCapitals ? "--no-capitalize" : "--capitalize");
×
175
    args.append(m_settings.avoidNumbers ? "--no-numerals" : "--numerals");
×
176
    if (m_settings.useSymbols) {
×
177
      args.append("--symbols");
×
178
    }
179
    args.append(QString::number(length));
×
180
    // executeBlocking returns 0 on success, non-zero on failure
181
    if (Executor::executeBlocking(m_settings.pwgenExecutable, args, &passwd) ==
×
182
        0) {
183
      static const QRegularExpression literalNewLines{"[\\n\\r]"};
×
184
      passwd.remove(literalNewLines);
×
185
    } else {
186
      passwd.clear();
×
187
#ifdef QT_DEBUG
188
      qDebug() << __FILE__ << ":" << __LINE__ << "\t"
189
               << "pwgen fail";
190
#endif
191
      // Error is already handled by clearing passwd; no need for critical
192
      // signal here
193
    }
194
  } else {
195
    // Validate charset - if CUSTOM is selected but chars are empty,
196
    // fall back to ALLCHARS to prevent weak passwords (issue #780)
197
    const QString cs = fallbackCharset(
198
        charset, m_settings.passwordConfiguration
199
                     .Characters[PasswordConfiguration::ALLCHARS]);
1,204 ✔
200
    if (cs.length() > 0) {
1,204 ✔
201
      passwd = generateRandomPassword(cs, length);
2,408 ✔
202
    } else {
203
      emit critical(
×
204
          tr("No characters chosen"),
×
205
          tr("Can't generate password, there are no characters to choose from "
×
206
             "set in the configuration!"));
207
    }
208
  }
209
  return passwd;
210
}
211

212
/**
213
 * @brief Pass::gpgSupportsEd25519 check if GPG supports ed25519 (ECC)
214
 * GPG 2.1+ supports ed25519 which is much faster for key generation
215
 * @return true if ed25519 is supported
216
 */
217
bool Pass::gpgSupportsEd25519(const QString &gpgExecutable) {
12 ✔
218
  const QString exe =
219
      gpgExecutable.isEmpty() ? QStringLiteral("gpg") : gpgExecutable;
12 ✔
220
  QString out, err;
12 ✔
221
  if (Executor::executeBlocking(exe, {"--version"}, &out, &err) != 0) {
36 ✔
222
    return false;
223
  }
224
  QRegularExpression versionRegex(R"(gpg \(GnuPG\) (\d+)\.(\d+))");
24 ✔
225
  QRegularExpressionMatch match = versionRegex.match(out);
12 ✔
226
  if (!match.hasMatch()) {
12 ✔
227
    return false;
228
  }
229
  int major = match.captured(1).toInt();
12 ✔
230
  int minor = match.captured(2).toInt();
12 ✔
231
  return major > 2 || (major == 2 && minor >= 1);
12 ✔
232
}
24 ✔
233

234
/**
235
 * @brief Pass::getDefaultKeyTemplate return default key generation template
236
 * Uses ed25519 if supported, otherwise falls back to RSA
237
 * @return GPG batch template string
238
 */
239
QString Pass::getDefaultKeyTemplate(const QString &gpgExecutable) {
11 ✔
240
  if (gpgSupportsEd25519(gpgExecutable)) {
11 ✔
241
    return QStringLiteral("%echo Generating a default key\n"
11 ✔
242
                          "Key-Type: EdDSA\n"
243
                          "Key-Curve: Ed25519\n"
244
                          "Subkey-Type: ECDH\n"
245
                          "Subkey-Curve: Curve25519\n"
246
                          "Name-Real: \n"
247
                          "Name-Comment: QtPass\n"
248
                          "Name-Email: \n"
249
                          "Expire-Date: 0\n"
250
                          "%no-protection\n"
251
                          "%commit\n"
252
                          "%echo done");
253
  }
254
  return QStringLiteral("%echo Generating a default key\n"
×
255
                        "Key-Type: RSA\n"
256
                        "Subkey-Type: RSA\n"
257
                        "Name-Real: \n"
258
                        "Name-Comment: QtPass\n"
259
                        "Name-Email: \n"
260
                        "Expire-Date: 0\n"
261
                        "%no-protection\n"
262
                        "%commit\n"
263
                        "%echo done");
264
}
265

266
namespace {
267
/**
268
 * @brief Resolve a candidate gpgconf path from the trailing WSL path segment.
269
 *
270
 * Takes the directory portion of @p lastPart (separated by '/' or '\\') and
271
 * appends "gpgconf"; if no separator is present, returns the bare executable
272
 * name "gpgconf".
273
 *
274
 * @param lastPart Path fragment that may contain a directory and executable.
275
 * @return Full path ending in "gpgconf", or "gpgconf" as a fallback.
276
 */
277
auto resolveWslGpgconfPath(const QString &lastPart) -> QString {
3 ✔
278
  qsizetype lastSep = lastPart.lastIndexOf('/');
3 ✔
279
  if (lastSep < 0) {
3 ✔
280
    lastSep = lastPart.lastIndexOf('\\');
2 ✔
281
  }
282
  if (lastSep >= 0) {
2 ✔
283
    return lastPart.left(lastSep + 1) + "gpgconf";
2 ✔
284
  }
285
  return QStringLiteral("gpgconf");
2 ✔
286
}
287

288
/**
289
 * @brief Finds the path to the gpgconf executable in the same directory as the
290
 * given GPG path.
291
 * @example
292
 * QString result = findGpgconfInGpgDir(gpgPath);
293
 * std::cout << result.toStdString() << std::endl; // Expected output: path to
294
 * gpgconf or empty string
295
 *
296
 * @param gpgPath - Absolute path to a GPG executable or related file used to
297
 * locate gpgconf.
298
 * @return QString - The full path to gpgconf if found and executable; otherwise
299
 * an empty QString.
300
 */
301
QString findGpgconfInGpgDir(const QString &gpgPath) {
1 ✔
302
  QFileInfo gpgInfo(gpgPath);
1 ✔
303
  if (!gpgInfo.isAbsolute()) {
1 ✔
304
    return {};
305
  }
306

307
  QDir dir(gpgInfo.absolutePath());
1 ✔
308

309
#ifdef Q_OS_WIN
310
  QFileInfo candidateExe(dir.filePath("gpgconf.exe"));
311
  if (candidateExe.isExecutable()) {
312
    return candidateExe.filePath();
313
  }
314
#endif
315

316
  QFileInfo candidate(dir.filePath("gpgconf"));
1 ✔
317
  if (candidate.isExecutable()) {
1 ✔
318
    return candidate.filePath();
×
319
  }
320
  return {};
321
}
1 ✔
322

323
// Compatibility shim for Qt < 5.15 where QProcess::splitCommand is not
324
// available. Keep this fallback while supporting pre-5.15 builds; remove once
325
// the project's minimum supported Qt version is raised to 5.15 or newer.
326
#if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
327
/**
328
 * @brief Splits a command string into arguments while respecting quotes and
329
 * escape characters.
330
 * @example
331
 * QStringList result = splitCommandCompat("cmd \"arg one\" 'arg two'
332
 * escaped\\ space");
333
 * // Expected output: ["cmd", "arg one", "arg two", "escaped space"]
334
 *
335
 * @param command - The input command string to split into individual arguments.
336
 * @return QStringList - A list of parsed command arguments.
337
 */
338
QStringList splitCommandCompat(const QString &command) {
339
  QStringList result;
340
  QString current;
341
  bool inSingleQuote = false;
342
  bool inDoubleQuote = false;
343
  bool escaping = false;
344
  for (QChar ch : command) {
345
    if (escaping) {
346
      current.append(ch);
347
      escaping = false;
348
      continue;
349
    }
350
    if (ch == '\\') {
351
      escaping = true;
352
      continue;
353
    }
354
    if (ch == '\'' && !inDoubleQuote) {
355
      inSingleQuote = !inSingleQuote;
356
      continue;
357
    }
358
    if (ch == '"' && !inSingleQuote) {
359
      inDoubleQuote = !inDoubleQuote;
360
      continue;
361
    }
362
    if (ch.isSpace() && !inSingleQuote && !inDoubleQuote) {
363
      if (!current.isEmpty()) {
364
        result.append(current);
365
        current.clear();
366
      }
367
      continue;
368
    }
369
    current.append(ch);
370
  }
371
  if (escaping) {
372
    current.append('\\');
373
  }
374
  if (!current.isEmpty()) {
375
    result.append(current);
376
  }
377
  return result;
378
}
379
#endif
380

381
} // namespace
382

383
/**
384
 * @brief Resolves the appropriate gpgconf command from a given GPG executable
385
 * path or command string.
386
 * @example
387
 * ResolvedGpgconfCommand result = Pass::resolveGpgconfCommand("wsl.exe
388
 * /usr/bin/gpg"); std::cout << result.first.toStdString() << std::endl; //
389
 * Expected output sample
390
 *
391
 * @param const QString &gpgPath - Path or command string pointing to the GPG
392
 * executable.
393
 * @return ResolvedGpgconfCommand - A pair containing the resolved gpgconf
394
 * command and its arguments.
395
 */
396
auto Pass::resolveGpgconfCommand(const QString &gpgPath)
8 ✔
397
    -> ResolvedGpgconfCommand {
398
  if (gpgPath.trimmed().isEmpty()) {
8 ✔
399
    return {"gpgconf", {}};
400
  }
401

402
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
403
  QStringList parts = QProcess::splitCommand(gpgPath);
7 ✔
404
#else
405
  QStringList parts = splitCommandCompat(gpgPath);
406
#endif
407

408
  if (parts.isEmpty()) {
7 ✔
409
    return {"gpgconf", {}};
410
  }
411

412
  const QString first = parts.first();
413
  if (first.compare("wsl", Qt::CaseInsensitive) == 0 ||
16 ✔
414
      first.compare("wsl.exe", Qt::CaseInsensitive) == 0) {
9 ✔
415
    if (parts.size() >= 2 && parts.at(1).startsWith("sh")) {
9 ✔
416
      return {"gpgconf", {}};
417
    }
418
    if (parts.size() >= 2 &&
4 ✔
419
        QFileInfo(parts.last()).fileName().startsWith("gpg")) {
10 ✔
420
      QString wslGpgconf = resolveWslGpgconfPath(parts.last());
3 ✔
421
      parts.removeLast();
3 ✔
422
      parts.append(wslGpgconf);
423
      return {parts.first(), parts.mid(1)};
424
    }
425
    return {"gpgconf", {}};
426
  }
427

428
  if (!first.contains('/') && !first.contains('\\')) {
2 ✔
429
    return {"gpgconf", {}};
430
  }
431

432
  QString gpgconfPath = findGpgconfInGpgDir(first);
1 ✔
433
  if (!gpgconfPath.isEmpty()) {
1 ✔
434
    return {gpgconfPath, {}};
×
435
  }
436

437
  return {"gpgconf", {}};
438
}
8 ✔
439

440
/**
441
 * @brief Pass::GenerateGPGKeys internal gpg keypair generator . .
442
 * @param batch GnuPG style configuration string
443
 */
444
void Pass::GenerateGPGKeys(QString batch) {
1 ✔
445
  const QString gpgPath = m_settings.gpgExecutable;
446
  if (gpgPath.isEmpty()) {
1 ✔
447
    // No gpg configured: executeWrapper would hand an empty executable to the
448
    // Executor, which silently drops it (see Executor::execute), leaving the
449
    // keygen dialog spinning with no feedback. Surface the misconfiguration
450
    // instead. Deferred via a queued call so we do not re-enter
451
    // KeygenDialog::done(), which drives key generation synchronously.
452
    QMetaObject::invokeMethod(
1 ✔
453
        this,
454
        [this]() {
1 ✔
455
          emit processErrorExit(1, tr("No GPG executable configured"));
1 ✔
456
        },
1 ✔
457
        Qt::QueuedConnection);
458
    return;
459
  }
460

461
  // Kill any stale GPG agents that might be holding locks on the key database.
462
  // This helps avoid "database locked" timeouts during key generation.
463
  ResolvedGpgconfCommand resolvedGpgconf = resolveGpgconfCommand(gpgPath);
×
464
  QStringList killArgs = resolvedGpgconf.arguments;
465
  killArgs << "--kill";
×
466
  killArgs << "gpg-agent";
×
467
  // Use same environment as key generation to target correct gpg-agent
468
  if (Executor::executeBlocking(env, resolvedGpgconf.program, killArgs) != 0) {
×
469
    qWarning() << "Failed to kill gpg-agent";
×
470
  }
471

472
  executeWrapper(GPG_GENKEYS, gpgPath, {"--gen-key", "--no-tty", "--batch"},
×
473
                 std::move(batch), true, true);
474
}
×
475

476
/**
477
 * @brief Pass::listKeys list users
478
 * @param keystrings
479
 * @param secret list private keys
480
 * @return QList<UserInfo> users
481
 */
482
auto Pass::listKeys(QStringList keystrings, bool secret) -> QList<UserInfo> {
3 ✔
483
  QStringList args = {"--no-tty", "--with-colons", "--with-fingerprint"};
12 ✔
484
  args.append(secret ? "--list-secret-keys" : "--list-keys");
8 ✔
485

486
  for (const QString &keystring : std::as_const(keystrings)) {
6 ✔
487
    if (!keystring.isEmpty()) {
3 ✔
488
      args.append(keystring);
489
    }
490
  }
491
  QString p_out;
3 ✔
492
  if (Executor::executeBlocking(m_settings.gpgExecutable, args, &p_out) != 0) {
3 ✔
493
    return {};
×
494
  }
495
  return parseGpgColonOutput(p_out, secret);
3 ✔
496
}
3 ✔
497

498
/**
499
 * @brief Pass::listKeys list users
500
 * @param keystring
501
 * @param secret list private keys
502
 * @return QList<UserInfo> users
503
 */
504
auto Pass::listKeys(const QString &keystring, bool secret) -> QList<UserInfo> {
2 ✔
505
  return listKeys(QStringList(keystring), secret);
4 ✔
506
}
507

508
/**
509
 * @brief Maps GPG stderr (which may include --status-fd 2 tokens) to a
510
 * user-friendly encryption error string.
511
 *
512
 * Checked in order: machine-readable [GNUPG:] status tokens first (locale-
513
 * independent), then case-insensitive substring fallbacks for GPG builds that
514
 * don't emit status tokens.
515
 *
516
 * @param err Raw stderr from GPG
517
 * @return Translated human-readable error, or empty string if not recognised
518
 */
519
namespace {
520

521
/**
522
 * @brief Checks if @p str contains any of the @p patterns (case-sensitive).
523
 * @param str String to search in.
524
 * @param patterns Patterns to search for.
525
 * @return true if any pattern is found, false otherwise.
526
 */
527
auto containsAny(const QString &str, const QStringList &patterns) -> bool {
31 ✔
528
  for (const QString &p : patterns) {
82 ✔
529
    if (str.contains(p)) {
58 ✔
530
      return true;
531
    }
532
  }
533
  return false;
534
}
535

536
/**
537
 * @brief Checks if str contains any of the patterns (case-insensitive).
538
 * @param str String to search in (will be lowercased once).
539
 * @param patterns List of patterns to search for (must be lowercase; caller
540
 * should convert patterns to lowercase before calling).
541
 * @return true if any pattern is found.
542
 */
543
auto containsAnyCaseInsensitive(const QString &str, const QStringList &patterns)
13 ✔
544
    -> bool {
545
  const QString lower = str.toLower();
546
  for (const QString &p : patterns) {
32 ✔
547
    if (lower.contains(p)) {
23 ✔
548
      return true;
549
    }
550
  }
551
  return false;
552
}
553

554
} // namespace
555

556
auto gpgErrorMessage(const QString &err) -> QString {
13 ✔
557
  // Machine-readable status tokens added by --status-fd 2
558
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYEXPIRED"),
65 ✔
559
                        QStringLiteral("[GNUPG:] INV_RECP 5 ")}))
13 ✔
560
    return QCoreApplication::translate(
561
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
562
                "replace it.");
3 ✔
563
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYREVOKED"),
50 ✔
564
                        QStringLiteral("[GNUPG:] INV_RECP 4 ")}))
10 ✔
565
    return QCoreApplication::translate(
566
        "Pass", "Encryption failed: GPG key has been revoked.");
2 ✔
567
  if (containsAny(err, {QStringLiteral("[GNUPG:] NO_PUBKEY"),
40 ✔
568
                        QStringLiteral("[GNUPG:] INV_RECP")}))
8 ✔
569
    return QCoreApplication::translate(
570
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
571
                "Check that the key ID in .gpg-id is correct and imported.");
2 ✔
572
  if (err.contains(QStringLiteral("[GNUPG:] FAILURE")))
6 ✔
573
    return QCoreApplication::translate(
574
        "Pass", "Encryption failed. Check that your GPG key is valid.");
1 ✔
575

576
  // Locale-dependent fallbacks
577
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has expired"),
20 ✔
578
                                       QLatin1String("key expired")}))
579
    return QCoreApplication::translate(
580
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
581
                "replace it.");
1 ✔
582
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has been revoked"),
16 ✔
583
                                       QLatin1String("revoked")}))
584
    return QCoreApplication::translate(
585
        "Pass", "Encryption failed: GPG key has been revoked.");
1 ✔
586
  if (containsAnyCaseInsensitive(err, {QLatin1String("no public key"),
15 ✔
587
                                       QLatin1String("unusable public key"),
588
                                       QLatin1String("no secret key")}))
589
    return QCoreApplication::translate(
590
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
591
                "Check that the key ID in .gpg-id is correct and imported.");
2 ✔
592
  if (containsAnyCaseInsensitive(err, {QLatin1String("encryption failed")}))
3 ✔
593
    return QCoreApplication::translate(
594
        "Pass", "Encryption failed. Check that your GPG key is valid.");
×
595

596
  return {};
597
}
×
598

599
namespace {
600
/**
601
 * @brief Determine whether a line from `pass grep` output is an entry header.
602
 *
603
 * Detects the ANSI blue escape (\x1B[94m) emitted by `pass grep`; as a
604
 * plain-text fallback, treats a non-indented line ending in ':' as a header.
605
 *
606
 * @param rawLine Original unmodified output line (with any ANSI codes).
607
 * @param trimmedLine The line after surrounding whitespace has been stripped.
608
 * @return true if the line is an entry header; otherwise false.
609
 */
610
auto isGrepHeaderLine(const QString &rawLine, const QString &trimmedLine)
45 ✔
611
    -> bool {
612
  return rawLine.startsWith(QStringLiteral("\x1B[94m")) ||
123 ✔
613
         (!rawLine.startsWith(' ') && !rawLine.startsWith('\t') &&
91 ✔
614
          trimmedLine.endsWith(':'));
119 ✔
615
}
616
} // namespace
617

618
/**
619
 * @brief Parses 'pass grep' raw output into (entry, matches) pairs.
620
 *
621
 * pass grep emits ANSI blue color (\x1B[94m) at the start of each entry
622
 * header line. This is checked before stripping ANSI so headers are detected
623
 * reliably regardless of locale.
624
 */
625
auto parseGrepOutput(const QString &rawOut)
12 ✔
626
    -> QList<QPair<QString, QStringList>> {
627
  static const QRegularExpression ansi(
628
      QStringLiteral(R"(\x1B\[[0-9;]*[a-zA-Z])"));
13 ✔
629
  QList<QPair<QString, QStringList>> results;
12 ✔
630
  QString currentEntry;
12 ✔
631
  QStringList currentMatches;
12 ✔
632
  for (const QString &rawLine : rawOut.split('\n')) {
69 ✔
633
    QString line = rawLine;
634
    line.remove('\r');
45 ✔
635
    line.remove(ansi);
45 ✔
636
    line = line.trimmed();
45 ✔
637
    const bool isHeader = isGrepHeaderLine(rawLine, line);
45 ✔
638
    if (isHeader) {
45 ✔
639
      if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
14 ✔
640
        results.append({currentEntry, currentMatches});
3 ✔
641
      currentEntry = line.endsWith(':') ? line.chopped(1) : line;
14 ✔
642
      currentMatches.clear();
14 ✔
643
    } else if (!currentEntry.isEmpty()) {
31 ✔
644
      if (!line.isEmpty())
29 ✔
645
        currentMatches << line;
646
    }
647
  }
648
  if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
12 ✔
649
    results.append({currentEntry, currentMatches});
11 ✔
650
  return results;
12 ✔
651
}
652

653
/**
654
 * @brief Pass::processFinished reemits specific signal based on what process
655
 * has finished
656
 * @param id    id of Pass process that was scheduled and finished
657
 * @param exitCode  return code of a process
658
 * @param out   output generated by process(if capturing was requested, empty
659
 *              otherwise)
660
 * @param err   error output generated by process(if capturing was requested,
661
 *              or error occurred)
662
 */
663
void Pass::finished(int id, int exitCode, const QString &out,
46 ✔
664
                    const QString &err) {
665
  auto pid = static_cast<PROCESS>(id);
46 ✔
666

667
  if (exitCode != 0) {
46 ✔
668
    handleProcessError(pid, exitCode, out, err);
3 ✔
669
    return;
3 ✔
670
  }
671

672
  emitProcessFinishedSignal(pid, out, err);
43 ✔
673
}
674

675
void Pass::handleProcessError(PROCESS pid, int exitCode, const QString &out,
3 ✔
676
                              const QString &err) {
677
  Q_UNUSED(out);
678

679
  if (pid == PASS_GREP) {
3 ✔
680
    handleGrepError(exitCode, err);
2 ✔
681
    return;
2 ✔
682
  }
683

684
  if (pid == PASS_INSERT) {
1 ✔
685
    const QString friendly = gpgErrorMessage(err);
×
686
    if (!friendly.isEmpty()) {
×
687
      emit processErrorExit(exitCode, formatInsertError(friendly, err));
×
688
      return;
689
    }
690
  }
691

692
  emit processErrorExit(exitCode, err);
1 ✔
693
}
694

695
void Pass::handleGrepError(int exitCode, const QString &err) {
2 ✔
696
  if (exitCode == 1) {
2 ✔
697
    emit finishedGrep({});
2 ✔
698
  } else {
699
    emit processErrorExit(exitCode, err);
1 ✔
700
    emit finishedGrep({});
2 ✔
701
  }
702
}
2 ✔
703

704
auto Pass::formatInsertError(const QString &friendly, const QString &err)
×
705
    -> QString {
706
  QStringList humanLines;
×
707
  for (const QString &line : err.split('\n')) {
×
708
    QString cleanedLine = line;
709
    cleanedLine.remove('\r');
×
710
    if (!cleanedLine.startsWith(QLatin1String("[GNUPG:]")))
×
711
      humanLines.append(cleanedLine);
712
  }
713
  const QString humanErr = humanLines.join('\n').trimmed();
×
714
  return humanErr.isEmpty() ? friendly : friendly + "\n\n" + humanErr;
×
715
}
716

717
/**
718
 * @brief Emit the appropriate finished signal for a completed subprocess.
719
 *
720
 * Emits a specific Qt signal corresponding to the given process identifier; for
721
 * grep results the stdout is parsed into a list of matches before emitting.
722
 *
723
 * @param pid The process identifier indicating which finished signal to emit.
724
 * @param out Standard output produced by the process.
725
 * @param err Standard error produced by the process.
726
 */
727
void Pass::emitProcessFinishedSignal(PROCESS pid, const QString &out,
43 ✔
728
                                     const QString &err) {
729
  /**
730
   * @brief Filter sensitive commands to prevent password leakage.
731
   *
732
   * Sensitive commands (PASS_SHOW, etc.) output plaintext passwords or
733
   * searchable content that should not be exposed to any UI listener.
734
   *
735
   * Using a default branch: if new PASS_* values are added, they
736
   * default to NOT leaking (safe by default). Making this
737
   * exhaustive would require updating here for every new
738
   * command and risk silent password leakage if forgotten.
739
   */
740
  switch (pid) {
43 ✔
741
  case PASS_SHOW:
742
  case PASS_OTP_GENERATE:
743
  case PASS_GREP:
744
  case PASS_INSERT:
745
    break;
746
  default:
1 ✔
747
    emit finishedAny(out, err);
1 ✔
748
    emit finishedAnyWithPid(out, err, pid);
1 ✔
749
    break;
1 ✔
750
  }
751

752
  switch (pid) {
43 ✔
753
  case GIT_INIT:
×
754
    emit finishedGitInit(out, err);
×
755
    break;
×
756
  case GIT_PULL:
×
757
    emit finishedGitPull(out, err);
×
758
    break;
×
759
  case GIT_PUSH:
×
760
    emit finishedGitPush(out, err);
×
761
    break;
×
762
  case PASS_SHOW:
15 ✔
763
    emit finishedShow(out);
15 ✔
764
    break;
15 ✔
765
  case PASS_OTP_GENERATE:
×
766
    emit finishedOtpGenerate(out);
×
767
    break;
×
768
  case PASS_INSERT:
26 ✔
769
    emit finishedInsert(out, err);
26 ✔
770
    break;
26 ✔
771
  case PASS_REMOVE:
×
772
    emit finishedRemove(out, err);
×
773
    break;
×
774
  case PASS_INIT:
1 ✔
775
    emit finishedInit(out, err);
1 ✔
776
    break;
1 ✔
777
  case PASS_MOVE:
×
778
    emit finishedMove(out, err);
×
779
    break;
×
780
  case PASS_COPY:
×
781
    emit finishedCopy(out, err);
×
782
    break;
×
783
  case GPG_GENKEYS:
×
784
    emit finishedGenerateGPGKeys(out, err);
×
785
    break;
×
786
  case PASS_GREP:
1 ✔
787
    emit finishedGrep(parseGrepOutput(out));
1 ✔
788
    break;
1 ✔
789
  default:
790
#ifdef QT_DEBUG
791
    dbg() << "Unhandled process type" << pid;
792
#endif
793
    break;
794
  }
795
}
43 ✔
796

797
/**
798
 * @brief Set or remove a single environment variable in the local env list.
799
 *
800
 * The provided key must include a trailing '=' (e.g. "FOO="). Existing entries
801
 * whose text begins with the given key are removed before the new value is
802
 * applied. If value is non-empty the pair "key+value" is appended; if value is
803
 * empty the variable is removed.
804
 *
805
 * The function asserts if key does not end with '='; if the assertion is not
806
 * active it will emit a warning and return without modifying env.
807
 *
808
 * @param key Environment variable name with trailing '=' (anchors the lookup).
809
 * @param value Value to set for the variable; an empty string unsets the
810
 * variable.
811
 */
812
void Pass::setEnvVar(const QString &key, const QString &value) {
206 ✔
813
  const bool hasEq = key.endsWith('=');
206 ✔
814
  Q_ASSERT_X(hasEq, "Pass::setEnvVar",
815
             "called with malformed key (missing '=')");
816
  if (!hasEq) {
206 ✔
817
    qWarning() << "Pass::setEnvVar called with malformed key (missing '='):"
×
818
               << key;
×
819
    return;
×
820
  }
821
  const QString varName = key.chopped(1);
822
  if (value.isEmpty())
206 ✔
823
    env.remove(varName);
57 ✔
824
  else
825
    env.insert(varName, value);
149 ✔
826
}
827

828
/**
829
 * @brief Update the process environment used for executing external commands.
830
 *
831
 * Updates environment entries for PASSWORD_STORE_SIGNING_KEY,
832
 * PASSWORD_STORE_DIR, PASSWORD_STORE_GENERATED_LENGTH, and
833
 * PASSWORD_STORE_CHARACTER_SET based on current settings, then applies the
834
 * environment to the internal executor.
835
 */
836
void Pass::updateEnv() {
50 ✔
837
  setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY="),
100 ✔
838
            m_settings.passSigningKey);
50 ✔
839
  setEnvVar(QStringLiteral("PASSWORD_STORE_DIR="), m_settings.passStore);
100 ✔
840

841
  const PasswordConfiguration &passConfig = m_settings.passwordConfiguration;
50 ✔
842
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH="),
100 ✔
843
            QString::number(passConfig.length));
50 ✔
844

845
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET="),
100 ✔
846
            effectiveCharset(passConfig));
50 ✔
847

848
  exec.setEnvironment(env);
50 ✔
849
}
50 ✔
850

851
/**
852
 * @brief Pass::getGpgIdPath return gpgid file path for some file (folder).
853
 * @param for_file which file (folder) would you like the gpgid file path for.
854
 * @return path to the gpgid file.
855
 */
856
auto Pass::getGpgIdPath(const QString &for_file, const QString &passStore)
82 ✔
857
    -> QString {
858
  QString normalizedStore = QDir::fromNativeSeparators(passStore);
82 ✔
859
  QString normalizedFile = QDir::fromNativeSeparators(for_file);
82 ✔
860
  QString fullPath = normalizedFile.startsWith(normalizedStore)
82 ✔
861
                         ? normalizedFile
82 ✔
862
                         : normalizedStore + "/" + normalizedFile;
53 ✔
863
  QDir gpgIdDir(QFileInfo(fullPath).absoluteDir());
82 ✔
864
  // QDir::cleanPath() always normalises to forward slashes, so use '/'
865
  // here rather than QDir::separator() (which returns '\\' on Windows).
866
  QString cleanPassStore = QDir::cleanPath(normalizedStore);
82 ✔
867
  bool found = false;
868
  while (gpgIdDir.exists()) {
108 ✔
869
    QString currentPath = QDir::cleanPath(gpgIdDir.absolutePath());
214 ✔
870
    const QString prefix =
871
        cleanPassStore.endsWith('/') ? cleanPassStore : cleanPassStore + "/";
107 ✔
872
    if (currentPath != cleanPassStore && !currentPath.startsWith(prefix)) {
107 ✔
873
      break;
874
    }
875
    if (QFile(gpgIdDir.absoluteFilePath(".gpg-id")).exists()) {
192 ✔
876
      found = true;
877
      break;
878
    }
879
    if (!gpgIdDir.cdUp()) {
26 ✔
880
      break;
881
    }
882
  }
883
  return found ? gpgIdDir.absoluteFilePath(".gpg-id")
82 ✔
884
               : QDir(normalizedStore).filePath(".gpg-id");
246 ✔
885
}
82 ✔
886

887
/**
888
 * @brief Pass::getRecipientList return list of gpg-id's to encrypt for
889
 * @param for_file which file (folder) would you like recipients for
890
 * @return recipients gpg-id contents
891
 */
892
auto Pass::getRecipientList(const QString &for_file, const QString &passStore)
47 ✔
893
    -> QStringList {
894
  QFile gpgId(getGpgIdPath(for_file, passStore));
47 ✔
895
  if (!gpgId.open(QIODevice::ReadOnly | QIODevice::Text)) {
47 ✔
896
    return {};
1 ✔
897
  }
898
  QStringList recipients;
46 ✔
899
  while (!gpgId.atEnd()) {
114 ✔
900
    QString recipient(gpgId.readLine());
136 ✔
901
    recipient = recipient.split("#")[0].trimmed();
136 ✔
902
    if (recipient.isEmpty()) {
68 ✔
903
      continue;
7 ✔
904
    }
905
    if (!Util::isValidKeyId(recipient)) {
61 ✔
906
      // Never drop a recipient silently: the list is written back verbatim
907
      // by UsersDialog, so a skipped line disappears from .gpg-id.
908
      qWarning() << "Skipping unusable recipient in" << gpgId.fileName() << ":"
4 ✔
909
                 << recipient;
2 ✔
910
      continue;
2 ✔
911
    }
912
    recipients += recipient;
913
  }
914
  return recipients;
915
}
47 ✔
916

917
/**
918
 * @brief Pass::getRecipientString formatted string for use with GPG
919
 * @param for_file which file (folder) would you like recipients for
920
 * @param separator formatting separator eg: " -r "
921
 * @param count
922
 * @return recipient string
923
 */
924
auto Pass::getRecipientString(const QString &for_file, const QString &passStore,
3 ✔
925
                              const QString &separator, int *count)
926
    -> QStringList {
927
  Q_UNUSED(separator)
928
  QStringList recipients = Pass::getRecipientList(for_file, passStore);
3 ✔
929
  if (count) {
3 ✔
930
    *count = static_cast<int>(recipients.size());
2 ✔
931
  }
932
  return recipients;
3 ✔
933
}
934

935
/**
936
 * @brief Pass::seedGpgIdFile write the inherited recipients into a new
937
 * folder's .gpg-id
938
 * @param newDir absolute path of the freshly created folder
939
 * @param passStore root directory of the password store
940
 * @return true when newDir/.gpg-id was written
941
 */
942
auto Pass::seedGpgIdFile(const QString &newDir, const QString &passStore)
5 ✔
943
    -> bool {
944
  const QString gpgIdFile = QDir(newDir).absoluteFilePath(".gpg-id");
10 ✔
945
  if (QFileInfo::exists(gpgIdFile)) {
5 ✔
946
    return false;
947
  }
948
  // Resolve from the file we are about to create: getGpgIdPath walks up from
949
  // its directory, so this yields the parent's .gpg-id whether or not newDir
950
  // carries a trailing separator.
951
  const QStringList recipients = getRecipientList(gpgIdFile, passStore);
4 ✔
952
  if (recipients.isEmpty()) {
4 ✔
953
    return false;
954
  }
955
  QSaveFile gpgId(gpgIdFile);
2 ✔
956
  if (!gpgId.open(QIODevice::WriteOnly)) {
2 ✔
957
    return false;
958
  }
959
  QTextStream out(&gpgId);
2 ✔
960
  for (const QString &recipient : recipients) {
5 ✔
961
    out << recipient << '\n';
3 ✔
962
  }
963
  out.flush();
2 ✔
964
  if (out.status() != QTextStream::Ok || !gpgId.commit()) {
2 ✔
NEW
965
    return false;
×
966
  }
967
  // Lock to owner-only access; see ImitatePass::writeGpgIdFile for the
968
  // rationale (NFS / USB / unusual umask). Best-effort where setPermissions
969
  // is a no-op.
970
  QFile::setPermissions(gpgIdFile, QFile::ReadOwner | QFile::WriteOwner);
2 ✔
971
  return true;
972
}
2 ✔
973

974
/* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
975
 */
976

977
/**
978
 * @brief Generates a random number bounded by the given value.
979
 * @param bound Upper bound (exclusive)
980
 * @return Random number in range [0, bound)
981
 */
982
auto Pass::boundedRandom(quint32 bound) -> quint32 {
7,592 ✔
983
  if (bound < 2) {
7,592 ✔
984
    return 0;
985
  }
986

987
  quint32 randval;
988
  // Rejection-sampling threshold to avoid modulo bias.
989
  // This follows the well-known "arc4random_uniform"-style approach:
990
  // reject values in the low range [0, min), where
991
  //   min = 2^32 % bound
992
  // so that the remaining range size is an exact multiple of `bound`.
993
  //
994
  // In quint32 arithmetic, (1 + ~bound) wraps to (2^32 - bound), therefore
995
  //   (1 + ~bound) % bound == 2^32 % bound.
996
  const quint32 rejectionThreshold = (1 + ~bound) % bound;
7,592 ✔
997

998
  do {
999
    randval = QRandomGenerator::system()->generate();
1000
  } while (randval < rejectionThreshold);
7,592 ✔
1001

1002
  return randval % bound;
7,592 ✔
1003
}
1004

1005
/**
1006
 * @brief Generates a random password from the given charset.
1007
 * @param charset Characters to use in the password
1008
 * @param length Desired password length
1009
 * @return Generated password string
1010
 */
1011
auto Pass::generateRandomPassword(const QString &charset, unsigned int length)
1,204 ✔
1012
    -> QString {
1013
  if (charset.isEmpty() || length == 0U) {
1,204 ✔
1014
    return {};
1015
  }
1016
  QString out;
1,204 ✔
1017
  for (unsigned int i = 0; i < length; ++i) {
8,796 ✔
1018
    out.append(charset.at(static_cast<int>(
7,592 ✔
1019
        boundedRandom(static_cast<quint32>(charset.length())))));
7,592 ✔
1020
  }
1021
  return out;
1022
}
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