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

IJHack / QtPass / 35070778702

16 Sep 2026 07:52AM UTC coverage: 70.165% (+0.2%) from 69.949%
35070778702

push

github

web-flow
Merge pull request #1752 from IJHack/fix/2.0-remove-webdav

Remove WebDAV mounting (#1682)

9 of 9 new or added lines in 3 files covered. (100.0%)

12 existing lines in 3 files now uncovered.

5221 of 7441 relevant lines covered (70.17%)

80.19 hits per line

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

78.61
/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,279 ✔
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 {
75 ✔
57
  int sel = passConfig.selected;
75 ✔
58
  if (sel < 0 || sel >= PasswordConfiguration::CHARSETS_COUNT)
75 ✔
59
    sel = PasswordConfiguration::ALLCHARS;
60
  return fallbackCharset(
61
      passConfig.Characters[sel],
75 ✔
62
      passConfig.Characters[PasswordConfiguration::ALLCHARS]);
75 ✔
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()) {
83 ✔
70
  connect(&exec, &Executor::finished, this, &Pass::finished);
83 ✔
71
  connect(&exec, &Executor::error, this, &Pass::finished);
83 ✔
72

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

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

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

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

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

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

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

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

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

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

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

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

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

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

357
} // namespace
358

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

532
} // namespace
533

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

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

574
  return {};
575
}
×
576

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

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

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

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

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

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

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

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

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

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

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

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

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

774
/**
775
 * @brief Set or remove a single environment variable in the local env list.
776
 *
777
 * The provided key must include a trailing '=' (e.g. "FOO="). Existing entries
778
 * whose text begins with the given key are removed before the new value is
779
 * applied. If value is non-empty the pair "key+value" is appended; if value is
780
 * empty the variable is removed.
781
 *
782
 * The function asserts if key does not end with '='; if the assertion is not
783
 * active it will emit a warning and return without modifying env.
784
 *
785
 * @param key Environment variable name with trailing '=' (anchors the lookup).
786
 * @param value Value to set for the variable; an empty string unsets the
787
 * variable.
788
 */
789
void Pass::setEnvVar(const QString &key, const QString &value) {
306 ✔
790
  const bool hasEq = key.endsWith('=');
306 ✔
791
  Q_ASSERT_X(hasEq, "Pass::setEnvVar",
792
             "called with malformed key (missing '=')");
793
  if (!hasEq) {
306 ✔
794
    qWarning() << "Pass::setEnvVar called with malformed key (missing '='):"
×
795
               << key;
×
796
    return;
×
797
  }
798
  const QString varName = key.chopped(1);
799
  if (value.isEmpty())
306 ✔
800
    env.remove(varName);
84 ✔
801
  else
802
    env.insert(varName, value);
222 ✔
803
}
804

805
/**
806
 * @brief Update the process environment used for executing external commands.
807
 *
808
 * Updates environment entries for PASSWORD_STORE_SIGNING_KEY,
809
 * PASSWORD_STORE_DIR, PASSWORD_STORE_GENERATED_LENGTH, and
810
 * PASSWORD_STORE_CHARACTER_SET based on current settings, then applies the
811
 * environment to the internal executor.
812
 */
813
void Pass::updateEnv() {
75 ✔
814
  setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY="),
150 ✔
815
            m_settings.passSigningKey);
75 ✔
816
  setEnvVar(QStringLiteral("PASSWORD_STORE_DIR="), m_settings.passStore);
150 ✔
817

818
  const PasswordConfiguration &passConfig = m_settings.passwordConfiguration;
75 ✔
819
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH="),
150 ✔
820
            QString::number(passConfig.length));
75 ✔
821

822
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET="),
150 ✔
823
            effectiveCharset(passConfig));
75 ✔
824

825
  exec.setEnvironment(env);
75 ✔
826
}
75 ✔
827

828
/**
829
 * @brief Pass::getGpgIdPath return gpgid file path for some file (folder).
830
 * @param for_file which file (folder) would you like the gpgid file path for.
831
 * @return path to the gpgid file.
832
 */
833
auto Pass::getGpgIdPath(const QString &for_file, const QString &passStore)
145 ✔
834
    -> QString {
835
  QString normalizedStore = QDir::fromNativeSeparators(passStore);
145 ✔
836
  QString normalizedFile = QDir::fromNativeSeparators(for_file);
145 ✔
837
  QString fullPath = normalizedFile.startsWith(normalizedStore)
145 ✔
838
                         ? normalizedFile
145 ✔
839
                         : normalizedStore + "/" + normalizedFile;
81 ✔
840
  QDir gpgIdDir(QFileInfo(fullPath).absoluteDir());
145 ✔
841
  // QDir::cleanPath() always normalises to forward slashes, so use '/'
842
  // here rather than QDir::separator() (which returns '\\' on Windows).
843
  QString cleanPassStore = QDir::cleanPath(normalizedStore);
145 ✔
844
  bool found = false;
845
  while (gpgIdDir.exists()) {
175 ✔
846
    QString currentPath = QDir::cleanPath(gpgIdDir.absolutePath());
348 ✔
847
    const QString prefix =
848
        cleanPassStore.endsWith('/') ? cleanPassStore : cleanPassStore + "/";
174 ✔
849
    if (currentPath != cleanPassStore && !currentPath.startsWith(prefix)) {
174 ✔
850
      break;
851
    }
852
    if (QFile(gpgIdDir.absoluteFilePath(".gpg-id")).exists()) {
326 ✔
853
      found = true;
854
      break;
855
    }
856
    if (!gpgIdDir.cdUp()) {
30 ✔
857
      break;
858
    }
859
  }
860
  return found ? gpgIdDir.absoluteFilePath(".gpg-id")
145 ✔
861
               : QDir(normalizedStore).filePath(".gpg-id");
435 ✔
862
}
145 ✔
863

864
/**
865
 * @brief Pass::getRecipientList return list of gpg-id's to encrypt for
866
 * @param for_file which file (folder) would you like recipients for
867
 * @return recipients gpg-id contents
868
 */
869
auto Pass::getRecipientList(const QString &for_file, const QString &passStore)
78 ✔
870
    -> QStringList {
871
  QFile gpgId(getGpgIdPath(for_file, passStore));
78 ✔
872
  if (!gpgId.open(QIODevice::ReadOnly | QIODevice::Text)) {
78 ✔
873
    return {};
1 ✔
874
  }
875
  QStringList recipients;
77 ✔
876
  while (!gpgId.atEnd()) {
177 ✔
877
    QString recipient(gpgId.readLine());
200 ✔
878
    recipient = recipient.split("#")[0].trimmed();
200 ✔
879
    if (recipient.isEmpty()) {
100 ✔
880
      continue;
7 ✔
881
    }
882
    if (!Util::isValidKeyId(recipient)) {
93 ✔
883
      // Never drop a recipient silently: the list is written back verbatim
884
      // by UsersDialog, so a skipped line disappears from .gpg-id.
885
      qWarning() << "Skipping unusable recipient in" << gpgId.fileName() << ":"
4 ✔
886
                 << recipient;
2 ✔
887
      continue;
2 ✔
888
    }
889
    recipients += recipient;
890
  }
891
  return recipients;
892
}
78 ✔
893

894
/**
895
 * @brief Pass::getRecipientString formatted string for use with GPG
896
 * @param for_file which file (folder) would you like recipients for
897
 * @param separator formatting separator eg: " -r "
898
 * @param count
899
 * @return recipient string
900
 */
901
auto Pass::getRecipientString(const QString &for_file, const QString &passStore,
3 ✔
902
                              const QString &separator, int *count)
903
    -> QStringList {
904
  Q_UNUSED(separator)
905
  QStringList recipients = Pass::getRecipientList(for_file, passStore);
3 ✔
906
  if (count) {
3 ✔
907
    *count = static_cast<int>(recipients.size());
2 ✔
908
  }
909
  return recipients;
3 ✔
910
}
911

912
/**
913
 * @brief Pass::seedGpgIdFile write the inherited recipients into a new
914
 * folder's .gpg-id
915
 * @param newDir absolute path of the freshly created folder
916
 * @param passStore root directory of the password store
917
 * @return true when newDir/.gpg-id was written
918
 */
919
auto Pass::seedGpgIdFile(const QString &newDir, const QString &passStore)
5 ✔
920
    -> bool {
921
  const QString gpgIdFile = QDir(newDir).absoluteFilePath(".gpg-id");
10 ✔
922
  if (QFileInfo::exists(gpgIdFile)) {
5 ✔
923
    return false;
924
  }
925
  // Resolve from the file we are about to create: getGpgIdPath walks up from
926
  // its directory, so this yields the parent's .gpg-id whether or not newDir
927
  // carries a trailing separator.
928
  const QStringList recipients = getRecipientList(gpgIdFile, passStore);
4 ✔
929
  if (recipients.isEmpty()) {
4 ✔
930
    return false;
931
  }
932
  QSaveFile gpgId(gpgIdFile);
2 ✔
933
  if (!gpgId.open(QIODevice::WriteOnly)) {
2 ✔
934
    return false;
935
  }
936
  QTextStream out(&gpgId);
2 ✔
937
  for (const QString &recipient : recipients) {
5 ✔
938
    out << recipient << '\n';
3 ✔
939
  }
940
  out.flush();
2 ✔
941
  if (out.status() != QTextStream::Ok || !gpgId.commit()) {
2 ✔
942
    return false;
×
943
  }
944
  // Lock to owner-only access; see ImitatePass::writeGpgIdFile for the
945
  // rationale (NFS / USB / unusual umask). Best-effort where setPermissions
946
  // is a no-op.
947
  QFile::setPermissions(gpgIdFile, QFile::ReadOwner | QFile::WriteOwner);
2 ✔
948
  return true;
949
}
2 ✔
950

951
/* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
952
 */
953

954
/**
955
 * @brief Generates a random number bounded by the given value.
956
 * @param bound Upper bound (exclusive)
957
 * @return Random number in range [0, bound)
958
 */
959
auto Pass::boundedRandom(quint32 bound) -> quint32 {
7,592 ✔
960
  if (bound < 2) {
7,592 ✔
961
    return 0;
962
  }
963

964
  quint32 randval;
965
  // Rejection-sampling threshold to avoid modulo bias.
966
  // This follows the well-known "arc4random_uniform"-style approach:
967
  // reject values in the low range [0, min), where
968
  //   min = 2^32 % bound
969
  // so that the remaining range size is an exact multiple of `bound`.
970
  //
971
  // In quint32 arithmetic, (1 + ~bound) wraps to (2^32 - bound), therefore
972
  //   (1 + ~bound) % bound == 2^32 % bound.
973
  const quint32 rejectionThreshold = (1 + ~bound) % bound;
7,592 ✔
974

975
  do {
976
    randval = QRandomGenerator::system()->generate();
977
  } while (randval < rejectionThreshold);
7,592 ✔
978

979
  return randval % bound;
7,592 ✔
980
}
981

982
/**
983
 * @brief Generates a random password from the given charset.
984
 * @param charset Characters to use in the password
985
 * @param length Desired password length
986
 * @return Generated password string
987
 */
988
auto Pass::generateRandomPassword(const QString &charset, unsigned int length)
1,204 ✔
989
    -> QString {
990
  if (charset.isEmpty() || length == 0U) {
1,204 ✔
991
    return {};
992
  }
993
  QString out;
1,204 ✔
994
  for (unsigned int i = 0; i < length; ++i) {
8,796 ✔
995
    out.append(charset.at(static_cast<int>(
7,592 ✔
996
        boundedRandom(static_cast<quint32>(charset.length())))));
7,592 ✔
997
  }
998
  return out;
999
}
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