10#include <QCoreApplication>
11#include <QCryptographicHash>
15#include <QJsonDocument>
20#include <QMutexLocker>
27 QByteArrayLiteral(
"# QtPass-GpgId-Generation: ");
29 QByteArrayLiteral(
"# QtPass-GpgId-Folder: ");
31 QByteArrayLiteral(
"# QtPass-GpgId-Store: ");
35constexpr int kMaxDigits = 18;
36const QByteArray kOurComment = QByteArrayLiteral(
"# QtPass-GpgId-");
41auto tr(
const char *text) -> QString {
42 return QCoreApplication::translate(
"GpgIdGeneration", text);
54constexpr int kRecordLockTimeoutMs = 3000;
59 qint64 generation = 0;
62using Entries = QMap<QString, Entry>;
65using Stores = QMap<QString, QString>;
74auto isStoreId(
const QByteArray &
id) ->
bool {
75 return id.size() == 32 && std::all_of(
id.cbegin(),
id.cend(), [](
char c) {
76 return (c >=
'0' && c <=
'9') || (c >=
'a' && c <=
'f');
80const QString kRecordName = QStringLiteral(
"QtPass-gpgid-generations.json");
84const QString kStoresName = QStringLiteral(
"QtPass-gpgid-stores.json");
85constexpr int kRecordFormat = 1;
91auto recordPath() -> QString {
92 const QSettings probe(QSettings::IniFormat, QSettings::UserScope,
93 QCoreApplication::organizationName().isEmpty()
94 ? QStringLiteral(
"IJHack")
95 : QCoreApplication::organizationName(),
96 QStringLiteral(
"QtPass-gpgid-generations"));
97 return QFileInfo(probe.fileName()).absolutePath() + QLatin1Char(
'/') +
102auto storesPath() -> QString {
103 return QFileInfo(recordPath()).absolutePath() + QLatin1Char(
'/') +
110auto recordEntry(
const QJsonValue &value) -> std::optional<Entry> {
111 if (!value.isObject()) {
114 const QJsonObject item = value.toObject();
115 const QString generation = item.value(QLatin1String(
"generation")).toString();
116 if (generation.isEmpty() || generation.size() > kMaxDigits) {
119 for (
const QChar c : generation) {
120 if (c < QLatin1Char(
'0') || c > QLatin1Char(
'9')) {
125 entry.generation = generation.toLongLong();
126 entry.digest = item.value(QLatin1String(
"sha256")).toString();
132auto recordObject(
const QByteArray &bytes,
const QString &name)
133 -> std::optional<QJsonObject> {
134 QJsonParseError parse{};
135 const QJsonDocument doc = QJsonDocument::fromJson(bytes, &parse);
136 if (parse.error != QJsonParseError::NoError || !doc.isObject()) {
139 const QJsonObject root = doc.object();
140 const QJsonValue inner = root.value(name);
141 if (root.value(QLatin1String(
"format")).toInt() != kRecordFormat ||
145 return inner.toObject();
150auto parseRecord(
const QByteArray &bytes) -> std::optional<Entries> {
151 const std::optional<QJsonObject> byKey =
152 recordObject(bytes, QStringLiteral(
"lists"));
157 for (
auto it = byKey->constBegin(); it != byKey->constEnd(); ++it) {
158 const std::optional<Entry> entry = recordEntry(it.value());
162 entries.insert(it.key(), *entry);
169auto parseStores(
const QByteArray &bytes) -> std::optional<Stores> {
170 const std::optional<QJsonObject> byKey =
171 recordObject(bytes, QStringLiteral(
"stores"));
176 for (
auto it = byKey->constBegin(); it != byKey->constEnd(); ++it) {
178 it.value().toObject().value(QLatin1String(
"id")).toString();
179 if (!it.value().isObject() || !isStoreId(
id.toLatin1())) {
182 stores.insert(it.key(),
id);
190template <
typename T,
typename Parse>
191auto loadFile(
const QString &path, Parse parse, QString *error)
192 -> std::optional<T> {
193 if (!QFileInfo::exists(path)) {
197 if (!file.open(QIODevice::ReadOnly)) {
199 *error = QCoreApplication::translate(
201 "The generation record of the recipient lists, %1, cannot "
206 std::optional<T> value = parse(file.readAll());
207 if (!value && error) {
208 *error = QCoreApplication::translate(
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 "
222auto saveFile(
const QString &path,
const QString &name,
223 const QJsonObject &inner, QString *error) ->
bool {
225 root.insert(QLatin1String(
"format"), kRecordFormat);
226 root.insert(name, inner);
227 QSaveFile file(path);
228 if (!file.open(QIODevice::WriteOnly)) {
230 *error = QCoreApplication::translate(
232 "The generation record of the recipient lists, %1, cannot "
237 file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
238 const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Indented);
239 if (file.write(bytes) != bytes.size() || !file.commit()) {
240 qCWarning(lcQtPass) <<
"Could not write the .gpg-id generation record"
241 << path << file.errorString();
243 *error = QCoreApplication::translate(
245 "The generation record of the recipient lists, %1, cannot "
253auto saveRecord(
const QString &path,
const Entries &entries, QString *error)
256 for (
auto it = entries.constBegin(); it != entries.constEnd(); ++it) {
258 item.insert(QLatin1String(
"generation"),
259 QString::number(it.value().generation));
260 if (!it.value().digest.isEmpty()) {
261 item.insert(QLatin1String(
"sha256"), it.value().digest);
263 lists.insert(it.key(), item);
265 return saveFile(path, QStringLiteral(
"lists"), lists, error);
268auto saveStores(
const QString &path,
const Stores &stores, QString *error)
271 for (
auto it = stores.constBegin(); it != stores.constEnd(); ++it) {
273 item.insert(QLatin1String(
"id"), it.value());
274 byKey.insert(it.key(), item);
276 return saveFile(path, QStringLiteral(
"stores"), byKey, error);
284 QMutexLocker<QMutex> mutex{&recordLock()};
285 QString path{recordPath()};
288 bool haveFolder{QDir().mkpath(QFileInfo(path).absolutePath())};
289 QLockFile lock{path + QStringLiteral(
".lock")};
290 bool locked{haveFolder && lock.tryLock(kRecordLockTimeoutMs)};
292 QString storesFile{storesPath()};
293 std::optional<Record> entries;
297 problem = QCoreApplication::translate(
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.")
305 std::optional<Entries> lists =
306 loadFile<Entries>(path, parseRecord, &problem);
310 std::optional<Stores> stores =
311 loadFile<Stores>(storesFile, parseStores, &problem);
315 entries = Record{std::move(*lists), std::move(*stores)};
318 [[nodiscard]]
auto ok(QString *error)
const ->
bool {
328 [[nodiscard]]
auto generation(
const QString &key)
const -> qint64 {
329 return entries->lists.value(key).generation;
334 [[nodiscard]]
auto store(
const QString &key)
const -> std::optional<QString> {
335 const auto it = entries->stores.constFind(key);
336 if (it == entries->stores.constEnd()) {
343 auto pinStore(
const QString &key,
const QString &
id, QString *error) ->
bool {
344 Stores updated = entries->stores;
345 updated.insert(key,
id);
346 if (!saveStores(storesFile, updated, error)) {
349 entries->stores = std::move(updated);
355 [[nodiscard]]
auto digest(
const QString &key)
const
356 -> std::optional<QString> {
357 const QString value = entries->lists.value(key).digest;
358 if (value.isEmpty()) {
366 auto remember(
const QString &key, qint64 generation,
const QString &digest,
367 QString *error) ->
bool {
368 Entries updated = entries->lists;
369 updated[key] = Entry{generation, digest};
370 if (!saveRecord(path, updated, error)) {
373 entries->lists = std::move(updated);
381auto hasControl(
const QByteArray &value) ->
bool {
382 return std::any_of(value.cbegin(), value.cend(), [](
const char c) {
383 const auto u = static_cast<unsigned char>(c);
384 return u < 0x20 || u == 0x7f;
390auto relativeFolder(
const QString &path,
const QString &root)
391 -> std::optional<QString> {
392 QString folder = QFileInfo(path).absoluteDir().canonicalPath();
393 if (folder.isEmpty()) {
394 folder = QFileInfo(path).absolutePath();
396 QString store = QFileInfo(root).canonicalFilePath();
397 if (store.isEmpty()) {
398 store = QDir::cleanPath(QFileInfo(root).absoluteFilePath());
400 folder = QDir::fromNativeSeparators(QDir::cleanPath(folder));
401 store = QDir::fromNativeSeparators(QDir::cleanPath(store));
403 folder = folder.toLower();
404 store = store.toLower();
406 if (folder == store) {
407 return QStringLiteral(
".");
409 const QString prefix =
410 store.endsWith(QLatin1Char(
'/')) ? store : store + QLatin1Char(
'/');
411 if (!folder.startsWith(prefix)) {
414 folder = folder.mid(prefix.size());
419 if (hasControl(folder.toUtf8())) {
430auto generationDigits(
const QByteArray &digits) -> std::optional<qint64> {
431 if (digits.isEmpty() || digits.size() > kMaxDigits) {
434 for (
const char c : digits) {
435 if (c <
'0' || c >
'9') {
439 return digits.toLongLong();
445auto takeHeaderLine(
const QByteArray &line, std::optional<qint64> &generation,
446 std::optional<QString> &folder,
447 std::optional<QString> &store) -> QString {
450 return QCoreApplication::translate(
451 "GpgIdGeneration",
"The list carries more than one store line.");
454 if (isStoreId(value)) {
455 store = QString::fromLatin1(value);
458 return QCoreApplication::translate(
"GpgIdGeneration",
459 "The store line is malformed: %1")
460 .arg(QString::fromUtf8(line));
464 return QCoreApplication::translate(
465 "GpgIdGeneration",
"The list carries more than one generation line.");
472 return QCoreApplication::translate(
"GpgIdGeneration",
473 "The generation line is malformed: %1")
474 .arg(QString::fromUtf8(line));
478 return QCoreApplication::translate(
479 "GpgIdGeneration",
"The list carries more than one folder line.");
485 if (!value.isEmpty() && !hasControl(value)) {
486 folder = QString::fromUtf8(value);
489 return QCoreApplication::translate(
"GpgIdGeneration",
490 "The folder line is malformed: %1")
491 .arg(QString::fromUtf8(line));
493 return QCoreApplication::translate(
"GpgIdGeneration",
494 "The header line is malformed: %1")
495 .arg(QString::fromUtf8(line));
501 -> std::optional<Header> {
502 std::optional<qint64> generation;
503 std::optional<QString> folder;
504 std::optional<QString> store;
505 for (
const QByteArray &rawLine : contents.split(
'\n')) {
506 QByteArray line = rawLine;
507 if (line.endsWith(
'\r')) {
513 if (!line.startsWith(kOurComment)) {
516 const QString complaint = takeHeaderLine(line, generation, folder, store);
517 if (complaint.isEmpty()) {
524 if (generation && !folder) {
526 *error = tr(
"The list carries a generation line but no folder line.");
530 if (store && !generation) {
532 *error = tr(
"The list carries a store line but no generation line.");
543 const QString &store) -> QByteArray {
546 if (!store.isEmpty()) {
553 const QByteArray &recipients,
554 const QString &store) -> QByteArray {
555 return header(generation, folder, store) + recipients;
559 const QString &storeRoot)
560 -> std::optional<QString> {
561 return relativeFolder(gpgIdFile, storeRoot);
565 const QFileInfo info(gpgIdFile);
568 QString canonical = info.canonicalFilePath();
569 if (canonical.isEmpty()) {
570 const QString dir = info.absoluteDir().canonicalPath();
571 canonical = (dir.isEmpty() ? info.absolutePath() : dir) + QLatin1Char(
'/') +
574 canonical = QDir::fromNativeSeparators(canonical);
576 canonical = canonical.toLower();
578 return QString::fromLatin1(
579 QCryptographicHash::hash(canonical.toUtf8(), QCryptographicHash::Sha256)
588 return QString::fromLatin1(
589 QCryptographicHash::hash(contents, QCryptographicHash::Sha256).toHex());
593 -> std::optional<qint64> {
598 return t.generation(
key(gpgIdFile));
603 const QString &storeRoot, QString *error)
608 const std::optional<QString> here =
folderOf(gpgIdFile, storeRoot);
609 if (here && *here == *
header.folder) {
614 tr(
"The signed recipient list %1 was written for the folder "
615 "\"%2\" of the store, not for \"%3\", and is not used. It "
616 "may have been copied here by someone else; if the folder "
617 "was moved or renamed instead, a holder of the signing key "
618 "opens Users on it and saves the recipients, which binds "
619 "the list to where it is now.")
620 .arg(gpgIdFile, *
header.folder, here.value_or(QStringLiteral(
"?")));
625 const QString &saving) -> QString {
627 return tr(
"Generation %1 is the highest there is, so no newer list "
628 "can be written here: removing %2 forgets what this device "
629 "accepted before, after which a holder of the signing key "
630 "gets through by opening Users and %3.")
632 .arg(recordPath, saving);
634 return tr(
"A holder of the signing key gets through by opening Users "
635 "and %1, which writes generation %2. Removing %3 forgets what "
636 "this device accepted before.")
643 qint64 last,
const QString &recordPath,
651 tr(
"The signed recipient list %1 carries no generation line, "
652 "while generation %2 was accepted here before. pass writes no "
653 "generation line (also through QtPass's pass backend), nor did "
654 "QtPass before 2.0; without one the list may also have been "
655 "written for another folder of the store and copied here. %3")
660 tr(
"selecting the recipients afresh and saving")));
665 tr(
"The signed recipient list %1 is generation %2, older than "
666 "generation %3, the last one QtPass accepted here. It may have "
667 "been put back by someone else. %4")
672 tr(
"saving the recipients: the preselected "
673 "recipients there are this list's, so remove "
674 "anyone who should no longer have access "
680 const QByteArray &contents,
681 const QString &storeRoot, QString *error)
684 const std::optional<Header>
header =
parse(contents, &why);
687 *error = tr(
"The signed recipient list %1 is not one to trust: %2")
688 .arg(gpgIdFile, why);
700 const QString k =
key(gpgIdFile);
701 const qint64 last = t.generation(k);
702 const qint64 generation =
header->generation;
703 const QString bytes =
digest(contents);
707 const QString storeKey =
key(storeRoot);
708 const std::optional<QString> pinned = t.store(storeKey);
710 generation == last && t.digest(k) == bytes,
711 t.storesFile, error)) {
715 const auto pinHere = [&]() {
716 if (pinned || !
header->store) {
719 return t.pinStore(storeKey, *
header->store, error);
721 if (generation < last) {
724 if (generation == 0) {
730 if (generation == last) {
734 const std::optional<QString> known = t.digest(k);
735 if (known && *known != bytes) {
737 *error = tr(
"The signed recipient list %1 is generation %2, the same "
738 "generation as a different list this device accepted "
739 "before. Either two devices saved recipients at the same "
740 "time (Git will have shown the conflict), or an authentic "
741 "list of that generation was swapped in. %3")
745 tr(
"checking the recipients and saving")));
754 if (!pinHere() || !t.remember(k, generation, bytes, error)) {
762 bool alreadyAccepted,
763 const QString &recordPath, QString *error)
766 if (*
header.store == pinned) {
771 tr(
"The signed recipient list %1 was written for another password "
772 "store (store ID %2; this device knows this store as %3) and is "
773 "not used. It may have been copied here from a store signed "
774 "with the same key. If this store was set up again instead, "
775 "removing %4 forgets the store IDs this device knows.")
776 .arg(gpgIdFile, *
header.store, pinned, recordPath);
782 if (
header.generation == 0 || alreadyAccepted) {
787 tr(
"The signed recipient list %1 carries no store line, while this "
788 "device knows this store by its ID %2: it may have been written "
789 "for another store and copied here, or by an older QtPass. A "
790 "holder of the signing key gets through by opening Users, "
791 "selecting the recipients afresh and saving, which writes the "
793 .arg(gpgIdFile, pinned);
798 const std::optional<QString> &onDisk,
799 QString *error) -> std::optional<QString> {
804 const QString storeKey =
key(storeRoot);
805 if (
const std::optional<QString> pinned = t.store(storeKey)) {
810 const QString
id = onDisk && isStoreId(onDisk->toLatin1())
812 : QUuid::createUuid().toString(QUuid::Id128).toLower();
813 if (!t.pinStore(storeKey,
id, error)) {
820 std::optional<qint64> verifiedOnDisk,
821 QString *error) -> std::optional<qint64> {
826 const QString k =
key(gpgIdFile);
827 const qint64 last = t.generation(k);
828 const qint64 base = qMax(last, verifiedOnDisk.value_or(0));
831 *error = tr(
"The recipient list %1 has reached generation %2, the "
832 "highest there is; the list cannot be written.")
837 const qint64 generation = base + 1;
840 if (!t.remember(k, generation, QString(), error)) {
847 const QByteArray &contents, QString *error)
849 const std::optional<Header>
header =
parse(contents, error);
857 const QString k =
key(gpgIdFile);
858 const qint64 last = t.generation(k);
859 if (
header->generation != last) {
863 *error = tr(
"The generation record of the recipient lists, %1, has "
864 "moved on to generation %2 while generation %3 was being "
871 return t.remember(k, last,
digest(contents), error);
static auto accept(const QString &gpgIdFile, const QByteArray &contents, const QString &storeRoot, QString *error=nullptr) -> Verdict
Decide about a verified list: its folder against where it sits, its generation against the remembered...
static auto ofThisStore(const QString &gpgIdFile, const Header &header, const QString &pinned, bool alreadyAccepted, const QString &recordPath, QString *error) -> bool
Whether header fits the store this device knows by pinned: its store line names that id,...
static auto digest(const QByteArray &contents) -> QString
The digest of a list as the record keeps it: SHA-256 of the exact bytes, hexadecimal,...
static auto header(qint64 generation, const QString &folder, const QString &store={}) -> QByteArray
The header lines for a list.
static auto storeRecordFile() -> QString
The file the store IDs are kept in, next to recordFile() and under its lock, so a QtPass from before ...
static auto folderOf(const QString &gpgIdFile, const QString &storeRoot) -> std::optional< QString >
The store-relative folder of gpgIdFile, as written in and compared against the folder line: ....
static auto reserveNext(const QString &gpgIdFile, std::optional< qint64 > verifiedOnDisk, QString *error=nullptr) -> std::optional< qint64 >
Reserve the generation to write next for gpgIdFile: one above both what this device remembers and ver...
static auto boundToItsFolder(const QString &gpgIdFile, const Header &header, const QString &storeRoot, QString *error) -> bool
Whether header's folder line, if it has one, names the folder the list sits in: a pair copied elsewhe...
static auto recordWritten(const QString &gpgIdFile, const QByteArray &contents, QString *error=nullptr) -> bool
Record the bytes just written for gpgIdFile, so that this device recognises its own list afterwards a...
static auto key(const QString &gpgIdFile) -> QString
The settings key for a .gpg-id: a hash of its canonical path, so path spelling (case,...
Verdict
The outcome of accept(). Only Accepted means the list may be used; the others say why not,...
@ Malformed
A header line malformed or duplicated.
@ Accepted
Folder, generation and record all agree; remembered.
@ RecordUnavailable
The record could not be read or written.
@ Rollback
Authentic and bound to this folder, but older than a generation this device accepted.
@ WrongFolder
Written for another folder of the store.
@ WrongStore
Written for another store (its store line names another id than this device knows the store by),...
@ Unbound
Older than a generation this device accepted and without a folder line: authentic,...
@ Conflict
The generation this device accepted last, but not the bytes: two devices saved at once (Git shows tha...
static auto remembered(const QString &gpgIdFile, QString *error=nullptr) -> std::optional< qint64 >
The highest generation this device has accepted or written for the .gpg-id at gpgIdFile....
static auto staleList(const QString &gpgIdFile, const Header &header, qint64 last, const QString &recordPath, QString *error) -> Verdict
The refusal for a list older than the one accepted here: a rollback, or an unbound list that could be...
static const QByteArray kStorePrefix
static const QByteArray kFolderPrefix
static const qint64 kMaxGeneration
static auto withHeader(qint64 generation, const QString &folder, const QByteArray &recipients, const QString &store={}) -> QByteArray
A complete list: the header in front of the recipients.
static auto recordFile() -> QString
The file the accepted generations are recorded in, so a refusal can name it: removing it forgets what...
static const QByteArray kGenerationPrefix
static auto parse(const QByteArray &contents, QString *error=nullptr) -> std::optional< Header >
The header a .gpg-id's bytes declare.
static auto storeIdFor(const QString &storeRoot, const std::optional< QString > &onDisk, QString *error=nullptr) -> std::optional< QString >
The store id to write into a list of storeRoot: the one this device knows the store by; else onDisk,...
static auto wayThrough(qint64 last, const QString &recordPath, const QString &saving) -> QString
How a holder of the signing key gets through a refusal, in words: saving the recipients writes the ne...
Rollback detection for signed .gpg-id files: the generation counter and the record of what was last s...
The "qtpass" logging category.