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 the `.gpg-id` generation header and the per-store record
6 : * of accepted generations.
7 : */
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 :
26 : const QByteArray GpgIdGeneration::kGenerationPrefix =
27 : QByteArrayLiteral("# QtPass-GpgId-Generation: ");
28 : const QByteArray GpgIdGeneration::kFolderPrefix =
29 : QByteArrayLiteral("# QtPass-GpgId-Folder: ");
30 : const QByteArray GpgIdGeneration::kStorePrefix =
31 : QByteArrayLiteral("# QtPass-GpgId-Store: ");
32 : const qint64 GpgIdGeneration::kMaxGeneration = 999999999999999999LL;
33 :
34 : namespace {
35 : constexpr int kMaxDigits = 18;
36 : const 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().
41 : auto tr(const char *text) -> QString {
42 60 : return QCoreApplication::translate("GpgIdGeneration", text);
43 : }
44 :
45 : /// Serialises read-compare-write on the record in this process (the
46 : /// re-encryption worker reads while the UI may write); see Transaction for
47 : /// the cross-process lock file.
48 264 : QMutex &recordLock() {
49 264 : static QMutex mutex;
50 264 : return mutex;
51 : }
52 :
53 : /// How long a transaction waits for another process to let go of the record.
54 : constexpr int kRecordLockTimeoutMs = 3000;
55 :
56 : /// Per list: the highest generation accepted or written, and the SHA-256 of
57 : /// its exact bytes once known (empty after a reservation).
58 6517 : struct Entry {
59 : qint64 generation = 0;
60 : QString digest;
61 : };
62 : using Entries = QMap<QString, Entry>;
63 : /// Per store, by GpgIdGeneration::key() of its root: the store id this
64 : /// device knows it by.
65 : using Stores = QMap<QString, QString>;
66 :
67 : /// Everything the record holds.
68 252 : struct Record {
69 : Entries lists;
70 : Stores stores;
71 : };
72 :
73 : /// Whether @p id is a store id as QtPass writes it: 32 lowercase hex digits.
74 829 : auto isStoreId(const QByteArray &id) -> bool {
75 829 : return id.size() == 32 && std::all_of(id.cbegin(), id.cend(), [](char c) {
76 26315 : return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
77 829 : });
78 : }
79 :
80 : const QString kRecordName = QStringLiteral("QtPass-gpgid-generations.json");
81 : /// The store ids live in a file of their own, under the record's lock: a
82 : /// QtPass from before store ids rewrites the record whole, and would drop
83 : /// anything it does not know from it.
84 : const QString kStoresName = QStringLiteral("QtPass-gpgid-stores.json");
85 : constexpr int kRecordFormat = 1;
86 :
87 : /// A file QtPass reads and writes itself, not QSettings: no cache between
88 : /// read and write, no registry (on Windows a QSettings "file" is a registry
89 : /// path). Placed where QSettings would put an ini file, so the platform
90 : /// convention and the tests' redirection of that path both apply.
91 540 : auto recordPath() -> QString {
92 : const QSettings probe(QSettings::IniFormat, QSettings::UserScope,
93 540 : QCoreApplication::organizationName().isEmpty()
94 1080 : ? QStringLiteral("IJHack")
95 : : QCoreApplication::organizationName(),
96 1080 : QStringLiteral("QtPass-gpgid-generations"));
97 1080 : return QFileInfo(probe.fileName()).absolutePath() + QLatin1Char('/') +
98 1080 : kRecordName;
99 540 : }
100 :
101 : /// The store ids' file, next to the record.
102 265 : auto storesPath() -> QString {
103 530 : return QFileInfo(recordPath()).absolutePath() + QLatin1Char('/') +
104 530 : kStoresName;
105 : }
106 :
107 : /// One list's entry in the record, or nothing when it is malformed. The
108 : /// generation travels as a string: JSON numbers are doubles, and the grammar
109 : /// admits 18 digits.
110 1181 : auto recordEntry(const QJsonValue &value) -> std::optional<Entry> {
111 1181 : if (!value.isObject()) {
112 0 : return std::nullopt;
113 : }
114 1181 : const QJsonObject item = value.toObject();
115 1181 : const QString generation = item.value(QLatin1String("generation")).toString();
116 1181 : if (generation.isEmpty() || generation.size() > kMaxDigits) {
117 1 : return std::nullopt;
118 : }
119 2681 : for (const QChar c : generation) {
120 1501 : if (c < QLatin1Char('0') || c > QLatin1Char('9')) {
121 0 : return std::nullopt;
122 : }
123 : }
124 1180 : Entry entry;
125 1180 : entry.generation = generation.toLongLong();
126 2360 : entry.digest = item.value(QLatin1String("sha256")).toString();
127 : return entry;
128 1181 : }
129 :
130 : /// The JSON object a record file holds, when it is one of @p format and
131 : /// carries an object under @p name.
132 388 : auto recordObject(const QByteArray &bytes, const QString &name)
133 : -> std::optional<QJsonObject> {
134 388 : QJsonParseError parse{};
135 388 : const QJsonDocument doc = QJsonDocument::fromJson(bytes, &parse);
136 388 : if (parse.error != QJsonParseError::NoError || !doc.isObject()) {
137 4 : return std::nullopt;
138 : }
139 384 : const QJsonObject root = doc.object();
140 384 : const QJsonValue inner = root.value(name);
141 768 : if (root.value(QLatin1String("format")).toInt() != kRecordFormat ||
142 : !inner.isObject()) {
143 0 : return std::nullopt;
144 : }
145 768 : return inner.toObject();
146 388 : }
147 :
148 : /// The record's entries from its bytes, or nothing when any part of it is
149 : /// not what QtPass writes.
150 245 : auto parseRecord(const QByteArray &bytes) -> std::optional<Entries> {
151 : const std::optional<QJsonObject> byKey =
152 490 : recordObject(bytes, QStringLiteral("lists"));
153 245 : if (!byKey) {
154 4 : return std::nullopt;
155 : }
156 241 : Entries entries;
157 1662 : for (auto it = byKey->constBegin(); it != byKey->constEnd(); ++it) {
158 1181 : const std::optional<Entry> entry = recordEntry(it.value());
159 1181 : if (!entry) {
160 1 : return std::nullopt;
161 : }
162 2360 : entries.insert(it.key(), *entry);
163 : }
164 : return entries;
165 : }
166 :
167 : /// The store ids from their file's bytes, or nothing when any part of it is
168 : /// not what QtPass writes.
169 143 : auto parseStores(const QByteArray &bytes) -> std::optional<Stores> {
170 : const std::optional<QJsonObject> byKey =
171 286 : recordObject(bytes, QStringLiteral("stores"));
172 143 : if (!byKey) {
173 0 : return std::nullopt;
174 : }
175 143 : Stores stores;
176 1038 : for (auto it = byKey->constBegin(); it != byKey->constEnd(); ++it) {
177 : const QString id =
178 754 : it.value().toObject().value(QLatin1String("id")).toString();
179 2262 : if (!it.value().isObject() || !isStoreId(id.toLatin1())) {
180 2 : return std::nullopt;
181 : }
182 1504 : stores.insert(it.key(), id);
183 : }
184 : return stores;
185 : }
186 :
187 : /// A record file as on disk, parsed by @p parse: empty where there is no
188 : /// file yet; nothing with @p error set when the file cannot be read or does
189 : /// not parse ("unknown", never "never seen").
190 : template <typename T, typename Parse>
191 513 : auto loadFile(const QString &path, Parse parse, QString *error)
192 : -> std::optional<T> {
193 513 : if (!QFileInfo::exists(path)) {
194 125 : return T{};
195 : }
196 388 : QFile file(path);
197 388 : if (!file.open(QIODevice::ReadOnly)) {
198 0 : if (error)
199 0 : *error = QCoreApplication::translate(
200 : "GpgIdGeneration",
201 : "The generation record of the recipient lists, %1, cannot "
202 : "be accessed.")
203 0 : .arg(path);
204 0 : return std::nullopt;
205 : }
206 776 : std::optional<T> value = parse(file.readAll());
207 388 : if (!value && error) {
208 7 : *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 14 : .arg(path);
215 : }
216 : return value;
217 388 : }
218 :
219 : /// A record file written through, whole, in place: `format` and @p inner
220 : /// under @p name. Nothing is cached: a failed write leaves the next
221 : /// transaction reading what is on disk.
222 134 : auto saveFile(const QString &path, const QString &name,
223 : const QJsonObject &inner, QString *error) -> bool {
224 134 : QJsonObject root;
225 134 : root.insert(QLatin1String("format"), kRecordFormat);
226 134 : root.insert(name, inner);
227 134 : QSaveFile file(path);
228 134 : if (!file.open(QIODevice::WriteOnly)) {
229 0 : if (error)
230 0 : *error = QCoreApplication::translate(
231 : "GpgIdGeneration",
232 : "The generation record of the recipient lists, %1, cannot "
233 : "be written.")
234 0 : .arg(path);
235 0 : return false;
236 : }
237 134 : file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
238 134 : const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Indented);
239 134 : if (file.write(bytes) != bytes.size() || !file.commit()) {
240 0 : qCWarning(lcQtPass) << "Could not write the .gpg-id generation record"
241 0 : << path << file.errorString();
242 0 : if (error)
243 0 : *error = QCoreApplication::translate(
244 : "GpgIdGeneration",
245 : "The generation record of the recipient lists, %1, cannot "
246 : "be written.")
247 0 : .arg(path);
248 0 : return false;
249 : }
250 : return true;
251 134 : }
252 :
253 105 : auto saveRecord(const QString &path, const Entries &entries, QString *error)
254 : -> bool {
255 105 : QJsonObject lists;
256 657 : for (auto it = entries.constBegin(); it != entries.constEnd(); ++it) {
257 552 : QJsonObject item;
258 552 : item.insert(QLatin1String("generation"),
259 1656 : QString::number(it.value().generation));
260 552 : if (!it.value().digest.isEmpty()) {
261 477 : item.insert(QLatin1String("sha256"), it.value().digest);
262 : }
263 552 : lists.insert(it.key(), item);
264 552 : }
265 315 : return saveFile(path, QStringLiteral("lists"), lists, error);
266 105 : }
267 :
268 29 : auto saveStores(const QString &path, const Stores &stores, QString *error)
269 : -> bool {
270 29 : QJsonObject byKey;
271 173 : for (auto it = stores.constBegin(); it != stores.constEnd(); ++it) {
272 144 : QJsonObject item;
273 144 : item.insert(QLatin1String("id"), it.value());
274 144 : byKey.insert(it.key(), item);
275 144 : }
276 87 : return saveFile(path, QStringLiteral("stores"), byKey, error);
277 29 : }
278 :
279 : /// Process mutex, then a lock file against a second QtPass (no single-instance
280 : /// guard, a Flatpak next to a native one), then a fresh read of the record and
281 : /// the store ids. When `ok()` is false (no lock, or a file that does not
282 : /// parse) the caller neither uses nor writes them: RecordUnavailable.
283 : struct 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 1061 : Transaction() {
296 264 : if (!locked) {
297 5 : 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 10 : .arg(path);
303 12 : return;
304 : }
305 : std::optional<Entries> lists =
306 259 : loadFile<Entries>(path, parseRecord, &problem);
307 259 : if (!lists) {
308 : return;
309 : }
310 : std::optional<Stores> stores =
311 254 : loadFile<Stores>(storesFile, parseStores, &problem);
312 254 : if (!stores) {
313 : return;
314 : }
315 504 : entries = Record{std::move(*lists), std::move(*stores)};
316 0 : }
317 :
318 264 : [[nodiscard]] auto ok(QString *error) const -> bool {
319 264 : if (entries) {
320 : return true;
321 : }
322 12 : if (error)
323 9 : *error = problem;
324 : return false;
325 : }
326 :
327 : /// The remembered generation for @p key, 0 when never seen.
328 211 : [[nodiscard]] auto generation(const QString &key) const -> qint64 {
329 211 : return entries->lists.value(key).generation;
330 : }
331 :
332 : /// The store id this device knows the store by, for @p key of its root;
333 : /// nothing before the first one.
334 136 : [[nodiscard]] auto store(const QString &key) const -> std::optional<QString> {
335 136 : const auto it = entries->stores.constFind(key);
336 136 : if (it == entries->stores.constEnd()) {
337 95 : return std::nullopt;
338 : }
339 : return it.value();
340 : }
341 :
342 : /// Remember @p id for the store with @p key, through to disk.
343 29 : auto pinStore(const QString &key, const QString &id, QString *error) -> bool {
344 : Stores updated = entries->stores;
345 29 : updated.insert(key, id);
346 29 : if (!saveStores(storesFile, updated, error)) {
347 : return false;
348 : }
349 : entries->stores = std::move(updated);
350 29 : return true;
351 : }
352 :
353 : /// The remembered digest for @p key; nothing when none was recorded (a
354 : /// reservation not yet written, or a record from before digests).
355 57 : [[nodiscard]] auto digest(const QString &key) const
356 : -> std::optional<QString> {
357 57 : const QString value = entries->lists.value(key).digest;
358 57 : if (value.isEmpty()) {
359 4 : return std::nullopt;
360 : }
361 : return value;
362 : }
363 :
364 : /// Write @p generation with @p digest (empty: none) for @p key through
365 : /// to disk.
366 105 : auto remember(const QString &key, qint64 generation, const QString &digest,
367 : QString *error) -> bool {
368 : Entries updated = entries->lists;
369 105 : updated[key] = Entry{generation, digest};
370 105 : if (!saveRecord(path, updated, error)) {
371 : return false;
372 : }
373 : entries->lists = std::move(updated);
374 105 : return true;
375 : }
376 : };
377 :
378 : /// Whether @p value holds a control character (a line break, a tab, a NUL,
379 : /// DEL): the folder line can carry none of them, so parse() refuses a line
380 : /// with one and folderOf() a folder whose name has one.
381 195 : auto hasControl(const QByteArray &value) -> bool {
382 : return std::any_of(value.cbegin(), value.cend(), [](const char c) {
383 598 : const auto u = static_cast<unsigned char>(c);
384 598 : return u < 0x20 || u == 0x7f;
385 195 : });
386 : }
387 :
388 : /// Store-relative, `/`-separated, canonical, `.` for the root; nothing when
389 : /// @p path is not under @p root, or the name holds a control character.
390 133 : auto relativeFolder(const QString &path, const QString &root)
391 : -> std::optional<QString> {
392 133 : QString folder = QFileInfo(path).absoluteDir().canonicalPath();
393 133 : if (folder.isEmpty()) {
394 0 : folder = QFileInfo(path).absolutePath();
395 : }
396 133 : QString store = QFileInfo(root).canonicalFilePath();
397 133 : if (store.isEmpty()) {
398 0 : store = QDir::cleanPath(QFileInfo(root).absoluteFilePath());
399 : }
400 133 : folder = QDir::fromNativeSeparators(QDir::cleanPath(folder));
401 266 : store = QDir::fromNativeSeparators(QDir::cleanPath(store));
402 : #ifdef Q_OS_WIN
403 : folder = folder.toLower();
404 : store = store.toLower();
405 : #endif
406 133 : if (folder == store) {
407 94 : return QStringLiteral(".");
408 : }
409 : const QString prefix =
410 39 : store.endsWith(QLatin1Char('/')) ? store : store + QLatin1Char('/');
411 39 : if (!folder.startsWith(prefix)) {
412 2 : return std::nullopt;
413 : }
414 74 : 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 37 : if (hasControl(folder.toUtf8())) {
420 4 : return std::nullopt;
421 : }
422 : return folder;
423 : }
424 : } // namespace
425 :
426 : namespace {
427 :
428 : /// The digits of a generation line, or nothing when the line does not hold
429 : /// one ASCII decimal number of at most kMaxDigits digits and nothing else.
430 166 : auto generationDigits(const QByteArray &digits) -> std::optional<qint64> {
431 166 : if (digits.isEmpty() || digits.size() > kMaxDigits) {
432 2 : return std::nullopt;
433 : }
434 400 : for (const char c : digits) {
435 242 : if (c < '0' || c > '9') {
436 6 : return std::nullopt;
437 : }
438 : }
439 158 : return digits.toLongLong();
440 : }
441 :
442 : /// Takes one line of ours into @p generation or @p folder. Returns why it
443 : /// cannot be taken (a second line of the kind, a malformed one, an unknown
444 : /// kind), or an empty string when it was.
445 403 : auto takeHeaderLine(const QByteArray &line, std::optional<qint64> &generation,
446 : std::optional<QString> &folder,
447 : std::optional<QString> &store) -> QString {
448 403 : if (line.startsWith(GpgIdGeneration::kStorePrefix)) {
449 73 : if (store) {
450 : return QCoreApplication::translate(
451 1 : "GpgIdGeneration", "The list carries more than one store line.");
452 : }
453 72 : const QByteArray value = line.mid(GpgIdGeneration::kStorePrefix.size());
454 72 : if (isStoreId(value)) {
455 67 : store = QString::fromLatin1(value);
456 67 : return {};
457 : }
458 10 : return QCoreApplication::translate("GpgIdGeneration",
459 : "The store line is malformed: %1")
460 10 : .arg(QString::fromUtf8(line));
461 : }
462 330 : if (line.startsWith(GpgIdGeneration::kGenerationPrefix)) {
463 167 : if (generation) {
464 : return QCoreApplication::translate(
465 1 : "GpgIdGeneration", "The list carries more than one generation line.");
466 : }
467 : generation =
468 332 : generationDigits(line.mid(GpgIdGeneration::kGenerationPrefix.size()));
469 166 : if (generation) {
470 158 : return {};
471 : }
472 16 : return QCoreApplication::translate("GpgIdGeneration",
473 : "The generation line is malformed: %1")
474 16 : .arg(QString::fromUtf8(line));
475 : }
476 163 : if (line.startsWith(GpgIdGeneration::kFolderPrefix)) {
477 160 : if (folder) {
478 : return QCoreApplication::translate(
479 1 : "GpgIdGeneration", "The list carries more than one folder line.");
480 : }
481 159 : 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 159 : if (!value.isEmpty() && !hasControl(value)) {
486 154 : folder = QString::fromUtf8(value);
487 154 : return {};
488 : }
489 10 : return QCoreApplication::translate("GpgIdGeneration",
490 : "The folder line is malformed: %1")
491 10 : .arg(QString::fromUtf8(line));
492 : }
493 6 : return QCoreApplication::translate("GpgIdGeneration",
494 : "The header line is malformed: %1")
495 6 : .arg(QString::fromUtf8(line));
496 : }
497 :
498 : } // namespace
499 :
500 185 : auto GpgIdGeneration::parse(const QByteArray &contents, QString *error)
501 : -> std::optional<Header> {
502 185 : std::optional<qint64> generation;
503 185 : std::optional<QString> folder;
504 185 : std::optional<QString> store;
505 1103 : for (const QByteArray &rawLine : contents.split('\n')) {
506 : QByteArray line = rawLine;
507 : if (line.endsWith('\r')) {
508 3 : 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 757 : if (!line.startsWith(kOurComment)) {
514 354 : continue;
515 : }
516 403 : const QString complaint = takeHeaderLine(line, generation, folder, store);
517 403 : if (complaint.isEmpty()) {
518 : continue;
519 : }
520 24 : if (error)
521 24 : *error = complaint;
522 24 : return std::nullopt;
523 : }
524 161 : if (generation && !folder) {
525 1 : if (error)
526 1 : *error = tr("The list carries a generation line but no folder line.");
527 1 : return std::nullopt;
528 : }
529 : // QtPass writes the store line only with the other two.
530 160 : if (store && !generation) {
531 1 : if (error)
532 1 : *error = tr("The list carries a store line but no generation line.");
533 1 : return std::nullopt;
534 : }
535 159 : Header header;
536 159 : header.generation = generation.value_or(0);
537 : header.folder = folder;
538 : header.store = store;
539 : return header;
540 : }
541 :
542 111 : auto GpgIdGeneration::header(qint64 generation, const QString &folder,
543 : const QString &store) -> QByteArray {
544 222 : QByteArray lines = kGenerationPrefix + QByteArray::number(generation) + '\n' +
545 222 : kFolderPrefix + folder.toUtf8() + '\n';
546 111 : if (!store.isEmpty()) {
547 86 : lines += kStorePrefix + store.toLatin1() + '\n';
548 : }
549 111 : return lines;
550 : }
551 :
552 111 : auto GpgIdGeneration::withHeader(qint64 generation, const QString &folder,
553 : const QByteArray &recipients,
554 : const QString &store) -> QByteArray {
555 222 : return header(generation, folder, store) + recipients;
556 : }
557 :
558 133 : auto GpgIdGeneration::folderOf(const QString &gpgIdFile,
559 : const QString &storeRoot)
560 : -> std::optional<QString> {
561 133 : return relativeFolder(gpgIdFile, storeRoot);
562 : }
563 :
564 356 : auto GpgIdGeneration::key(const QString &gpgIdFile) -> QString {
565 356 : 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 356 : QString canonical = info.canonicalFilePath();
569 356 : if (canonical.isEmpty()) {
570 88 : const QString dir = info.absoluteDir().canonicalPath();
571 263 : canonical = (dir.isEmpty() ? info.absolutePath() : dir) + QLatin1Char('/') +
572 264 : info.fileName();
573 : }
574 712 : canonical = QDir::fromNativeSeparators(canonical);
575 : #ifdef Q_OS_WIN
576 : canonical = canonical.toLower();
577 : #endif
578 : return QString::fromLatin1(
579 356 : QCryptographicHash::hash(canonical.toUtf8(), QCryptographicHash::Sha256)
580 1068 : .toHex());
581 356 : }
582 :
583 11 : auto GpgIdGeneration::recordFile() -> QString { return recordPath(); }
584 :
585 1 : auto GpgIdGeneration::storeRecordFile() -> QString { return storesPath(); }
586 :
587 126 : auto GpgIdGeneration::digest(const QByteArray &contents) -> QString {
588 : return QString::fromLatin1(
589 252 : QCryptographicHash::hash(contents, QCryptographicHash::Sha256).toHex());
590 : }
591 :
592 43 : auto GpgIdGeneration::remembered(const QString &gpgIdFile, QString *error)
593 : -> std::optional<qint64> {
594 43 : Transaction t;
595 43 : if (!t.ok(error)) {
596 3 : return std::nullopt;
597 : }
598 40 : return t.generation(key(gpgIdFile));
599 43 : }
600 :
601 107 : auto GpgIdGeneration::boundToItsFolder(const QString &gpgIdFile,
602 : const Header &header,
603 : const QString &storeRoot, QString *error)
604 : -> bool {
605 107 : if (!header.folder) {
606 : return true;
607 : }
608 99 : const std::optional<QString> here = folderOf(gpgIdFile, storeRoot);
609 99 : if (here && *here == *header.folder) {
610 : return true;
611 : }
612 6 : if (error)
613 : *error =
614 6 : 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 12 : .arg(gpgIdFile, *header.folder, here.value_or(QStringLiteral("?")));
621 : return false;
622 : }
623 :
624 22 : auto GpgIdGeneration::wayThrough(qint64 last, const QString &recordPath,
625 : const QString &saving) -> QString {
626 22 : if (last >= kMaxGeneration) {
627 2 : 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 4 : .arg(last)
632 2 : .arg(recordPath, saving);
633 : }
634 20 : 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 40 : .arg(saving)
638 60 : .arg(last + 1)
639 20 : .arg(recordPath);
640 : }
641 :
642 18 : auto GpgIdGeneration::staleList(const QString &gpgIdFile, const Header &header,
643 : qint64 last, const QString &recordPath,
644 : QString *error) -> Verdict {
645 18 : 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 3 : if (error)
650 : *error =
651 3 : 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 6 : .arg(gpgIdFile)
657 6 : .arg(last)
658 9 : .arg(
659 3 : wayThrough(last, recordPath,
660 3 : tr("selecting the recipients afresh and saving")));
661 3 : return Verdict::Unbound;
662 : }
663 15 : if (error)
664 : *error =
665 15 : 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 30 : .arg(gpgIdFile)
669 30 : .arg(header.generation)
670 30 : .arg(last)
671 30 : .arg(wayThrough(last, recordPath,
672 15 : 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 :
679 109 : auto GpgIdGeneration::accept(const QString &gpgIdFile,
680 : const QByteArray &contents,
681 : const QString &storeRoot, QString *error)
682 : -> Verdict {
683 109 : QString why;
684 109 : const std::optional<Header> header = parse(contents, &why);
685 109 : if (!header) {
686 2 : if (error)
687 2 : *error = tr("The signed recipient list %1 is not one to trust: %2")
688 4 : .arg(gpgIdFile, why);
689 2 : 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 107 : if (!boundToItsFolder(gpgIdFile, *header, storeRoot, error)) {
694 : return Verdict::WrongFolder;
695 : }
696 101 : Transaction t;
697 101 : if (!t.ok(error)) {
698 : return Verdict::RecordUnavailable;
699 : }
700 95 : const QString k = key(gpgIdFile);
701 95 : const qint64 last = t.generation(k);
702 95 : const qint64 generation = header->generation;
703 95 : 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 95 : const QString storeKey = key(storeRoot);
708 95 : const std::optional<QString> pinned = t.store(storeKey);
709 121 : if (pinned && !ofThisStore(gpgIdFile, *header, *pinned,
710 147 : 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 64 : const auto pinHere = [&]() {
716 64 : if (pinned || !header->store) {
717 : return true;
718 : }
719 3 : return t.pinStore(storeKey, *header->store, error);
720 90 : };
721 90 : if (generation < last) {
722 18 : return staleList(gpgIdFile, *header, last, t.path, error);
723 : }
724 72 : 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).
728 5 : return pinHere() ? Verdict::Accepted : Verdict::RecordUnavailable;
729 : }
730 67 : 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 38 : const std::optional<QString> known = t.digest(k);
735 38 : if (known && *known != bytes) {
736 8 : if (error)
737 4 : *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 8 : .arg(gpgIdFile)
743 8 : .arg(generation)
744 8 : .arg(wayThrough(last, t.path,
745 8 : tr("checking the recipients and saving")));
746 8 : return Verdict::Conflict;
747 : }
748 30 : if (known) {
749 27 : return pinHere() ? Verdict::Accepted : Verdict::RecordUnavailable;
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 32 : if (!pinHere() || !t.remember(k, generation, bytes, error)) {
755 0 : return Verdict::RecordUnavailable;
756 : }
757 : return Verdict::Accepted;
758 101 : }
759 :
760 26 : auto GpgIdGeneration::ofThisStore(const QString &gpgIdFile,
761 : const Header &header, const QString &pinned,
762 : bool alreadyAccepted,
763 : const QString &recordPath, QString *error)
764 : -> bool {
765 26 : if (header.store) {
766 23 : if (*header.store == pinned) {
767 : return true;
768 : }
769 4 : if (error)
770 : *error =
771 2 : 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 4 : .arg(gpgIdFile, *header.store, pinned, recordPath);
777 4 : 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 3 : if (header.generation == 0 || alreadyAccepted) {
783 : return true;
784 : }
785 1 : if (error)
786 : *error =
787 1 : 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 2 : .arg(gpgIdFile, pinned);
794 : return false;
795 : }
796 :
797 42 : auto GpgIdGeneration::storeIdFor(const QString &storeRoot,
798 : const std::optional<QString> &onDisk,
799 : QString *error) -> std::optional<QString> {
800 42 : Transaction t;
801 42 : if (!t.ok(error)) {
802 1 : return std::nullopt;
803 : }
804 41 : const QString storeKey = key(storeRoot);
805 41 : 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 3 : const QString id = onDisk && isStoreId(onDisk->toLatin1())
811 26 : ? *onDisk
812 50 : : QUuid::createUuid().toString(QUuid::Id128).toLower();
813 26 : if (!t.pinStore(storeKey, id, error)) {
814 0 : return std::nullopt;
815 : }
816 : return id;
817 42 : }
818 :
819 46 : auto GpgIdGeneration::reserveNext(const QString &gpgIdFile,
820 : std::optional<qint64> verifiedOnDisk,
821 : QString *error) -> std::optional<qint64> {
822 46 : Transaction t;
823 46 : if (!t.ok(error)) {
824 2 : return std::nullopt;
825 : }
826 44 : const QString k = key(gpgIdFile);
827 44 : const qint64 last = t.generation(k);
828 : const qint64 base = qMax(last, verifiedOnDisk.value_or(0));
829 44 : if (base >= kMaxGeneration) {
830 2 : if (error)
831 2 : *error = tr("The recipient list %1 has reached generation %2, the "
832 : "highest there is; the list cannot be written.")
833 4 : .arg(gpgIdFile)
834 6 : .arg(base);
835 2 : return std::nullopt;
836 : }
837 42 : 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 84 : if (!t.remember(k, generation, QString(), error)) {
841 0 : return std::nullopt;
842 : }
843 42 : return generation;
844 46 : }
845 :
846 32 : auto GpgIdGeneration::recordWritten(const QString &gpgIdFile,
847 : const QByteArray &contents, QString *error)
848 : -> bool {
849 32 : const std::optional<Header> header = parse(contents, error);
850 32 : if (!header) {
851 : return false;
852 : }
853 32 : Transaction t;
854 32 : if (!t.ok(error)) {
855 : return false;
856 : }
857 32 : const QString k = key(gpgIdFile);
858 32 : const qint64 last = t.generation(k);
859 32 : 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 1 : if (error)
863 1 : *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 2 : .arg(t.path)
867 2 : .arg(last)
868 3 : .arg(header->generation);
869 1 : return false;
870 : }
871 62 : return t.remember(k, last, digest(contents), error);
872 32 : }
|