Line data Source code
1 : // SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements ProfileInit; the environments its gpg and git processes
6 : * run in are built by withGpgHome() and gitEnvironment() here.
7 : */
8 : #include "profileinit.h"
9 : #include "appsettings.h"
10 : #include "executor.h"
11 : #include "gpgidgeneration.h"
12 : #include "gpgidsigner.h"
13 : #include "qtpasslogging.h"
14 : #include "userinfo.h"
15 : #include "util.h"
16 : #include <QDir>
17 : #include <QFile>
18 : #include <QFileInfo>
19 : #include <QProcess>
20 : #include <algorithm>
21 :
22 : namespace {
23 : /// @p env with the configured GPG home applied as Pass::init() applies it:
24 : /// GNUPGHOME is that folder when it exists; otherwise the inherited one
25 : /// stays. The new store's .gpg-id is signed, and a signed commit made, with
26 : /// the keyring QtPass uses everywhere else, where the signing key may be
27 : /// the only place it is.
28 236 : auto withGpgHome(QProcessEnvironment env, const AppSettings &s)
29 : -> QProcessEnvironment {
30 236 : if (!s.gpgHome.isEmpty()) {
31 11 : QDir home(s.gpgHome);
32 11 : home.makeAbsolute();
33 11 : if (home.exists()) {
34 16 : env.insert(QStringLiteral("GNUPGHOME"), home.path());
35 : }
36 11 : }
37 236 : return env;
38 : }
39 :
40 : /// As Pass::updateEnv() sets it for the active store: git in @p dir does not
41 : /// look above it, so neither the identity check nor a commit reads or uses a
42 : /// repository the new store merely lies in (a dotfiles one in the home
43 : /// folder), unless the user opted into that; and paths are names to it, not
44 : /// patterns.
45 26 : auto gitEnvironment(const QString &dir, const AppSettings &s)
46 : -> QProcessEnvironment {
47 : QProcessEnvironment env =
48 26 : withGpgHome(QProcessEnvironment::systemEnvironment(), s);
49 : // As for the active store: the paths handed to git are names, not
50 : // patterns (see Pass::updateEnv()).
51 52 : env.insert(QStringLiteral("GIT_LITERAL_PATHSPECS"), QStringLiteral("1"));
52 26 : if (!s.gitUseParentRepository) {
53 : for (const auto &[name, value] :
54 75 : Util::gitStoreVariables(dir, s.gitExecutable)) {
55 25 : env.insert(name, value);
56 : }
57 : }
58 26 : return env;
59 0 : }
60 : /// "commit", and "-S" when git config pass.signcommits is true for the
61 : /// repository @p git runs in (a new one: so the user's own git config), as
62 : /// pass signs its commits.
63 7 : auto commitArgs(QProcess &git, const AppSettings &s) -> QStringList {
64 7 : QString out;
65 : const bool sign =
66 42 : Executor::executeBlocking(
67 7 : git, s.gitExecutable,
68 7 : {QStringLiteral("config"), QStringLiteral("--bool"),
69 7 : QStringLiteral("--get"), QStringLiteral("pass.signcommits")},
70 14 : QString(), &out, nullptr) == 0 &&
71 10 : out.trimmed() == QLatin1String("true");
72 14 : return sign ? QStringList{QStringLiteral("commit"), QStringLiteral("-S")}
73 21 : : QStringList{QStringLiteral("commit")};
74 : }
75 : } // namespace
76 :
77 201 : auto ProfileInit::gpgEnvironment(const AppSettings &s) -> QProcessEnvironment {
78 201 : return withGpgHome(QProcessEnvironment::systemEnvironment(), s);
79 : }
80 :
81 11 : auto ProfileInit::needsInit(const QString &path) -> bool {
82 11 : if (path.isEmpty()) {
83 : return false;
84 : }
85 10 : QDir dir(path);
86 10 : if (!dir.exists()) {
87 : return false;
88 : }
89 9 : return !dir.exists(".gpg-id");
90 10 : }
91 :
92 26 : auto ProfileInit::initialise(const QString &dir, const QList<UserInfo> &users,
93 : const AppSettings &s, bool useGit, QString *note)
94 : -> bool {
95 26 : QString scratch;
96 26 : QString &out = note != nullptr ? *note : scratch;
97 26 : out.clear();
98 :
99 26 : const QDir folder(dir);
100 32 : if (!folder.exists() && !Util::mkpathForStore(folder.absolutePath())) {
101 2 : out = tr("Could not create %1.").arg(folder.absolutePath());
102 1 : return false;
103 : }
104 50 : const QString gpgIdFile = folder.filePath(QStringLiteral(".gpg-id"));
105 25 : QByteArray written;
106 50 : if (!writeGpgId(gpgIdFile, users, !s.passSigningKey.trimmed().isEmpty(), &out,
107 : &written)) {
108 : return false;
109 : }
110 22 : QString sigFile;
111 22 : if (!s.passSigningKey.trimmed().isEmpty()) {
112 6 : if (!signGpgId(gpgIdFile, written, s, &out)) {
113 : return false;
114 : }
115 4 : sigFile = gpgIdFile + QStringLiteral(".sig");
116 : }
117 23 : if (useGit &&
118 23 : !commitGpgId(folder.absolutePath(), gpgIdFile, sigFile, s, &out)) {
119 : return false;
120 : }
121 :
122 : // Entries in sub-folders too: a store taken over whole has most of them
123 : // there. Visible regular files only, not through links, .git not walked.
124 : // Not those under a sub-folder with a .gpg-id of its own: its recipients
125 : // did not change here, and Users on the profile's root leaves it alone,
126 : // as pass does.
127 16 : const QString root = QDir::cleanPath(folder.absolutePath());
128 16 : const bool viaPass = s.usePass;
129 : const QString rootList =
130 48 : QFileInfo(QDir(root).filePath(QStringLiteral(".gpg-id")))
131 16 : .canonicalFilePath();
132 17 : const auto underSubStore = [&root, &rootList, viaPass](const QString &entry) {
133 17 : QString dir = QFileInfo(entry).absolutePath();
134 36 : while (dir != root && dir.startsWith(root + QLatin1Char('/'))) {
135 : // A list of its own: a regular file. A linked .gpg-id is no list to
136 : // the native backend, as everywhere in QtPass, so its folder's
137 : // entries still count; pass follows the link (`-f`) and keeps that
138 : // folder on the linked list, so with it they do not, unless the link
139 : // leads to the root's own list: that folder follows the root, and
140 : // Users on it re-encrypts them.
141 24 : const QFileInfo list(QDir(dir).filePath(QStringLiteral(".gpg-id")));
142 12 : if (viaPass && list.isFile() && !rootList.isEmpty() &&
143 15 : list.canonicalFilePath() == rootList) {
144 : return false;
145 : }
146 11 : if (list.isFile() &&
147 5 : (viaPass || (!list.isSymLink() && !list.isJunction()))) {
148 4 : return true;
149 : }
150 14 : dir = QFileInfo(dir).absolutePath();
151 12 : }
152 : return false;
153 16 : };
154 : QStringList existing =
155 48 : Util::regularFilesUnder(root, {QStringLiteral("*.gpg")});
156 16 : existing.erase(
157 32 : std::remove_if(existing.begin(), existing.end(), underSubStore),
158 16 : existing.end());
159 16 : if (!existing.isEmpty()) {
160 9 : out = tr("%1 already contains %n encrypted file(s); they were not "
161 : "re-encrypted. Switch to the profile and open Users to do that.",
162 : nullptr, static_cast<int>(existing.size()))
163 18 : .arg(folder.absolutePath());
164 : }
165 : return true;
166 26 : }
167 :
168 25 : auto ProfileInit::writeGpgId(const QString &gpgIdFile,
169 : const QList<UserInfo> &users, bool signed_,
170 : QString *note, QByteArray *written) -> bool {
171 25 : QStringList ids;
172 71 : for (const UserInfo &user : users) {
173 46 : if (user.enabled) {
174 24 : ids << user.key_id;
175 : }
176 : }
177 25 : if (ids.isEmpty()) {
178 2 : *note = tr("No recipient selected; %1 was not written.").arg(gpgIdFile);
179 1 : return false;
180 : }
181 : // As ImitatePass::writeGpgIdFile: renamed into place whole, so an
182 : // interrupted write leaves no half .gpg-id to sign or use. Owner-only: the
183 : // list leaks which keys the store is encrypted to.
184 : QByteArray contents =
185 24 : (ids.join(QLatin1Char('\n')) + QLatin1Char('\n')).toUtf8();
186 24 : if (signed_) {
187 : // Same rule as ImitatePass::writeGpgIdFile: reserved and recorded
188 : // before the write, bound to the store root (GpgIdGeneration). A new
189 : // profile has no list on disk to take a generation from.
190 7 : QString why;
191 : const std::optional<QString> store = GpgIdGeneration::storeIdFor(
192 14 : QFileInfo(gpgIdFile).absolutePath(), std::nullopt, &why);
193 7 : if (!store) {
194 0 : *note = why;
195 0 : return false;
196 : }
197 : const std::optional<qint64> generation =
198 7 : GpgIdGeneration::reserveNext(gpgIdFile, std::nullopt, &why);
199 7 : if (!generation) {
200 1 : *note = why;
201 1 : return false;
202 : }
203 12 : contents = GpgIdGeneration::withHeader(*generation, QStringLiteral("."),
204 : contents, *store);
205 : }
206 23 : QString why;
207 23 : if (!Util::writeFileReplacing(gpgIdFile, contents, true, &why)) {
208 1 : *note = why;
209 1 : return false;
210 : }
211 22 : if (written != nullptr) {
212 22 : *written = contents;
213 : }
214 22 : if (signed_) {
215 : // Same as ImitatePass::writeGpgIdFile: the bytes written are the ones
216 : // recognised; not fatal, the first read digests them otherwise.
217 6 : QString why;
218 6 : if (!GpgIdGeneration::recordWritten(gpgIdFile, contents, &why)) {
219 0 : qCWarning(lcQtPass) << "Could not record the written .gpg-id:" << why;
220 : }
221 : }
222 : return true;
223 : }
224 :
225 6 : auto ProfileInit::signGpgId(const QString &gpgIdFile,
226 : const QByteArray &contents, const AppSettings &s,
227 : QString *note) -> bool {
228 : // In the configured GPG home, as ImitatePass's signer runs in the store's
229 : // environment.
230 : const GpgIdSigner signer(
231 12 : s.gpgExecutable, GpgIdSigner::keysFromSetting(s.passSigningKey),
232 15 : [&s](const QString &app, const QStringList &args, const QString &input,
233 : QString *out, QString *err) {
234 9 : QProcess gpg;
235 9 : gpg.setProcessEnvironment(
236 18 : withGpgHome(QProcessEnvironment::systemEnvironment(), s));
237 18 : return Executor::executeBlocking(gpg, app, args, input, out, err);
238 21 : });
239 6 : QString err;
240 6 : if (!signer.sign(gpgIdFile, contents, &err)) {
241 4 : *note = err.trimmed().isEmpty()
242 12 : ? tr("Could not sign %1 with %2.")
243 : .arg(gpgIdFile, signer.keys().first())
244 4 : : tr("Could not sign %1 with %2: %3")
245 7 : .arg(gpgIdFile, signer.keys().first(), err.trimmed());
246 : // withGpgHome() fell back to the inherited home: gpg's "no secret key"
247 : // would not say that the configured one is missing.
248 4 : if (!s.gpgHome.isEmpty()) {
249 1 : QDir home(s.gpgHome);
250 1 : home.makeAbsolute();
251 1 : if (!home.exists()) {
252 : *note +=
253 1 : QLatin1Char(' ') +
254 3 : tr("The configured GPG home %1 does not exist.").arg(home.path());
255 : }
256 1 : }
257 4 : return false;
258 : }
259 : return true;
260 6 : }
261 :
262 5 : auto ProfileInit::commitGpgId(const QString &dir, const QString &gpgIdFile,
263 : const QString &sigFile, const AppSettings &s,
264 : QString *note) -> bool {
265 : // A process of our own in the profile directory: nothing here may touch
266 : // the shared Executor or PASSWORD_STORE_DIR of the active store.
267 5 : QProcess git;
268 5 : git.setWorkingDirectory(dir);
269 5 : git.setProcessEnvironment(gitEnvironment(dir, s));
270 11 : auto run = [&](const QStringList &args) -> bool {
271 11 : QString err;
272 11 : const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
273 11 : QString(), nullptr, &err);
274 11 : if (rc != 0) {
275 : *note =
276 4 : tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
277 2 : return false;
278 : }
279 : return true;
280 5 : };
281 15 : QStringList files{QFileInfo(gpgIdFile).fileName()};
282 5 : if (!sigFile.isEmpty()) {
283 4 : files << QFileInfo(sigFile).fileName();
284 : }
285 : const QStringList add =
286 20 : QStringList{QStringLiteral("add"), QStringLiteral("--")} + files;
287 15 : if (!run({QStringLiteral("init")}) || !run(add)) {
288 : return false;
289 : }
290 9 : return run(commitArgs(git, s) +
291 6 : QStringList{QStringLiteral("-m"),
292 3 : QStringLiteral("Added .gpg-id using QtPass."),
293 21 : QStringLiteral("--")} +
294 3 : files);
295 10 : }
296 :
297 6 : auto ProfileInit::initGit(const QString &dir, const AppSettings &s,
298 : QString *note) -> bool {
299 6 : QProcess git;
300 6 : git.setWorkingDirectory(dir);
301 6 : git.setProcessEnvironment(gitEnvironment(dir, s));
302 14 : auto run = [&](const QStringList &args) -> bool {
303 14 : QString err;
304 14 : const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
305 14 : QString(), nullptr, &err);
306 14 : if (rc != 0) {
307 : *note =
308 6 : tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
309 3 : return false;
310 : }
311 : return true;
312 6 : };
313 : // Stage only store files (as the re-encryption backup commit does): an
314 : // existing folder may hold plaintext exports or swap files. Regular files
315 : // only: a link or junction must not have its target staged.
316 6 : QStringList files;
317 6 : const QDir base(dir);
318 : // Hidden directories (.git among them) are not walked, hidden files are:
319 : // .gpg-id is one.
320 30 : const QStringList found = Util::regularFilesUnder(
321 : dir,
322 6 : {QStringLiteral("*.gpg"), QStringLiteral(".gpg-id"),
323 6 : QStringLiteral(".gpg-id.sig")},
324 6 : nullptr, true);
325 13 : for (const QString &path : found) {
326 14 : files << base.relativeFilePath(path);
327 : }
328 18 : if (!run({QStringLiteral("init")})) {
329 : return false;
330 : }
331 9 : if (!files.isEmpty() &&
332 21 : !run(QStringList{QStringLiteral("add"), QStringLiteral("--")} + files)) {
333 : return false;
334 : }
335 12 : return run(commitArgs(git, s) +
336 4 : QStringList{QStringLiteral("--allow-empty"), QStringLiteral("-m"),
337 28 : QStringLiteral("Added password store using QtPass.")});
338 12 : }
339 :
340 15 : auto ProfileInit::gitIdentityConfigured(const QString &dir,
341 : const AppSettings &s) -> bool {
342 15 : QProcess git;
343 15 : git.setWorkingDirectory(dir);
344 15 : git.setProcessEnvironment(gitEnvironment(dir, s));
345 34 : for (const char *key : {"user.name", "user.email"}) {
346 25 : QString out;
347 125 : if (Executor::executeBlocking(git, s.gitExecutable,
348 25 : {QStringLiteral("config"),
349 25 : QStringLiteral("--get"),
350 : QString::fromLatin1(key)},
351 94 : QString(), &out, nullptr) != 0 ||
352 25 : out.trimmed().isEmpty()) {
353 : return false;
354 : }
355 : }
356 : return true;
357 65 : }
|