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

IJHack / QtPass / 42702176309

09 Nov 2026 09:24PM UTC coverage: 92.727% (+0.02%) from 92.705%
42702176309

push

github

web-flow
Bind a signed .gpg-id to its store, not only to its folder (#2124)

* Bind a signed .gpg-id to its store, not only to its folder

#2050: one signing key may sign the recipient lists of several stores,
and an authentic list of one, copied into another at the same folder,
verified there too; with a higher generation it was accepted, and QtPass
encrypted that store's entries to the other store's recipients.

A list QtPass writes for a signed store now carries a third signed
comment line, "# QtPass-GpgId-Store: <id>", a random 128-bit id given
when the store's first signed list is written. Each device remembers the
id it first saw per store in the generation record (outside the store,
next to the generations; format 1 still, older readers ignore the new
"stores" object), and GpgIdGeneration::accept() refuses, as WrongStore:

- a list whose store line names another id, before the staleness check,
  so another store's older list never reaches the Users dialog as this
  folder's rollback;
- a new list without a store line once the store has an id. A list this
  device accepted before keeps working, and a headerless list (pass) is
  as before.

Writers take the id from GpgIdGeneration::storeIdFor(): the one this
device knows, else the one on the verified list being replaced (a device
joining a store takes its id over), else a new one. SECURITY.md has the
binding and its limits (first sight, pass-written lists); the CHANGELOG's
upgrade notes ask signed teams to update together, since a list saved by
2.0-rc3 or older is refused once a device knows the store's id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Anne Jan Brouwer <brouwer@annejan.com>

* chore: update localization source references

make lupdate for the store-binding messages; location lines move only
for the files this branch changes, build-generated ones stripped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Anne Jan Brouwer <brouwer@annejan.com... (continued)

129 of 136 new or added lines in 3 files covered. (94.85%)

2 existing lines in 1 file now uncovered.

10391 of 11206 relevant lines covered (92.73%)

403.37 hits per line

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

97.87
/src/profileinit.cpp
1
// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "profileinit.h"
4
#include "appsettings.h"
5
#include "executor.h"
6
#include "gpgidgeneration.h"
7
#include "gpgidsigner.h"
8
#include "qtpasslogging.h"
9
#include "userinfo.h"
10
#include "util.h"
11
#include <QDir>
12
#include <QFile>
13
#include <QFileInfo>
14
#include <QProcess>
15
#include <algorithm>
16

17
namespace {
18
/// @p env with the configured GPG home applied as Pass::init() applies it:
19
/// GNUPGHOME is that folder when it exists; otherwise the inherited one
20
/// stays. The new store's .gpg-id is signed, and a signed commit made, with
21
/// the keyring QtPass uses everywhere else, where the signing key may be
22
/// the only place it is.
23
auto withGpgHome(QProcessEnvironment env, const AppSettings &s)
236 ✔
24
    -> QProcessEnvironment {
25
  if (!s.gpgHome.isEmpty()) {
236 ✔
26
    QDir home(s.gpgHome);
11 ✔
27
    home.makeAbsolute();
11 ✔
28
    if (home.exists()) {
11 ✔
29
      env.insert(QStringLiteral("GNUPGHOME"), home.path());
16 ✔
30
    }
31
  }
11 ✔
32
  return env;
236 ✔
33
}
34

35
/// As Pass::updateEnv() sets it for the active store: git in @p dir does not
36
/// look above it, so neither the identity check nor a commit reads or uses a
37
/// repository the new store merely lies in (a dotfiles one in the home
38
/// folder), unless the user opted into that; and paths are names to it, not
39
/// patterns.
40
auto gitEnvironment(const QString &dir, const AppSettings &s)
26 ✔
41
    -> QProcessEnvironment {
42
  QProcessEnvironment env =
43
      withGpgHome(QProcessEnvironment::systemEnvironment(), s);
26 ✔
44
  // As for the active store: the paths handed to git are names, not
45
  // patterns (see Pass::updateEnv()).
46
  env.insert(QStringLiteral("GIT_LITERAL_PATHSPECS"), QStringLiteral("1"));
52 ✔
47
  if (!s.gitUseParentRepository) {
26 ✔
48
    for (const auto &[name, value] :
49
         Util::gitStoreVariables(dir, s.gitExecutable)) {
75 ✔
50
      env.insert(name, value);
25 ✔
51
    }
52
  }
53
  return env;
26 ✔
54
}
×
55
/// "commit", and "-S" when git config pass.signcommits is true for the
56
/// repository @p git runs in (a new one: so the user's own git config), as
57
/// pass signs its commits.
58
auto commitArgs(QProcess &git, const AppSettings &s) -> QStringList {
7 ✔
59
  QString out;
7 ✔
60
  const bool sign =
61
      Executor::executeBlocking(
42 ✔
62
          git, s.gitExecutable,
7 ✔
63
          {QStringLiteral("config"), QStringLiteral("--bool"),
7 ✔
64
           QStringLiteral("--get"), QStringLiteral("pass.signcommits")},
7 ✔
65
          QString(), &out, nullptr) == 0 &&
7 ✔
66
      out.trimmed() == QLatin1String("true");
7 ✔
67
  return sign ? QStringList{QStringLiteral("commit"), QStringLiteral("-S")}
14 ✔
68
              : QStringList{QStringLiteral("commit")};
21 ✔
69
}
70
} // namespace
71

72
auto ProfileInit::gpgEnvironment(const AppSettings &s) -> QProcessEnvironment {
201 ✔
73
  return withGpgHome(QProcessEnvironment::systemEnvironment(), s);
201 ✔
74
}
75

76
auto ProfileInit::needsInit(const QString &path) -> bool {
11 ✔
77
  if (path.isEmpty()) {
11 ✔
78
    return false;
79
  }
80
  QDir dir(path);
10 ✔
81
  if (!dir.exists()) {
10 ✔
82
    return false;
83
  }
84
  return !dir.exists(".gpg-id");
9 ✔
85
}
10 ✔
86

87
auto ProfileInit::initialise(const QString &dir, const QList<UserInfo> &users,
26 ✔
88
                             const AppSettings &s, bool useGit, QString *note)
89
    -> bool {
90
  QString scratch;
26 ✔
91
  QString &out = note != nullptr ? *note : scratch;
26 ✔
92
  out.clear();
26 ✔
93

94
  const QDir folder(dir);
26 ✔
95
  if (!folder.exists() && !Util::mkpathForStore(folder.absolutePath())) {
32 ✔
96
    out = tr("Could not create %1.").arg(folder.absolutePath());
2 ✔
97
    return false;
1 ✔
98
  }
99
  const QString gpgIdFile = folder.filePath(QStringLiteral(".gpg-id"));
50 ✔
100
  QByteArray written;
25 ✔
101
  if (!writeGpgId(gpgIdFile, users, !s.passSigningKey.trimmed().isEmpty(), &out,
50 ✔
102
                  &written)) {
103
    return false;
104
  }
105
  QString sigFile;
22 ✔
106
  if (!s.passSigningKey.trimmed().isEmpty()) {
22 ✔
107
    if (!signGpgId(gpgIdFile, written, s, &out)) {
6 ✔
108
      return false;
109
    }
110
    sigFile = gpgIdFile + QStringLiteral(".sig");
4 ✔
111
  }
112
  if (useGit &&
23 ✔
113
      !commitGpgId(folder.absolutePath(), gpgIdFile, sigFile, s, &out)) {
23 ✔
114
    return false;
115
  }
116

117
  // Entries in sub-folders too: a store taken over whole has most of them
118
  // there. Visible regular files only, not through links, .git not walked.
119
  // Not those under a sub-folder with a .gpg-id of its own: its recipients
120
  // did not change here, and Users on the profile's root leaves it alone,
121
  // as pass does.
122
  const QString root = QDir::cleanPath(folder.absolutePath());
16 ✔
123
  const bool viaPass = s.usePass;
16 ✔
124
  const QString rootList =
125
      QFileInfo(QDir(root).filePath(QStringLiteral(".gpg-id")))
48 ✔
126
          .canonicalFilePath();
16 ✔
127
  const auto underSubStore = [&root, &rootList, viaPass](const QString &entry) {
17 ✔
128
    QString dir = QFileInfo(entry).absolutePath();
17 ✔
129
    while (dir != root && dir.startsWith(root + QLatin1Char('/'))) {
36 ✔
130
      // A list of its own: a regular file. A linked .gpg-id is no list to
131
      // the native backend, as everywhere in QtPass, so its folder's
132
      // entries still count; pass follows the link (`-f`) and keeps that
133
      // folder on the linked list, so with it they do not, unless the link
134
      // leads to the root's own list: that folder follows the root, and
135
      // Users on it re-encrypts them.
136
      const QFileInfo list(QDir(dir).filePath(QStringLiteral(".gpg-id")));
24 ✔
137
      if (viaPass && list.isFile() && !rootList.isEmpty() &&
12 ✔
138
          list.canonicalFilePath() == rootList) {
15 ✔
139
        return false;
140
      }
141
      if (list.isFile() &&
11 ✔
142
          (viaPass || (!list.isSymLink() && !list.isJunction()))) {
5 ✔
143
        return true;
4 ✔
144
      }
145
      dir = QFileInfo(dir).absolutePath();
14 ✔
146
    }
12 ✔
147
    return false;
148
  };
16 ✔
149
  QStringList existing =
150
      Util::regularFilesUnder(root, {QStringLiteral("*.gpg")});
48 ✔
151
  existing.erase(
32 ✔
152
      std::remove_if(existing.begin(), existing.end(), underSubStore),
153
      existing.end());
154
  if (!existing.isEmpty()) {
16 ✔
155
    out = tr("%1 already contains %n encrypted file(s); they were not "
9 ✔
156
             "re-encrypted. Switch to the profile and open Users to do that.",
157
             nullptr, static_cast<int>(existing.size()))
158
              .arg(folder.absolutePath());
18 ✔
159
  }
160
  return true;
161
}
26 ✔
162

163
auto ProfileInit::writeGpgId(const QString &gpgIdFile,
25 ✔
164
                             const QList<UserInfo> &users, bool signed_,
165
                             QString *note, QByteArray *written) -> bool {
166
  QStringList ids;
25 ✔
167
  for (const UserInfo &user : users) {
71 ✔
168
    if (user.enabled) {
46 ✔
169
      ids << user.key_id;
24 ✔
170
    }
171
  }
172
  if (ids.isEmpty()) {
25 ✔
173
    *note = tr("No recipient selected; %1 was not written.").arg(gpgIdFile);
2 ✔
174
    return false;
1 ✔
175
  }
176
  // As ImitatePass::writeGpgIdFile: renamed into place whole, so an
177
  // interrupted write leaves no half .gpg-id to sign or use. Owner-only: the
178
  // list leaks which keys the store is encrypted to.
179
  QByteArray contents =
180
      (ids.join(QLatin1Char('\n')) + QLatin1Char('\n')).toUtf8();
24 ✔
181
  if (signed_) {
24 ✔
182
    // Same rule as ImitatePass::writeGpgIdFile: reserved and recorded
183
    // before the write, bound to the store root (GpgIdGeneration). A new
184
    // profile has no list on disk to take a generation from.
185
    QString why;
7 ✔
186
    const std::optional<QString> store = GpgIdGeneration::storeIdFor(
187
        QFileInfo(gpgIdFile).absolutePath(), std::nullopt, &why);
14 ✔
188
    if (!store) {
7 ✔
NEW
189
      *note = why;
×
NEW
190
      return false;
×
191
    }
192
    const std::optional<qint64> generation =
193
        GpgIdGeneration::reserveNext(gpgIdFile, std::nullopt, &why);
7 ✔
194
    if (!generation) {
7 ✔
195
      *note = why;
1 ✔
196
      return false;
1 ✔
197
    }
198
    contents = GpgIdGeneration::withHeader(*generation, QStringLiteral("."),
12 ✔
199
                                           contents, *store);
200
  }
201
  QString why;
23 ✔
202
  if (!Util::writeFileReplacing(gpgIdFile, contents, true, &why)) {
23 ✔
203
    *note = why;
1 ✔
204
    return false;
1 ✔
205
  }
206
  if (written != nullptr) {
22 ✔
207
    *written = contents;
22 ✔
208
  }
209
  if (signed_) {
22 ✔
210
    // Same as ImitatePass::writeGpgIdFile: the bytes written are the ones
211
    // recognised; not fatal, the first read digests them otherwise.
212
    QString why;
6 ✔
213
    if (!GpgIdGeneration::recordWritten(gpgIdFile, contents, &why)) {
6 ✔
214
      qCWarning(lcQtPass) << "Could not record the written .gpg-id:" << why;
×
215
    }
216
  }
217
  return true;
218
}
219

220
auto ProfileInit::signGpgId(const QString &gpgIdFile,
6 ✔
221
                            const QByteArray &contents, const AppSettings &s,
222
                            QString *note) -> bool {
223
  // In the configured GPG home, as ImitatePass's signer runs in the store's
224
  // environment.
225
  const GpgIdSigner signer(
226
      s.gpgExecutable, GpgIdSigner::keysFromSetting(s.passSigningKey),
12 ✔
227
      [&s](const QString &app, const QStringList &args, const QString &input,
9 ✔
228
           QString *out, QString *err) {
229
        QProcess gpg;
9 ✔
230
        gpg.setProcessEnvironment(
9 ✔
231
            withGpgHome(QProcessEnvironment::systemEnvironment(), s));
18 ✔
232
        return Executor::executeBlocking(gpg, app, args, input, out, err);
18 ✔
233
      });
21 ✔
234
  QString err;
6 ✔
235
  if (!signer.sign(gpgIdFile, contents, &err)) {
6 ✔
236
    *note = err.trimmed().isEmpty()
4 ✔
237
                ? tr("Could not sign %1 with %2.")
12 ✔
238
                      .arg(gpgIdFile, signer.keys().first())
239
                : tr("Could not sign %1 with %2: %3")
4 ✔
240
                      .arg(gpgIdFile, signer.keys().first(), err.trimmed());
7 ✔
241
    // withGpgHome() fell back to the inherited home: gpg's "no secret key"
242
    // would not say that the configured one is missing.
243
    if (!s.gpgHome.isEmpty()) {
4 ✔
244
      QDir home(s.gpgHome);
1 ✔
245
      home.makeAbsolute();
1 ✔
246
      if (!home.exists()) {
1 ✔
247
        *note +=
248
            QLatin1Char(' ') +
2 ✔
249
            tr("The configured GPG home %1 does not exist.").arg(home.path());
2 ✔
250
      }
251
    }
1 ✔
252
    return false;
4 ✔
253
  }
254
  return true;
255
}
6 ✔
256

257
auto ProfileInit::commitGpgId(const QString &dir, const QString &gpgIdFile,
5 ✔
258
                              const QString &sigFile, const AppSettings &s,
259
                              QString *note) -> bool {
260
  // A process of our own in the profile directory: nothing here may touch
261
  // the shared Executor or PASSWORD_STORE_DIR of the active store.
262
  QProcess git;
5 ✔
263
  git.setWorkingDirectory(dir);
5 ✔
264
  git.setProcessEnvironment(gitEnvironment(dir, s));
5 ✔
265
  auto run = [&](const QStringList &args) -> bool {
11 ✔
266
    QString err;
11 ✔
267
    const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
11 ✔
268
                                             QString(), nullptr, &err);
11 ✔
269
    if (rc != 0) {
11 ✔
270
      *note =
271
          tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
4 ✔
272
      return false;
2 ✔
273
    }
274
    return true;
275
  };
5 ✔
276
  QStringList files{QFileInfo(gpgIdFile).fileName()};
15 ✔
277
  if (!sigFile.isEmpty()) {
5 ✔
278
    files << QFileInfo(sigFile).fileName();
4 ✔
279
  }
280
  const QStringList add =
281
      QStringList{QStringLiteral("add"), QStringLiteral("--")} + files;
20 ✔
282
  if (!run({QStringLiteral("init")}) || !run(add)) {
15 ✔
283
    return false;
284
  }
285
  return run(commitArgs(git, s) +
9 ✔
286
             QStringList{QStringLiteral("-m"),
15 ✔
287
                         QStringLiteral("Added .gpg-id using QtPass."),
3 ✔
288
                         QStringLiteral("--")} +
15 ✔
289
             files);
290
}
10 ✔
291

292
auto ProfileInit::initGit(const QString &dir, const AppSettings &s,
6 ✔
293
                          QString *note) -> bool {
294
  QProcess git;
6 ✔
295
  git.setWorkingDirectory(dir);
6 ✔
296
  git.setProcessEnvironment(gitEnvironment(dir, s));
6 ✔
297
  auto run = [&](const QStringList &args) -> bool {
14 ✔
298
    QString err;
14 ✔
299
    const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
14 ✔
300
                                             QString(), nullptr, &err);
14 ✔
301
    if (rc != 0) {
14 ✔
302
      *note =
303
          tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
6 ✔
304
      return false;
3 ✔
305
    }
306
    return true;
307
  };
6 ✔
308
  // Stage only store files (as the re-encryption backup commit does): an
309
  // existing folder may hold plaintext exports or swap files. Regular files
310
  // only: a link or junction must not have its target staged.
311
  QStringList files;
6 ✔
312
  const QDir base(dir);
6 ✔
313
  // Hidden directories (.git among them) are not walked, hidden files are:
314
  // .gpg-id is one.
315
  const QStringList found = Util::regularFilesUnder(
18 ✔
316
      dir,
317
      {QStringLiteral("*.gpg"), QStringLiteral(".gpg-id"),
6 ✔
318
       QStringLiteral(".gpg-id.sig")},
6 ✔
319
      nullptr, true);
36 ✔
320
  for (const QString &path : found) {
13 ✔
321
    files << base.relativeFilePath(path);
14 ✔
322
  }
323
  if (!run({QStringLiteral("init")})) {
18 ✔
324
    return false;
325
  }
326
  if (!files.isEmpty() &&
9 ✔
327
      !run(QStringList{QStringLiteral("add"), QStringLiteral("--")} + files)) {
29 ✔
328
    return false;
329
  }
330
  return run(commitArgs(git, s) +
12 ✔
331
             QStringList{QStringLiteral("--allow-empty"), QStringLiteral("-m"),
24 ✔
332
                         QStringLiteral("Added password store using QtPass.")});
16 ✔
333
}
12 ✔
334

335
auto ProfileInit::gitIdentityConfigured(const QString &dir,
15 ✔
336
                                        const AppSettings &s) -> bool {
337
  QProcess git;
15 ✔
338
  git.setWorkingDirectory(dir);
15 ✔
339
  git.setProcessEnvironment(gitEnvironment(dir, s));
15 ✔
340
  for (const char *key : {"user.name", "user.email"}) {
34 ✔
341
    QString out;
25 ✔
342
    if (Executor::executeBlocking(git, s.gitExecutable,
125 ✔
343
                                  {QStringLiteral("config"),
25 ✔
344
                                   QStringLiteral("--get"),
25 ✔
345
                                   QString::fromLatin1(key)},
346
                                  QString(), &out, nullptr) != 0 ||
69 ✔
347
        out.trimmed().isEmpty()) {
25 ✔
348
      return false;
349
    }
350
  }
351
  return true;
9 ✔
352
}
65 ✔
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