Line data Source code
1 : // SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements RealPass: builds the `pass` command lines and does itself
6 : * the few things `pass` must not be trusted with, such as removing a linked
7 : * folder without following it.
8 : */
9 : #include "realpass.h"
10 : #include "executor.h"
11 : #include "gpgidsigner.h"
12 : #include "pathvalidator.h"
13 : #include "qtpasslogging.h"
14 : #include "util.h"
15 :
16 : #include <QCoreApplication>
17 : #include <QCryptographicHash>
18 : #include <QDir>
19 : #include <QFile>
20 : #include <QFileInfo>
21 : #include <QProcess>
22 : #include <QRegularExpression>
23 : #include <utility>
24 :
25 : using Enums::GIT_COMMIT;
26 : using Enums::GIT_INIT;
27 : using Enums::GIT_PULL;
28 : using Enums::GIT_PUSH;
29 : using Enums::PASS_COPY;
30 : using Enums::PASS_INIT;
31 : using Enums::PASS_INSERT;
32 : using Enums::PASS_MOVE;
33 : using Enums::PASS_REMOVE;
34 : using Enums::PASS_SHOW;
35 :
36 32 : RealPass::RealPass() : m_grep(this) {
37 32 : connect(&m_grep, &NativeGrep::finished, this, &RealPass::finishedGrep);
38 32 : connect(&exec, &Executor::dequeuing, this, &RealPass::onDequeuing);
39 32 : }
40 :
41 84 : RealPass::~RealPass() { m_grep.cancel(); }
42 :
43 6 : void RealPass::GitInit() { executePass(GIT_INIT, {"git", "init"}); }
44 :
45 2 : void RealPass::GitPull_b() {
46 : int result =
47 12 : Executor::executeBlocking(m_settings.passExecutable, {"git", "pull"});
48 2 : if (result != 0) {
49 2 : qCDebug(lcQtPass) << "Git pull failed with code:" << result;
50 : }
51 2 : }
52 :
53 12 : void RealPass::GitPull() { executePass(GIT_PULL, {"git", "pull"}); }
54 :
55 6 : void RealPass::GitPush() { executePass(GIT_PUSH, {"git", "push"}); }
56 :
57 4 : void RealPass::Show(QString file) {
58 : // pass follows links as readily as gpg does.
59 8 : if (refuseLinkedPath(file + ".gpg")) {
60 : return;
61 : }
62 : queueShow(file);
63 10 : executePass(PASS_SHOW, {"show", file}, "", true);
64 2 : }
65 :
66 44 : auto RealPass::listSigner() -> GpgIdSigner {
67 : return {m_settings.gpgExecutable,
68 88 : GpgIdSigner::keysFromSetting(m_settings.passSigningKey),
69 44 : [this](const QString &app, const QStringList &args,
70 : const QString &input, QString *out, QString *err) {
71 22 : QProcess gpg;
72 22 : gpg.setProcessEnvironment(exec.environment());
73 44 : return Executor::executeBlocking(gpg, app, args, input, out, err);
74 110 : }};
75 : }
76 :
77 30 : auto RealPass::refuseList(const QString &file, const GpgIdSigner &signer,
78 : QSet<QString> &verified, const QString &within,
79 : const QString &except) -> bool {
80 30 : if (refuseSkippedGpgId(file, true, except)) {
81 : return true;
82 : }
83 29 : const QString gpgIdFile = Pass::getGpgIdPath(file, m_settings.passStore);
84 : const QString absolute =
85 58 : QDir::cleanPath(QFileInfo(gpgIdFile).absoluteFilePath());
86 28 : if (verified.contains(gpgIdFile) ||
87 7 : (!except.isEmpty() &&
88 106 : absolute == QDir::cleanPath(QFileInfo(except).absoluteFilePath())) ||
89 13 : (!within.isEmpty() && !absolute.startsWith(within))) {
90 : return false;
91 : }
92 : QString why =
93 22 : QCoreApplication::translate("ImitatePass", "Signature for %1 is invalid.")
94 22 : .arg(gpgIdFile);
95 22 : QByteArray contents;
96 22 : if (!verifySignedList(signer, gpgIdFile, &contents, &why)) {
97 11 : emit critical(QCoreApplication::translate("ImitatePass",
98 : "Check .gpg-id file signature!"),
99 : why);
100 11 : return true;
101 : }
102 11 : verified.insert(gpgIdFile);
103 11 : return false;
104 : }
105 :
106 28 : auto RealPass::refuseUnverifiedList(const QString &file) -> bool {
107 : // pass verifies the signature itself, but knows nothing of the generation
108 : // and folder lines: an older authentic list put back, or one signed for
109 : // another folder, would get the secret (#2077). The same checks as the
110 : // native backend, with gpg in the store's environment. pass reads the list
111 : // again afterwards; a swap in between is a concurrent writer, out of
112 : // scope (SECURITY.md).
113 28 : const GpgIdSigner signer = listSigner();
114 28 : if (!signer.enabled()) {
115 : return false;
116 : }
117 : QSet<QString> verified;
118 22 : return refuseList(file, signer, verified);
119 28 : }
120 :
121 : namespace {
122 : /// @p dir and every folder under it as pass's reencrypt_path() walks them
123 : /// (find without -L): hidden ones included, .git left out, no link
124 : /// followed. Each entry pass re-encrypts lies in one of them, and its list
125 : /// is that folder's nearest.
126 19 : auto passFoldersUnder(const QString &dir) -> QStringList {
127 38 : QStringList folders{QDir::cleanPath(dir)};
128 38 : const QFileInfoList children = QDir(dir).entryInfoList(
129 19 : QDir::Dirs | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot);
130 26 : for (const QFileInfo &child : children) {
131 7 : if (child.isSymLink() || child.isJunction() ||
132 14 : child.fileName() == QLatin1String(".git")) {
133 0 : continue;
134 : }
135 14 : folders << passFoldersUnder(child.absoluteFilePath());
136 : }
137 19 : return folders;
138 19 : }
139 : } // namespace
140 :
141 16 : auto RealPass::refuseUnverifiedTree(const QString &dir, bool onlyListsWithin,
142 : const QString &except) -> bool {
143 16 : const GpgIdSigner signer = listSigner();
144 16 : if (!signer.enabled()) {
145 : return false;
146 : }
147 : // One gpg call per list, before pass runs: a store with many lists makes
148 : // that a noticeable pause, so say what it is.
149 24 : emit statusMsg(tr("Checking the recipient lists under %1…")
150 24 : .arg(QDir::toNativeSeparators(QDir::cleanPath(dir))),
151 : 3000);
152 : // pass mv and cp re-encrypt every entry of the folder they write to, each
153 : // to its nearest list, walking as passFoldersUnder() does. For the folder
154 : // that moves, the lists inside it go along: checked where they lie now
155 : // (after the move their folder line names the old place until the
156 : // recipients are saved there again), while a list above it is replaced
157 : // by the destination's, which the caller checks. For entries already in
158 : // the destination, their lists stay where they are: all of them count.
159 : // Absolute, as getGpgIdPath() hands lists back: a relative or other
160 : // spelling would match nothing and check nothing.
161 : const QString within =
162 42 : onlyListsWithin ? QDir::cleanPath(QFileInfo(dir).absoluteFilePath()) +
163 : QLatin1Char('/')
164 33 : : QString();
165 : QSet<QString> verified;
166 : // Every folder's list counts, also where no entry uses it yet: cp -r
167 : // merges an incoming folder into one of the same name, so the entries it
168 : // brings get a list already there, and a list it brings replaces the one
169 : // there for the entries already in it.
170 35 : for (const QString &folder : passFoldersUnder(dir)) {
171 19 : if (refuseList(folder, signer, verified, within, except)) {
172 : return true;
173 : }
174 : }
175 4 : return false;
176 16 : }
177 :
178 7 : auto RealPass::storeGit(const QStringList &args, QString *out) -> bool {
179 7 : if (!m_settings.useGit) {
180 : return false;
181 : }
182 : // `pass git` runs git in the store with PASSWORD_STORE_DIR set, as the
183 : // store's other quick lookups do; its exit code is git's own.
184 7 : QString err;
185 7 : return Executor::executeBlocking(
186 14 : exec.environment(), m_settings.passExecutable,
187 28 : QStringList{QStringLiteral("git")} + args, out, &err) == 0;
188 : }
189 :
190 19 : void RealPass::Insert(QString file, QString newValue, bool overwrite) {
191 38 : if (refuseLinkedPath(file + ".gpg")) {
192 6 : return;
193 : }
194 : const QString entry =
195 : QDir::cleanPath(QDir::isAbsolutePath(file)
196 34 : ? file + ".gpg"
197 68 : : QDir(m_settings.passStore).filePath(file) + ".gpg");
198 17 : if (refuseUnverifiedList(entry)) {
199 : return;
200 : }
201 15 : if (refuseUnfinishedGitOperation() || refusePendingPull()) {
202 2 : return;
203 : }
204 : m_insertQueue.enqueue(entry);
205 : // Past every refusal: this insert is now queued and will report once.
206 : queueInsert();
207 65 : QStringList args = {"insert", "-m"};
208 13 : if (overwrite) {
209 12 : args.append("-f");
210 : }
211 : args.append(file);
212 26 : executePass(PASS_INSERT, args, newValue);
213 : }
214 :
215 32 : void RealPass::onDequeuing(int id, bool completes) {
216 32 : if (id != PASS_INSERT || m_insertQueue.isEmpty()) {
217 19 : return;
218 : }
219 : const QString target = m_insertQueue.dequeue();
220 13 : if (completes) {
221 13 : m_insertPending.enqueue({target, entryFingerprint(target)});
222 : }
223 13 : }
224 :
225 6 : auto RealPass::insertWritten(const QString &err) const -> bool {
226 6 : if (m_insertDone.target.isEmpty()) {
227 : return false;
228 : }
229 : // gpg broke off (a full disk, killed): pass says so, and what gpg left
230 : // at the entry's name, if anything, is no entry. The line is pass's own
231 : // (`die "Password encryption aborted."` after gpg in cmd_insert and
232 : // cmd_edit of password-store.sh), which pass does not translate.
233 6 : if (err.contains(QLatin1String("Password encryption aborted."))) {
234 : return false;
235 : }
236 : // pass writes the new ciphertext to the entry's name before it commits,
237 : // and an encryption never gives the same bytes twice (a fresh session
238 : // key), so an edited entry has changed as surely as a new one appeared.
239 5 : const QByteArray after = entryFingerprint(m_insertDone.target);
240 5 : return !after.isEmpty() && after != m_insertDone.before;
241 : }
242 :
243 18 : auto RealPass::entryFingerprint(const QString &path) -> QByteArray {
244 18 : QFile file;
245 18 : if (!Util::openRegularFile(path, file)) {
246 9 : return {};
247 : }
248 9 : QCryptographicHash hash(QCryptographicHash::Sha256);
249 9 : if (!hash.addData(&file)) {
250 0 : return {};
251 : }
252 9 : return hash.result();
253 18 : }
254 :
255 9 : void RealPass::Remove(QString file, bool isDir) {
256 : // Nothing behind a link is the store's to delete.
257 24 : if (refuseLinkedPath(isDir ? file : file + ".gpg", LinkScope::ParentsOnly)) {
258 6 : return;
259 : }
260 8 : if (refuseUnfinishedGitOperation() || refusePendingPull()) {
261 0 : return;
262 : }
263 : // pass rm turns a folder into "<folder>/" and rm -rf then follows a link
264 : // and empties its target, so a linked folder is unlinked here and pass's
265 : // git is only asked to forget it. pass rm -f on a linked .gpg unlinks.
266 8 : const QString full = QDir::cleanPath(m_settings.passStore + file);
267 8 : if (isDir && Util::isLinkedFolder(full)) {
268 5 : if (!Util::removeTree(full)) {
269 1 : const QString why = tr("Could not remove the link %1.").arg(full);
270 1 : emit critical(tr("Delete failed"), why);
271 1 : emit processErrorExit(1, why);
272 : return;
273 : }
274 4 : if (m_settings.useGit) {
275 : // `pass git` exits 0 whatever git did, so ask by output whether git
276 : // knew the link (these quick lookups block; the commit is queued).
277 4 : const QString rel = QDir(m_settings.passStore).relativeFilePath(full);
278 : // With the store's environment (PASSWORD_STORE_DIR), as executePass.
279 4 : QString known;
280 4 : QString err;
281 : // The link is gone from disk either way. A failed lookup or rm leaves
282 : // git tracking it: say so rather than report a clean removal.
283 28 : bool recorded = Executor::executeBlocking(
284 8 : exec.environment(), m_settings.passExecutable,
285 4 : {"git", "ls-files", "--", rel}, &known, &err) == 0;
286 12 : if (recorded && !known.trimmed().isEmpty()) {
287 : recorded =
288 27 : Executor::executeBlocking(
289 6 : exec.environment(), m_settings.passExecutable,
290 : {"git", "rm", "-q", "--cached", "--", rel}, &known, &err) == 0;
291 3 : if (recorded) {
292 : // `pass git commit` is git's commit as it is: pass adds -S only
293 : // to the commits it makes itself, so ask as its git_commit() does.
294 2 : QString signs;
295 : const bool sign =
296 26 : Executor::executeBlocking(
297 4 : exec.environment(), m_settings.passExecutable,
298 : {"git", "config", "--bool", "--get", "pass.signcommits"},
299 2 : &signs, nullptr) == 0 &&
300 4 : signs.trimmed() == QLatin1String("true");
301 14 : QStringList commit{"git", "commit", "-q"};
302 2 : if (sign) {
303 2 : commit << "-S";
304 : }
305 : // Queued, as every other commit, so a signer or hook that waits
306 : // (a pinentry, an agent) does not freeze the window; finished()
307 : // judges it by what is left staged, since `pass git` exits 0
308 : // whatever git did.
309 2 : m_linkCommits.enqueue(
310 : {rel, m_settings.passExecutable, exec.environment()});
311 4 : executePass(
312 : GIT_COMMIT,
313 2 : commit + QStringList{"-m", "Remove for " + rel + " using QtPass.",
314 14 : "--", rel});
315 : return;
316 : }
317 : }
318 2 : if (!recorded) {
319 : const QString why =
320 1 : tr("The link %1 was removed, but git could not record it: %2")
321 2 : .arg(rel, err.trimmed());
322 1 : emit critical(tr("Delete incomplete"), why);
323 1 : emit processErrorExit(1, why);
324 : }
325 : }
326 : // Nothing ran for pass to finish: the removal is done here, and the
327 : // store changed.
328 4 : emit finishedRemove(QString(), QString());
329 2 : return;
330 : }
331 15 : executePass(PASS_REMOVE, {"rm", (isDir ? "-rf" : "-f"), file});
332 18 : }
333 :
334 39 : void RealPass::finished(int id, int exitCode, const QString &out,
335 : const QString &err) {
336 39 : if (static_cast<PROCESS>(id) == GIT_COMMIT && !m_linkCommits.isEmpty()) {
337 : // Whatever the exit code (pass itself failing, or 0 whatever git
338 : // did), what is left staged decides.
339 2 : finishLinkCommit(m_linkCommits.dequeue(), out, err);
340 2 : return;
341 : }
342 37 : if (static_cast<PROCESS>(id) == PASS_INSERT) {
343 : // This completion's own insert, not whichever left the queue last.
344 : m_insertDone =
345 41 : m_insertPending.isEmpty() ? InsertRecord{} : m_insertPending.dequeue();
346 : }
347 37 : Pass::finished(id, exitCode, out, err);
348 : }
349 :
350 2 : void RealPass::finishLinkCommit(const LinkCommit &commit, const QString &out,
351 : const QString &err) {
352 : // In the store the commit ran in, which a profile switch meanwhile does
353 : // not change.
354 2 : const QString &rel = commit.rel;
355 2 : QString staged;
356 : const bool committed =
357 16 : Executor::executeBlocking(
358 2 : commit.environment, commit.passExecutable,
359 : {"git", "diff", "--cached", "--name-only", "--", rel}, &staged,
360 4 : nullptr) == 0 &&
361 2 : staged.trimmed().isEmpty();
362 2 : if (committed) {
363 : // As Pass::finished() would: git's output (a hook's warning, say) for
364 : // the output panel, then the removal.
365 1 : emit finishedAnyWithPid(out, err, GIT_COMMIT);
366 1 : emit finishedRemove(out, err);
367 : return;
368 : }
369 0 : const QString why = tr("The link %1 was removed and git no longer tracks "
370 : "it, but the commit failed: %2")
371 2 : .arg(rel, err.trimmed());
372 1 : emit critical(tr("Delete incomplete"), why);
373 1 : emit processErrorExit(1, why);
374 2 : emit finishedRemove(QString(), QString());
375 2 : }
376 :
377 11 : void RealPass::Init(QString path, const QList<UserInfo> &users) {
378 : // A relative path names a folder in the store, as --path does for pass:
379 : // resolved once, so the checks below look where pass writes, not in the
380 : // working directory.
381 : const QString folder = QDir::cleanPath(
382 13 : QDir::isAbsolutePath(path) ? path
383 24 : : QDir(m_settings.passStore).filePath(path));
384 : // pass init writes .gpg-id with a shell redirection and gpg writes the
385 : // signature with --output; both follow a link planted under those names.
386 : // --path is store-relative, or pass creates passStore/passStore/dir: the
387 : // store check hands it over as it judged it, so a store root reached
388 : // through a link and a folder spelled through its target agree (a lexical
389 : // relativeFilePath() made that ../../target/dir, which pass refuses).
390 11 : QString dirWithoutPassdir;
391 : const auto refusePlaced = [this](const QString &placed) {
392 30 : return refuseLinkedPath(placed, LinkScope::EntryAndParents,
393 : PathSpelling::PlacedByCaller);
394 : };
395 10 : if (refusePlaced(folder) ||
396 40 : refusePlaced(folder + QStringLiteral("/.gpg-id")) ||
397 48 : refusePlaced(folder + QStringLiteral("/.gpg-id.sig")) ||
398 9 : refuseFolderOutsideStore(folder, &dirWithoutPassdir)) {
399 : return;
400 : }
401 : // pass init writes this folder's list and then re-encrypts everything
402 : // under it to each entry's nearest list, a deeper folder's own included:
403 : // those are checked as for a move. This folder's list is the one being
404 : // written, so it is neither verified nor missed. Walked in the store's
405 : // own spelling: a folder spelled through the target of a linked store
406 : // root is not under the store as named, and the lookup of each nearest
407 : // list would stop at once and miss the deeper ones.
408 : const QString inStore =
409 14 : QDir::cleanPath(QDir(m_settings.passStore).filePath(dirWithoutPassdir));
410 7 : if (refuseUnverifiedTree(inStore, true,
411 14 : QDir(inStore).filePath(QStringLiteral(".gpg-id")))) {
412 : return;
413 : }
414 4 : if (refuseUnfinishedGitOperation() || refusePendingPull()) {
415 0 : return;
416 : }
417 12 : QStringList args = {"init", "--path=" + dirWithoutPassdir};
418 10 : for (const UserInfo &user : users) {
419 6 : if (user.enabled) {
420 5 : args.append(user.key_id);
421 : }
422 : }
423 8 : executePass(PASS_INIT, args);
424 4 : }
425 :
426 10 : void RealPass::Move(const QString src, const QString dest, const bool force) {
427 20 : passMoveOrCopy(PASS_MOVE, QStringLiteral("mv"), src, dest, force);
428 10 : }
429 :
430 6 : void RealPass::Copy(const QString src, const QString dest, const bool force) {
431 12 : passMoveOrCopy(PASS_COPY, QStringLiteral("cp"), src, dest, force);
432 6 : }
433 :
434 8 : auto RealPass::passName(const QString &path, bool stripGpg) const -> QString {
435 : QString name =
436 8 : QDir(QDir::cleanPath(m_settings.passStore))
437 16 : .relativeFilePath(QDir::cleanPath(QDir(path).absolutePath()));
438 8 : if (stripGpg) {
439 8 : name.replace(Util::endsWithGpg(), "");
440 : }
441 8 : return name;
442 : }
443 :
444 16 : void RealPass::passMoveOrCopy(PROCESS id, const QString &subcommand,
445 : const QString &src, const QString &dest,
446 : const bool force) {
447 16 : if (refuseLinkedPath(src) || refuseLinkedPath(dest)) {
448 12 : return;
449 : }
450 13 : QFileInfo srcFileInfo = QFileInfo(src);
451 13 : QFileInfo destFileInfo = QFileInfo(dest);
452 : // A drop hands over the folder; pass then writes <folder>/<name>, and cp
453 : // writes through a link planted under that name.
454 13 : if (destFileInfo.isDir() &&
455 29 : refuseLinkedPath(QDir(dest).filePath(srcFileInfo.fileName()))) {
456 : return;
457 : }
458 :
459 : // pass always forces when called from QtPass, so refuse file-onto-file.
460 12 : if (!force && srcFileInfo.isFile() && destFileInfo.isFile()) {
461 : return;
462 : }
463 :
464 : // pass re-encrypts what it moves or copies to the destination's list, a
465 : // folder's entries also to the lists that go along with it, and, when it
466 : // writes into an existing folder, everything already in that folder too
467 : // (reencrypt_path on the whole destination).
468 22 : if (refuseUnverifiedList(destFileInfo.isDir()
469 29 : ? QDir(dest).filePath(srcFileInfo.fileName())
470 9 : : dest) ||
471 34 : (srcFileInfo.isDir() && refuseUnverifiedTree(src, true)) ||
472 20 : (destFileInfo.isDir() && refuseUnverifiedTree(dest, false))) {
473 : return;
474 : }
475 4 : if (refuseUnfinishedGitOperation() || refusePendingPull()) {
476 0 : return;
477 : }
478 :
479 : // pass appends .gpg itself, so an entry named with it would come out as
480 : // "name.gpg.gpg"; the destination usually does not exist yet.
481 : const QString passSrc =
482 4 : passName(src, srcFileInfo.isFile() &&
483 6 : srcFileInfo.suffix() == QLatin1String("gpg"));
484 : const QString passDest =
485 4 : passName(dest, !destFileInfo.isDir() &&
486 7 : destFileInfo.suffix() == QLatin1String("gpg"));
487 :
488 4 : QStringList args;
489 : args << subcommand;
490 4 : if (force) {
491 4 : args << "-f";
492 : }
493 : args << passSrc;
494 : args << passDest;
495 8 : executePass(id, args);
496 13 : }
497 :
498 : // Not `pass grep` (find -L follows links out of the store): the native
499 : // search walks real files only (Util::regularFilesUnder), so it needs gpg.
500 2 : void RealPass::Grep(QString pattern, bool caseInsensitive) {
501 2 : if (m_settings.gpgExecutable.isEmpty()) {
502 1 : emit statusMsg(tr("Search needs the GPG executable to be configured."),
503 : 5000);
504 1 : emit finishedGrep({});
505 1 : return;
506 : }
507 2 : m_grep.search(pattern, caseInsensitive, m_settings.gpgExecutable,
508 1 : m_settings.passStore, exec.environment());
509 : }
510 :
511 32 : void RealPass::executePass(PROCESS id, const QStringList &args, QString input,
512 : bool readStdout, bool readStderr) {
513 32 : executeWrapper(id, m_settings.passExecutable, args, std::move(input),
514 : readStdout, readStderr);
515 32 : }
|