QtPass 2.0.0
Developer documentation
Loading...
Searching...
No Matches
gpgidgeneration.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2// SPDX-License-Identifier: GPL-3.0-or-later
8#include "gpgidgeneration.h"
9#include "qtpasslogging.h"
10#include <QCoreApplication>
11#include <QCryptographicHash>
12#include <QDir>
13#include <QFile>
14#include <QFileInfo>
15#include <QJsonDocument>
16#include <QJsonObject>
17#include <QLockFile>
18#include <QMap>
19#include <QMutex>
20#include <QMutexLocker>
21#include <QSaveFile>
22#include <QSettings>
23#include <QUuid>
24#include <algorithm>
25
27 QByteArrayLiteral("# QtPass-GpgId-Generation: ");
28const QByteArray GpgIdGeneration::kFolderPrefix =
29 QByteArrayLiteral("# QtPass-GpgId-Folder: ");
30const QByteArray GpgIdGeneration::kStorePrefix =
31 QByteArrayLiteral("# QtPass-GpgId-Store: ");
32const qint64 GpgIdGeneration::kMaxGeneration = 999999999999999999LL;
33
34namespace {
35constexpr int kMaxDigits = 18;
36const QByteArray kOurComment = QByteArrayLiteral("# QtPass-GpgId-");
37
38// Not a QObject: messages live under the class name. lupdate gets that context
39// from the class's own methods, but drops strings in the free functions and
40// structs here, so those spell it out with QCoreApplication::translate().
41auto tr(const char *text) -> QString {
42 return QCoreApplication::translate("GpgIdGeneration", text);
43}
44
48QMutex &recordLock() {
49 static QMutex mutex;
50 return mutex;
51}
52
54constexpr int kRecordLockTimeoutMs = 3000;
55
58struct Entry {
59 qint64 generation = 0;
60 QString digest;
61};
62using Entries = QMap<QString, Entry>;
65using Stores = QMap<QString, QString>;
66
68struct Record {
69 Entries lists;
70 Stores stores;
71};
72
74auto isStoreId(const QByteArray &id) -> bool {
75 return id.size() == 32 && std::all_of(id.cbegin(), id.cend(), [](char c) {
76 return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
77 });
78}
79
80const QString kRecordName = QStringLiteral("QtPass-gpgid-generations.json");
84const QString kStoresName = QStringLiteral("QtPass-gpgid-stores.json");
85constexpr int kRecordFormat = 1;
86
91auto recordPath() -> QString {
92 const QSettings probe(QSettings::IniFormat, QSettings::UserScope,
93 QCoreApplication::organizationName().isEmpty()
94 ? QStringLiteral("IJHack")
95 : QCoreApplication::organizationName(),
96 QStringLiteral("QtPass-gpgid-generations"));
97 return QFileInfo(probe.fileName()).absolutePath() + QLatin1Char('/') +
98 kRecordName;
99}
100
102auto storesPath() -> QString {
103 return QFileInfo(recordPath()).absolutePath() + QLatin1Char('/') +
104 kStoresName;
105}
106
110auto recordEntry(const QJsonValue &value) -> std::optional<Entry> {
111 if (!value.isObject()) {
112 return std::nullopt;
113 }
114 const QJsonObject item = value.toObject();
115 const QString generation = item.value(QLatin1String("generation")).toString();
116 if (generation.isEmpty() || generation.size() > kMaxDigits) {
117 return std::nullopt;
118 }
119 for (const QChar c : generation) {
120 if (c < QLatin1Char('0') || c > QLatin1Char('9')) {
121 return std::nullopt;
122 }
123 }
124 Entry entry;
125 entry.generation = generation.toLongLong();
126 entry.digest = item.value(QLatin1String("sha256")).toString();
127 return entry;
128}
129
132auto recordObject(const QByteArray &bytes, const QString &name)
133 -> std::optional<QJsonObject> {
134 QJsonParseError parse{};
135 const QJsonDocument doc = QJsonDocument::fromJson(bytes, &parse);
136 if (parse.error != QJsonParseError::NoError || !doc.isObject()) {
137 return std::nullopt;
138 }
139 const QJsonObject root = doc.object();
140 const QJsonValue inner = root.value(name);
141 if (root.value(QLatin1String("format")).toInt() != kRecordFormat ||
142 !inner.isObject()) {
143 return std::nullopt;
144 }
145 return inner.toObject();
146}
147
150auto parseRecord(const QByteArray &bytes) -> std::optional<Entries> {
151 const std::optional<QJsonObject> byKey =
152 recordObject(bytes, QStringLiteral("lists"));
153 if (!byKey) {
154 return std::nullopt;
155 }
156 Entries entries;
157 for (auto it = byKey->constBegin(); it != byKey->constEnd(); ++it) {
158 const std::optional<Entry> entry = recordEntry(it.value());
159 if (!entry) {
160 return std::nullopt;
161 }
162 entries.insert(it.key(), *entry);
163 }
164 return entries;
165}
166
169auto parseStores(const QByteArray &bytes) -> std::optional<Stores> {
170 const std::optional<QJsonObject> byKey =
171 recordObject(bytes, QStringLiteral("stores"));
172 if (!byKey) {
173 return std::nullopt;
174 }
175 Stores stores;
176 for (auto it = byKey->constBegin(); it != byKey->constEnd(); ++it) {
177 const QString id =
178 it.value().toObject().value(QLatin1String("id")).toString();
179 if (!it.value().isObject() || !isStoreId(id.toLatin1())) {
180 return std::nullopt;
181 }
182 stores.insert(it.key(), id);
183 }
184 return stores;
185}
186
190template <typename T, typename Parse>
191auto loadFile(const QString &path, Parse parse, QString *error)
192 -> std::optional<T> {
193 if (!QFileInfo::exists(path)) {
194 return T{};
195 }
196 QFile file(path);
197 if (!file.open(QIODevice::ReadOnly)) {
198 if (error)
199 *error = QCoreApplication::translate(
200 "GpgIdGeneration",
201 "The generation record of the recipient lists, %1, cannot "
202 "be accessed.")
203 .arg(path);
204 return std::nullopt;
205 }
206 std::optional<T> value = parse(file.readAll());
207 if (!value && error) {
208 *error = QCoreApplication::translate(
209 "GpgIdGeneration",
210 "The generation record of the recipient lists, %1, is not "
211 "readable. Signed recipient lists are not accepted until "
212 "it is repaired or removed (which forgets what was accepted "
213 "before).")
214 .arg(path);
215 }
216 return value;
217}
218
222auto saveFile(const QString &path, const QString &name,
223 const QJsonObject &inner, QString *error) -> bool {
224 QJsonObject root;
225 root.insert(QLatin1String("format"), kRecordFormat);
226 root.insert(name, inner);
227 QSaveFile file(path);
228 if (!file.open(QIODevice::WriteOnly)) {
229 if (error)
230 *error = QCoreApplication::translate(
231 "GpgIdGeneration",
232 "The generation record of the recipient lists, %1, cannot "
233 "be written.")
234 .arg(path);
235 return false;
236 }
237 file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
238 const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Indented);
239 if (file.write(bytes) != bytes.size() || !file.commit()) {
240 qCWarning(lcQtPass) << "Could not write the .gpg-id generation record"
241 << path << file.errorString();
242 if (error)
243 *error = QCoreApplication::translate(
244 "GpgIdGeneration",
245 "The generation record of the recipient lists, %1, cannot "
246 "be written.")
247 .arg(path);
248 return false;
249 }
250 return true;
251}
252
253auto saveRecord(const QString &path, const Entries &entries, QString *error)
254 -> bool {
255 QJsonObject lists;
256 for (auto it = entries.constBegin(); it != entries.constEnd(); ++it) {
257 QJsonObject item;
258 item.insert(QLatin1String("generation"),
259 QString::number(it.value().generation));
260 if (!it.value().digest.isEmpty()) {
261 item.insert(QLatin1String("sha256"), it.value().digest);
262 }
263 lists.insert(it.key(), item);
264 }
265 return saveFile(path, QStringLiteral("lists"), lists, error);
266}
267
268auto saveStores(const QString &path, const Stores &stores, QString *error)
269 -> bool {
270 QJsonObject byKey;
271 for (auto it = stores.constBegin(); it != stores.constEnd(); ++it) {
272 QJsonObject item;
273 item.insert(QLatin1String("id"), it.value());
274 byKey.insert(it.key(), item);
275 }
276 return saveFile(path, QStringLiteral("stores"), byKey, error);
277}
278
283struct Transaction {
284 QMutexLocker<QMutex> mutex{&recordLock()};
285 QString path{recordPath()};
286 // The record's folder may not exist before its first write; the lock file
287 // needs it now.
288 bool haveFolder{QDir().mkpath(QFileInfo(path).absolutePath())};
289 QLockFile lock{path + QStringLiteral(".lock")};
290 bool locked{haveFolder && lock.tryLock(kRecordLockTimeoutMs)};
291 QString problem;
292 QString storesFile{storesPath()};
293 std::optional<Record> entries;
294
295 Transaction() {
296 if (!locked) {
297 problem = QCoreApplication::translate(
298 "GpgIdGeneration",
299 "The generation record of the recipient lists, %1, could "
300 "not be locked: another QtPass may be using it, or its "
301 "folder cannot be written.")
302 .arg(path);
303 return;
304 }
305 std::optional<Entries> lists =
306 loadFile<Entries>(path, parseRecord, &problem);
307 if (!lists) {
308 return;
309 }
310 std::optional<Stores> stores =
311 loadFile<Stores>(storesFile, parseStores, &problem);
312 if (!stores) {
313 return;
314 }
315 entries = Record{std::move(*lists), std::move(*stores)};
316 }
317
318 [[nodiscard]] auto ok(QString *error) const -> bool {
319 if (entries) {
320 return true;
321 }
322 if (error)
323 *error = problem;
324 return false;
325 }
326
328 [[nodiscard]] auto generation(const QString &key) const -> qint64 {
329 return entries->lists.value(key).generation;
330 }
331
334 [[nodiscard]] auto store(const QString &key) const -> std::optional<QString> {
335 const auto it = entries->stores.constFind(key);
336 if (it == entries->stores.constEnd()) {
337 return std::nullopt;
338 }
339 return it.value();
340 }
341
343 auto pinStore(const QString &key, const QString &id, QString *error) -> bool {
344 Stores updated = entries->stores;
345 updated.insert(key, id);
346 if (!saveStores(storesFile, updated, error)) {
347 return false;
348 }
349 entries->stores = std::move(updated);
350 return true;
351 }
352
355 [[nodiscard]] auto digest(const QString &key) const
356 -> std::optional<QString> {
357 const QString value = entries->lists.value(key).digest;
358 if (value.isEmpty()) {
359 return std::nullopt;
360 }
361 return value;
362 }
363
366 auto remember(const QString &key, qint64 generation, const QString &digest,
367 QString *error) -> bool {
368 Entries updated = entries->lists;
369 updated[key] = Entry{generation, digest};
370 if (!saveRecord(path, updated, error)) {
371 return false;
372 }
373 entries->lists = std::move(updated);
374 return true;
375 }
376};
377
381auto hasControl(const QByteArray &value) -> bool {
382 return std::any_of(value.cbegin(), value.cend(), [](const char c) {
383 const auto u = static_cast<unsigned char>(c);
384 return u < 0x20 || u == 0x7f;
385 });
386}
387
390auto relativeFolder(const QString &path, const QString &root)
391 -> std::optional<QString> {
392 QString folder = QFileInfo(path).absoluteDir().canonicalPath();
393 if (folder.isEmpty()) {
394 folder = QFileInfo(path).absolutePath();
395 }
396 QString store = QFileInfo(root).canonicalFilePath();
397 if (store.isEmpty()) {
398 store = QDir::cleanPath(QFileInfo(root).absoluteFilePath());
399 }
400 folder = QDir::fromNativeSeparators(QDir::cleanPath(folder));
401 store = QDir::fromNativeSeparators(QDir::cleanPath(store));
402#ifdef Q_OS_WIN
403 folder = folder.toLower();
404 store = store.toLower();
405#endif
406 if (folder == store) {
407 return QStringLiteral(".");
408 }
409 const QString prefix =
410 store.endsWith(QLatin1Char('/')) ? store : store + QLatin1Char('/');
411 if (!folder.startsWith(prefix)) {
412 return std::nullopt;
413 }
414 folder = folder.mid(prefix.size());
415 // The folder line is one line: a name with a line break in it would end
416 // it, and what follows would be read as a recipient of the signed list.
417 // Any other control character parse() refuses: the list written would be
418 // one that cannot be read back.
419 if (hasControl(folder.toUtf8())) {
420 return std::nullopt;
421 }
422 return folder;
423}
424} // namespace
425
426namespace {
427
430auto generationDigits(const QByteArray &digits) -> std::optional<qint64> {
431 if (digits.isEmpty() || digits.size() > kMaxDigits) {
432 return std::nullopt;
433 }
434 for (const char c : digits) {
435 if (c < '0' || c > '9') {
436 return std::nullopt;
437 }
438 }
439 return digits.toLongLong();
440}
441
445auto takeHeaderLine(const QByteArray &line, std::optional<qint64> &generation,
446 std::optional<QString> &folder,
447 std::optional<QString> &store) -> QString {
448 if (line.startsWith(GpgIdGeneration::kStorePrefix)) {
449 if (store) {
450 return QCoreApplication::translate(
451 "GpgIdGeneration", "The list carries more than one store line.");
452 }
453 const QByteArray value = line.mid(GpgIdGeneration::kStorePrefix.size());
454 if (isStoreId(value)) {
455 store = QString::fromLatin1(value);
456 return {};
457 }
458 return QCoreApplication::translate("GpgIdGeneration",
459 "The store line is malformed: %1")
460 .arg(QString::fromUtf8(line));
461 }
462 if (line.startsWith(GpgIdGeneration::kGenerationPrefix)) {
463 if (generation) {
464 return QCoreApplication::translate(
465 "GpgIdGeneration", "The list carries more than one generation line.");
466 }
467 generation =
468 generationDigits(line.mid(GpgIdGeneration::kGenerationPrefix.size()));
469 if (generation) {
470 return {};
471 }
472 return QCoreApplication::translate("GpgIdGeneration",
473 "The generation line is malformed: %1")
474 .arg(QString::fromUtf8(line));
475 }
476 if (line.startsWith(GpgIdGeneration::kFolderPrefix)) {
477 if (folder) {
478 return QCoreApplication::translate(
479 "GpgIdGeneration", "The list carries more than one folder line.");
480 }
481 const QByteArray value = line.mid(GpgIdGeneration::kFolderPrefix.size());
482 // A control character (a second \r of "\r\r\n", a tab, a NUL) is not
483 // part of a folder name QtPass writes, and would not survive being
484 // written back: header() ends the line, parse() strips one \r.
485 if (!value.isEmpty() && !hasControl(value)) {
486 folder = QString::fromUtf8(value);
487 return {};
488 }
489 return QCoreApplication::translate("GpgIdGeneration",
490 "The folder line is malformed: %1")
491 .arg(QString::fromUtf8(line));
492 }
493 return QCoreApplication::translate("GpgIdGeneration",
494 "The header line is malformed: %1")
495 .arg(QString::fromUtf8(line));
496}
497
498} // namespace
499
500auto GpgIdGeneration::parse(const QByteArray &contents, QString *error)
501 -> std::optional<Header> {
502 std::optional<qint64> generation;
503 std::optional<QString> folder;
504 std::optional<QString> store;
505 for (const QByteArray &rawLine : contents.split('\n')) {
506 QByteArray line = rawLine;
507 if (line.endsWith('\r')) {
508 line.chop(1);
509 }
510 // Only a line under our name is ours; any other comment is somebody
511 // else's. But a near miss under the same name is suspicious, not
512 // ignorable.
513 if (!line.startsWith(kOurComment)) {
514 continue;
515 }
516 const QString complaint = takeHeaderLine(line, generation, folder, store);
517 if (complaint.isEmpty()) {
518 continue;
519 }
520 if (error)
521 *error = complaint;
522 return std::nullopt;
523 }
524 if (generation && !folder) {
525 if (error)
526 *error = tr("The list carries a generation line but no folder line.");
527 return std::nullopt;
528 }
529 // QtPass writes the store line only with the other two.
530 if (store && !generation) {
531 if (error)
532 *error = tr("The list carries a store line but no generation line.");
533 return std::nullopt;
534 }
536 header.generation = generation.value_or(0);
537 header.folder = folder;
538 header.store = store;
539 return header;
540}
541
542auto GpgIdGeneration::header(qint64 generation, const QString &folder,
543 const QString &store) -> QByteArray {
544 QByteArray lines = kGenerationPrefix + QByteArray::number(generation) + '\n' +
545 kFolderPrefix + folder.toUtf8() + '\n';
546 if (!store.isEmpty()) {
547 lines += kStorePrefix + store.toLatin1() + '\n';
548 }
549 return lines;
550}
551
552auto GpgIdGeneration::withHeader(qint64 generation, const QString &folder,
553 const QByteArray &recipients,
554 const QString &store) -> QByteArray {
555 return header(generation, folder, store) + recipients;
556}
557
558auto GpgIdGeneration::folderOf(const QString &gpgIdFile,
559 const QString &storeRoot)
560 -> std::optional<QString> {
561 return relativeFolder(gpgIdFile, storeRoot);
562}
563
564auto GpgIdGeneration::key(const QString &gpgIdFile) -> QString {
565 const QFileInfo info(gpgIdFile);
566 // canonicalFilePath() is empty for a file that does not exist yet (a new
567 // profile's list): its folder does, so canonicalise that and append.
568 QString canonical = info.canonicalFilePath();
569 if (canonical.isEmpty()) {
570 const QString dir = info.absoluteDir().canonicalPath();
571 canonical = (dir.isEmpty() ? info.absolutePath() : dir) + QLatin1Char('/') +
572 info.fileName();
573 }
574 canonical = QDir::fromNativeSeparators(canonical);
575#ifdef Q_OS_WIN
576 canonical = canonical.toLower();
577#endif
578 return QString::fromLatin1(
579 QCryptographicHash::hash(canonical.toUtf8(), QCryptographicHash::Sha256)
580 .toHex());
581}
582
583auto GpgIdGeneration::recordFile() -> QString { return recordPath(); }
584
585auto GpgIdGeneration::storeRecordFile() -> QString { return storesPath(); }
586
587auto GpgIdGeneration::digest(const QByteArray &contents) -> QString {
588 return QString::fromLatin1(
589 QCryptographicHash::hash(contents, QCryptographicHash::Sha256).toHex());
590}
591
592auto GpgIdGeneration::remembered(const QString &gpgIdFile, QString *error)
593 -> std::optional<qint64> {
594 Transaction t;
595 if (!t.ok(error)) {
596 return std::nullopt;
597 }
598 return t.generation(key(gpgIdFile));
599}
600
601auto GpgIdGeneration::boundToItsFolder(const QString &gpgIdFile,
602 const Header &header,
603 const QString &storeRoot, QString *error)
604 -> bool {
605 if (!header.folder) {
606 return true;
607 }
608 const std::optional<QString> here = folderOf(gpgIdFile, storeRoot);
609 if (here && *here == *header.folder) {
610 return true;
611 }
612 if (error)
613 *error =
614 tr("The signed recipient list %1 was written for the folder "
615 "\"%2\" of the store, not for \"%3\", and is not used. It "
616 "may have been copied here by someone else; if the folder "
617 "was moved or renamed instead, a holder of the signing key "
618 "opens Users on it and saves the recipients, which binds "
619 "the list to where it is now.")
620 .arg(gpgIdFile, *header.folder, here.value_or(QStringLiteral("?")));
621 return false;
622}
623
624auto GpgIdGeneration::wayThrough(qint64 last, const QString &recordPath,
625 const QString &saving) -> QString {
626 if (last >= kMaxGeneration) {
627 return tr("Generation %1 is the highest there is, so no newer list "
628 "can be written here: removing %2 forgets what this device "
629 "accepted before, after which a holder of the signing key "
630 "gets through by opening Users and %3.")
631 .arg(last)
632 .arg(recordPath, saving);
633 }
634 return tr("A holder of the signing key gets through by opening Users "
635 "and %1, which writes generation %2. Removing %3 forgets what "
636 "this device accepted before.")
637 .arg(saving)
638 .arg(last + 1)
639 .arg(recordPath);
640}
641
642auto GpgIdGeneration::staleList(const QString &gpgIdFile, const Header &header,
643 qint64 last, const QString &recordPath,
644 QString *error) -> Verdict {
645 if (!header.folder) {
646 // Below what was accepted here and with no folder line to say it was
647 // written here at all: an authentic headerless pair from any folder's
648 // history would pass as this folder's rollback otherwise.
649 if (error)
650 *error =
651 tr("The signed recipient list %1 carries no generation line, "
652 "while generation %2 was accepted here before. pass writes no "
653 "generation line (also through QtPass's pass backend), nor did "
654 "QtPass before 2.0; without one the list may also have been "
655 "written for another folder of the store and copied here. %3")
656 .arg(gpgIdFile)
657 .arg(last)
658 .arg(
659 wayThrough(last, recordPath,
660 tr("selecting the recipients afresh and saving")));
661 return Verdict::Unbound;
662 }
663 if (error)
664 *error =
665 tr("The signed recipient list %1 is generation %2, older than "
666 "generation %3, the last one QtPass accepted here. It may have "
667 "been put back by someone else. %4")
668 .arg(gpgIdFile)
669 .arg(header.generation)
670 .arg(last)
671 .arg(wayThrough(last, recordPath,
672 tr("saving the recipients: the preselected "
673 "recipients there are this list's, so remove "
674 "anyone who should no longer have access "
675 "first")));
676 return Verdict::Rollback;
677}
678
679auto GpgIdGeneration::accept(const QString &gpgIdFile,
680 const QByteArray &contents,
681 const QString &storeRoot, QString *error)
682 -> Verdict {
683 QString why;
684 const std::optional<Header> header = parse(contents, &why);
685 if (!header) {
686 if (error)
687 *error = tr("The signed recipient list %1 is not one to trust: %2")
688 .arg(gpgIdFile, why);
689 return Verdict::Malformed;
690 }
691 // A pair is only valid where it was written for: copied into another
692 // folder it is a rollback in disguise (that folder's first list).
693 if (!boundToItsFolder(gpgIdFile, *header, storeRoot, error)) {
695 }
696 Transaction t;
697 if (!t.ok(error)) {
699 }
700 const QString k = key(gpgIdFile);
701 const qint64 last = t.generation(k);
702 const qint64 generation = header->generation;
703 const QString bytes = digest(contents);
704 // Before anything that could preselect the list's recipients (a rollback
705 // is recovered through the Users dialog): a list of another store is no
706 // history of this one.
707 const QString storeKey = key(storeRoot);
708 const std::optional<QString> pinned = t.store(storeKey);
709 if (pinned && !ofThisStore(gpgIdFile, *header, *pinned,
710 generation == last && t.digest(k) == bytes,
711 t.storesFile, error)) {
712 return Verdict::WrongStore;
713 }
714 // First sight of the store's id, on a list that is otherwise accepted.
715 const auto pinHere = [&]() {
716 if (pinned || !header->store) {
717 return true;
718 }
719 return t.pinStore(storeKey, *header->store, error);
720 };
721 if (generation < last) {
722 return staleList(gpgIdFile, *header, last, t.path, error);
723 }
724 if (generation == 0) {
725 // pass writes headerless lists on every change, so differing bytes here
726 // are normal, not a conflict. Generation 0 has no freshness to keep
727 // (SECURITY.md).
729 }
730 if (generation == last) {
731 // Same generation, different bytes: two devices both made 19 from 18
732 // (Git's conflict), or a swap. A record without a digest (an unrecorded
733 // reservation) takes these bytes as the ones.
734 const std::optional<QString> known = t.digest(k);
735 if (known && *known != bytes) {
736 if (error)
737 *error = tr("The signed recipient list %1 is generation %2, the same "
738 "generation as a different list this device accepted "
739 "before. Either two devices saved recipients at the same "
740 "time (Git will have shown the conflict), or an authentic "
741 "list of that generation was swapped in. %3")
742 .arg(gpgIdFile)
743 .arg(generation)
744 .arg(wayThrough(last, t.path,
745 tr("checking the recipients and saving")));
746 return Verdict::Conflict;
747 }
748 if (known) {
750 }
751 }
752 // The pin first: a generation recorded with the pin's write failing would
753 // leave the store unpinned for the next list, another store's included.
754 if (!pinHere() || !t.remember(k, generation, bytes, error)) {
756 }
757 return Verdict::Accepted;
758}
759
760auto GpgIdGeneration::ofThisStore(const QString &gpgIdFile,
761 const Header &header, const QString &pinned,
762 bool alreadyAccepted,
763 const QString &recordPath, QString *error)
764 -> bool {
765 if (header.store) {
766 if (*header.store == pinned) {
767 return true;
768 }
769 if (error)
770 *error =
771 tr("The signed recipient list %1 was written for another password "
772 "store (store ID %2; this device knows this store as %3) and is "
773 "not used. It may have been copied here from a store signed "
774 "with the same key. If this store was set up again instead, "
775 "removing %4 forgets the store IDs this device knows.")
776 .arg(gpgIdFile, *header.store, pinned, recordPath);
777 return false;
778 }
779 // No store line: a list without a generation (pass) has none to carry,
780 // and one this device accepted before it had a store id still is what it
781 // was. Anything new must say which store it is for.
782 if (header.generation == 0 || alreadyAccepted) {
783 return true;
784 }
785 if (error)
786 *error =
787 tr("The signed recipient list %1 carries no store line, while this "
788 "device knows this store by its ID %2: it may have been written "
789 "for another store and copied here, or by an older QtPass. A "
790 "holder of the signing key gets through by opening Users, "
791 "selecting the recipients afresh and saving, which writes the "
792 "store line.")
793 .arg(gpgIdFile, pinned);
794 return false;
795}
796
797auto GpgIdGeneration::storeIdFor(const QString &storeRoot,
798 const std::optional<QString> &onDisk,
799 QString *error) -> std::optional<QString> {
800 Transaction t;
801 if (!t.ok(error)) {
802 return std::nullopt;
803 }
804 const QString storeKey = key(storeRoot);
805 if (const std::optional<QString> pinned = t.store(storeKey)) {
806 return pinned;
807 }
808 // Joining a store that has an id already: take it over, rather than
809 // giving the store a second one every other device would refuse.
810 const QString id = onDisk && isStoreId(onDisk->toLatin1())
811 ? *onDisk
812 : QUuid::createUuid().toString(QUuid::Id128).toLower();
813 if (!t.pinStore(storeKey, id, error)) {
814 return std::nullopt;
815 }
816 return id;
817}
818
819auto GpgIdGeneration::reserveNext(const QString &gpgIdFile,
820 std::optional<qint64> verifiedOnDisk,
821 QString *error) -> std::optional<qint64> {
822 Transaction t;
823 if (!t.ok(error)) {
824 return std::nullopt;
825 }
826 const QString k = key(gpgIdFile);
827 const qint64 last = t.generation(k);
828 const qint64 base = qMax(last, verifiedOnDisk.value_or(0));
829 if (base >= kMaxGeneration) {
830 if (error)
831 *error = tr("The recipient list %1 has reached generation %2, the "
832 "highest there is; the list cannot be written.")
833 .arg(gpgIdFile)
834 .arg(base);
835 return std::nullopt;
836 }
837 const qint64 generation = base + 1;
838 // No digest until the bytes are written: the previous generation's must
839 // not pass for this one's.
840 if (!t.remember(k, generation, QString(), error)) {
841 return std::nullopt;
842 }
843 return generation;
844}
845
846auto GpgIdGeneration::recordWritten(const QString &gpgIdFile,
847 const QByteArray &contents, QString *error)
848 -> bool {
849 const std::optional<Header> header = parse(contents, error);
850 if (!header) {
851 return false;
852 }
853 Transaction t;
854 if (!t.ok(error)) {
855 return false;
856 }
857 const QString k = key(gpgIdFile);
858 const qint64 last = t.generation(k);
859 if (header->generation != last) {
860 // Not the generation reserved last: something else moved the record in
861 // between; whatever is there is not overwritten with these bytes.
862 if (error)
863 *error = tr("The generation record of the recipient lists, %1, has "
864 "moved on to generation %2 while generation %3 was being "
865 "written.")
866 .arg(t.path)
867 .arg(last)
868 .arg(header->generation);
869 return false;
870 }
871 return t.remember(k, last, digest(contents), error);
872}
static auto accept(const QString &gpgIdFile, const QByteArray &contents, const QString &storeRoot, QString *error=nullptr) -> Verdict
Decide about a verified list: its folder against where it sits, its generation against the remembered...
static auto ofThisStore(const QString &gpgIdFile, const Header &header, const QString &pinned, bool alreadyAccepted, const QString &recordPath, QString *error) -> bool
Whether header fits the store this device knows by pinned: its store line names that id,...
static auto digest(const QByteArray &contents) -> QString
The digest of a list as the record keeps it: SHA-256 of the exact bytes, hexadecimal,...
static auto header(qint64 generation, const QString &folder, const QString &store={}) -> QByteArray
The header lines for a list.
static auto storeRecordFile() -> QString
The file the store IDs are kept in, next to recordFile() and under its lock, so a QtPass from before ...
static auto folderOf(const QString &gpgIdFile, const QString &storeRoot) -> std::optional< QString >
The store-relative folder of gpgIdFile, as written in and compared against the folder line: ....
static auto reserveNext(const QString &gpgIdFile, std::optional< qint64 > verifiedOnDisk, QString *error=nullptr) -> std::optional< qint64 >
Reserve the generation to write next for gpgIdFile: one above both what this device remembers and ver...
static auto boundToItsFolder(const QString &gpgIdFile, const Header &header, const QString &storeRoot, QString *error) -> bool
Whether header's folder line, if it has one, names the folder the list sits in: a pair copied elsewhe...
static auto recordWritten(const QString &gpgIdFile, const QByteArray &contents, QString *error=nullptr) -> bool
Record the bytes just written for gpgIdFile, so that this device recognises its own list afterwards a...
static auto key(const QString &gpgIdFile) -> QString
The settings key for a .gpg-id: a hash of its canonical path, so path spelling (case,...
Verdict
The outcome of accept(). Only Accepted means the list may be used; the others say why not,...
@ Malformed
A header line malformed or duplicated.
@ Accepted
Folder, generation and record all agree; remembered.
@ RecordUnavailable
The record could not be read or written.
@ Rollback
Authentic and bound to this folder, but older than a generation this device accepted.
@ WrongFolder
Written for another folder of the store.
@ WrongStore
Written for another store (its store line names another id than this device knows the store by),...
@ Unbound
Older than a generation this device accepted and without a folder line: authentic,...
@ Conflict
The generation this device accepted last, but not the bytes: two devices saved at once (Git shows tha...
static auto remembered(const QString &gpgIdFile, QString *error=nullptr) -> std::optional< qint64 >
The highest generation this device has accepted or written for the .gpg-id at gpgIdFile....
static auto staleList(const QString &gpgIdFile, const Header &header, qint64 last, const QString &recordPath, QString *error) -> Verdict
The refusal for a list older than the one accepted here: a rollback, or an unbound list that could be...
static const QByteArray kStorePrefix
static const QByteArray kFolderPrefix
static const qint64 kMaxGeneration
static auto withHeader(qint64 generation, const QString &folder, const QByteArray &recipients, const QString &store={}) -> QByteArray
A complete list: the header in front of the recipients.
static auto recordFile() -> QString
The file the accepted generations are recorded in, so a refusal can name it: removing it forgets what...
static const QByteArray kGenerationPrefix
static auto parse(const QByteArray &contents, QString *error=nullptr) -> std::optional< Header >
The header a .gpg-id's bytes declare.
static auto storeIdFor(const QString &storeRoot, const std::optional< QString > &onDisk, QString *error=nullptr) -> std::optional< QString >
The store id to write into a list of storeRoot: the one this device knows the store by; else onDisk,...
static auto wayThrough(qint64 last, const QString &recordPath, const QString &saving) -> QString
How a holder of the signing key gets through a refusal, in words: saving the recipients writes the ne...
Rollback detection for signed .gpg-id files: the generation counter and the record of what was last s...
The "qtpass" logging category.
What a list's header says.
std::optional< QString > folder
Nothing when the list carries no folder line.
qint64 generation
0 when the list carries no generation line.
std::optional< QString > store
Nothing when the list carries no store line.