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

IJHack / QtPass / 24590073580

17 Apr 2026 10:49PM UTC coverage: 22.061% (+0.3%) from 21.778%
24590073580

push

github

web-flow
fix: show user-friendly error when GPG key is expired or invalid (#1035)

* fix: show user-friendly error when GPG key is expired or invalid (#247)

When ImitatePass::Insert fails because the recipient key is expired,
revoked, or missing, GPG exits non-zero and the raw stderr was shown
verbatim in the output panel — not helpful to users who don't read GPG
output daily.

Add --status-fd 2 to the GPG invocation so machine-readable [GNUPG:]
status tokens (KEY_EXPIRED, KEY_REVOKED, NO_PUBKEY, INV_RECP, FAILURE)
are included in stderr. These are locale-independent and checked first;
case-insensitive substring fallbacks handle GPG builds or configurations
that don't emit status tokens.

In Pass::finished, when PASS_INSERT fails and a known pattern is found,
a translated friendly message is prepended and the [GNUPG:] machine
lines are stripped before emitting processErrorExit, so the output pane
shows e.g. "Encryption failed: GPG key has expired. Please renew or
replace it." followed by the human-readable GPG lines only.

The transaction mechanism in ImitatePass::finished already cancels the
queued GIT_ADD and GIT_COMMIT commands when GPG fails, so the
misleading git pathspec error is not shown.

11 new unit tests cover all status-token paths, all fallback paths,
the empty-result case, and priority ordering (token beats fallback).

Closes #247

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use correct GPG status-fd token names in gpgErrorMessage

Replace KEY_EXPIRED/KEY_REVOKED (which are not real --status-fd tokens)
with KEYEXPIRED and KEYREVOKED. Remove REVKEYSIG (signature verification
only). Add INV_RECP 5 (expired) and INV_RECP 4 (revoked) checks before
the generic INV_RECP fallback. Update tests accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: strip \r from GPG output lines before displaying to user

Split on '\n' leaves trailing '\r' on CRLF output (Windows/WSL).
Remove '\r' from each line before appe... (continued)

23 of 36 new or added lines in 2 files covered. (63.89%)

1199 of 5435 relevant lines covered (22.06%)

8.64 hits per line

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

6.06
/src/imitatepass.cpp
1
// SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "imitatepass.h"
4
#include "qtpasssettings.h"
5
#include "util.h"
6
#include <QDirIterator>
7
#include <QRegularExpression>
8
#include <utility>
9

10
#ifdef QT_DEBUG
11
#include "debughelper.h"
12
#endif
13

14
using Enums::CLIPBOARD_ALWAYS;
15
using Enums::CLIPBOARD_NEVER;
16
using Enums::CLIPBOARD_ON_DEMAND;
17
using Enums::GIT_ADD;
18
using Enums::GIT_COMMIT;
19
using Enums::GIT_COPY;
20
using Enums::GIT_INIT;
21
using Enums::GIT_MOVE;
22
using Enums::GIT_PULL;
23
using Enums::GIT_PUSH;
24
using Enums::GIT_RM;
25
using Enums::GPG_GENKEYS;
26
using Enums::INVALID;
27
using Enums::PASS_COPY;
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
using Enums::PROCESS_COUNT;
35

36
/**
37
 * @brief ImitatePass::ImitatePass for situations when pass is not available
38
 * we imitate the behavior of pass https://www.passwordstore.org/
39
 */
40
ImitatePass::ImitatePass() = default;
32✔
41

42
static auto pgit(const QString &path) -> QString {
×
43
  if (!QtPassSettings::getGitExecutable().startsWith("wsl ")) {
×
44
    return path;
45
  }
46
  QString res = "$(wslpath " + path + ")";
×
47
  return res.replace('\\', '/');
×
48
}
49

50
static auto pgpg(const QString &path) -> QString {
×
51
  if (!QtPassSettings::getGpgExecutable().startsWith("wsl ")) {
×
52
    return path;
53
  }
54
  QString res = "$(wslpath " + path + ")";
×
55
  return res.replace('\\', '/');
×
56
}
57

58
/**
59
 * @brief ImitatePass::GitInit git init wrapper
60
 */
61
void ImitatePass::GitInit() {
×
62
  executeGit(GIT_INIT, {"init", pgit(QtPassSettings::getPassStore())});
×
63
}
×
64

65
/**
66
 * @brief ImitatePass::GitPull git pull wrapper
67
 */
68
void ImitatePass::GitPull() { executeGit(GIT_PULL, {"pull"}); }
×
69

70
/**
71
 * @brief ImitatePass::GitPull_b git pull wrapper
72
 */
73
void ImitatePass::GitPull_b() {
×
74
  Executor::executeBlocking(QtPassSettings::getGitExecutable(), {"pull"});
×
75
}
×
76

77
/**
78
 * @brief ImitatePass::GitPush git push wrapper
79
 */
80
void ImitatePass::GitPush() {
×
81
  if (QtPassSettings::isUseGit()) {
×
82
    executeGit(GIT_PUSH, {"push"});
×
83
  }
84
}
×
85

86
/**
87
 * @brief ImitatePass::Show shows content of file
88
 */
89
void ImitatePass::Show(QString file) {
×
90
  file = QtPassSettings::getPassStore() + file + ".gpg";
×
91
  QStringList args = {"-d",      "--quiet",     "--yes",   "--no-encrypt-to",
92
                      "--batch", "--use-agent", pgpg(file)};
×
93
  executeGpg(PASS_SHOW, args);
×
94
}
×
95

96
/**
97
 * @brief ImitatePass::OtpGenerate generates an otp code
98
 */
99
void ImitatePass::OtpGenerate(QString file) {
×
100
#ifdef QT_DEBUG
101
  dbg() << "No OTP generation code for fake pass yet, attempting for file: " +
102
               file;
103
#else
104
  Q_UNUSED(file)
105
#endif
106
}
×
107

108
/**
109
 * @brief ImitatePass::Insert create new file with encrypted content
110
 *
111
 * @param file      file to be created
112
 * @param newValue  value to be stored in file
113
 * @param overwrite whether to overwrite existing file
114
 */
115
void ImitatePass::Insert(QString file, QString newValue, bool overwrite) {
×
116
  file = file + ".gpg";
×
117
  QString gpgIdPath = Pass::getGpgIdPath(file);
×
118
  if (!verifyGpgIdFile(gpgIdPath)) {
×
119
    emit critical(tr("Check .gpgid file signature!"),
×
120
                  tr("Signature for %1 is invalid.").arg(gpgIdPath));
×
121
    return;
×
122
  }
123
  transactionHelper trans(this, PASS_INSERT);
×
124
  QStringList recipients = Pass::getRecipientList(file);
×
125
  if (recipients.isEmpty()) {
×
126
    // Already emit critical signal to notify user of error - no need to throw
127
    emit critical(tr("Can not edit"),
×
128
                  tr("Could not read encryption key to use, .gpg-id "
×
129
                     "file missing or invalid."));
130
    return;
131
  }
132
  QStringList args = {"--batch", "--status-fd", "2",
NEW
133
                      "-eq",     "--output",    pgpg(file)};
×
134
  for (auto &r : recipients) {
×
135
    args.append("-r");
×
136
    args.append(r);
137
  }
138
  if (overwrite) {
×
139
    args.append("--yes");
×
140
  }
141
  args.append("-");
×
142
  executeGpg(PASS_INSERT, args, newValue);
×
143
  if (!QtPassSettings::isUseWebDav() && QtPassSettings::isUseGit()) {
×
144
    // Git is used when enabled - this is the standard pass workflow
145
    if (!overwrite) {
×
146
      executeGit(GIT_ADD, {"add", pgit(file)});
×
147
    }
148
    QString path = QDir(QtPassSettings::getPassStore()).relativeFilePath(file);
×
149
    path.replace(Util::endsWithGpg(), "");
×
150
    QString msg =
151
        QString(overwrite ? "Edit" : "Add") + " for " + path + " using QtPass.";
×
152
    gitCommit(file, msg);
×
153
  }
154
}
×
155

156
/**
157
 * @brief ImitatePass::gitCommit commit a file to git with an appropriate commit
158
 * message
159
 * @param file
160
 * @param msg
161
 */
162
void ImitatePass::gitCommit(const QString &file, const QString &msg) {
×
163
  if (file.isEmpty()) {
×
164
    executeGit(GIT_COMMIT, {"commit", "-m", msg});
×
165
  } else {
166
    executeGit(GIT_COMMIT, {"commit", "-m", msg, "--", pgit(file)});
×
167
  }
168
}
×
169

170
/**
171
 * @brief ImitatePass::Remove custom implementation of "pass remove"
172
 */
173
void ImitatePass::Remove(QString file, bool isDir) {
×
174
  file = QtPassSettings::getPassStore() + file;
×
175
  transactionHelper trans(this, PASS_REMOVE);
×
176
  if (!isDir) {
×
177
    file += ".gpg";
×
178
  }
179
  if (QtPassSettings::isUseGit()) {
×
180
    executeGit(GIT_RM, {"rm", (isDir ? "-rf" : "-f"), pgit(file)});
×
181
    // Normalize path the same way as add/edit operations
182
    QString path = QDir(QtPassSettings::getPassStore()).relativeFilePath(file);
×
183
    path.replace(Util::endsWithGpg(), "");
×
184
    gitCommit(file, "Remove for " + path + " using QtPass.");
×
185
  } else {
186
    if (isDir) {
×
187
      QDir dir(file);
×
188
      dir.removeRecursively();
×
189
    } else {
×
190
      QFile(file).remove();
×
191
    }
192
  }
193
}
×
194

195
/**
196
 * @brief ImitatePass::Init initialize pass repository
197
 *
198
 * @param path      path in which new password-store will be created
199
 * @param users     list of users who shall be able to decrypt passwords in
200
 * path
201
 */
202
auto ImitatePass::checkSigningKeys(const QStringList &signingKeys) -> bool {
×
203
  QString out;
×
204
  QStringList args =
205
      QStringList{"--status-fd=1", "--list-secret-keys"} + signingKeys;
×
206
  int result =
207
      Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args, &out);
×
208
  if (result != 0) {
×
209
#ifdef QT_DEBUG
210
    dbg() << "GPG list-secret-keys failed with code:" << result;
211
#endif
212
    return false;
213
  }
214
  for (auto &key : signingKeys) {
×
215
    if (out.contains("[GNUPG:] KEY_CONSIDERED " + key)) {
×
216
      return true;
217
    }
218
  }
219
  return false;
220
}
×
221

222
/**
223
 * @brief Writes the selected users' GPG key IDs to a .gpg-id file.
224
 * @details Opens the specified file for writing, stores the key ID of each
225
 * enabled user on a separate line, and warns if none of the selected users has
226
 * a secret key available.
227
 *
228
 * @param QString &gpgIdFile - Path to the .gpg-id file to be written.
229
 * @param QList<UserInfo> &users - List of users to evaluate and write to the
230
 * file.
231
 * @return void - This function does not return a value.
232
 *
233
 */
234
void ImitatePass::writeGpgIdFile(const QString &gpgIdFile,
×
235
                                 const QList<UserInfo> &users) {
236
  QFile gpgId(gpgIdFile);
×
237
  if (!gpgId.open(QIODevice::WriteOnly | QIODevice::Text)) {
×
238
    emit critical(tr("Cannot update"),
×
239
                  tr("Failed to open .gpg-id for writing."));
×
240
    return;
241
  }
242
  bool secret_selected = false;
243
  for (const UserInfo &user : users) {
×
244
    if (user.enabled) {
×
245
      gpgId.write((user.key_id + "\n").toUtf8());
×
246
      secret_selected |= user.have_secret;
×
247
    }
248
  }
249
  gpgId.close();
×
250
  if (!secret_selected) {
×
251
    emit critical(
×
252
        tr("Check selected users!"),
×
253
        tr("None of the selected keys have a secret key available.\n"
×
254
           "You will not be able to decrypt any newly added passwords!"));
255
  }
256
}
×
257

258
/**
259
 * @brief Signs a GPG ID file and verifies its signature.
260
 * @example
261
 * bool result = ImitatePass::signGpgIdFile(gpgIdFile, signingKeys);
262
 * std::cout << result << std::endl; // Expected output: true if signing and
263
 * verification succeed
264
 *
265
 * @param QString &gpgIdFile - Path to the .gpgid file to be signed.
266
 * @param QStringList &signingKeys - List of signing keys; only the first key is
267
 * used.
268
 * @return bool - True if the file was signed and its signature verified
269
 * successfully; otherwise false.
270
 */
271
auto ImitatePass::signGpgIdFile(const QString &gpgIdFile,
×
272
                                const QStringList &signingKeys) -> bool {
273
  QStringList args;
×
274
  // Use only the first signing key; multiple --default-key options would
275
  // override each other and only the last one would take effect.
276
  if (!signingKeys.isEmpty()) {
×
277
#ifdef QT_DEBUG
278
    if (signingKeys.size() > 1) {
279
      dbg() << "Multiple signing keys configured; using only the first key:"
280
            << signingKeys.first();
281
    }
282
#endif
283
    args.append(QStringList{"--default-key", signingKeys.first()});
×
284
  }
285
  args.append(QStringList{"--yes", "--detach-sign", gpgIdFile});
×
286
  int result =
287
      Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args);
×
288
  if (result != 0) {
×
289
#ifdef QT_DEBUG
290
    dbg() << "GPG signing failed with code:" << result;
291
#endif
292
    emit critical(tr("GPG signing failed!"),
×
293
                  tr("Failed to sign %1.").arg(gpgIdFile));
×
294
    return false;
×
295
  }
296
  if (!verifyGpgIdFile(gpgIdFile)) {
×
297
    emit critical(tr("Check .gpgid file signature!"),
×
298
                  tr("Signature for %1 is invalid.").arg(gpgIdFile));
×
299
    return false;
×
300
  }
301
  return true;
302
}
×
303

304
/**
305
 * @brief Adds a GPG ID file and optionally its signature file to git, then
306
 * creates corresponding commit(s).
307
 * @example
308
 * void result = ImitatePass::gitAddGpgId(gpgIdFile, gpgIdSigFile, true, true);
309
 *
310
 * @param const QString &gpgIdFile - Path to the GPG ID file to add and commit.
311
 * @param const QString &gpgIdSigFile - Path to the signature file associated
312
 * with the GPG ID file.
313
 * @param bool addFile - Whether to stage and commit the GPG ID file.
314
 * @param bool addSigFile - Whether to stage and commit the signature file.
315
 * @return void - This function does not return a value.
316
 */
317
void ImitatePass::gitAddGpgId(const QString &gpgIdFile,
×
318
                              const QString &gpgIdSigFile, bool addFile,
319
                              bool addSigFile) {
320
  if (addFile) {
×
321
    executeGit(GIT_ADD, {"add", pgit(gpgIdFile)});
×
322
  }
323
  QString commitPath = gpgIdFile;
324
  commitPath.replace(Util::endsWithGpg(), "");
×
325
  gitCommit(gpgIdFile, "Added " + commitPath + " using QtPass.");
×
326
  if (!addSigFile) {
×
327
    return;
328
  }
329
  executeGit(GIT_ADD, {"add", pgit(gpgIdSigFile)});
×
330
  commitPath = gpgIdSigFile;
×
331
  commitPath.replace(QRegularExpression("\\.gpg$"), "");
×
332
  gitCommit(gpgIdSigFile, "Added " + commitPath + " using QtPass.");
×
333
}
×
334

335
/**
336
 * @brief Initializes the pass entry by writing and optionally signing the GPG
337
 * ID files.
338
 *
339
 * @example
340
 * void result = ImitatePass::Init(path, users);
341
 *
342
 * @param QString path - Base path for the pass entry where ".gpg-id" and
343
 * optional signature files are created.
344
 * @param const QList<UserInfo> &users - List of users whose keys are written
345
 * into the GPG ID file.
346
 * @return void - No return value.
347
 */
348
void ImitatePass::Init(QString path, const QList<UserInfo> &users) {
×
349
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
350
  QStringList signingKeys =
351
      QtPassSettings::getPassSigningKey().split(" ", Qt::SkipEmptyParts);
×
352
#else
353
  QStringList signingKeys =
354
      QtPassSettings::getPassSigningKey().split(" ", QString::SkipEmptyParts);
355
#endif
356
  QString gpgIdSigFile = path + ".gpg-id.sig";
×
357
  bool addSigFile = false;
358
  if (!signingKeys.isEmpty()) {
×
359
    if (!checkSigningKeys(signingKeys)) {
×
360
      emit critical(tr("No signing key!"),
×
361
                    tr("None of the secret signing keys is available.\n"
×
362
                       "You will not be able to change the user list!"));
363
      return;
×
364
    }
365
    QFileInfo checkFile(gpgIdSigFile);
×
366
    if (!checkFile.exists() || !checkFile.isFile()) {
×
367
      addSigFile = true;
368
    }
369
  }
×
370

371
  QString gpgIdFile = path + ".gpg-id";
×
372
  bool addFile = false;
373
  transactionHelper trans(this, PASS_INIT);
×
374
  if (QtPassSettings::isAddGPGId(true)) {
×
375
    QFileInfo checkFile(gpgIdFile);
×
376
    if (!checkFile.exists() || !checkFile.isFile()) {
×
377
      addFile = true;
378
    }
379
  }
×
380
  writeGpgIdFile(gpgIdFile, users);
×
381

382
  if (!signingKeys.isEmpty()) {
×
383
    if (!signGpgIdFile(gpgIdFile, signingKeys)) {
×
384
      return;
385
    }
386
  }
387

388
  if (!QtPassSettings::isUseWebDav() && QtPassSettings::isUseGit() &&
×
389
      !QtPassSettings::getGitExecutable().isEmpty()) {
×
390
    gitAddGpgId(gpgIdFile, gpgIdSigFile, addFile, addSigFile);
×
391
  }
392
  reencryptPath(path);
×
393
}
394

395
/**
396
 * @brief ImitatePass::verifyGpgIdFile verify detached gpgid file signature.
397
 * @param file which gpgid file.
398
 * @return was verification successful?
399
 */
400
auto ImitatePass::verifyGpgIdFile(const QString &file) -> bool {
×
401
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
402
  QStringList signingKeys =
403
      QtPassSettings::getPassSigningKey().split(" ", Qt::SkipEmptyParts);
×
404
#else
405
  QStringList signingKeys =
406
      QtPassSettings::getPassSigningKey().split(" ", QString::SkipEmptyParts);
407
#endif
408
  if (signingKeys.isEmpty()) {
×
409
    return true;
410
  }
411
  QString out;
×
412
  QStringList args =
413
      QStringList{"--verify", "--status-fd=1", pgpg(file) + ".sig", pgpg(file)};
×
414
  int result =
415
      Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args, &out);
×
416
  if (result != 0) {
×
417
#ifdef QT_DEBUG
418
    dbg() << "GPG verify failed with code:" << result;
419
#endif
420
    return false;
421
  }
422
  QRegularExpression re(
423
      R"(^\[GNUPG:\] VALIDSIG ([A-F0-9]{40}) .* ([A-F0-9]{40})\r?$)",
424
      QRegularExpression::MultilineOption);
×
425
  QRegularExpressionMatch m = re.match(out);
×
426
  if (!m.hasMatch()) {
×
427
    return false;
428
  }
429
  QStringList fingerprints = m.capturedTexts();
×
430
  fingerprints.removeFirst();
×
431
  for (auto &key : signingKeys) {
×
432
    if (fingerprints.contains(key)) {
×
433
      return true;
×
434
    }
435
  }
436
  return false;
×
437
}
×
438

439
/**
440
 * @brief ImitatePass::removeDir delete folder recursive.
441
 * @param dirName which folder.
442
 * @return was removal successful?
443
 */
444
auto ImitatePass::removeDir(const QString &dirName) -> bool {
1✔
445
  bool result = true;
446
  QDir dir(dirName);
1✔
447

448
  if (dir.exists(dirName)) {
1✔
449
    for (const QFileInfo &info :
450
         dir.entryInfoList(QDir::NoDotAndDotDot | QDir::System | QDir::Hidden |
451
                               QDir::AllDirs | QDir::Files,
452
                           QDir::DirsFirst)) {
1✔
453
      if (info.isDir()) {
×
454
        result = removeDir(info.absoluteFilePath());
×
455
      } else {
456
        result = QFile::remove(info.absoluteFilePath());
×
457
      }
458

459
      if (!result) {
×
460
        return result;
461
      }
462
    }
463
    result = dir.rmdir(dirName);
1✔
464
  }
465
  return result;
466
}
1✔
467

468
/**
469
 * @brief ImitatePass::reencryptPath reencrypt all files under the chosen
470
 * directory
471
 *
472
 * This is still quite experimental..
473
 * @param dir
474
 */
475
auto ImitatePass::verifyGpgIdForDir(const QString &file,
×
476
                                    QStringList &gpgIdFilesVerified,
477
                                    QStringList &gpgId) -> bool {
478
  QString gpgIdPath = Pass::getGpgIdPath(file);
×
479
  if (gpgIdFilesVerified.contains(gpgIdPath)) {
×
480
    return true;
481
  }
482
  if (!verifyGpgIdFile(gpgIdPath)) {
×
483
    emit critical(tr("Check .gpgid file signature!"),
×
484
                  tr("Signature for %1 is invalid.").arg(gpgIdPath));
×
485
    return false;
×
486
  }
487
  gpgIdFilesVerified.append(gpgIdPath);
488
  gpgId = getRecipientList(file);
×
489
  gpgId.sort();
490
  return true;
491
}
492

493
/**
494
 * @brief Extracts and returns a sorted list of valid key IDs from a GPG key
495
 * listing file.
496
 * @example
497
 * QStringList result = ImitatePass::getKeysFromFile(fileName);
498
 * std::cout << result.join(", ").toStdString() << std::endl;
499
 *
500
 * @param fileName - Path to the file used to query and parse GPG key
501
 * information.
502
 * @return QStringList - A sorted list of 16-character key IDs found in the
503
 * file.
504
 */
505
auto ImitatePass::getKeysFromFile(const QString &fileName) -> QStringList {
×
506
  QStringList args = {
507
      "-v",          "--no-secmem-warning", "--no-permission-warning",
508
      "--list-only", "--keyid-format=long", pgpg(fileName)};
×
509
  QString keys;
×
510
  QString err;
×
511
  const int result = Executor::executeBlocking(
×
512
      QtPassSettings::getGpgExecutable(), args, &keys, &err);
×
513
  if (result != 0 && keys.isEmpty() && err.isEmpty()) {
×
514
    return QStringList();
×
515
  }
516
  QStringList actualKeys;
×
517
  keys += err;
518
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
519
  QStringList key = keys.split(Util::newLinesRegex(), Qt::SkipEmptyParts);
×
520
#else
521
  QStringList key = keys.split(Util::newLinesRegex(), QString::SkipEmptyParts);
522
#endif
523
  QListIterator<QString> itr(key);
524
  while (itr.hasNext()) {
×
525
    QString current = itr.next();
526
    QStringList cur = current.split(" ");
×
527
    if (cur.length() > 4) {
×
528
      QString actualKey = cur.takeAt(4);
×
529
      if (actualKey.length() == 16) {
×
530
        actualKeys << actualKey;
531
      }
532
    }
533
  }
534
  actualKeys.sort();
535
  return actualKeys;
536
}
×
537

538
/**
539
 * @brief Re-encrypts a single encrypted file for a new set of recipients.
540
 * @example
541
 * bool result = ImitatePass::reencryptSingleFile(fileName, recipients);
542
 * std::cout << result << std::endl; // Expected output: true on success, false
543
 * on failure
544
 *
545
 * @param const QString &fileName - Path to the encrypted file to re-encrypt.
546
 * @param const QStringList &recipients - List of recipient keys to encrypt the
547
 * file to.
548
 * @return bool - True if the file was successfully decrypted, re-encrypted,
549
 * verified, and replaced; otherwise false.
550
 */
551
auto ImitatePass::reencryptSingleFile(const QString &fileName,
×
552
                                      const QStringList &recipients) -> bool {
553
#ifdef QT_DEBUG
554
  dbg() << "reencrypt " << fileName << " for " << recipients;
555
#endif
556
  QString local_lastDecrypt;
×
557
  QStringList args = {
558
      "-d",      "--quiet",     "--yes",       "--no-encrypt-to",
559
      "--batch", "--use-agent", pgpg(fileName)};
×
560
  int result = Executor::executeBlocking(QtPassSettings::getGpgExecutable(),
×
561
                                         args, &local_lastDecrypt);
562

563
  if (result != 0 || local_lastDecrypt.isEmpty()) {
×
564
#ifdef QT_DEBUG
565
    dbg() << "Decrypt error on re-encrypt for:" << fileName;
566
#endif
567
    return false;
568
  }
569

570
  if (local_lastDecrypt.right(1) != "\n") {
×
571
    local_lastDecrypt += "\n";
×
572
  }
573

574
  // Use passed recipients instead of re-reading from file
575
  if (recipients.isEmpty()) {
×
576
    emit critical(tr("Can not edit"),
×
577
                  tr("Could not read encryption key to use, .gpg-id "
×
578
                     "file missing or invalid."));
579
    return false;
×
580
  }
581

582
  // Encrypt to temporary file for atomic replacement
583
  QString tempPath = fileName + ".reencrypt.tmp";
×
584
  args = QStringList{"--yes", "--batch", "-eq", "--output", pgpg(tempPath)};
×
585
  for (const auto &i : recipients) {
×
586
    args.append("-r");
×
587
    args.append(i);
588
  }
589
  args.append("-");
×
590
  result = Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args,
×
591
                                     local_lastDecrypt);
592

593
  if (result != 0) {
×
594
#ifdef QT_DEBUG
595
    dbg() << "Encrypt error on re-encrypt for:" << fileName;
596
#endif
597
    QFile::remove(tempPath);
×
598
    return false;
599
  }
600

601
  // Verify encryption worked by attempting to decrypt the temp file
602
  QString verifyOutput;
×
603
  args = QStringList{"-d", "--quiet", "--batch", "--use-agent", pgpg(tempPath)};
×
604
  result = Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args,
×
605
                                     &verifyOutput);
606
  if (result != 0 || verifyOutput.isEmpty()) {
×
607
#ifdef QT_DEBUG
608
    dbg() << "Verification failed for:" << tempPath;
609
#endif
610
    QFile::remove(tempPath);
×
611
    return false;
612
  }
613
  // Verify content matches original decrypted content (defense in depth)
614
  if (verifyOutput.trimmed() != local_lastDecrypt.trimmed()) {
×
615
#ifdef QT_DEBUG
616
    dbg() << "Verification content mismatch for:" << tempPath;
617
#endif
618
    QFile::remove(tempPath);
×
619
    return false;
620
  }
621

622
  // Atomic replace with backup: rename original to .bak, rename temp to
623
  // original, then remove backup
624
  QString backupPath = fileName + ".reencrypt.bak";
×
625
  if (!QFile::rename(fileName, backupPath)) {
×
626
#ifdef QT_DEBUG
627
    dbg() << "Failed to backup original file:" << fileName;
628
#endif
629
    QFile::remove(tempPath);
×
630
    return false;
631
  }
632
  if (!QFile::rename(tempPath, fileName)) {
×
633
#ifdef QT_DEBUG
634
    dbg() << "Failed to rename temp file to:" << fileName;
635
#endif
636
    // Restore backup and clean up temp file
637
    QFile::rename(backupPath, fileName);
×
638
    QFile::remove(tempPath);
×
639
    emit critical(
×
640
        tr("Re-encryption failed"),
×
641
        tr("Failed to replace %1. Original has been restored.").arg(fileName));
×
642
    return false;
×
643
  }
644
  // Success - remove backup
645
  QFile::remove(backupPath);
×
646

647
  if (!QtPassSettings::isUseWebDav() && QtPassSettings::isUseGit()) {
×
648
    Executor::executeBlocking(QtPassSettings::getGitExecutable(),
×
649
                              {"add", pgit(fileName)});
650
    QString path =
651
        QDir(QtPassSettings::getPassStore()).relativeFilePath(fileName);
×
652
    path.replace(Util::endsWithGpg(), "");
×
653
    Executor::executeBlocking(QtPassSettings::getGitExecutable(),
×
654
                              {"commit", pgit(fileName), "-m",
655
                               "Re-encrypt for " + path + " using QtPass."});
×
656
  }
657

658
  return true;
659
}
×
660

661
/**
662
 * @brief Create git backup commit before re-encryption.
663
 * @return true if backup created or not needed, false if backup failed.
664
 */
665
auto ImitatePass::createBackupCommit() -> bool {
×
666
  if (!QtPassSettings::isUseGit() ||
×
667
      QtPassSettings::getGitExecutable().isEmpty()) {
×
668
    return true;
669
  }
670
  emit statusMsg(tr("Creating backup commit"), 2000);
×
671
  const QString git = QtPassSettings::getGitExecutable();
×
672
  QString statusOut;
×
673
  if (Executor::executeBlocking(git, {"status", "--porcelain"}, &statusOut) !=
×
674
      0) {
675
    emit critical(
×
676
        tr("Backup commit failed"),
×
677
        tr("Could not inspect git status. Re-encryption was aborted."));
×
678
    return false;
×
679
  }
680
  if (!statusOut.trimmed().isEmpty()) {
×
681
    if (Executor::executeBlocking(git, {"add", "-A"}) != 0 ||
×
682
        Executor::executeBlocking(
×
683
            git, {"commit", "-m", "Backup before re-encryption"}) != 0) {
684
      emit critical(tr("Backup commit failed"),
×
685
                    tr("Re-encryption was aborted because a git backup could "
×
686
                       "not be created."));
687
      return false;
×
688
    }
689
  }
690
  return true;
691
}
×
692

693
/**
694
 * @brief Re-encrypts all `.gpg` files under the given directory using the
695
 *        verified GPG key configuration for each folder.
696
 *
697
 * This method optionally pulls the latest changes before starting, creates a
698
 * backup commit, verifies `.gpg-id` files per directory, and re-encrypts files
699
 * whose current recipients do not match the expected keys. It emits progress,
700
 * status, and error signals throughout the process, and optionally pushes the
701
 * updated password-store when finished.
702
 *
703
 * @param dir - Root directory to scan recursively for `.gpg` files.
704
 * @return void
705
 */
706
void ImitatePass::reencryptPath(const QString &dir) {
×
707
  emit statusMsg(tr("Re-encrypting from folder %1").arg(dir), 3000);
×
708
  emit startReencryptPath();
×
709
  if (QtPassSettings::isAutoPull()) {
×
710
    emit statusMsg(tr("Updating password-store"), 2000);
×
711
    GitPull_b();
×
712
  }
713

714
  // Create backup before re-encryption - abort if it fails
715
  if (!createBackupCommit()) {
×
716
    emit endReencryptPath();
×
717
    return;
×
718
  }
719

720
  QDir currentDir;
×
721
  QDirIterator gpgFiles(dir, QStringList() << "*.gpg", QDir::Files,
×
722
                        QDirIterator::Subdirectories);
×
723
  QStringList gpgIdFilesVerified;
×
724
  QStringList gpgId;
×
725
  int successCount = 0;
726
  int failCount = 0;
727
  while (gpgFiles.hasNext()) {
×
728
    QString fileName = gpgFiles.next();
×
729
    if (gpgFiles.fileInfo().path() != currentDir.path()) {
×
730
      if (!verifyGpgIdForDir(fileName, gpgIdFilesVerified, gpgId)) {
×
731
        emit endReencryptPath();
×
732
        return;
733
      }
734
      if (gpgId.isEmpty() && !gpgIdFilesVerified.isEmpty()) {
×
735
        emit critical(tr("GPG ID verification failed"),
×
736
                      tr("Could not verify .gpg-id for directory."));
×
737
        emit endReencryptPath();
×
738
        return;
739
      }
740
    }
741
    QStringList actualKeys = getKeysFromFile(fileName);
×
742
    if (actualKeys != gpgId) {
×
743
      if (reencryptSingleFile(fileName, gpgId)) {
×
744
        successCount++;
×
745
      } else {
746
        failCount++;
×
747
        emit critical(tr("Re-encryption failed"),
×
748
                      tr("Failed to re-encrypt %1").arg(fileName));
×
749
      }
750
    }
751
  }
752

753
  if (failCount > 0) {
×
754
    emit statusMsg(tr("Re-encryption completed: %1 succeeded, %2 failed")
×
755
                       .arg(successCount)
×
756
                       .arg(failCount),
×
757
                   5000);
758
  } else {
759
    emit statusMsg(
×
760
        tr("Re-encryption completed: %1 files re-encrypted").arg(successCount),
×
761
        3000);
762
  }
763

764
  if (QtPassSettings::isAutoPush()) {
×
765
    emit statusMsg(tr("Updating password-store"), 2000);
×
766
    GitPush();
×
767
  }
768
  emit endReencryptPath();
×
769
}
×
770

771
/**
772
 * @brief Resolves the final destination path for moving a file or directory,
773
 * applying .gpg handling for files.
774
 * @example
775
 * QString result = ImitatePass::resolveMoveDestination("/tmp/source.txt",
776
 * "/backup", false); std::cout << result.toStdString() << std::endl; //
777
 * Expected output sample: "/backup/source.txt.gpg"
778
 *
779
 * @param src - Source path to the file or directory.
780
 * @param dest - Requested destination path, which may be a file or directory.
781
 * @param force - When true, allows overwriting an existing destination file.
782
 * @return QString - Resolved destination path, or an empty QString if the
783
 * source/destination is invalid or conflicts occur.
784
 */
785
auto ImitatePass::resolveMoveDestination(const QString &src,
5✔
786
                                         const QString &dest, bool force)
787
    -> QString {
788
  QFileInfo srcFileInfo(src);
5✔
789
  QFileInfo destFileInfo(dest);
5✔
790
  QString destFile;
5✔
791
  QString srcFileBaseName = srcFileInfo.fileName();
5✔
792

793
  if (srcFileInfo.isFile()) {
5✔
794
    if (destFileInfo.isFile()) {
4✔
795
      if (!force) {
2✔
796
#ifdef QT_DEBUG
797
        dbg() << "Destination file already exists";
798
#endif
799
        return QString();
800
      }
801
      destFile = dest;
1✔
802
    } else if (destFileInfo.isDir()) {
2✔
803
      destFile = QDir(dest).filePath(srcFileBaseName);
2✔
804
    } else {
805
      destFile = dest;
1✔
806
    }
807

808
    if (destFile.endsWith(".gpg", Qt::CaseInsensitive)) {
6✔
809
      destFile.chop(4);
3✔
810
    }
811
    destFile.append(".gpg");
3✔
812
  } else if (srcFileInfo.isDir()) {
1✔
813
    if (destFileInfo.isDir()) {
×
814
      destFile = QDir(dest).filePath(srcFileBaseName);
×
815
    } else if (destFileInfo.isFile()) {
×
816
#ifdef QT_DEBUG
817
      dbg() << "Destination is a file";
818
#endif
819
      return QString();
820
    } else {
821
      destFile = dest;
×
822
    }
823
  } else {
824
#ifdef QT_DEBUG
825
    dbg() << "Source file does not exist";
826
#endif
827
    return QString();
828
  }
829
  return destFile;
830
}
5✔
831

832
/**
833
 * @brief Moves a password store item in the Git repository and commits the
834
 * change.
835
 * @example
836
 * void result = className.executeMoveGit(src, destFile, force);
837
 *
838
 * @param const QString &src - Source path of the item to move.
839
 * @param const QString &destFile - Destination path of the item after the move.
840
 * @param bool force - Whether to force the move using Git's -f option.
841
 * @return void - This method does not return a value.
842
 */
843
void ImitatePass::executeMoveGit(const QString &src, const QString &destFile,
×
844
                                 bool force) {
845
  QStringList args;
×
846
  args << "mv";
×
847
  if (force) {
×
848
    args << "-f";
×
849
  }
850
  args << pgit(src);
×
851
  args << pgit(destFile);
×
852
  executeGit(GIT_MOVE, args);
×
853

854
  QString relSrc = QDir(QtPassSettings::getPassStore()).relativeFilePath(src);
×
855
  relSrc.replace(Util::endsWithGpg(), "");
×
856
  QString relDest =
857
      QDir(QtPassSettings::getPassStore()).relativeFilePath(destFile);
×
858
  relDest.replace(Util::endsWithGpg(), "");
×
859
  QString message = QString("Moved for %1 to %2 using QtPass.");
×
860
  message = message.arg(relSrc, relDest);
×
861
  gitCommit("", message);
×
862
}
×
863

864
/**
865
 * @brief Moves a password entry from the source path to the destination path.
866
 * @example
867
 * ImitatePass::Move(src, dest, true);
868
 *
869
 * @param const QString src - The source path or entry name to move.
870
 * @param const QString dest - The destination path or entry name.
871
 * @param const bool force - If true, overwrites an existing destination entry
872
 * when necessary.
873
 * @return void - This function does not return a value.
874
 */
875
void ImitatePass::Move(const QString src, const QString dest,
×
876
                       const bool force) {
877
  transactionHelper trans(this, PASS_MOVE);
×
878
  QString destFile = resolveMoveDestination(src, dest, force);
×
879
  if (destFile.isEmpty()) {
×
880
    return;
881
  }
882

883
#ifdef QT_DEBUG
884
  dbg() << "Move Source: " << src;
885
  dbg() << "Move Destination: " << destFile;
886
#endif
887

888
  if (QtPassSettings::isUseGit()) {
×
889
    executeMoveGit(src, destFile, force);
×
890
  } else {
891
    QDir qDir;
×
892
    if (force) {
×
893
      qDir.remove(destFile);
×
894
    }
895
    qDir.rename(src, destFile);
×
896
  }
×
897
}
898

899
/**
900
 * @brief Copies a file or directory from source to destination, optionally
901
 * forcing overwrite.
902
 * @example
903
 * void result = ImitatePass::Copy(src, dest, force);
904
 *
905
 * @param QString src - Source path to copy from.
906
 * @param QString dest - Destination path to copy to.
907
 * @param bool force - If true, overwrites the destination when it already
908
 * exists.
909
 * @return void - This function does not return a value.
910
 */
911
void ImitatePass::Copy(const QString src, const QString dest,
×
912
                       const bool force) {
913
  QFileInfo destFileInfo(dest);
×
914
  transactionHelper trans(this, PASS_COPY);
×
915
  if (QtPassSettings::isUseGit()) {
×
916
    QStringList args;
×
917
    args << "cp";
×
918
    if (force) {
×
919
      args << "-f";
×
920
    }
921
    args << pgit(src);
×
922
    args << pgit(dest);
×
923
    executeGit(GIT_COPY, args);
×
924

925
    QString message = QString("Copied from %1 to %2 using QtPass.");
×
926
    message = message.arg(src, dest);
×
927
    gitCommit("", message);
×
928
  } else {
929
    QDir qDir;
×
930
    if (force) {
×
931
      qDir.remove(dest);
×
932
    }
933
    QFile::copy(src, dest);
×
934
  }
×
935
  // reecrypt all files under the new folder
936
  if (destFileInfo.isDir()) {
×
937
    reencryptPath(destFileInfo.absoluteFilePath());
×
938
  } else if (destFileInfo.isFile()) {
×
939
    reencryptPath(destFileInfo.dir().path());
×
940
  }
941
}
×
942

943
/**
944
 * @brief ImitatePass::executeGpg easy wrapper for running gpg commands
945
 * @param args
946
 */
947
void ImitatePass::executeGpg(PROCESS id, const QStringList &args, QString input,
×
948
                             bool readStdout, bool readStderr) {
949
  executeWrapper(id, QtPassSettings::getGpgExecutable(), args, std::move(input),
×
950
                 readStdout, readStderr);
951
}
×
952

953
/**
954
 * @brief ImitatePass::executeGit easy wrapper for running git commands
955
 * @param args
956
 */
957
void ImitatePass::executeGit(PROCESS id, const QStringList &args, QString input,
×
958
                             bool readStdout, bool readStderr) {
959
  executeWrapper(id, QtPassSettings::getGitExecutable(), args, std::move(input),
×
960
                 readStdout, readStderr);
961
}
×
962

963
/**
964
 * @brief ImitatePass::finished this function is overloaded to ensure
965
 *                              identical behaviour to RealPass ie. only PASS_*
966
 *                              processes are visible inside Pass::finish, so
967
 *                              that interface-wise it all looks the same
968
 * @param id
969
 * @param exitCode
970
 * @param out
971
 * @param err
972
 */
973
void ImitatePass::finished(int id, int exitCode, const QString &out,
×
974
                           const QString &err) {
975
#ifdef QT_DEBUG
976
  dbg() << "Imitate Pass";
977
#endif
978
  static QString transactionOutput;
×
979
  PROCESS pid = transactionIsOver(static_cast<PROCESS>(id));
×
980
  transactionOutput.append(out);
×
981

982
  if (exitCode == 0) {
×
983
    if (pid == INVALID) {
×
984
      return;
985
    }
986
  } else {
987
    while (pid == INVALID) {
×
988
      id = exec.cancelNext();
×
989
      if (id == -1) {
×
990
        //  this is probably irrecoverable and shall not happen
991
#ifdef QT_DEBUG
992
        dbg() << "No such transaction!";
993
#endif
994
        return;
995
      }
996
      pid = transactionIsOver(static_cast<PROCESS>(id));
×
997
    }
998
  }
999
  Pass::finished(pid, exitCode, transactionOutput, err);
×
1000
  transactionOutput.clear();
×
1001
}
1002

1003
/**
1004
 * @brief executeWrapper    overrided so that every execution is a transaction
1005
 * @param id
1006
 * @param app
1007
 * @param args
1008
 * @param input
1009
 * @param readStdout
1010
 * @param readStderr
1011
 */
1012
void ImitatePass::executeWrapper(PROCESS id, const QString &app,
×
1013
                                 const QStringList &args, QString input,
1014
                                 bool readStdout, bool readStderr) {
1015
  transactionAdd(id);
×
1016
  Pass::executeWrapper(id, app, args, input, readStdout, readStderr);
×
1017
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc