Files
keepassxc/src/fdosecrets/objects/Service.cpp
Aetf 0f7b674cbb FdoSecrets: add smarter handling of database unlock requests
This commit implements the following logic:
* If there're already unlocked collections, just use those,
* otherwise, show the unlock dialog until there's an unlocked and exposed collection.

* Fixes #7574
2022-06-04 16:27:18 -04:00

624 lines
21 KiB
C++

/*
* Copyright (C) 2018 Aetf <aetf@unlimitedcodeworks.xyz>
*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "Service.h"
#include "fdosecrets/FdoSecretsPlugin.h"
#include "fdosecrets/FdoSecretsSettings.h"
#include "fdosecrets/objects/Collection.h"
#include "fdosecrets/objects/Item.h"
#include "fdosecrets/objects/Prompt.h"
#include "fdosecrets/objects/Session.h"
#include "gui/DatabaseTabWidget.h"
#include "gui/DatabaseWidget.h"
namespace
{
constexpr auto DEFAULT_ALIAS = "default";
}
namespace FdoSecrets
{
QSharedPointer<Service>
Service::Create(FdoSecretsPlugin* plugin, QPointer<DatabaseTabWidget> dbTabs, QSharedPointer<DBusMgr> dbus)
{
QSharedPointer<Service> res{new Service(plugin, std::move(dbTabs), std::move(dbus))};
if (!res->initialize()) {
return {};
}
return res;
}
Service::Service(FdoSecretsPlugin* plugin,
QPointer<DatabaseTabWidget> dbTabs,
QSharedPointer<DBusMgr> dbus) // clazy: exclude=ctor-missing-parent-argument
: DBusObject(std::move(dbus))
, m_plugin(plugin)
, m_databases(std::move(dbTabs))
, m_insideEnsureDefaultAlias(false)
{
connect(m_databases,
&DatabaseTabWidget::databaseUnlockDialogFinished,
this,
&Service::onDatabaseUnlockDialogFinished);
}
Service::~Service() = default;
bool Service::initialize()
{
if (!dbus()->registerObject(this)) {
return false;
}
// Add existing database tabs
for (int idx = 0; idx != m_databases->count(); ++idx) {
auto dbWidget = m_databases->databaseWidgetFromIndex(idx);
onDatabaseTabOpened(dbWidget, false);
}
// Connect to new database signal
// No need to connect to close signal, as collection will remove itself when backend delete/close database tab.
connect(m_databases.data(), &DatabaseTabWidget::databaseOpened, this, [this](DatabaseWidget* dbWidget) {
onDatabaseTabOpened(dbWidget, true);
});
// make default alias track current activated database
connect(m_databases.data(), &DatabaseTabWidget::activeDatabaseChanged, this, &Service::ensureDefaultAlias);
return true;
}
void Service::onDatabaseTabOpened(DatabaseWidget* dbWidget, bool emitSignal)
{
// The Collection will monitor the database's exposed group.
// When the Collection finds that no exposed group, it will delete itself.
// Thus, the service also needs to monitor it and recreate the collection if the user changes
// from no exposed to exposed something.
connect(dbWidget, &DatabaseWidget::databaseReplaced, this, [this, dbWidget]() {
monitorDatabaseExposedGroup(dbWidget);
});
if (!dbWidget->isLocked()) {
monitorDatabaseExposedGroup(dbWidget);
}
auto coll = Collection::Create(this, dbWidget);
if (!coll) {
return;
}
m_collections << coll;
m_dbToCollection[dbWidget] = coll;
// keep record of the collection existence
connect(coll, &Collection::collectionAboutToDelete, this, [this, coll]() {
m_collections.removeAll(coll);
m_dbToCollection.remove(coll->backend());
});
// keep record of alias
connect(coll, &Collection::aliasAboutToAdd, this, &Service::onCollectionAliasAboutToAdd);
connect(coll, &Collection::aliasAdded, this, &Service::onCollectionAliasAdded);
connect(coll, &Collection::aliasRemoved, this, &Service::onCollectionAliasRemoved);
// Forward delete signal, we have to rely on filepath to identify the database being closed,
// but we can not access m_backend safely because during the databaseClosed signal,
// m_backend may already be reset to nullptr
// We want to remove the collection object from dbus as early as possible, to avoid
// race conditions when deleteLater was called on the m_backend, but not delivered yet,
// and new method calls from dbus occurred. Therefore, we can't rely on the destroyed
// signal on m_backend.
// bind to coll lifespan
connect(m_databases.data(), &DatabaseTabWidget::databaseClosed, coll, [coll](const QString& filePath) {
if (filePath == coll->backendFilePath()) {
coll->removeFromDBus();
}
});
// actual load, must after updates to m_collections, because reloading may trigger
// another onDatabaseTabOpen, and m_collections will be used to prevent recursion.
if (!coll->reloadBackend()) {
// error in dbus
return;
}
if (!coll->backend()) {
// no exposed group on this db
return;
}
ensureDefaultAlias();
// only start relay signals when the collection is fully setup
connect(coll, &Collection::collectionChanged, this, [this, coll]() { emit collectionChanged(coll); });
connect(coll, &Collection::collectionAboutToDelete, this, [this, coll]() { emit collectionDeleted(coll); });
if (emitSignal) {
emit collectionCreated(coll);
}
}
void Service::monitorDatabaseExposedGroup(DatabaseWidget* dbWidget)
{
Q_ASSERT(dbWidget);
connect(dbWidget->database()->metadata()->customData(), &CustomData::modified, this, [this, dbWidget]() {
if (!FdoSecrets::settings()->exposedGroup(dbWidget->database()).isNull() && !findCollection(dbWidget)) {
onDatabaseTabOpened(dbWidget, true);
}
});
}
void Service::ensureDefaultAlias()
{
if (m_insideEnsureDefaultAlias) {
return;
}
m_insideEnsureDefaultAlias = true;
auto coll = findCollection(m_databases->currentDatabaseWidget());
if (coll) {
// adding alias will automatically remove the association with previous collection.
coll->addAlias(DEFAULT_ALIAS).okOrDie();
}
m_insideEnsureDefaultAlias = false;
}
DBusResult Service::collections(QList<Collection*>& collections) const
{
collections = m_collections;
return {};
}
DBusResult Service::unlockedCollections(QList<Collection*>& unlocked) const
{
auto ret = collections(unlocked);
if (ret.err()) {
return ret;
}
// filter out locked collections
auto it = unlocked.begin();
while (it != unlocked.end()) {
bool isLocked = true;
ret = (*it)->locked(isLocked);
if (ret.err()) {
return ret;
}
if (isLocked) {
it = unlocked.erase(it);
} else {
++it;
}
}
return {};
}
DBusResult Service::openSession(const DBusClientPtr& client,
const QString& algorithm,
const QVariant& input,
QVariant& output,
Session*& result)
{
// negotiate cipher
bool incomplete = false;
auto ciphers = client->negotiateCipher(algorithm, input, output, incomplete);
if (incomplete) {
result = nullptr;
return {};
}
if (!ciphers) {
return QDBusError::NotSupported;
}
// create session using the negotiated cipher
result = Session::Create(std::move(ciphers), client->name(), this);
if (!result) {
return QDBusError::InternalError;
}
// remove session when the client disconnects
connect(dbus().data(), &DBusMgr::clientDisconnected, result, [result, client](const DBusClientPtr& toRemove) {
if (toRemove == client) {
result->close().okOrDie();
}
});
// keep a list of sessions
m_sessions.append(result);
connect(result, &Session::aboutToClose, this, [this, result]() { m_sessions.removeAll(result); });
return {};
}
DBusResult Service::createCollection(const QVariantMap& properties,
const QString& alias,
Collection*& collection,
PromptBase*& prompt)
{
prompt = nullptr;
// return existing collection if alias is non-empty and exists.
collection = findCollection(alias);
if (!collection) {
prompt = PromptBase::Create<CreateCollectionPrompt>(this, properties, alias);
if (!prompt) {
return QDBusError::InternalError;
}
}
return {};
}
DBusResult Service::searchItems(const DBusClientPtr& client,
const StringStringMap& attributes,
QList<Item*>& unlocked,
QList<Item*>& locked)
{
// we can only search unlocked collections
QList<Collection*> unlockedColls;
auto ret = unlockedCollections(unlockedColls);
if (ret.err()) {
return ret;
}
while (unlockedColls.isEmpty() && settings()->unlockBeforeSearch()) {
// enable compatibility mode by making sure at least one database is unlocked
QEventLoop loop;
bool wasAccepted = false;
connect(this, &Service::doneUnlockDatabaseInDialog, &loop, [&](bool accepted) {
wasAccepted = accepted;
loop.quit();
});
doUnlockAnyDatabaseInDialog();
// blocking wait
loop.exec();
if (!wasAccepted) {
// user cancelled, do not proceed
qWarning() << "user cancelled";
return {};
}
// need to recompute this because collections may disappear while in event loop
ret = unlockedCollections(unlockedColls);
if (ret.err()) {
return ret;
}
}
for (const auto& coll : asConst(unlockedColls)) {
QList<Item*> items;
ret = coll->searchItems(client, attributes, items);
if (ret.err()) {
return ret;
}
// item locked state already covers its collection's locked state
for (const auto& item : asConst(items)) {
Q_ASSERT(item);
bool itemLocked;
ret = item->locked(client, itemLocked);
if (ret.err()) {
return ret;
}
if (itemLocked) {
locked.append(item);
} else {
unlocked.append(item);
}
}
}
return {};
}
DBusResult Service::unlock(const DBusClientPtr& client,
const QList<DBusObject*>& objects,
QList<DBusObject*>& unlocked,
PromptBase*& prompt)
{
QSet<Collection*> collectionsToUnlock;
QSet<Item*> itemsToUnlock;
collectionsToUnlock.reserve(objects.size());
itemsToUnlock.reserve(objects.size());
for (const auto& obj : asConst(objects)) {
// the object is either an item or a collection
auto item = qobject_cast<Item*>(obj);
auto coll = item ? item->collection() : qobject_cast<Collection*>(obj);
// either way there should be a collection
if (!coll) {
continue;
}
bool collLocked{false}, itemLocked{false};
// if the collection needs unlock
auto ret = coll->locked(collLocked);
if (ret.err()) {
return ret;
}
if (collLocked) {
collectionsToUnlock << coll;
}
if (item) {
// item may also need unlock
ret = item->locked(client, itemLocked);
if (ret.err()) {
return ret;
}
if (itemLocked) {
itemsToUnlock << item;
}
}
// both collection and item are not locked
if (!collLocked && !itemLocked) {
unlocked << obj;
}
}
prompt = nullptr;
if (!collectionsToUnlock.isEmpty() || !itemsToUnlock.isEmpty()) {
prompt = PromptBase::Create<UnlockPrompt>(this, collectionsToUnlock, itemsToUnlock);
if (!prompt) {
return QDBusError::InternalError;
}
}
return {};
}
DBusResult Service::lock(const QList<DBusObject*>& objects, QList<DBusObject*>& locked, PromptBase*& prompt)
{
QSet<Collection*> needLock;
needLock.reserve(objects.size());
for (const auto& obj : asConst(objects)) {
auto coll = qobject_cast<Collection*>(obj);
if (coll) {
needLock << coll;
} else {
auto item = qobject_cast<Item*>(obj);
if (!item) {
continue;
}
// we lock the whole collection for item
needLock << item->collection();
}
}
// return anything already locked
QList<Collection*> toLock;
for (const auto& coll : asConst(needLock)) {
bool l;
auto ret = coll->locked(l);
if (ret.err()) {
return ret;
}
if (l) {
locked << coll;
} else {
toLock << coll;
}
}
if (!toLock.isEmpty()) {
prompt = PromptBase::Create<LockCollectionsPrompt>(this, toLock);
if (!prompt) {
return QDBusError::InternalError;
}
}
return {};
}
DBusResult Service::getSecrets(const DBusClientPtr& client,
const QList<Item*>& items,
Session* session,
ItemSecretMap& secrets) const
{
if (!session) {
return DBusResult(DBUS_ERROR_SECRET_NO_SESSION);
}
for (const auto& item : asConst(items)) {
auto ret = item->getSecretNoNotification(client, session, secrets[item]);
if (ret.err()) {
return ret;
}
}
plugin()->emitRequestShowNotification(
tr(R"(%n Entry(s) was used by %1)", "%1 is the name of an application", secrets.size())
.arg(client->name()));
return {};
}
DBusResult Service::readAlias(const QString& name, Collection*& collection) const
{
collection = findCollection(name);
return {};
}
DBusResult Service::setAlias(const QString& name, Collection* collection)
{
if (!collection) {
// remove alias name from its collection
collection = findCollection(name);
if (!collection) {
return DBusResult(DBUS_ERROR_SECRET_NO_SUCH_OBJECT);
}
return collection->removeAlias(name);
}
return collection->addAlias(name);
}
Collection* Service::findCollection(const QString& alias) const
{
if (alias.isEmpty()) {
return nullptr;
}
auto it = m_aliases.find(alias);
if (it != m_aliases.end()) {
return it.value();
}
return nullptr;
}
void Service::onCollectionAliasAboutToAdd(const QString& alias)
{
auto coll = qobject_cast<Collection*>(sender());
auto it = m_aliases.constFind(alias);
if (it != m_aliases.constEnd() && it.value() != coll) {
// another collection holds the alias
// remove it first
it.value()->removeAlias(alias).okOrDie();
// onCollectionAliasRemoved called through signal
// `it` becomes invalidated now
}
}
void Service::onCollectionAliasAdded(const QString& alias)
{
auto coll = qobject_cast<Collection*>(sender());
m_aliases[alias] = coll;
}
void Service::onCollectionAliasRemoved(const QString& alias)
{
m_aliases.remove(alias);
ensureDefaultAlias();
}
Collection* Service::findCollection(const DatabaseWidget* db) const
{
return m_dbToCollection.value(db, nullptr);
}
QList<Session*> Service::sessions() const
{
return m_sessions;
}
bool Service::doCloseDatabase(DatabaseWidget* dbWidget)
{
return m_databases->closeDatabaseTab(dbWidget);
}
Collection* Service::doNewDatabase()
{
auto dbWidget = m_databases->newDatabase();
if (!dbWidget) {
return nullptr;
}
// database created through dbus will be exposed to dbus by default
auto db = dbWidget->database();
FdoSecrets::settings()->setExposedGroup(db, db->rootGroup()->uuid());
auto collection = findCollection(dbWidget);
Q_ASSERT(collection);
return collection;
}
void Service::doSwitchToDatabaseSettings(DatabaseWidget* dbWidget)
{
if (dbWidget->isLocked()) {
return;
}
// switch selected to current
m_databases->setCurrentWidget(dbWidget);
m_databases->showDatabaseSettings();
// open settings (switch from app settings to m_dbTabs)
m_plugin->emitRequestSwitchToDatabases();
}
bool Service::doLockDatabase(DatabaseWidget* dbWidget)
{
// return immediately if the db is already unlocked
if (dbWidget && dbWidget->isLocked()) {
return true;
}
// mark the db as being unlocked to prevent multiple dialogs for the same db
if (m_lockingDb.contains(dbWidget)) {
return true;
}
m_lockingDb.insert(dbWidget);
auto ret = dbWidget->lock();
m_lockingDb.remove(dbWidget);
return ret;
}
void Service::doUnlockDatabaseInDialog(DatabaseWidget* dbWidget)
{
// return immediately if the db is already unlocked
if (dbWidget && !dbWidget->isLocked()) {
emit doneUnlockDatabaseInDialog(true, dbWidget);
return;
}
// check if the db is already being unlocked to prevent multiple dialogs for the same db
if (m_unlockingAnyDatabase || m_unlockingDb.contains(dbWidget)) {
return;
}
// insert a dummy one here, just to prevent multiple dialogs
// the real one will be inserted in onDatabaseUnlockDialogFinished
m_unlockingDb[dbWidget] = {};
// actually show the dialog
m_databases->unlockDatabaseInDialog(dbWidget, DatabaseOpenDialog::Intent::None);
}
void Service::doUnlockAnyDatabaseInDialog()
{
if (m_unlockingAnyDatabase || !m_unlockingDb.isEmpty()) {
return;
}
m_unlockingAnyDatabase = true;
m_databases->unlockAnyDatabaseInDialog(DatabaseOpenDialog::Intent::None);
}
void Service::onDatabaseUnlockDialogFinished(bool accepted, DatabaseWidget* dbWidget)
{
if (!m_unlockingAnyDatabase && !m_unlockingDb.contains(dbWidget)) {
// not our concern
return;
}
if (!accepted) {
emit doneUnlockDatabaseInDialog(false, dbWidget);
m_unlockingAnyDatabase = false;
m_unlockingDb.remove(dbWidget);
} else {
// delay the done signal to when the database is actually done with unlocking
// this is a oneshot connection to prevent superfluous signals
auto conn = connect(dbWidget, &DatabaseWidget::databaseUnlocked, this, [dbWidget, this]() {
emit doneUnlockDatabaseInDialog(true, dbWidget);
m_unlockingAnyDatabase = false;
disconnect(m_unlockingDb.take(dbWidget));
});
m_unlockingDb[dbWidget] = conn;
}
}
} // namespace FdoSecrets