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_
|