From c57bfd45eec93e8bfe7f7796e8421c970a455489 Mon Sep 17 00:00:00 2001 From: Yaroslav98214 Date: Wed, 4 Feb 2026 22:04:36 +0000 Subject: [PATCH] fix: prevent E2EE metadata rollback Persist counters and key checksums so clients can reject downgraded metadata. Signed-off-by: Yaroslav98214 --- src/common/syncjournaldb.cpp | 25 +++++ src/common/syncjournaldb.h | 1 + .../encryptedfoldermetadatahandler.cpp | 99 +++++++++++++++++++ src/libsync/foldermetadata.cpp | 50 +++++++--- src/libsync/foldermetadata.h | 1 + test/testclientsideencryptionv2.cpp | 94 ++++++++++++++++++ 6 files changed, 257 insertions(+), 13 deletions(-) diff --git a/src/common/syncjournaldb.cpp b/src/common/syncjournaldb.cpp index ab99460a0f2a6..6d4c5d84bbda6 100644 --- a/src/common/syncjournaldb.cpp +++ b/src/common/syncjournaldb.cpp @@ -1289,6 +1289,31 @@ qint64 SyncJournalDb::keyValueStoreGetInt(const QString &key, qint64 defaultValu return query->int64Value(0); } +QString SyncJournalDb::keyValueStoreGetString(const QString &key, const QString &defaultValue) +{ + QMutexLocker locker(&_mutex); + if (!checkConnect()) { + return defaultValue; + } + + const auto query = _queryManager.get(PreparedSqlQueryManager::GetKeyValueStoreQuery, QByteArrayLiteral("SELECT value FROM key_value_store WHERE key=?1"), _db); + if (!query) { + qCWarning(lcDb) << "database error:" << query->error(); + return defaultValue; + } + + query->bindValue(1, key); + query->exec(); + const auto result = query->next(); + + if (!result.ok || !result.hasData) { + qCWarning(lcDb) << "database error:" << query->error(); + return defaultValue; + } + + return query->stringValue(0); +} + void SyncJournalDb::keyValueStoreDelete(const QString &key) { const auto query = _queryManager.get(PreparedSqlQueryManager::DeleteKeyValueStoreQuery, QByteArrayLiteral("DELETE FROM key_value_store WHERE key=?1;"), _db); diff --git a/src/common/syncjournaldb.h b/src/common/syncjournaldb.h index 8cd868e1f0b71..48b6bdce29b3c 100644 --- a/src/common/syncjournaldb.h +++ b/src/common/syncjournaldb.h @@ -66,6 +66,7 @@ class OCSYNC_EXPORT SyncJournalDb : public QObject void keyValueStoreSet(const QString &key, QVariant value); [[nodiscard]] qint64 keyValueStoreGetInt(const QString &key, qint64 defaultValue); + [[nodiscard]] QString keyValueStoreGetString(const QString &key, const QString &defaultValue = {}); void keyValueStoreDelete(const QString &key); [[nodiscard]] bool deleteFileRecord(const QString &filename, bool recursively = false); diff --git a/src/libsync/encryptedfoldermetadatahandler.cpp b/src/libsync/encryptedfoldermetadatahandler.cpp index 7b141ab192ca3..b23e07cfe4d8c 100644 --- a/src/libsync/encryptedfoldermetadatahandler.cpp +++ b/src/libsync/encryptedfoldermetadatahandler.cpp @@ -11,8 +11,11 @@ #include "clientsideencryptionjobs.h" #include "clientsideencryption.h" +#include +#include #include #include +#include namespace OCC { @@ -20,6 +23,71 @@ Q_LOGGING_CATEGORY(lcFetchAndUploadE2eeFolderMetadataJob, "nextcloud.sync.propag } +namespace { +constexpr auto counterStoreKeyPrefix = "e2ee_metadata_counter:"; +constexpr auto keyChecksumsStoreKeyPrefix = "e2ee_metadata_key_checksums:"; + +QString normalizeStorePath(const QString &path) +{ + const auto normalized = OCC::Utility::noLeadingSlashPath(OCC::Utility::noTrailingSlashPath(path)); + return normalized.isEmpty() ? QStringLiteral("/") : normalized; +} + +QString counterStoreKey(const QString &folderPath) +{ + return QString::fromLatin1(counterStoreKeyPrefix) + normalizeStorePath(folderPath); +} + +QString keyChecksumsStoreKey(const QString &rootPath) +{ + return QString::fromLatin1(keyChecksumsStoreKeyPrefix) + normalizeStorePath(rootPath); +} + +QString rootKeyPath(const OCC::RootEncryptedFolderInfo &rootEncryptedFolderInfo, const QString &folderFullRemotePath) +{ + const auto normalizedRoot = normalizeStorePath(rootEncryptedFolderInfo.path); + if (normalizedRoot == QStringLiteral("/")) { + return normalizeStorePath(folderFullRemotePath); + } + return normalizedRoot; +} + +QSet parseKeyChecksums(const QString &value) +{ + if (value.isEmpty()) { + return {}; + } + const auto doc = QJsonDocument::fromJson(value.toUtf8()); + if (!doc.isArray()) { + return {}; + } + QSet checksums; + for (const auto &entry : doc.array()) { + const auto checksum = entry.toString().toUtf8(); + if (!checksum.isEmpty()) { + checksums.insert(checksum); + } + } + return checksums; +} + +QString serializeKeyChecksums(const QSet &checksums) +{ + if (checksums.isEmpty()) { + return {}; + } + auto sortedChecksums = checksums.values(); + std::sort(sortedChecksums.begin(), sortedChecksums.end()); + QJsonArray array; + for (const auto &checksum : sortedChecksums) { + if (!checksum.isEmpty()) { + array.push_back(QString::fromUtf8(checksum)); + } + } + return QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact)); +} +} + namespace OCC { EncryptedFolderMetadataHandler::EncryptedFolderMetadataHandler(const AccountPtr &account, @@ -44,6 +112,22 @@ EncryptedFolderMetadataHandler::EncryptedFolderMetadataHandler(const AccountPtr void EncryptedFolderMetadataHandler::fetchMetadata(const FetchMode fetchMode) { _fetchMode = fetchMode; + if (_journalDb) { + const auto storedCounter = _journalDb->keyValueStoreGetInt(counterStoreKey(_folderFullRemotePath), -1); + if (storedCounter >= 0) { + const auto storedCounterValue = static_cast(storedCounter); + if (_rootEncryptedFolderInfo.counter < storedCounterValue) { + _rootEncryptedFolderInfo.counter = storedCounterValue; + } + } + + const auto storedChecksumsRaw = + _journalDb->keyValueStoreGetString(keyChecksumsStoreKey(rootKeyPath(_rootEncryptedFolderInfo, _folderFullRemotePath))); + const auto storedChecksums = parseKeyChecksums(storedChecksumsRaw); + if (!storedChecksums.isEmpty()) { + _rootEncryptedFolderInfo.keyChecksums.unite(storedChecksums); + } + } fetchFolderEncryptedId(); } @@ -190,6 +274,21 @@ void EncryptedFolderMetadataHandler::slotMetadataReceived(const QJsonDocument &j emit fetchFinished(-1, tr("Error parsing or decrypting metadata.")); return; } + if (_journalDb) { + const auto counterValue = metadata->counter(); + if (counterValue > 0) { + _journalDb->keyValueStoreSet(counterStoreKey(_folderFullRemotePath), static_cast(counterValue)); + } + const auto keyChecksums = metadata->keyChecksums(); + if (!keyChecksums.isEmpty()) { + const auto rootPath = rootKeyPath(_rootEncryptedFolderInfo, _folderFullRemotePath); + _journalDb->keyValueStoreSet(keyChecksumsStoreKey(rootPath), serializeKeyChecksums(keyChecksums)); + } + } + _rootEncryptedFolderInfo.counter = metadata->counter(); + if (!metadata->keyChecksums().isEmpty()) { + _rootEncryptedFolderInfo.keyChecksums = metadata->keyChecksums(); + } _folderMetadata = metadata; emit fetchFinished(200); }); diff --git a/src/libsync/foldermetadata.cpp b/src/libsync/foldermetadata.cpp index 333640374d5ad..ba36f169e4a4d 100644 --- a/src/libsync/foldermetadata.cpp +++ b/src/libsync/foldermetadata.cpp @@ -77,6 +77,7 @@ FolderMetadata::FolderMetadata(AccountPtr account, , _metadataKeyForEncryption(rootEncryptedFolderInfo.keyForEncryption) , _metadataKeyForDecryption(rootEncryptedFolderInfo.keyForDecryption) , _keyChecksums(rootEncryptedFolderInfo.keyChecksums) + , _counter(rootEncryptedFolderInfo.counter) , _initialSignature(signature) { Q_ASSERT(!_remoteFolderRoot.isEmpty()); @@ -213,17 +214,32 @@ void FolderMetadata::setupExistingMetadata(const QByteArray &metadata) const auto cipherTextDocument = QJsonDocument::fromJson(cipherTextDecrypted); + const auto previousKeyChecksums = _keyChecksums; const auto keyCheckSums = cipherTextDocument[keyChecksumsKey].toArray(); - if (!keyCheckSums.isEmpty()) { - _keyChecksums.clear(); - } - for (auto it = keyCheckSums.constBegin(); it != keyCheckSums.constEnd(); ++it) { - const auto keyChecksum = it->toVariant().toString().toUtf8(); - if (!keyChecksum.isEmpty()) { - //TODO: check that no hash has been removed from the keyChecksums - // How do we check that? - _keyChecksums.insert(keyChecksum); + if (keyCheckSums.isEmpty()) { + if (_isRootEncryptedFolder && !previousKeyChecksums.isEmpty()) { + qCWarning(lcCseMetadata()) << "Missing keyChecksums in metadata."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return; + } + } else { + QSet parsedKeyChecksums; + for (auto it = keyCheckSums.constBegin(); it != keyCheckSums.constEnd(); ++it) { + const auto keyChecksum = it->toVariant().toString().toUtf8(); + if (!keyChecksum.isEmpty()) { + parsedKeyChecksums.insert(keyChecksum); + } } + if (_isRootEncryptedFolder && !previousKeyChecksums.isEmpty()) { + for (auto it = previousKeyChecksums.constBegin(); it != previousKeyChecksums.constEnd(); ++it) { + if (!parsedKeyChecksums.contains(*it)) { + qCWarning(lcCseMetadata()) << "Detected removal of metadata key checksums."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return; + } + } + } + _keyChecksums = parsedKeyChecksums; } if (!verifyMetadataKey(metadataKeyForDecryption())) { @@ -238,10 +254,13 @@ void FolderMetadata::setupExistingMetadata(const QByteArray &metadata) const auto counterVariantFromJson = cipherTextObj.value(counterKey).toVariant(); if (counterVariantFromJson.isValid() && counterVariantFromJson.canConvert()) { - // TODO: We need to check counter: new counter must be greater than locally stored counter - // What does that mean? We store the counter in metadata, should we now store it in local database as we do for all file records in SyncJournal? - // What if metadata was not updated for a while? The counter will then not be greater than locally stored (in SyncJournal DB?) - _counter = counterVariantFromJson.value(); + const auto counterFromJson = counterVariantFromJson.value(); + if (_counter > 0 && counterFromJson < _counter) { + qCWarning(lcCseMetadata()) << "Detected metadata counter rollback."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return; + } + _counter = counterFromJson; } for (auto it = files.constBegin(), end = files.constEnd(); it != end; ++it) { @@ -835,6 +854,11 @@ quint64 FolderMetadata::newCounter() const return _counter + 1; } +quint64 FolderMetadata::counter() const +{ + return _counter; +} + EncryptionStatusEnums::ItemEncryptionStatus FolderMetadata::fromMedataVersionToItemEncryptionStatus(const MetadataVersion metadataVersion) { switch (metadataVersion) { diff --git a/src/libsync/foldermetadata.h b/src/libsync/foldermetadata.h index 4c5ebf2f75194..4fd28384b0888 100644 --- a/src/libsync/foldermetadata.h +++ b/src/libsync/foldermetadata.h @@ -132,6 +132,7 @@ class OWNCLOUDSYNC_EXPORT FolderMetadata : public QObject [[nodiscard]] bool isVersion2AndUp() const; [[nodiscard]] quint64 newCounter() const; + [[nodiscard]] quint64 counter() const; [[nodiscard]] QByteArray metadataSignature() const; diff --git a/test/testclientsideencryptionv2.cpp b/test/testclientsideencryptionv2.cpp index 25d95f4b80de5..425f5b45db160 100644 --- a/test/testclientsideencryptionv2.cpp +++ b/test/testclientsideencryptionv2.cpp @@ -5,6 +5,7 @@ #include "syncenginetestutils.h" #include "clientsideencryption.h" #include "foldermetadata.h" +#include "common/checksums.h" #include using namespace OCC; @@ -412,6 +413,99 @@ private slots: } QVERIFY(isFirstUserPresentAndCanDecrypt); } + + void testRejectsKeyChecksumRemoval() + { + QScopedPointer metadata(new FolderMetadata(_account, "/", FolderMetadata::FolderType::Root)); + QSignalSpy metadataSetupCompleteSpy(metadata.data(), &FolderMetadata::setupComplete); + metadataSetupCompleteSpy.wait(); + QCOMPARE(metadataSetupCompleteSpy.count(), 1); + QVERIFY(metadata->isValid()); + + FolderMetadata::EncryptedFile encryptedFile; + encryptedFile.encryptionKey = EncryptionHelper::generateRandom(16); + encryptedFile.encryptedFilename = EncryptionHelper::generateRandomFilename(); + encryptedFile.originalFilename = "fakefile.txt"; + encryptedFile.mimetype = "application/octet-stream"; + encryptedFile.initializationVector = EncryptionHelper::generateRandom(16); + metadata->addEncryptedFile(encryptedFile); + + QVERIFY(metadata->addUser(_secondAccount->davUser(), _secondAccount->e2e()->getCertificate(), FolderMetadata::CertificateType::SoftwareNextcloudCertificate)); + + const auto previousChecksums = metadata->keyChecksums(); + QVERIFY(previousChecksums.size() > 1); + + const auto currentChecksum = calcSha256(metadata->metadataKeyForEncryption()); + QVERIFY(previousChecksums.contains(currentChecksum)); + + auto reducedChecksums = previousChecksums; + for (const auto &checksum : previousChecksums) { + if (checksum != currentChecksum) { + reducedChecksums.remove(checksum); + break; + } + } + QCOMPARE(reducedChecksums.size(), previousChecksums.size() - 1); + + metadata->_keyChecksums = reducedChecksums; + + const auto tamperedMetadata = metadata->encryptedMetadata(); + const auto tamperedSignature = metadata->metadataSignature(); + QVERIFY(!tamperedMetadata.isEmpty()); + QVERIFY(!tamperedSignature.isEmpty()); + + auto tamperedMetadataCopy = tamperedMetadata; + tamperedMetadataCopy.replace("\"", "\\\""); + const QJsonDocument ocsDoc = QJsonDocument::fromJson( + QStringLiteral("{\"ocs\": {\"data\": {\"meta-data\": \"%1\"}}}").arg(QString::fromUtf8(tamperedMetadataCopy)).toUtf8()); + + const auto rootInfo = RootEncryptedFolderInfo(QStringLiteral("/"), + metadata->metadataKeyForEncryption(), + metadata->metadataKeyForEncryption(), + previousChecksums); + + QScopedPointer metadataFromJson(new FolderMetadata(_account, "/", ocsDoc.toJson(), rootInfo, tamperedSignature)); + QSignalSpy metadataSetupExistingCompleteSpy(metadataFromJson.data(), &FolderMetadata::setupComplete); + metadataSetupExistingCompleteSpy.wait(); + QCOMPARE(metadataSetupExistingCompleteSpy.count(), 1); + QVERIFY(!metadataFromJson->isValid()); + } + + void testRejectsCounterRollback() + { + QScopedPointer metadata(new FolderMetadata(_account, "/", FolderMetadata::FolderType::Root)); + QSignalSpy metadataSetupCompleteSpy(metadata.data(), &FolderMetadata::setupComplete); + metadataSetupCompleteSpy.wait(); + QCOMPARE(metadataSetupCompleteSpy.count(), 1); + QVERIFY(metadata->isValid()); + + FolderMetadata::EncryptedFile encryptedFile; + encryptedFile.encryptionKey = EncryptionHelper::generateRandom(16); + encryptedFile.encryptedFilename = EncryptionHelper::generateRandomFilename(); + encryptedFile.originalFilename = "fakefile.txt"; + encryptedFile.mimetype = "application/octet-stream"; + encryptedFile.initializationVector = EncryptionHelper::generateRandom(16); + metadata->addEncryptedFile(encryptedFile); + + const auto encryptedMetadata = metadata->encryptedMetadata(); + const auto signature = metadata->metadataSignature(); + QVERIFY(!encryptedMetadata.isEmpty()); + QVERIFY(!signature.isEmpty()); + + auto encryptedMetadataCopy = encryptedMetadata; + encryptedMetadataCopy.replace("\"", "\\\""); + const QJsonDocument ocsDoc = QJsonDocument::fromJson( + QStringLiteral("{\"ocs\": {\"data\": {\"meta-data\": \"%1\"}}}").arg(QString::fromUtf8(encryptedMetadataCopy)).toUtf8()); + + const auto previousCounter = metadata->newCounter() + 1; + const auto rootInfo = RootEncryptedFolderInfo(QStringLiteral("/"), {}, {}, metadata->keyChecksums(), previousCounter); + + QScopedPointer metadataFromJson(new FolderMetadata(_account, "/", ocsDoc.toJson(), rootInfo, signature)); + QSignalSpy metadataSetupExistingCompleteSpy(metadataFromJson.data(), &FolderMetadata::setupComplete); + metadataSetupExistingCompleteSpy.wait(); + QCOMPARE(metadataSetupExistingCompleteSpy.count(), 1); + QVERIFY(!metadataFromJson->isValid()); + } }; QTEST_GUILESS_MAIN(TestClientSideEncryptionV2)