Prevent KeeShare from merging database custom data

This issue previously caused parent databases to be marked as modified on unlock. This was because of the new protections against byte-by-byte side channel attacks adds a randomized string to the database custom data. We should never be merging database custom data with keeshare or imports since we are merging groups only.

Also prevent overwrite of auto-generated custom data fields, Last Modified and Random Slug.
This commit is contained in:
Jonathan White 2024-04-28 23:22:01 -04:00
parent 4f12f57a0b
commit 3829bcdd8f
9 changed files with 68 additions and 5 deletions

View File

@ -25,6 +25,8 @@ const QString CustomData::Created = QStringLiteral("_CREATED");
const QString CustomData::BrowserKeyPrefix = QStringLiteral("KPXC_BROWSER_"); const QString CustomData::BrowserKeyPrefix = QStringLiteral("KPXC_BROWSER_");
const QString CustomData::BrowserLegacyKeyPrefix = QStringLiteral("Public Key: "); const QString CustomData::BrowserLegacyKeyPrefix = QStringLiteral("Public Key: ");
const QString CustomData::ExcludeFromReportsLegacy = QStringLiteral("KnownBad"); const QString CustomData::ExcludeFromReportsLegacy = QStringLiteral("KnownBad");
const QString CustomData::FdoSecretsExposedGroup = QStringLiteral("FDO_SECRETS_EXPOSED_GROUP");
const QString CustomData::RandomSlug = QStringLiteral("KPXC_RANDOM_SLUG");
// Fallback item for return by reference // Fallback item for return by reference
static const CustomData::CustomDataItem NULL_ITEM{}; static const CustomData::CustomDataItem NULL_ITEM{};
@ -191,6 +193,11 @@ bool CustomData::isProtected(const QString& key) const
return key.startsWith(CustomData::BrowserKeyPrefix) || key.startsWith(CustomData::Created); return key.startsWith(CustomData::BrowserKeyPrefix) || key.startsWith(CustomData::Created);
} }
bool CustomData::isAutoGenerated(const QString& key) const
{
return key == LastModified || key == RandomSlug;
}
bool CustomData::operator==(const CustomData& other) const bool CustomData::operator==(const CustomData& other) const
{ {
return (m_data == other.m_data); return (m_data == other.m_data);

View File

@ -51,6 +51,7 @@ public:
QDateTime lastModified() const; QDateTime lastModified() const;
QDateTime lastModified(const QString& key) const; QDateTime lastModified(const QString& key) const;
bool isProtected(const QString& key) const; bool isProtected(const QString& key) const;
bool isAutoGenerated(const QString& key) const;
void set(const QString& key, CustomDataItem item); void set(const QString& key, CustomDataItem item);
void set(const QString& key, const QString& value, const QDateTime& lastModified = {}); void set(const QString& key, const QString& value, const QDateTime& lastModified = {});
void remove(const QString& key); void remove(const QString& key);
@ -68,6 +69,8 @@ public:
static const QString Created; static const QString Created;
static const QString BrowserKeyPrefix; static const QString BrowserKeyPrefix;
static const QString BrowserLegacyKeyPrefix; static const QString BrowserLegacyKeyPrefix;
static const QString FdoSecretsExposedGroup;
static const QString RandomSlug;
// Pre-KDBX 4.1 // Pre-KDBX 4.1
static const QString ExcludeFromReportsLegacy; static const QString ExcludeFromReportsLegacy;

View File

@ -276,7 +276,7 @@ bool Database::saveAs(const QString& filePath, SaveAction action, const QString&
// Add random data to prevent side-channel data deduplication attacks // Add random data to prevent side-channel data deduplication attacks
int length = Random::instance()->randomUIntRange(64, 512); int length = Random::instance()->randomUIntRange(64, 512);
m_metadata->customData()->set("KPXC_RANDOM_SLUG", Random::instance()->randomArray(length).toHex()); m_metadata->customData()->set(CustomData::RandomSlug, Random::instance()->randomArray(length).toHex());
// Prevent destructive operations while saving // Prevent destructive operations while saving
QMutexLocker locker(&m_saveMutex); QMutexLocker locker(&m_saveMutex);

View File

@ -57,6 +57,11 @@ void Merger::resetForcedMergeMode()
m_mode = Group::Default; m_mode = Group::Default;
} }
void Merger::setSkipDatabaseCustomData(bool state)
{
m_skipCustomData = state;
}
QStringList Merger::merge() QStringList Merger::merge()
{ {
// Order of merge steps is important - it is possible that we // Order of merge steps is important - it is possible that we
@ -514,6 +519,11 @@ Merger::ChangeList Merger::mergeMetadata(const MergeContext& context)
} }
} }
// Some merges shouldn't modify the database custom data
if (m_skipCustomData) {
return changes;
}
// Merge Custom Data if source is newer // Merge Custom Data if source is newer
const auto targetCustomDataModificationTime = targetMetadata->customData()->lastModified(); const auto targetCustomDataModificationTime = targetMetadata->customData()->lastModified();
const auto sourceCustomDataModificationTime = sourceMetadata->customData()->lastModified(); const auto sourceCustomDataModificationTime = sourceMetadata->customData()->lastModified();
@ -535,8 +545,8 @@ Merger::ChangeList Merger::mergeMetadata(const MergeContext& context)
// Transfer new/existing keys // Transfer new/existing keys
for (const auto& key : sourceCustomDataKeys) { for (const auto& key : sourceCustomDataKeys) {
// Don't merge this meta field, it is updated automatically. // Don't merge auto-generated keys
if (key == CustomData::LastModified) { if (sourceMetadata->customData()->isAutoGenerated(key)) {
continue; continue;
} }

View File

@ -31,6 +31,7 @@ public:
Merger(const Group* sourceGroup, Group* targetGroup); Merger(const Group* sourceGroup, Group* targetGroup);
void setForcedMergeMode(Group::MergeMode mode); void setForcedMergeMode(Group::MergeMode mode);
void resetForcedMergeMode(); void resetForcedMergeMode();
void setSkipDatabaseCustomData(bool state);
QStringList merge(); QStringList merge();
private: private:
@ -66,6 +67,7 @@ private:
private: private:
MergeContext m_context; MergeContext m_context;
Group::MergeMode m_mode; Group::MergeMode m_mode;
bool m_skipCustomData = false;
}; };
#endif // KEEPASSXC_MERGER_H #endif // KEEPASSXC_MERGER_H

View File

@ -272,6 +272,7 @@ DatabaseWidget* DatabaseTabWidget::importFile()
if (newDb) { if (newDb) {
// Merge the imported db into the new one // Merge the imported db into the new one
Merger merger(db.data(), newDb.data()); Merger merger(db.data(), newDb.data());
merger.setSkipDatabaseCustomData(true);
merger.merge(); merger.merge();
// Show the new database // Show the new database
auto dbWidget = new DatabaseWidget(newDb, this); auto dbWidget = new DatabaseWidget(newDb, this);

View File

@ -80,10 +80,12 @@ ShareObserver::Result ShareImport::containerInto(const QString& resolvedPath,
auto key = QSharedPointer<CompositeKey>::create(); auto key = QSharedPointer<CompositeKey>::create();
key->addKey(QSharedPointer<PasswordKey>::create(reference.password)); key->addKey(QSharedPointer<PasswordKey>::create(reference.password));
auto sourceDb = QSharedPointer<Database>::create(); auto sourceDb = QSharedPointer<Database>::create();
sourceDb->setEmitModified(false);
if (!reader.readDatabase(&buffer, key, sourceDb.data())) { if (!reader.readDatabase(&buffer, key, sourceDb.data())) {
qCritical("Error while parsing the database: %s", qPrintable(reader.errorString())); qCritical("Error while parsing the database: %s", qPrintable(reader.errorString()));
return {reference.path, ShareObserver::Result::Error, reader.errorString()}; return {reference.path, ShareObserver::Result::Error, reader.errorString()};
} }
sourceDb->setEmitModified(true);
qDebug("Synchronize %s %s with %s", qDebug("Synchronize %s %s with %s",
qPrintable(reference.path), qPrintable(reference.path),
@ -92,6 +94,7 @@ ShareObserver::Result ShareImport::containerInto(const QString& resolvedPath,
Merger merger(sourceDb->rootGroup(), targetGroup); Merger merger(sourceDb->rootGroup(), targetGroup);
merger.setForcedMergeMode(Group::Synchronize); merger.setForcedMergeMode(Group::Synchronize);
merger.setSkipDatabaseCustomData(true);
auto changelist = merger.merge(); auto changelist = merger.merge();
if (!changelist.isEmpty()) { if (!changelist.isEmpty()) {
return {reference.path, ShareObserver::Result::Success, ShareImport::tr("Successful import")}; return {reference.path, ShareObserver::Result::Success, ShareImport::tr("Successful import")};

View File

@ -87,18 +87,54 @@ void TestMerge::testMergeNoChanges()
m_clock->advanceSecond(1); m_clock->advanceSecond(1);
Merger merger1(dbSource.data(), dbDestination.data()); Merger merger1(dbSource.data(), dbDestination.data());
merger1.merge(); auto changes = merger1.merge();
QVERIFY(changes.isEmpty());
QCOMPARE(dbDestination->rootGroup()->entriesRecursive().size(), 2); QCOMPARE(dbDestination->rootGroup()->entriesRecursive().size(), 2);
QCOMPARE(dbSource->rootGroup()->entriesRecursive().size(), 2); QCOMPARE(dbSource->rootGroup()->entriesRecursive().size(), 2);
m_clock->advanceSecond(1); m_clock->advanceSecond(1);
Merger merger2(dbSource.data(), dbDestination.data()); Merger merger2(dbSource.data(), dbDestination.data());
merger2.merge(); changes = merger2.merge();
QVERIFY(changes.isEmpty());
QCOMPARE(dbDestination->rootGroup()->entriesRecursive().size(), 2);
QCOMPARE(dbSource->rootGroup()->entriesRecursive().size(), 2);
}
/**
* Merging without database custom data (used by imports and KeeShare)
*/
void TestMerge::testMergeCustomData()
{
QScopedPointer<Database> dbDestination(createTestDatabase());
QScopedPointer<Database> dbSource(
createTestDatabaseStructureClone(dbDestination.data(), Entry::CloneNoFlags, Group::CloneIncludeEntries));
QCOMPARE(dbDestination->rootGroup()->entriesRecursive().size(), 2); QCOMPARE(dbDestination->rootGroup()->entriesRecursive().size(), 2);
QCOMPARE(dbSource->rootGroup()->entriesRecursive().size(), 2); QCOMPARE(dbSource->rootGroup()->entriesRecursive().size(), 2);
dbDestination->metadata()->customData()->set("TEST_CUSTOM_DATA", "OLD TESTING");
m_clock->advanceSecond(1);
dbSource->metadata()->customData()->set("TEST_CUSTOM_DATA", "TESTING");
// First check that the custom data is not merged when skipped
Merger merger1(dbSource.data(), dbDestination.data());
merger1.setSkipDatabaseCustomData(true);
auto changes = merger1.merge();
QVERIFY(changes.isEmpty());
QCOMPARE(dbDestination->metadata()->customData()->value("TEST_CUSTOM_DATA"), QString("OLD TESTING"));
// Second check that the custom data is merged otherwise
Merger merger2(dbSource.data(), dbDestination.data());
changes = merger2.merge();
QCOMPARE(changes.size(), 1);
QCOMPARE(dbDestination->metadata()->customData()->value("TEST_CUSTOM_DATA"), QString("TESTING"));
} }
/** /**

View File

@ -30,6 +30,7 @@ private slots:
void cleanup(); void cleanup();
void testMergeIntoNew(); void testMergeIntoNew();
void testMergeNoChanges(); void testMergeNoChanges();
void testMergeCustomData();
void testResolveConflictNewer(); void testResolveConflictNewer();
void testResolveConflictExisting(); void testResolveConflictExisting();
void testResolveGroupConflictOlder(); void testResolveGroupConflictOlder();