QtPass Developer documentation
QtPass test coverage
Current view: top level - src/gpgidgeneration.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 93.9 % 395 371
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 42 42

            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 : }
        

Generated by: LCOV version 2.0-1