QtPass Developer documentation
QtPass test coverage
Current view: top level - src/gpgidgeneration.h Coverage Total Hit
Test: QtPass Lines: 100.0 % 1 1
Test Date: 2026-11-14 13:19:18 Functions: - 0 0

            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 Rollback detection for signed `.gpg-id` files: the generation counter
       6              :  * and the record of what was last seen.
       7              :  */
       8              : #ifndef SRC_GPGIDGENERATION_H_
       9              : #define SRC_GPGIDGENERATION_H_
      10              : 
      11              : #include <QByteArray>
      12              : #include <QString>
      13              : #include <optional>
      14              : 
      15              : /**
      16              :  * @ingroup keys
      17              :  * @brief Rollback detection for signed `.gpg-id` files.
      18              :  *
      19              :  * A signature proves that a recipient list is authentic and unmodified; it
      20              :  * does not prove that it is the current one, nor that it was written for
      21              :  * the folder it sits in. Whoever can write to a shared store can put back an
      22              :  * older, genuinely signed list that still names a member since removed, or
      23              :  * copy such a pair into a folder that never had a list of its own, and every
      24              :  * later encryption there would include them again.
      25              :  *
      26              :  * So a `.gpg-id` QtPass writes for a store with a signing key starts with
      27              :  * three comment lines, `# QtPass-GpgId-Generation: N`,
      28              :  * `# QtPass-GpgId-Folder: <store-relative folder>` and
      29              :  * `# QtPass-GpgId-Store: <store id>`, which `pass` (1.7.4 and later),
      30              :  * QtPass (1.8 and later) and QtPass's own recipient parser ignore and the
      31              :  * signature covers. The store id binds the list to its store, as the folder
      32              :  * line binds it to its folder: one signing key may sign several stores, and
      33              :  * a list of one copied into another at the same folder would verify there
      34              :  * too (#2050). Each device remembers the id it first saw per store and
      35              :  * refuses a list of another store, or a new list without the line. QtPass
      36              :  * remembers, per `.gpg-id` file, the highest generation it has accepted or
      37              :  * written, and refuses a signed list whose generation is lower or whose folder
      38              :  * is not the one it sits in. Stores without a signing key get plain lists, as
      39              :  * before: nothing checks their freshness, and older clients (pass up to 1.7.3,
      40              :  * Android Password Store) take a comment for a recipient.
      41              :  *
      42              :  * What this is and is not: rollback detection on a device that has seen the
      43              :  * newer list. A device seeing a store for the first time, or one that was
      44              :  * offline, has nothing to compare against. The generation is monotonic per
      45              :  * observer, not a distributed sequence number: two devices can both produce
      46              :  * generation 19 from 18, and Git is what sorts that out; a device that
      47              :  * accepted one of the two then meets the other as the same generation with
      48              :  * different bytes, which is a conflict, not a rollback, and is refused until
      49              :  * a holder of the signing key saves the recipients once more. For that the
      50              :  * record keeps, next to the generation, a digest of the exact bytes it
      51              :  * accepted or wrote. A list without the lines (written by `pass`, or by
      52              :  * QtPass before 2.0) is generation 0 and is refused once anything higher
      53              :  * was accepted; a holder of the signing key saving the recipients from
      54              :  * QtPass writes the next generation. Without a readable and writable record
      55              :  * nothing is accepted or written: unknown state is not "never seen".
      56              :  */
      57              : class GpgIdGeneration {
      58              : public:
      59              :   /// The generation line. The grammar is strict: this prefix, one ASCII
      60              :   /// decimal number of at most 18 digits, nothing else on the line.
      61              :   static const QByteArray kGenerationPrefix;
      62              :   /// The folder line: this prefix and the store-relative folder the list was
      63              :   /// written for, `.` for the store root, `/`-separated.
      64              :   static const QByteArray kFolderPrefix;
      65              :   /// The store line: this prefix and the store's id, 32 lowercase hex
      66              :   /// digits (a random 128-bit number given when the store's first signed
      67              :   /// list is written).
      68              :   static const QByteArray kStorePrefix;
      69              :   /// The largest generation the grammar admits (18 nines). reserveNext()
      70              :   /// never goes above it: a header QtPass writes must be one it reads back.
      71              :   static const qint64 kMaxGeneration;
      72              : 
      73              :   /// What a list's header says.
      74          166 :   struct Header {
      75              :     /// 0 when the list carries no generation line.
      76              :     qint64 generation = 0;
      77              :     /// Nothing when the list carries no folder line.
      78              :     std::optional<QString> folder;
      79              :     /// Nothing when the list carries no store line.
      80              :     std::optional<QString> store;
      81              :   };
      82              : 
      83              :   /**
      84              :    * @brief The header a `.gpg-id`'s bytes declare.
      85              :    * @param contents The file, as verified.
      86              :    * @param error Receives why the metadata is unusable, if not null.
      87              :    * @return The header (generation 0 and no folder for a list written
      88              :    *         before generations existed, or by `pass`); nothing when a line
      89              :    *         is malformed, appears twice, a generation line comes without a
      90              :    *         folder line or a store line without a generation line, which is
      91              :    *         not a list to trust.
      92              :    */
      93              :   static auto parse(const QByteArray &contents, QString *error = nullptr)
      94              :       -> std::optional<Header>;
      95              : 
      96              :   /**
      97              :    * @brief The header lines for a list.
      98              :    * @param generation The generation to declare.
      99              :    * @param folder The store-relative folder the list is written for.
     100              :    * @param store The store's id (storeIdFor()); empty writes no store line.
     101              :    * @return The lines, newlines included.
     102              :    */
     103              :   static auto header(qint64 generation, const QString &folder,
     104              :                      const QString &store = {}) -> QByteArray;
     105              : 
     106              :   /**
     107              :    * @brief A complete list: the header in front of the recipients.
     108              :    * @param generation The generation to declare.
     109              :    * @param folder The store-relative folder the list is written for.
     110              :    * @param recipients The recipient lines, one per line, no header.
     111              :    * @param store The store's id (storeIdFor()); empty writes no store line.
     112              :    * @return The bytes to write.
     113              :    */
     114              :   static auto withHeader(qint64 generation, const QString &folder,
     115              :                          const QByteArray &recipients,
     116              :                          const QString &store = {}) -> QByteArray;
     117              : 
     118              :   /**
     119              :    * @brief The store id to write into a list of @p storeRoot: the one this
     120              :    * device knows the store by; else @p onDisk, the store line of the
     121              :    * verified list being replaced, so a device joining a store takes its id
     122              :    * over; else a new random one. Remembered before it is returned.
     123              :    * @param storeRoot The configured store.
     124              :    * @param onDisk The store line of the verified list on disk, if any.
     125              :    * @param error Receives why the record could not be read or written.
     126              :    * @return The id, or nothing when the record is unavailable; the caller
     127              :    *         must not write the list then.
     128              :    */
     129              :   static auto storeIdFor(const QString &storeRoot,
     130              :                          const std::optional<QString> &onDisk,
     131              :                          QString *error = nullptr) -> std::optional<QString>;
     132              : 
     133              :   /**
     134              :    * @brief The store-relative folder of @p gpgIdFile, as written in and
     135              :    * compared against the folder line: `.` for the store root, `/`-separated,
     136              :    * from canonical paths, lower-cased on Windows.
     137              :    * @param gpgIdFile The `.gpg-id`.
     138              :    * @param storeRoot The configured store.
     139              :    * @return The folder, or nothing when @p gpgIdFile is not under the store
     140              :    *         or a folder name on the way holds a control character, a line
     141              :    *         break among them (the folder line could not record it).
     142              :    */
     143              :   static auto folderOf(const QString &gpgIdFile, const QString &storeRoot)
     144              :       -> std::optional<QString>;
     145              : 
     146              :   /**
     147              :    * @brief The highest generation this device has accepted or written for
     148              :    * the `.gpg-id` at @p gpgIdFile. Unreadable is not "never seen": a signed
     149              :    * list must then be refused, not compared against 0.
     150              :    * @param gpgIdFile The `.gpg-id`.
     151              :    * @param error Receives why the record could not be read, if not null.
     152              :    * @return 0 when this device has never seen the list, the generation
     153              :    *         otherwise; nothing when the record cannot be read.
     154              :    */
     155              :   static auto remembered(const QString &gpgIdFile, QString *error = nullptr)
     156              :       -> std::optional<qint64>;
     157              : 
     158              :   /**
     159              :    * @brief The outcome of accept(). Only Accepted means the list may be
     160              :    * used; the others say why not, and Rollback alone is an authentic list
     161              :    * of this folder.
     162              :    */
     163              :   enum class Verdict {
     164              :     /** @brief Folder, generation and record all agree; remembered. */
     165              :     Accepted,
     166              :     /**
     167              :      * @brief Authentic and bound to this folder, but older than a
     168              :      * generation this device accepted.
     169              :      */
     170              :     Rollback,
     171              :     /**
     172              :      * @brief Older than a generation this device accepted and without a
     173              :      * folder line: authentic, but written for who knows which folder
     174              :      * (`pass`, or QtPass before 2.0, wrote it), so not this folder's to
     175              :      * recover.
     176              :      */
     177              :     Unbound,
     178              :     /**
     179              :      * @brief The generation this device accepted last, but not the bytes:
     180              :      * two devices saved at once (Git shows that as a conflict), or an
     181              :      * authentic list of that generation was swapped in.
     182              :      */
     183              :     Conflict,
     184              :     /** @brief Written for another folder of the store. */
     185              :     WrongFolder,
     186              :     /**
     187              :      * @brief Written for another store (its store line names another id
     188              :      * than this device knows the store by), or new to this device and
     189              :      * without a store line while the store has an id.
     190              :      */
     191              :     WrongStore,
     192              :     /** @brief A header line malformed or duplicated. */
     193              :     Malformed,
     194              :     /** @brief The record could not be read or written. */
     195              :     RecordUnavailable,
     196              :   };
     197              : 
     198              :   /**
     199              :    * @brief Decide about a verified list: its folder against where it sits,
     200              :    * its generation against the remembered one, its bytes against the
     201              :    * remembered digest when the generations are equal, and remember it when
     202              :    * it passes. One transaction under the lock: read, compare, write through.
     203              :    * @param gpgIdFile The `.gpg-id` the bytes came from.
     204              :    * @param contents The verified bytes.
     205              :    * @param storeRoot The configured store, for the folder check.
     206              :    * @param error Receives the reason for a refusal, if not null. Where the
     207              :    *        way through is saving the list again, the text says so, and that
     208              :    *        the recipients the dialog then preselects are this list's.
     209              :    * @return Accepted when the list may be used; otherwise why not. Without
     210              :    *         established freshness state nothing is accepted.
     211              :    */
     212              :   static auto accept(const QString &gpgIdFile, const QByteArray &contents,
     213              :                      const QString &storeRoot, QString *error = nullptr)
     214              :       -> Verdict;
     215              : 
     216              :   /**
     217              :    * @brief Reserve the generation to write next for @p gpgIdFile: one above
     218              :    * both what this device remembers and @p verifiedOnDisk, recorded as
     219              :    * remembered before it is returned. One transaction under the lock, so two
     220              :    * writers in this process cannot get the same number.
     221              :    *
     222              :    * Reserving before the file is written is what makes a failed write
     223              :    * harmless (the record is ahead, the old list stays refused until a save
     224              :    * succeeds) and a failed record fatal for the write: a list written but
     225              :    * not recorded would let the previous one back in.
     226              :    * @param gpgIdFile The `.gpg-id` about to be written.
     227              :    * @param verifiedOnDisk The generation of the list currently on disk, if
     228              :    *        its signature verified; nothing otherwise. An unverified number
     229              :    *        is not taken: planted at the top of the grammar it would exhaust
     230              :    *        the counter, planted high it would make every other device see a
     231              :    *        jump.
     232              :    * @param error Receives why nothing could be reserved, if not null.
     233              :    * @return The generation to write, or nothing when the record could not
     234              :    *         be read or written or the counter is exhausted; the caller must
     235              :    *         not write the list then.
     236              :    */
     237              :   static auto reserveNext(const QString &gpgIdFile,
     238              :                           std::optional<qint64> verifiedOnDisk,
     239              :                           QString *error = nullptr) -> std::optional<qint64>;
     240              : 
     241              :   /**
     242              :    * @brief Record the bytes just written for @p gpgIdFile, so that this
     243              :    * device recognises its own list afterwards and refuses another list of
     244              :    * the same generation. Called after the file is on disk; the generation
     245              :    * in @p contents must be the one reserveNext() gave.
     246              :    * @param gpgIdFile The `.gpg-id` just written.
     247              :    * @param contents The exact bytes written.
     248              :    * @param error Receives why the record could not be written, if not null.
     249              :    * @return Whether the digest was recorded. A list written but not
     250              :    *         digested is still recognised by its generation; only a second
     251              :    *         list of that generation would pass until it is read once.
     252              :    */
     253              :   static auto recordWritten(const QString &gpgIdFile,
     254              :                             const QByteArray &contents,
     255              :                             QString *error = nullptr) -> bool;
     256              : 
     257              :   /**
     258              :    * @brief The digest of a list as the record keeps it: SHA-256 of the
     259              :    * exact bytes, hexadecimal, so that two authentic lists of one generation
     260              :    * can be told apart.
     261              :    * @param contents The bytes.
     262              :    * @return 64 hexadecimal characters.
     263              :    */
     264              :   static auto digest(const QByteArray &contents) -> QString;
     265              : 
     266              :   /**
     267              :    * @brief The settings key for a `.gpg-id`: a hash of its canonical path,
     268              :    * so path spelling (case, separators, links on the way) and QSettings'
     269              :    * treatment of `/` do not matter.
     270              :    * @param gpgIdFile The `.gpg-id`, existing or about to be written.
     271              :    * @return 64 hexadecimal characters.
     272              :    */
     273              :   static auto key(const QString &gpgIdFile) -> QString;
     274              : 
     275              :   /**
     276              :    * @brief The file the accepted generations are recorded in, so a refusal
     277              :    * can name it: removing it forgets what was accepted, which is the manual
     278              :    * way out for a device that cannot save the list itself.
     279              :    * @return The record's path.
     280              :    */
     281              :   static auto recordFile() -> QString;
     282              : 
     283              :   /**
     284              :    * @brief The file the store IDs are kept in, next to recordFile() and
     285              :    * under its lock, so a QtPass from before store IDs, rewriting the record,
     286              :    * cannot drop them. Removing it forgets the IDs.
     287              :    * @return The file's path.
     288              :    */
     289              :   static auto storeRecordFile() -> QString;
     290              : 
     291              : private:
     292              :   /**
     293              :    * @brief Whether @p header's folder line, if it has one, names the folder
     294              :    * the list sits in: a pair copied elsewhere is that folder's first list,
     295              :    * a rollback in disguise.
     296              :    * @param gpgIdFile The `.gpg-id` the bytes came from.
     297              :    * @param header What its header says.
     298              :    * @param storeRoot The configured store.
     299              :    * @param error Receives why not, if not null.
     300              :    * @return Whether the list belongs where it is.
     301              :    */
     302              :   static auto boundToItsFolder(const QString &gpgIdFile, const Header &header,
     303              :                                const QString &storeRoot, QString *error)
     304              :       -> bool;
     305              : 
     306              :   /**
     307              :    * @brief Whether @p header fits the store this device knows by @p pinned:
     308              :    * its store line names that id, or it has none and is either generation 0
     309              :    * (`pass`) or a list this device accepted before it knew the id.
     310              :    * @param gpgIdFile The `.gpg-id` the bytes came from.
     311              :    * @param header What its header says.
     312              :    * @param pinned The store id this device knows the store by.
     313              :    * @param alreadyAccepted Whether these exact bytes are the generation
     314              :    *        this device accepted last for the file.
     315              :    * @param recordPath The store IDs' file, which the refusal names.
     316              :    * @param error Receives why not, if not null.
     317              :    * @return Whether the list is this store's.
     318              :    */
     319              :   static auto ofThisStore(const QString &gpgIdFile, const Header &header,
     320              :                           const QString &pinned, bool alreadyAccepted,
     321              :                           const QString &recordPath, QString *error) -> bool;
     322              : 
     323              :   /**
     324              :    * @brief How a holder of the signing key gets through a refusal, in words:
     325              :    * saving the recipients writes the next generation, unless the record is
     326              :    * already at the ceiling of the grammar, where it has to be removed first.
     327              :    * @param last The generation this device accepted before.
     328              :    * @param recordPath The record file, which the text names.
     329              :    * @param saving What the user does in the Users dialog, in this refusal's
     330              :    *        terms ("checking the recipients and saving").
     331              :    * @return The sentence to append to a refusal.
     332              :    */
     333              :   static auto wayThrough(qint64 last, const QString &recordPath,
     334              :                          const QString &saving) -> QString;
     335              : 
     336              :   /**
     337              :    * @brief The refusal for a list older than the one accepted here: a
     338              :    * rollback, or an unbound list that could be any folder's history.
     339              :    * @param gpgIdFile The `.gpg-id` the bytes came from.
     340              :    * @param header What its header says.
     341              :    * @param last The generation this device accepted before.
     342              :    * @param recordPath The record file, which the text names.
     343              :    * @param error Receives the reason, if not null.
     344              :    * @return Rollback for a list that says which folder it is for, Unbound
     345              :    *         for one that does not.
     346              :    */
     347              :   static auto staleList(const QString &gpgIdFile, const Header &header,
     348              :                         qint64 last, const QString &recordPath, QString *error)
     349              :       -> Verdict;
     350              : };
     351              : 
     352              : #endif // SRC_GPGIDGENERATION_H_
        

Generated by: LCOV version 2.0-1