/* * Copyright (C) 2019 Aetf * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 or (at your option) * version 3 of the License. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "TestGuiFdoSecrets.h" #include "fdosecrets/FdoSecretsPlugin.h" #include "fdosecrets/FdoSecretsSettings.h" #include "fdosecrets/objects/Collection.h" #include "fdosecrets/objects/Item.h" #include "fdosecrets/objects/SessionCipher.h" #include "fdosecrets/widgets/AccessControlDialog.h" #include "config-keepassx-tests.h" #include "core/Global.h" #include "core/Tools.h" #include "crypto/Crypto.h" #include "gui/Application.h" #include "gui/DatabaseTabWidget.h" #include "gui/FileDialog.h" #include "gui/MainWindow.h" #include "gui/MessageBox.h" #include "gui/PasswordWidget.h" #include "gui/wizard/NewDatabaseWizard.h" #include "util/FdoSecretsProxy.h" #include "util/TemporaryFile.h" #include #include #include #include #include int main(int argc, char* argv[]) { QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QGuiApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); Application app(argc, argv); app.setApplicationName("KeePassXC"); app.setApplicationVersion(KEEPASSXC_VERSION); app.setQuitOnLastWindowClosed(false); app.setAttribute(Qt::AA_Use96Dpi, true); app.applyTheme(); QTEST_DISABLE_KEYPAD_NAVIGATION TestGuiFdoSecrets tc; QTEST_SET_MAIN_SOURCE_PATH return QTest::qExec(&tc, argc, argv); } #define DBUS_PATH_DEFAULT_ALIAS "/org/freedesktop/secrets/aliases/default" // assert macros compatible with function having return values #define VERIFY2_RET(statement, msg) \ do { \ if (!QTest::qVerify(static_cast(statement), #statement, (msg), __FILE__, __LINE__)) \ return {}; \ } while (false) #define COMPARE_RET(actual, expected) \ do { \ if (!QTest::qCompare(actual, expected, #actual, #expected, __FILE__, __LINE__)) \ return {}; \ } while (false) // by default use these with Qt macros #define VERIFY QVERIFY #define COMPARE QCOMPARE #define VERIFY2 QVERIFY2 #define DBUS_COMPARE(actual, expected) \ do { \ auto reply = (actual); \ VERIFY2(reply.isValid(), reply.error().name().toLocal8Bit()); \ COMPARE(reply.value(), (expected)); \ } while (false) #define DBUS_VERIFY(stmt) \ do { \ auto reply = (stmt); \ VERIFY2(reply.isValid(), reply.error().name().toLocal8Bit()); \ } while (false) #define DBUS_GET(var, stmt) \ std::remove_cv())>::type var; \ do { \ const auto rep = (stmt); \ VERIFY2(rep.isValid(), rep.error().name().toLocal8Bit()); \ var = rep.argumentAt<0>(); \ } while (false) #define DBUS_GET2(name1, name2, stmt) \ std::remove_cv())>::type name1; \ std::remove_cv())>::type name2; \ do { \ const auto rep = (stmt); \ VERIFY2(rep.isValid(), rep.error().name().toLocal8Bit()); \ name1 = rep.argumentAt<0>(); \ name2 = rep.argumentAt<1>(); \ } while (false) using namespace FdoSecrets; class FakeClient : public DBusClient { public: explicit FakeClient(DBusMgr* dbus) : DBusClient( dbus, {QStringLiteral("local"), 0, true, {ProcInfo{0, 0, QStringLiteral("fake-client"), QString{}, QString{}}}}) { } }; // pretty print QDBusObjectPath in QCOMPARE char* toString(const QDBusObjectPath& path) { return QTest::toString("ObjectPath(" + path.path() + ")"); } TestGuiFdoSecrets::~TestGuiFdoSecrets() = default; void TestGuiFdoSecrets::initTestCase() { VERIFY(Crypto::init()); Config::createTempFileInstance(); config()->set(Config::AutoSaveAfterEveryChange, false); config()->set(Config::AutoSaveOnExit, false); config()->set(Config::GUI_ShowTrayIcon, true); config()->set(Config::UpdateCheckMessageShown, true); // Disable quick unlock config()->set(Config::Security_QuickUnlock, false); // Disable secret service integration (activate within individual tests to test the plugin) FdoSecrets::settings()->setEnabled(false); // activate within individual tests FdoSecrets::settings()->setShowNotification(false); Application::bootstrap(); m_mainWindow.reset(new MainWindow()); m_tabWidget = m_mainWindow->findChild("tabWidget"); VERIFY(m_tabWidget); m_plugin = FdoSecretsPlugin::getPlugin(); VERIFY(m_plugin); m_mainWindow->show(); auto key = QByteArray::fromHex("e407997e8b918419cf851cf3345358fdf" "ffb9564a220ac9c3934efd277cea20d17" "467ecdc56e817f75ac39501f38a4a04ff" "64d627e16c09981c7ad876da255b61c8e" "6a8408236c2a4523cfe6961c26dbdfc77" "c1a27a5b425ca71a019e829fae32c0b42" "0e1b3096b48bc2ce9ccab1d1ff13a5eb4" "b263cee30bdb1a57af9bfa93f"); m_clientCipher.reset(new DhIetf1024Sha256Aes128CbcPkcs7(key)); // Load the NewDatabase.kdbx file into temporary storage QFile sourceDbFile(QStringLiteral(KEEPASSX_TEST_DATA_DIR "/NewDatabase.kdbx")); VERIFY(sourceDbFile.open(QIODevice::ReadOnly)); VERIFY(Tools::readAllFromDevice(&sourceDbFile, m_dbData)); sourceDbFile.close(); // set a fake dbus client all the time so we can freely access DBusMgr anywhere m_client.reset(new FakeClient(m_plugin->dbus().data())); m_plugin->dbus()->overrideClient(m_client); } // Every test starts with opening the temp database void TestGuiFdoSecrets::init() { m_dbFile.reset(new TemporaryFile()); // Write the temp storage to a temp database file for use in our tests VERIFY(m_dbFile->open()); COMPARE(m_dbFile->write(m_dbData), static_cast(m_dbData.size())); m_dbFile->close(); // make sure window is activated or focus tests may fail m_mainWindow->activateWindow(); processEvents(); // open and unlock the database m_tabWidget->addDatabaseTab(m_dbFile->fileName(), false, "a"); m_dbWidget = m_tabWidget->currentDatabaseWidget(); m_db = m_dbWidget->database(); // by default expose the root group FdoSecrets::settings()->setExposedGroup(m_db, m_db->rootGroup()->uuid()); VERIFY(m_dbWidget->save()); // enforce consistent default settings at the beginning FdoSecrets::settings()->setUnlockBeforeSearch(false); FdoSecrets::settings()->setShowNotification(false); FdoSecrets::settings()->setConfirmAccessItem(false); FdoSecrets::settings()->setEnabled(false); } // Every test ends with closing the temp database without saving void TestGuiFdoSecrets::cleanup() { // restore to default settings FdoSecrets::settings()->setUnlockBeforeSearch(false); FdoSecrets::settings()->setShowNotification(false); FdoSecrets::settings()->setConfirmAccessItem(false); FdoSecrets::settings()->setEnabled(false); if (m_plugin) { m_plugin->updateServiceState(); } // DO NOT save the database for (int i = 0; i != m_tabWidget->count(); ++i) { m_tabWidget->databaseWidgetFromIndex(i)->database()->markAsClean(); } // Close any dialogs while (auto w = QApplication::activeModalWidget()) { w->close(); } VERIFY(m_tabWidget->closeAllDatabaseTabs()); processEvents(); if (m_dbFile) { m_dbFile->remove(); } m_client->clearAuthorization(); } void TestGuiFdoSecrets::cleanupTestCase() { m_plugin->dbus()->overrideClient({}); if (m_dbFile) { m_dbFile->remove(); } } void TestGuiFdoSecrets::testServiceEnable() { QSignalSpy sigError(m_plugin, SIGNAL(error(QString))); VERIFY(sigError.isValid()); QSignalSpy sigStarted(m_plugin, SIGNAL(secretServiceStarted())); VERIFY(sigStarted.isValid()); // make sure no one else is holding the service VERIFY(!QDBusConnection::sessionBus().interface()->isServiceRegistered(DBUS_SERVICE_SECRET)); // enable the service auto service = enableService(); VERIFY(service); // service started without error VERIFY(sigError.isEmpty()); COMPARE(sigStarted.size(), 1); processEvents(); VERIFY(QDBusConnection::sessionBus().interface()->isServiceRegistered(DBUS_SERVICE_SECRET)); // there will be one default collection auto coll = getDefaultCollection(service); VERIFY(coll); DBUS_COMPARE(coll->locked(), false); DBUS_COMPARE(coll->label(), m_db->metadata()->name()); DBUS_COMPARE(coll->created(), static_cast(m_db->rootGroup()->timeInfo().creationTime().toMSecsSinceEpoch() / 1000)); DBUS_COMPARE( coll->modified(), static_cast(m_db->rootGroup()->timeInfo().lastModificationTime().toMSecsSinceEpoch() / 1000)); } void TestGuiFdoSecrets::testServiceEnableNoExposedDatabase() { // reset the exposed group and then enable the service FdoSecrets::settings()->setExposedGroup(m_db, {}); auto service = enableService(); VERIFY(service); // no collections DBUS_COMPARE(service->collections(), QList{}); } void TestGuiFdoSecrets::testServiceSearch() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto item = getFirstItem(coll); VERIFY(item); auto itemObj = m_plugin->dbus()->pathToObject(QDBusObjectPath(item->path())); VERIFY(itemObj); auto entry = itemObj->backend(); VERIFY(entry); entry->attributes()->set("fdosecrets-test", "1"); entry->attributes()->set("fdosecrets-test-protected", "2", true); const QString crazyKey = "_a:bc&-+'-e%12df_d"; const QString crazyValue = "[v]al@-ue"; entry->attributes()->set(crazyKey, crazyValue); // search by title { DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", entry->title()}})); COMPARE(locked, {}); COMPARE(unlocked, {QDBusObjectPath(item->path())}); } // search by attribute { DBUS_GET2(unlocked, locked, service->SearchItems({{"fdosecrets-test", "1"}})); COMPARE(locked, {}); COMPARE(unlocked, {QDBusObjectPath(item->path())}); } { DBUS_GET2(unlocked, locked, service->SearchItems({{crazyKey, crazyValue}})); COMPARE(locked, {}); COMPARE(unlocked, {QDBusObjectPath(item->path())}); } // searching using empty terms returns nothing { DBUS_GET2(unlocked, locked, service->SearchItems({})); COMPARE(locked, {}); COMPARE(unlocked, {}); } // searching using protected attributes or password returns nothing { DBUS_GET2(unlocked, locked, service->SearchItems({{"Password", entry->password()}})); COMPARE(locked, {}); COMPARE(unlocked, {}); } { DBUS_GET2(unlocked, locked, service->SearchItems({{"fdosecrets-test-protected", "2"}})); COMPARE(locked, {}); COMPARE(unlocked, {}); } } void TestGuiFdoSecrets::testServiceSearchBlockingUnlock() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto entries = m_db->rootGroup()->entriesRecursive(); VERIFY(!entries.isEmpty()); // assumes the db is not empty auto title = entries.first()->title(); // NOTE: entries are no longer valid after locking lockDatabaseInBackend(); // when database is locked, nothing is returned FdoSecrets::settings()->setUnlockBeforeSearch(false); { DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", title}})); COMPARE(locked, {}); COMPARE(unlocked, {}); } // when database is locked, nothing is returned FdoSecrets::settings()->setUnlockBeforeSearch(true); { // SearchItems will block because the blocking wait is implemented // using a local QEventLoop. // so we do a little trick here to get the return value back bool unlockDialogWorks = false; QTimer::singleShot(50, [&]() { unlockDialogWorks = driveUnlockDialog(); }); DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", title}})); VERIFY(unlockDialogWorks); COMPARE(locked, {}); COMPARE(unlocked.size(), 1); auto item = getProxy(unlocked.first()); DBUS_COMPARE(item->label(), title); } } void TestGuiFdoSecrets::testServiceSearchBlockingUnlockMultiple() { // setup: two databases, both locked, one with exposed db, the other not. // add another database tab with a database with no exposed group // to avoid modify the original, copy to a temp file first QFile sourceDbFile(QStringLiteral(KEEPASSX_TEST_DATA_DIR "/NewDatabase2.kdbx")); QByteArray dbData; VERIFY(sourceDbFile.open(QIODevice::ReadOnly)); VERIFY(Tools::readAllFromDevice(&sourceDbFile, dbData)); sourceDbFile.close(); QTemporaryFile anotherFile; VERIFY(anotherFile.open()); COMPARE(anotherFile.write(dbData), static_cast(dbData.size())); anotherFile.close(); m_tabWidget->addDatabaseTab(anotherFile.fileName(), false); auto anotherWidget = m_tabWidget->currentDatabaseWidget(); auto service = enableService(); VERIFY(service); // when there are multiple locked databases, // repeatedly show the dialog until there is at least one unlocked collection FdoSecrets::settings()->setUnlockBeforeSearch(true); // when only unlocking the one with no exposed group, a second dialog is shown lockDatabaseInBackend(); { bool unlockDialogWorks = false; QTimer::singleShot(50, [&]() { unlockDialogWorks = driveUnlockDialog(anotherWidget); QTimer::singleShot(50, [&]() { unlockDialogWorks &= driveUnlockDialog(); }); }); DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", "Sample Entry"}})); VERIFY(unlockDialogWorks); COMPARE(locked, {}); COMPARE(unlocked.size(), 1); } // when unlocking the one with exposed group, the other one remains locked lockDatabaseInBackend(); { bool unlockDialogWorks = false; QTimer::singleShot(50, [&]() { unlockDialogWorks = driveUnlockDialog(m_dbWidget); }); DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", "Sample Entry"}})); VERIFY(unlockDialogWorks); COMPARE(locked, {}); COMPARE(unlocked.size(), 1); VERIFY(anotherWidget->isLocked()); } } void TestGuiFdoSecrets::testServiceSearchForce() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto item = getFirstItem(coll); VERIFY(item); auto itemObj = m_plugin->dbus()->pathToObject(QDBusObjectPath(item->path())); VERIFY(itemObj); auto entry = itemObj->backend(); VERIFY(entry); // fdosecrets should still find the item even if searching is disabled entry->group()->setSearchingEnabled(Group::Disable); // search by title { DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", entry->title()}})); COMPARE(locked, {}); COMPARE(unlocked, {QDBusObjectPath(item->path())}); } } void TestGuiFdoSecrets::testServiceUnlock() { lockDatabaseInBackend(); auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); QSignalSpy spyCollectionCreated(service.data(), SIGNAL(CollectionCreated(QDBusObjectPath))); VERIFY(spyCollectionCreated.isValid()); QSignalSpy spyCollectionDeleted(service.data(), SIGNAL(CollectionDeleted(QDBusObjectPath))); VERIFY(spyCollectionDeleted.isValid()); QSignalSpy spyCollectionChanged(service.data(), SIGNAL(CollectionChanged(QDBusObjectPath))); VERIFY(spyCollectionChanged.isValid()); DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(coll->path())})); // nothing is unlocked immediately without user's action COMPARE(unlocked, {}); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // nothing is unlocked yet VERIFY(waitForSignal(spyPromptCompleted, 0)); DBUS_COMPARE(coll->locked(), true); // show the prompt DBUS_VERIFY(prompt->Prompt("")); // still not unlocked before user action VERIFY(waitForSignal(spyPromptCompleted, 0)); DBUS_COMPARE(coll->locked(), true); VERIFY(driveUnlockDialog()); VERIFY(waitForSignal(spyPromptCompleted, 1)); { auto args = spyPromptCompleted.takeFirst(); COMPARE(args.size(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(getSignalVariantArgument>(args.at(1)), {QDBusObjectPath(coll->path())}); } // check unlocked *AFTER* the prompt signal DBUS_COMPARE(coll->locked(), false); VERIFY(waitForSignal(spyCollectionCreated, 0)); QTRY_VERIFY(!spyCollectionChanged.isEmpty()); for (const auto& args : spyCollectionChanged) { COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), coll->path()); } VERIFY(waitForSignal(spyCollectionDeleted, 0)); } void TestGuiFdoSecrets::testServiceUnlockDatabaseConcurrent() { lockDatabaseInBackend(); auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(coll->path())})); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); DBUS_VERIFY(prompt->Prompt("")); // while the first prompt is running, another request come in DBUS_GET2(unlocked2, promptPath2, service->Unlock({QDBusObjectPath(coll->path())})); auto prompt2 = getProxy(promptPath2); VERIFY(prompt2); QSignalSpy spyPromptCompleted2(prompt2.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted2.isValid()); DBUS_VERIFY(prompt2->Prompt("")); // there should be only one unlock dialog VERIFY(driveUnlockDialog()); // both prompts should complete VERIFY(waitForSignal(spyPromptCompleted, 1)); { auto args = spyPromptCompleted.takeFirst(); COMPARE(args.size(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(getSignalVariantArgument>(args.at(1)), {QDBusObjectPath(coll->path())}); } VERIFY(waitForSignal(spyPromptCompleted2, 1)); { auto args = spyPromptCompleted2.takeFirst(); COMPARE(args.size(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(getSignalVariantArgument>(args.at(1)), {QDBusObjectPath(coll->path())}); } // check unlocked *AFTER* prompt signal DBUS_COMPARE(coll->locked(), false); } void TestGuiFdoSecrets::testServiceUnlockItems() { FdoSecrets::settings()->setConfirmAccessItem(true); auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto item = getFirstItem(coll); VERIFY(item); auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); DBUS_COMPARE(item->locked(), true); { DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(item->path())})); // nothing is unlocked immediately without user's action COMPARE(unlocked, {}); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // nothing is unlocked yet COMPARE(spyPromptCompleted.count(), 0); DBUS_COMPARE(item->locked(), true); // drive the prompt DBUS_VERIFY(prompt->Prompt("")); // only allow once VERIFY(driveAccessControlDialog(false)); VERIFY(waitForSignal(spyPromptCompleted, 1)); { auto args = spyPromptCompleted.takeFirst(); COMPARE(args.size(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(getSignalVariantArgument>(args.at(1)), {QDBusObjectPath(item->path())}); } // unlocked DBUS_COMPARE(item->locked(), false); } // access the secret should reset the locking state { DBUS_GET(ss, item->GetSecret(QDBusObjectPath(sess->path()))); } DBUS_COMPARE(item->locked(), true); // unlock again with remember { DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(item->path())})); // nothing is unlocked immediately without user's action COMPARE(unlocked, {}); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // nothing is unlocked yet COMPARE(spyPromptCompleted.count(), 0); DBUS_COMPARE(item->locked(), true); // drive the prompt DBUS_VERIFY(prompt->Prompt("")); // only allow and remember VERIFY(driveAccessControlDialog(true)); VERIFY(waitForSignal(spyPromptCompleted, 1)); { auto args = spyPromptCompleted.takeFirst(); COMPARE(args.size(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(getSignalVariantArgument>(args.at(1)), {QDBusObjectPath(item->path())}); } // unlocked DBUS_COMPARE(item->locked(), false); } // access the secret does not reset the locking state { DBUS_GET(ss, item->GetSecret(QDBusObjectPath(sess->path()))); } DBUS_COMPARE(item->locked(), false); } void TestGuiFdoSecrets::testServiceUnlockItemsIncludeFutureEntries() { FdoSecrets::settings()->setConfirmAccessItem(true); auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto item = getFirstItem(coll); VERIFY(item); auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); DBUS_COMPARE(item->locked(), true); { DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(item->path())})); // nothing is unlocked immediately without user's action COMPARE(unlocked, {}); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // nothing is unlocked yet COMPARE(spyPromptCompleted.count(), 0); DBUS_COMPARE(item->locked(), true); // drive the prompt DBUS_VERIFY(prompt->Prompt("")); // remember and include future entries VERIFY(driveAccessControlDialog(true, true)); VERIFY(waitForSignal(spyPromptCompleted, 1)); { auto args = spyPromptCompleted.takeFirst(); COMPARE(args.size(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(getSignalVariantArgument>(args.at(1)), {QDBusObjectPath(item->path())}); } // unlocked DBUS_COMPARE(item->locked(), false); } // check other entries are also unlocked { DBUS_GET(itemPaths, coll->items()); VERIFY(itemPaths.size() > 1); auto anotherItem = getProxy(itemPaths.last()); VERIFY(anotherItem); DBUS_COMPARE(anotherItem->locked(), false); } } void TestGuiFdoSecrets::testServiceLock() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); QSignalSpy spyCollectionCreated(service.data(), SIGNAL(CollectionCreated(QDBusObjectPath))); VERIFY(spyCollectionCreated.isValid()); QSignalSpy spyCollectionDeleted(service.data(), SIGNAL(CollectionDeleted(QDBusObjectPath))); VERIFY(spyCollectionDeleted.isValid()); QSignalSpy spyCollectionChanged(service.data(), SIGNAL(CollectionChanged(QDBusObjectPath))); VERIFY(spyCollectionChanged.isValid()); // if the db is modified, prompt user m_db->markAsModified(); { DBUS_GET2(locked, promptPath, service->Lock({QDBusObjectPath(coll->path())})); COMPARE(locked, {}); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // prompt and click cancel MessageBox::setNextAnswer(MessageBox::Cancel); DBUS_VERIFY(prompt->Prompt("")); processEvents(); VERIFY(waitForSignal(spyPromptCompleted, 1)); auto args = spyPromptCompleted.takeFirst(); COMPARE(args.count(), 2); COMPARE(args.at(0).toBool(), true); COMPARE(getSignalVariantArgument>(args.at(1)), {}); DBUS_COMPARE(coll->locked(), false); } { DBUS_GET2(locked, promptPath, service->Lock({QDBusObjectPath(coll->path())})); COMPARE(locked, {}); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // prompt and click save MessageBox::setNextAnswer(MessageBox::Save); DBUS_VERIFY(prompt->Prompt("")); processEvents(); VERIFY(waitForSignal(spyPromptCompleted, 1)); auto args = spyPromptCompleted.takeFirst(); COMPARE(args.count(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(getSignalVariantArgument>(args.at(1)), {QDBusObjectPath(coll->path())}); DBUS_COMPARE(coll->locked(), true); } VERIFY(waitForSignal(spyCollectionCreated, 0)); QTRY_VERIFY(!spyCollectionChanged.isEmpty()); for (const auto& args : spyCollectionChanged) { COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), coll->path()); } VERIFY(waitForSignal(spyCollectionDeleted, 0)); // locking item locks the whole db unlockDatabaseInBackend(); { auto item = getFirstItem(coll); DBUS_GET2(locked, promptPath, service->Lock({QDBusObjectPath(item->path())})); COMPARE(locked, {}); auto prompt = getProxy(promptPath); VERIFY(prompt); MessageBox::setNextAnswer(MessageBox::Save); DBUS_VERIFY(prompt->Prompt("")); processEvents(); DBUS_COMPARE(coll->locked(), true); } } void TestGuiFdoSecrets::testServiceLockConcurrent() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); m_db->markAsModified(); DBUS_GET2(locked, promptPath, service->Lock({QDBusObjectPath(coll->path())})); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); DBUS_GET2(locked2, promptPath2, service->Lock({QDBusObjectPath(coll->path())})); auto prompt2 = getProxy(promptPath2); VERIFY(prompt2); QSignalSpy spyPromptCompleted2(prompt2.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted2.isValid()); // prompt and click save MessageBox::setNextAnswer(MessageBox::Save); DBUS_VERIFY(prompt->Prompt("")); // second prompt should not show dialog DBUS_VERIFY(prompt2->Prompt("")); VERIFY(waitForSignal(spyPromptCompleted, 1)); { auto args = spyPromptCompleted.takeFirst(); COMPARE(args.count(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(getSignalVariantArgument>(args.at(1)), {QDBusObjectPath(coll->path())}); } VERIFY(waitForSignal(spyPromptCompleted2, 1)); { auto args = spyPromptCompleted2.takeFirst(); COMPARE(args.count(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(getSignalVariantArgument>(args.at(1)), {QDBusObjectPath(coll->path())}); } DBUS_COMPARE(coll->locked(), true); } void TestGuiFdoSecrets::testSessionOpen() { auto service = enableService(); VERIFY(service); auto sess = openSession(service, PlainCipher::Algorithm); VERIFY(sess); sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); } void TestGuiFdoSecrets::testSessionClose() { auto service = enableService(); VERIFY(service); auto sess = openSession(service, PlainCipher::Algorithm); VERIFY(sess); DBUS_VERIFY(sess->Close()); } void TestGuiFdoSecrets::testCollectionCreate() { auto service = enableService(); VERIFY(service); QSignalSpy spyCollectionCreated(service.data(), SIGNAL(CollectionCreated(QDBusObjectPath))); VERIFY(spyCollectionCreated.isValid()); // returns existing if alias is nonempty and exists { auto existing = getDefaultCollection(service); DBUS_GET2(collPath, promptPath, service->CreateCollection({{DBUS_INTERFACE_SECRET_COLLECTION + ".Label", "NewDB"}}, "default")); COMPARE(promptPath, QDBusObjectPath("/")); COMPARE(collPath.path(), existing->path()); } VERIFY(waitForSignal(spyCollectionCreated, 0)); // create new one and set properties { DBUS_GET2(collPath, promptPath, service->CreateCollection({{DBUS_INTERFACE_SECRET_COLLECTION + ".Label", "Test NewDB"}}, "mydatadb")); COMPARE(collPath, QDBusObjectPath("/")); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); DBUS_VERIFY(prompt->Prompt("")); VERIFY(driveNewDatabaseWizard()); VERIFY(waitForSignal(spyPromptCompleted, 1)); auto args = spyPromptCompleted.takeFirst(); COMPARE(args.size(), 2); COMPARE(args.at(0).toBool(), false); auto coll = getProxy(getSignalVariantArgument(args.at(1))); VERIFY(coll); DBUS_COMPARE(coll->label(), QStringLiteral("Test NewDB")); VERIFY(waitForSignal(spyCollectionCreated, 1)); { args = spyCollectionCreated.takeFirst(); COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), coll->path()); } } } void TestGuiFdoSecrets::testCollectionDelete() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); // save the path which will be gone after the deletion. auto collPath = coll->path(); QSignalSpy spyCollectionDeleted(service.data(), SIGNAL(CollectionDeleted(QDBusObjectPath))); VERIFY(spyCollectionDeleted.isValid()); m_db->markAsModified(); DBUS_GET(promptPath, coll->Delete()); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // prompt and click save MessageBox::setNextAnswer(MessageBox::Save); DBUS_VERIFY(prompt->Prompt("")); // closing the tab should have deleted the database if not in testing // but deleteLater is not processed in QApplication::processEvent // see https://doc.qt.io/qt-5/qcoreapplication.html#processEvents VERIFY(waitForSignal(spyPromptCompleted, 1)); auto args = spyPromptCompleted.takeFirst(); COMPARE(args.count(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(args.at(1).value().variant().toString(), QStringLiteral("")); // however, the object should already be taken down from dbus { auto reply = coll->locked(); VERIFY(reply.isFinished() && reply.isError()); COMPARE(reply.error().type(), QDBusError::UnknownObject); } VERIFY(waitForSignal(spyCollectionDeleted, 1)); { args = spyCollectionDeleted.takeFirst(); COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), collPath); } } void TestGuiFdoSecrets::testCollectionDeleteConcurrent() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); m_db->markAsModified(); DBUS_GET(promptPath, coll->Delete()); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // before interacting with the prompt, another request come in DBUS_GET(promptPath2, coll->Delete()); auto prompt2 = getProxy(promptPath); VERIFY(prompt2); QSignalSpy spyPromptCompleted2(prompt2.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted2.isValid()); // prompt and click save MessageBox::setNextAnswer(MessageBox::Save); DBUS_VERIFY(prompt->Prompt("")); // there should be no prompt DBUS_VERIFY(prompt2->Prompt("")); VERIFY(waitForSignal(spyPromptCompleted, 1)); { auto args = spyPromptCompleted.takeFirst(); COMPARE(args.count(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(args.at(1).value().variant().toString(), QStringLiteral("")); } VERIFY(waitForSignal(spyPromptCompleted2, 1)); { auto args = spyPromptCompleted2.takeFirst(); COMPARE(args.count(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(args.at(1).value().variant().toString(), QStringLiteral("")); } { auto reply = coll->locked(); VERIFY(reply.isFinished() && reply.isError()); COMPARE(reply.error().type(), QDBusError::UnknownObject); } } void TestGuiFdoSecrets::testCollectionChange() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); QSignalSpy spyCollectionChanged(service.data(), SIGNAL(CollectionChanged(QDBusObjectPath))); VERIFY(spyCollectionChanged.isValid()); DBUS_VERIFY(coll->setLabel("anotherLabel")); COMPARE(m_db->metadata()->name(), QStringLiteral("anotherLabel")); QTRY_COMPARE(spyCollectionChanged.size(), 1); { auto args = spyCollectionChanged.takeFirst(); COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), coll->path()); } } void TestGuiFdoSecrets::testHiddenFilename() { // when file name contains leading dot, all parts excepting the last should be used // for collection name, and the registration should success VERIFY(m_dbFile->rename(QFileInfo(*m_dbFile).path() + "/.Name.kdbx")); // reset is necessary to not hold database longer and cause connections // not cleaned up when the database tab is closed. m_db.reset(); VERIFY(m_tabWidget->closeAllDatabaseTabs()); m_tabWidget->addDatabaseTab(m_dbFile->fileName(), false, "a"); m_dbWidget = m_tabWidget->currentDatabaseWidget(); m_db = m_dbWidget->database(); // enable the service auto service = enableService(); VERIFY(service); // collection is properly registered auto coll = getDefaultCollection(service); auto collObj = m_plugin->dbus()->pathToObject(QDBusObjectPath(coll->path())); VERIFY(collObj); COMPARE(collObj->name(), QStringLiteral(".Name")); } void TestGuiFdoSecrets::testDuplicateName() { QTemporaryDir dir; VERIFY(dir.isValid()); // create another file under different path but with the same filename QString anotherFile = dir.path() + "/" + QFileInfo(*m_dbFile).fileName(); m_dbFile->copy(anotherFile); m_tabWidget->addDatabaseTab(anotherFile, false, "a"); auto service = enableService(); VERIFY(service); // when two databases have the same name, one of it will have part of its uuid suffixed const QString pathNoSuffix = QStringLiteral("/org/freedesktop/secrets/collection/KeePassXC"); DBUS_GET(colls, service->collections()); COMPARE(colls.size(), 2); COMPARE(colls[0].path(), pathNoSuffix); VERIFY(colls[1].path() != pathNoSuffix); } void TestGuiFdoSecrets::testItemCreate() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); QSignalSpy spyItemCreated(coll.data(), SIGNAL(ItemCreated(QDBusObjectPath))); VERIFY(spyItemCreated.isValid()); // create item StringStringMap attributes{ {"application", "fdosecrets-test"}, {"attr-i[bute]", "![some] -value*"}, }; auto item = createItem(sess, coll, "abc", "Password", attributes, false); VERIFY(item); // signals { VERIFY(waitForSignal(spyItemCreated, 1)); auto args = spyItemCreated.takeFirst(); COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), item->path()); } // attributes { DBUS_GET(actual, item->attributes()); for (const auto& key : attributes.keys()) { COMPARE(actual[key], attributes[key]); } } // label DBUS_COMPARE(item->label(), QStringLiteral("abc")); // secrets { DBUS_GET(ss, item->GetSecret(QDBusObjectPath(sess->path()))); auto decrypted = m_clientCipher->decrypt(ss.unmarshal(m_plugin->dbus())); COMPARE(decrypted.value, QByteArrayLiteral("Password")); } // searchable { DBUS_GET2(unlocked, locked, service->SearchItems(attributes)); COMPARE(locked, {}); COMPARE(unlocked, {QDBusObjectPath(item->path())}); } { DBUS_GET(unlocked, coll->SearchItems(attributes)); VERIFY(unlocked.contains(QDBusObjectPath(item->path()))); } } void TestGuiFdoSecrets::testItemCreateUnlock() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); // NOTE: entries are no longer valid after locking lockDatabaseInBackend(); QSignalSpy spyItemCreated(coll.data(), SIGNAL(ItemCreated(QDBusObjectPath))); VERIFY(spyItemCreated.isValid()); // create item StringStringMap attributes{ {"application", "fdosecrets-test"}, {"attr-i[bute]", "![some] -value*"}, }; auto item = createItem(sess, coll, "abc", "Password", attributes, false, false, true); VERIFY(item); } void TestGuiFdoSecrets::testItemChange() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto item = getFirstItem(coll); VERIFY(item); auto itemObj = m_plugin->dbus()->pathToObject(QDBusObjectPath(item->path())); VERIFY(itemObj); auto entry = itemObj->backend(); VERIFY(entry); QSignalSpy spyItemChanged(coll.data(), SIGNAL(ItemChanged(QDBusObjectPath))); VERIFY(spyItemChanged.isValid()); DBUS_VERIFY(item->setLabel("anotherLabel")); COMPARE(entry->title(), QStringLiteral("anotherLabel")); QTRY_VERIFY(!spyItemChanged.isEmpty()); for (const auto& args : spyItemChanged) { COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), item->path()); } spyItemChanged.clear(); DBUS_VERIFY(item->setAttributes({ {"abc", "def"}, })); COMPARE(entry->attributes()->value("abc"), QStringLiteral("def")); QTRY_VERIFY(!spyItemChanged.isEmpty()); for (const auto& args : spyItemChanged) { COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), item->path()); } } void TestGuiFdoSecrets::testItemReplace() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); // create item StringStringMap attr1{ {"application", "fdosecrets-test"}, {"attr-i[bute]", "![some] -value*"}, {"fdosecrets-attr", "1"}, }; StringStringMap attr2{ {"application", "fdosecrets-test"}, {"attr-i[bute]", "![some] -value*"}, {"fdosecrets-attr", "2"}, }; auto item1 = createItem(sess, coll, "abc1", "Password", attr1, false); VERIFY(item1); auto item2 = createItem(sess, coll, "abc2", "Password", attr2, false); VERIFY(item2); { DBUS_GET2(unlocked, locked, service->SearchItems({{"application", "fdosecrets-test"}})); QSet expected{QDBusObjectPath(item1->path()), QDBusObjectPath(item2->path())}; COMPARE(Tools::asSet(unlocked), expected); } QSignalSpy spyItemCreated(coll.data(), SIGNAL(ItemCreated(QDBusObjectPath))); VERIFY(spyItemCreated.isValid()); QSignalSpy spyItemChanged(coll.data(), SIGNAL(ItemChanged(QDBusObjectPath))); VERIFY(spyItemChanged.isValid()); { // when replace, existing item with matching attr is updated auto item3 = createItem(sess, coll, "abc3", "Password", attr2, true); VERIFY(item3); COMPARE(item2->path(), item3->path()); DBUS_COMPARE(item3->label(), QStringLiteral("abc3")); // there are still 2 entries DBUS_GET2(unlocked, locked, service->SearchItems({{"application", "fdosecrets-test"}})); QSet expected{QDBusObjectPath(item1->path()), QDBusObjectPath(item2->path())}; COMPARE(Tools::asSet(unlocked), expected); VERIFY(waitForSignal(spyItemCreated, 0)); // there may be multiple changed signals, due to each item attribute is set separately QTRY_VERIFY(!spyItemChanged.isEmpty()); for (const auto& args : spyItemChanged) { COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), item3->path()); } } spyItemCreated.clear(); spyItemChanged.clear(); { // when NOT replace, another entry is created auto item4 = createItem(sess, coll, "abc4", "Password", attr2, false); VERIFY(item4); DBUS_COMPARE(item2->label(), QStringLiteral("abc3")); DBUS_COMPARE(item4->label(), QStringLiteral("abc4")); // there are 3 entries DBUS_GET2(unlocked, locked, service->SearchItems({{"application", "fdosecrets-test"}})); QSet expected{ QDBusObjectPath(item1->path()), QDBusObjectPath(item2->path()), QDBusObjectPath(item4->path()), }; COMPARE(Tools::asSet(unlocked), expected); VERIFY(waitForSignal(spyItemCreated, 1)); { auto args = spyItemCreated.takeFirst(); COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), item4->path()); } // there may be multiple changed signals, due to each item attribute is set separately VERIFY(!spyItemChanged.isEmpty()); for (const auto& args : spyItemChanged) { COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), item4->path()); } } } void TestGuiFdoSecrets::testItemReplaceExistingLocked() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); // create item StringStringMap attr1{ {"application", "fdosecrets-test"}, {"attr-i[bute]", "![some] -value*"}, {"fdosecrets-attr", "1"}, }; auto item = createItem(sess, coll, "abc1", "Password", attr1, false); VERIFY(item); // make sure the item is locked { auto itemObj = m_plugin->dbus()->pathToObject(QDBusObjectPath(item->path())); VERIFY(itemObj); auto entry = itemObj->backend(); VERIFY(entry); FdoSecrets::settings()->setConfirmAccessItem(true); m_client->setItemAuthorized(entry->uuid(), AuthDecision::Undecided); DBUS_COMPARE(item->locked(), true); } // when replace with a locked item, there will be a prompt auto item2 = createItem(sess, coll, "abc2", "PasswordUpdated", attr1, true, true); VERIFY(item2); COMPARE(item2->path(), item->path()); DBUS_COMPARE(item2->label(), QStringLiteral("abc2")); } void TestGuiFdoSecrets::testItemSecret() { const QString TEXT_PLAIN = "text/plain"; const QString APPLICATION_OCTET_STREAM = "application/octet-stream"; auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto item = getFirstItem(coll); VERIFY(item); auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); auto itemObj = m_plugin->dbus()->pathToObject(QDBusObjectPath(item->path())); VERIFY(itemObj); auto entry = itemObj->backend(); VERIFY(entry); // plain text secret { DBUS_GET(encrypted, item->GetSecret(QDBusObjectPath(sess->path()))); auto ss = m_clientCipher->decrypt(encrypted.unmarshal(m_plugin->dbus())); COMPARE(ss.contentType, TEXT_PLAIN); COMPARE(ss.value, entry->password().toUtf8()); } // get secret with notification FdoSecrets::settings()->setShowNotification(true); { QSignalSpy spyShowNotification(m_plugin, SIGNAL(requestShowNotification(QString, QString, int))); VERIFY(spyShowNotification.isValid()); DBUS_GET(encrypted, item->GetSecret(QDBusObjectPath(sess->path()))); auto ss = m_clientCipher->decrypt(encrypted.unmarshal(m_plugin->dbus())); COMPARE(ss.contentType, TEXT_PLAIN); COMPARE(ss.value, entry->password().toUtf8()); COMPARE(ss.contentType, TEXT_PLAIN); COMPARE(ss.value, entry->password().toUtf8()); VERIFY(waitForSignal(spyShowNotification, 1)); } FdoSecrets::settings()->setShowNotification(false); // set secret with plain text { // first create Secret in wire format, // then convert to internal format and encrypt // finally convert encrypted internal format back to wire format to pass to SetSecret const QByteArray expected = QByteArrayLiteral("NewPassword"); auto encrypted = encryptPassword(expected, TEXT_PLAIN, sess); DBUS_VERIFY(item->SetSecret(encrypted)); COMPARE(entry->password().toUtf8(), expected); } // set secret with something else is saved as attachment const QByteArray expected = QByteArrayLiteral("NewPasswordBinary"); { auto encrypted = encryptPassword(expected, APPLICATION_OCTET_STREAM, sess); DBUS_VERIFY(item->SetSecret(encrypted)); COMPARE(entry->password(), QStringLiteral("")); } { DBUS_GET(encrypted, item->GetSecret(QDBusObjectPath(sess->path()))); auto ss = m_clientCipher->decrypt(encrypted.unmarshal(m_plugin->dbus())); COMPARE(ss.contentType, APPLICATION_OCTET_STREAM); COMPARE(ss.value, expected); } } void TestGuiFdoSecrets::testItemDelete() { FdoSecrets::settings()->setConfirmDeleteItem(true); auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto item = getFirstItem(coll); VERIFY(item); // save the path which will be gone after the deletion. auto itemPath = item->path(); QSignalSpy spyItemDeleted(coll.data(), SIGNAL(ItemDeleted(QDBusObjectPath))); VERIFY(spyItemDeleted.isValid()); DBUS_GET(promptPath, item->Delete()); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // prompt and click save auto itemObj = m_plugin->dbus()->pathToObject(QDBusObjectPath(item->path())); VERIFY(itemObj); MessageBox::setNextAnswer(MessageBox::Delete); DBUS_VERIFY(prompt->Prompt("")); VERIFY(waitForSignal(spyPromptCompleted, 1)); auto args = spyPromptCompleted.takeFirst(); COMPARE(args.count(), 2); COMPARE(args.at(0).toBool(), false); COMPARE(args.at(1).toString(), QStringLiteral("")); VERIFY(waitForSignal(spyItemDeleted, 1)); args = spyItemDeleted.takeFirst(); COMPARE(args.size(), 1); COMPARE(args.at(0).value().path(), itemPath); } void TestGuiFdoSecrets::testItemLockState() { auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto item = getFirstItem(coll); VERIFY(item); auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); auto itemObj = m_plugin->dbus()->pathToObject(QDBusObjectPath(item->path())); VERIFY(itemObj); auto entry = itemObj->backend(); VERIFY(entry); auto secret = wire::Secret{ QDBusObjectPath(sess->path()), {}, "NewPassword", "text/plain", } .unmarshal(m_plugin->dbus()); auto encrypted = m_clientCipher->encrypt(secret).marshal(); // when access confirmation is disabled, item is unlocked when the collection is unlocked FdoSecrets::settings()->setConfirmAccessItem(false); DBUS_COMPARE(item->locked(), false); // when access confirmation is enabled, item is locked if the client has no authorization FdoSecrets::settings()->setConfirmAccessItem(true); DBUS_COMPARE(item->locked(), true); // however, item properties are still accessible as long as the collection is unlocked DBUS_VERIFY(item->attributes()); DBUS_VERIFY(item->setAttributes({})); DBUS_VERIFY(item->label()); DBUS_VERIFY(item->setLabel("abc")); DBUS_VERIFY(item->created()); DBUS_VERIFY(item->modified()); // except secret, which is locked { auto reply = item->GetSecret(QDBusObjectPath(sess->path())); VERIFY(reply.isError()); COMPARE(reply.error().name(), DBUS_ERROR_SECRET_IS_LOCKED); } { auto reply = item->SetSecret(encrypted); VERIFY(reply.isError()); COMPARE(reply.error().name(), DBUS_ERROR_SECRET_IS_LOCKED); } // item is unlocked if the client is authorized m_client->setItemAuthorized(entry->uuid(), AuthDecision::Allowed); DBUS_COMPARE(item->locked(), false); DBUS_VERIFY(item->GetSecret(QDBusObjectPath(sess->path()))); DBUS_VERIFY(item->SetSecret(encrypted)); } void TestGuiFdoSecrets::testItemRejectSetReferenceFields() { // expose a subgroup, entries in it should not be able to retrieve data from entries outside it auto rootEntry = m_db->rootGroup()->entries().first(); VERIFY(rootEntry); auto subgroup = m_db->rootGroup()->findGroupByPath("/Homebanking/Subgroup"); VERIFY(subgroup); FdoSecrets::settings()->setExposedGroup(m_db, subgroup->uuid()); auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); auto item = getFirstItem(coll); VERIFY(item); auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm); VERIFY(sess); const auto refText = QStringLiteral("{REF:P@T:%1}").arg(rootEntry->title()); // reject ref in label { auto reply = item->setLabel(refText); VERIFY(reply.isFinished() && reply.isError()); COMPARE(reply.error().type(), QDBusError::InvalidArgs); } // reject ref in custom attributes { auto reply = item->setAttributes({{"steal", refText}}); VERIFY(reply.isFinished() && reply.isError()); COMPARE(reply.error().type(), QDBusError::InvalidArgs); } // reject ref in password { auto reply = item->SetSecret(encryptPassword(refText.toUtf8(), "text/plain", sess)); VERIFY(reply.isFinished() && reply.isError()); COMPARE(reply.error().type(), QDBusError::InvalidArgs); } // reject ref in content type { auto reply = item->SetSecret(encryptPassword("dummy", refText, sess)); VERIFY(reply.isFinished() && reply.isError()); COMPARE(reply.error().type(), QDBusError::InvalidArgs); } } void TestGuiFdoSecrets::testAlias() { auto service = enableService(); VERIFY(service); // read default alias DBUS_GET(collPath, service->ReadAlias("default")); auto coll = getProxy(collPath); VERIFY(coll); // set extra alias DBUS_VERIFY(service->SetAlias("another", QDBusObjectPath(collPath))); // get using extra alias DBUS_GET(collPath2, service->ReadAlias("another")); COMPARE(collPath2, collPath); } void TestGuiFdoSecrets::testDefaultAliasAlwaysPresent() { auto service = enableService(); VERIFY(service); // one collection, which is default alias auto coll = getDefaultCollection(service); VERIFY(coll); // after locking, the collection is still there, but locked lockDatabaseInBackend(); coll = getDefaultCollection(service); VERIFY(coll); DBUS_COMPARE(coll->locked(), true); // unlock the database, the alias and collection is present unlockDatabaseInBackend(); coll = getDefaultCollection(service); VERIFY(coll); DBUS_COMPARE(coll->locked(), false); } void TestGuiFdoSecrets::testExposeSubgroup() { auto subgroup = m_db->rootGroup()->findGroupByPath("/Homebanking/Subgroup"); VERIFY(subgroup); FdoSecrets::settings()->setExposedGroup(m_db, subgroup->uuid()); auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); // exposing subgroup does not expose entries in other groups DBUS_GET(itemPaths, coll->items()); QSet exposedEntries; for (const auto& itemPath : itemPaths) { exposedEntries << m_plugin->dbus()->pathToObject(itemPath)->backend(); } COMPARE(exposedEntries, Tools::asSet(subgroup->entries())); } void TestGuiFdoSecrets::testModifyingExposedGroup() { // test when exposed group is removed the collection is not exposed anymore auto subgroup = m_db->rootGroup()->findGroupByPath("/Homebanking"); VERIFY(subgroup); FdoSecrets::settings()->setExposedGroup(m_db, subgroup->uuid()); auto service = enableService(); VERIFY(service); { DBUS_GET(collPaths, service->collections()); COMPARE(collPaths.size(), 1); } m_db->metadata()->setRecycleBinEnabled(true); m_db->recycleGroup(subgroup); processEvents(); { DBUS_GET(collPaths, service->collections()); COMPARE(collPaths, {}); } // test setting another exposed group, the collection will be exposed again FdoSecrets::settings()->setExposedGroup(m_db, m_db->rootGroup()->uuid()); processEvents(); { DBUS_GET(collPaths, service->collections()); COMPARE(collPaths.size(), 1); } } void TestGuiFdoSecrets::testNoExposeRecycleBin() { // when the recycle bin is underneath the exposed group // be careful not to expose entries in there FdoSecrets::settings()->setExposedGroup(m_db, m_db->rootGroup()->uuid()); m_db->metadata()->setRecycleBinEnabled(true); auto entry = m_db->rootGroup()->entries().first(); VERIFY(entry); m_db->recycleEntry(entry); processEvents(); auto service = enableService(); VERIFY(service); auto coll = getDefaultCollection(service); VERIFY(coll); // exposing subgroup does not expose entries in other groups DBUS_GET(itemPaths, coll->items()); QSet exposedEntries; for (const auto& itemPath : itemPaths) { exposedEntries << m_plugin->dbus()->pathToObject(itemPath)->backend(); } VERIFY(!exposedEntries.contains(entry)); // searching should not return the entry DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", entry->title()}})); COMPARE(locked, {}); COMPARE(unlocked, {}); } void TestGuiFdoSecrets::lockDatabaseInBackend() { m_tabWidget->lockDatabases(); m_db.reset(); processEvents(); } void TestGuiFdoSecrets::unlockDatabaseInBackend() { m_dbWidget->performUnlockDatabase("a"); m_db = m_dbWidget->database(); processEvents(); } void TestGuiFdoSecrets::processEvents() { // Couldn't use QApplication::processEvents, because per Qt documentation: // events that are posted while the function runs will be queued until a later round of event processing. // and we may post QTimer single shot events during event handling to achieve async method. // So we directly call event dispatcher in a loop until no events can be handled while (QAbstractEventDispatcher::instance()->processEvents(QEventLoop::AllEvents)) { // pass } } // the following functions have return value, switch macros to the version supporting that #undef VERIFY #undef VERIFY2 #undef COMPARE #define VERIFY(stmt) VERIFY2_RET(stmt, "") #define VERIFY2 VERIFY2_RET #define COMPARE COMPARE_RET QSharedPointer TestGuiFdoSecrets::enableService() { FdoSecrets::settings()->setEnabled(true); VERIFY(m_plugin); m_plugin->updateServiceState(); return getProxy(QDBusObjectPath(DBUS_PATH_SECRETS)); } QSharedPointer TestGuiFdoSecrets::openSession(const QSharedPointer& service, const QString& algo) { VERIFY(service); if (algo == PlainCipher::Algorithm) { DBUS_GET2(output, sessPath, service->OpenSession(algo, QDBusVariant(""))); return getProxy(sessPath); } else if (algo == DhIetf1024Sha256Aes128CbcPkcs7::Algorithm) { DBUS_GET2(output, sessPath, service->OpenSession(algo, QDBusVariant(m_clientCipher->negotiationOutput()))); m_clientCipher->updateClientPublicKey(output.variant().toByteArray()); return getProxy(sessPath); } QTest::qFail("Unsupported algorithm", __FILE__, __LINE__); return {}; } QSharedPointer TestGuiFdoSecrets::getDefaultCollection(const QSharedPointer& service) { VERIFY(service); DBUS_GET(collPath, service->ReadAlias("default")); return getProxy(collPath); } QSharedPointer TestGuiFdoSecrets::getFirstItem(const QSharedPointer& coll) { VERIFY(coll); DBUS_GET(itemPaths, coll->items()); VERIFY(!itemPaths.isEmpty()); return getProxy(itemPaths.first()); } QSharedPointer TestGuiFdoSecrets::createItem(const QSharedPointer& sess, const QSharedPointer& coll, const QString& label, const QString& pass, const StringStringMap& attr, bool replace, bool expectPrompt, bool expectUnlockPrompt) { VERIFY(sess); VERIFY(coll); QVariantMap properties{ {DBUS_INTERFACE_SECRET_ITEM + ".Label", QVariant::fromValue(label)}, {DBUS_INTERFACE_SECRET_ITEM + ".Attributes", QVariant::fromValue(attr)}, }; wire::Secret ss; ss.session = QDBusObjectPath(sess->path()); ss.value = pass.toLocal8Bit(); ss.contentType = "plain/text"; auto encrypted = m_clientCipher->encrypt(ss.unmarshal(m_plugin->dbus())).marshal(); DBUS_GET2(itemPath, promptPath, coll->CreateItem(properties, encrypted, replace)); auto prompt = getProxy(promptPath); VERIFY(prompt); QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant))); VERIFY(spyPromptCompleted.isValid()); // drive the prompt DBUS_VERIFY(prompt->Prompt("")); bool unlockFound = driveUnlockDialog(); COMPARE(unlockFound, expectUnlockPrompt); bool found = driveAccessControlDialog(); COMPARE(found, expectPrompt); VERIFY(waitForSignal(spyPromptCompleted, 1)); auto args = spyPromptCompleted.takeFirst(); COMPARE(args.size(), 2); COMPARE(args.at(0).toBool(), false); itemPath = getSignalVariantArgument(args.at(1)); return getProxy(itemPath); } FdoSecrets::wire::Secret TestGuiFdoSecrets::encryptPassword(QByteArray value, QString contentType, const QSharedPointer& sess) { wire::Secret ss; ss.contentType = std::move(contentType); ss.value = std::move(value); ss.session = QDBusObjectPath(sess->path()); return m_clientCipher->encrypt(ss.unmarshal(m_plugin->dbus())).marshal(); } bool TestGuiFdoSecrets::driveAccessControlDialog(bool remember, bool includeFutureEntries) { processEvents(); for (auto w : QApplication::topLevelWidgets()) { if (!w->isWindow()) { continue; } auto dlg = qobject_cast(w); if (dlg && dlg->isVisible()) { auto rememberCheck = dlg->findChild("rememberCheck"); VERIFY(rememberCheck); rememberCheck->setChecked(remember); if (includeFutureEntries) { dlg->done(AccessControlDialog::AllowAll); } else { dlg->done(AccessControlDialog::AllowSelected); } processEvents(); VERIFY(dlg->isHidden()); return true; } } return false; } bool TestGuiFdoSecrets::driveNewDatabaseWizard() { // processEvents will block because the NewDatabaseWizard is shown using exec // which creates a local QEventLoop. // so we do a little trick here to get the return value back bool ret = false; QTimer::singleShot(0, this, [this, &ret]() { ret = [this]() -> bool { auto wizard = m_tabWidget->findChild(); VERIFY(wizard); COMPARE(wizard->currentId(), 0); wizard->next(); wizard->next(); COMPARE(wizard->currentId(), 2); // enter password auto* passwordEdit = wizard->findChild("enterPasswordEdit")->findChild("passwordEdit"); auto* passwordRepeatEdit = wizard->findChild("repeatPasswordEdit")->findChild("passwordEdit"); VERIFY(passwordEdit); VERIFY(passwordRepeatEdit); QTest::keyClicks(passwordEdit, "test"); QTest::keyClick(passwordEdit, Qt::Key::Key_Tab); QTest::keyClicks(passwordRepeatEdit, "test"); // save database to temporary file TemporaryFile tmpFile; VERIFY(tmpFile.open()); tmpFile.close(); fileDialog()->setNextFileName(tmpFile.fileName()); // click Continue on the warning due to weak password MessageBox::setNextAnswer(MessageBox::ContinueWithWeakPass); wizard->accept(); tmpFile.remove(); return true; }(); }); processEvents(); return ret; } bool TestGuiFdoSecrets::driveUnlockDialog(DatabaseWidget* target) { processEvents(); auto dbOpenDlg = m_tabWidget->findChild(); VERIFY(dbOpenDlg); if (!dbOpenDlg->isVisible()) { return false; } dbOpenDlg->setActiveDatabaseTab(target); auto editPassword = dbOpenDlg->findChild("editPassword")->findChild("passwordEdit"); VERIFY(editPassword); editPassword->setFocus(); QTest::keyClicks(editPassword, "a"); QTest::keyClick(editPassword, Qt::Key_Enter); processEvents(); return true; } bool TestGuiFdoSecrets::waitForSignal(QSignalSpy& spy, int expectedCount) { processEvents(); // If already expected count, do not wait and return immediately if (spy.count() == expectedCount) { return true; } else if (spy.count() > expectedCount) { return false; } spy.wait(); COMPARE(spy.count(), expectedCount); return true; } #undef VERIFY #define VERIFY QVERIFY #undef COMPARE #define COMPARE QCOMPARE