keepassxc/src/fdosecrets/objects/Collection.cpp
Aetf a31c5ba006 FdoSecrets: Implement unlock before search
Fixes #6942 and fixes #4443

- Return number of deleted entries
- Fix minor memory leak
- FdoSecrets: make all prompt truly async per spec and update tests
    * the waited signal may already be emitted before calling spy.wait(),
      causing the test to fail. This commit checks the count before waiting.
    * check unlock result after waiting for signal
- FdoSecrets: implement unlockBeforeSearch option
- FdoSecrets: make search always work regardless of entry group searching settings, fixes #6942
- FdoSecrets: cleanup gracefully even if some test failed
- FdoSecrets: make it safe to call prompts concurrently
- FdoSecrets: make sure in unit test we click on the correct dialog

Note on the unit tests: objects are not deleted (due to deleteLater event not handled).
So there may be multiple AccessControlDialog. But only one of
it is visible and is the correctly one to click on.

Before this change, a random one may be clicked on, causing the
completed signal never be sent.
2021-10-16 22:50:04 -04:00

737 lines
23 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 "Collection.h"
#include "fdosecrets/FdoSecretsSettings.h"
#include "fdosecrets/objects/Item.h"
#include "fdosecrets/objects/Prompt.h"
#include "fdosecrets/objects/Service.h"
#include "core/Tools.h"
#include "gui/DatabaseWidget.h"
#include "gui/GuiTools.h"
#include <QEventLoop>
#include <QFileInfo>
namespace FdoSecrets
{
Collection* Collection::Create(Service* parent, DatabaseWidget* backend)
{
return new Collection(parent, backend);
}
Collection::Collection(Service* parent, DatabaseWidget* backend)
: DBusObject(parent)
, m_backend(backend)
, m_exposedGroup(nullptr)
{
// whenever the file path or the database object itself change, we do a full reload.
connect(backend, &DatabaseWidget::databaseFilePathChanged, this, &Collection::reloadBackendOrDelete);
connect(backend, &DatabaseWidget::databaseReplaced, this, &Collection::reloadBackendOrDelete);
// also remember to clear/populate the database when lock state changes.
connect(backend, &DatabaseWidget::databaseUnlocked, this, &Collection::onDatabaseLockChanged);
connect(backend, &DatabaseWidget::databaseLocked, this, &Collection::onDatabaseLockChanged);
// get notified whenever unlock db dialog finishes
connect(parent, &Service::doneUnlockDatabaseInDialog, this, [this](bool accepted, DatabaseWidget* dbWidget) {
if (!dbWidget || dbWidget != m_backend) {
return;
}
emit doneUnlockCollection(accepted);
});
}
bool Collection::reloadBackend()
{
Q_ASSERT(m_backend);
// delete all items
// this has to be done because the backend is actually still there, just we don't expose them
// NOTE: Do NOT use a for loop, because Item::removeFromDBus will remove itself from m_items.
while (!m_items.isEmpty()) {
m_items.first()->removeFromDBus();
}
cleanupConnections();
dbus()->unregisterObject(this);
// make sure we have updated copy of the filepath, which is used to identify the database.
m_backendPath = m_backend->database()->canonicalFilePath();
// register the object, handling potentially duplicated name
if (!dbus()->registerObject(this)) {
return false;
}
// populate contents after expose on dbus, because items rely on parent's dbus object path
if (!backendLocked()) {
populateContents();
} else {
cleanupConnections();
}
emit collectionChanged();
return true;
}
void Collection::reloadBackendOrDelete()
{
if (!reloadBackend()) {
removeFromDBus();
}
}
DBusResult Collection::ensureBackend() const
{
if (!m_backend) {
return DBusResult(DBUS_ERROR_SECRET_NO_SUCH_OBJECT);
}
return {};
}
DBusResult Collection::ensureUnlocked() const
{
if (backendLocked()) {
return DBusResult(DBUS_ERROR_SECRET_IS_LOCKED);
}
return {};
}
DBusResult Collection::items(QList<Item*>& items) const
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
items = m_items;
return {};
}
DBusResult Collection::label(QString& label) const
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
if (backendLocked()) {
label = name();
} else {
label = m_backend->database()->metadata()->name();
}
return {};
}
DBusResult Collection::setLabel(const QString& label)
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
ret = ensureUnlocked();
if (ret.err()) {
return ret;
}
m_backend->database()->metadata()->setName(label);
return {};
}
DBusResult Collection::locked(bool& locked) const
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
locked = backendLocked();
return {};
}
DBusResult Collection::created(qulonglong& created) const
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
ret = ensureUnlocked();
if (ret.err()) {
return ret;
}
created = static_cast<qulonglong>(
m_backend->database()->rootGroup()->timeInfo().creationTime().toMSecsSinceEpoch() / 1000);
return {};
}
DBusResult Collection::modified(qulonglong& modified) const
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
ret = ensureUnlocked();
if (ret.err()) {
return ret;
}
// FIXME: there seems not to have a global modified time.
// Use a more accurate time, considering all metadata, group, entry.
modified = static_cast<qulonglong>(
m_backend->database()->rootGroup()->timeInfo().lastModificationTime().toMSecsSinceEpoch() / 1000);
return {};
}
DBusResult Collection::remove(PromptBase*& prompt)
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
prompt = PromptBase::Create<DeleteCollectionPrompt>(service(), this);
if (!prompt) {
return QDBusError::InternalError;
}
return {};
}
DBusResult
Collection::searchItems(const DBusClientPtr& client, const StringStringMap& attributes, QList<Item*>& items)
{
items.clear();
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
if (backendLocked() && settings()->unlockBeforeSearch()) {
// do a blocking unlock prompt first.
// while technically not correct, this should improve compatibility.
// see issue #4443
auto prompt = PromptBase::Create<UnlockPrompt>(service(), QSet<Collection*>{this}, QSet<Item*>{});
if (!prompt) {
return QDBusError::InternalError;
}
// we don't know the windowId to show the prompt correctly,
// but the default of showing over the KPXC main window should be good enough
ret = prompt->prompt(client, "");
// blocking wait
QEventLoop loop;
connect(prompt, &PromptBase::completed, &loop, &QEventLoop::quit);
loop.exec();
}
// check again because the prompt may be cancelled
if (backendLocked()) {
// searchItems should work, whether `this` is locked or not.
// however, we can't search items the same way as in gnome-keying,
// because there's no database at all when locked.
return {};
}
// shortcut logic for Uuid/Path attributes, as they can uniquely identify an item.
if (attributes.contains(ItemAttributes::UuidKey)) {
auto uuid = QUuid::fromRfc4122(QByteArray::fromHex(attributes.value(ItemAttributes::UuidKey).toLatin1()));
auto entry = m_exposedGroup->findEntryByUuid(uuid);
if (entry) {
items += m_entryToItem.value(entry);
}
return {};
}
if (attributes.contains(ItemAttributes::PathKey)) {
auto path = attributes.value(ItemAttributes::PathKey);
auto entry = m_exposedGroup->findEntryByPath(path);
if (entry) {
items += m_entryToItem.value(entry);
}
return {};
}
QList<EntrySearcher::SearchTerm> terms;
for (auto it = attributes.constBegin(); it != attributes.constEnd(); ++it) {
terms << attributeToTerm(it.key(), it.value());
}
constexpr auto caseSensitive = false;
constexpr auto skipProtected = true;
constexpr auto forceSearch = true;
const auto foundEntries =
EntrySearcher(caseSensitive, skipProtected).search(terms, m_exposedGroup, forceSearch);
items.reserve(foundEntries.size());
for (const auto& entry : foundEntries) {
items << m_entryToItem.value(entry);
}
return {};
}
EntrySearcher::SearchTerm Collection::attributeToTerm(const QString& key, const QString& value)
{
static QMap<QString, EntrySearcher::Field> attrKeyToField{
{EntryAttributes::TitleKey, EntrySearcher::Field::Title},
{EntryAttributes::UserNameKey, EntrySearcher::Field::Username},
{EntryAttributes::URLKey, EntrySearcher::Field::Url},
{EntryAttributes::NotesKey, EntrySearcher::Field::Notes},
};
EntrySearcher::SearchTerm term{};
term.field = attrKeyToField.value(key, EntrySearcher::Field::AttributeValue);
term.word = key;
term.exclude = false;
const auto useWildcards = false;
const auto exactMatch = true;
const auto caseSensitive = true;
term.regex = Tools::convertToRegex(QRegularExpression::escape(value), useWildcards, exactMatch, caseSensitive);
return term;
}
DBusResult Collection::createItem(const QVariantMap& properties,
const Secret& secret,
bool replace,
Item*& item,
PromptBase*& prompt)
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
item = nullptr;
prompt = PromptBase::Create<CreateItemPrompt>(service(), this, properties, secret, replace);
if (!prompt) {
return QDBusError::InternalError;
}
return {};
}
DBusResult Collection::setProperties(const QVariantMap& properties)
{
auto label = properties.value(DBUS_INTERFACE_SECRET_COLLECTION + ".Label").toString();
auto ret = setLabel(label);
if (ret.err()) {
return ret;
}
return {};
}
const QSet<QString> Collection::aliases() const
{
return m_aliases;
}
DBusResult Collection::addAlias(QString alias)
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
alias = encodePath(alias);
if (m_aliases.contains(alias)) {
return {};
}
emit aliasAboutToAdd(alias);
if (dbus()->registerAlias(this, alias)) {
m_aliases.insert(alias);
emit aliasAdded(alias);
} else {
return QDBusError::InvalidObjectPath;
}
return {};
}
DBusResult Collection::removeAlias(QString alias)
{
auto ret = ensureBackend();
if (ret.err()) {
return ret;
}
alias = encodePath(alias);
if (!m_aliases.contains(alias)) {
return {};
}
dbus()->unregisterAlias(alias);
m_aliases.remove(alias);
emit aliasRemoved(alias);
return {};
}
QString Collection::name() const
{
if (m_backendPath.isEmpty()) {
// This is a newly created db without saving to file,
// but we have to give a name, which is used to register dbus path.
// We use database name for this purpose. For simplicity, we don't monitor the name change.
// So the dbus object path is not updated if the db name changes.
// This should not be a problem because once the database gets saved,
// the dbus path will be updated to use filename and everything back to normal.
return m_backend->database()->metadata()->name();
}
return QFileInfo(m_backendPath).completeBaseName();
}
DatabaseWidget* Collection::backend() const
{
return m_backend;
}
void Collection::onDatabaseLockChanged()
{
if (!reloadBackend()) {
removeFromDBus();
return;
}
emit collectionLockChanged(backendLocked());
}
void Collection::populateContents()
{
if (!m_backend) {
return;
}
// we have an unlocked db
auto newUuid = FdoSecrets::settings()->exposedGroup(m_backend->database());
auto newGroup = m_backend->database()->rootGroup()->findGroupByUuid(newUuid);
if (!newGroup || inRecycleBin(newGroup)) {
// no exposed group, delete self
removeFromDBus();
return;
}
// clean up old group connections
cleanupConnections();
m_exposedGroup = newGroup;
// Attach signal to update exposed group settings if the group was removed.
//
// When the group object is normally deleted due to ~Database, the databaseReplaced
// signal should be first emitted, and we will clean up connection in reloadDatabase,
// so this handler won't be triggered.
connect(m_exposedGroup.data(), &Group::groupAboutToRemove, this, [this](Group* toBeRemoved) {
if (backendLocked()) {
return;
}
auto db = m_backend->database();
if (toBeRemoved->database() != db) {
// should not happen, but anyway.
// somehow our current database has been changed, and the old group is being deleted
// possibly logic changes in replaceDatabase.
return;
}
auto uuid = FdoSecrets::settings()->exposedGroup(db);
auto exposedGroup = db->rootGroup()->findGroupByUuid(uuid);
if (toBeRemoved == exposedGroup) {
// reset the exposed group to none
FdoSecrets::settings()->setExposedGroup(db, {});
}
});
// Another possibility is the group being moved to recycle bin.
connect(m_exposedGroup.data(), &Group::modified, this, [this]() {
if (inRecycleBin(m_exposedGroup->parentGroup())) {
// reset the exposed group to none
FdoSecrets::settings()->setExposedGroup(m_backend->database().data(), {});
}
});
// Monitor exposed group settings
connect(m_backend->database()->metadata()->customData(), &CustomData::modified, this, [this]() {
if (!m_exposedGroup || backendLocked()) {
return;
}
if (m_exposedGroup->uuid() == FdoSecrets::settings()->exposedGroup(m_backend->database())) {
// no change
return;
}
onDatabaseExposedGroupChanged();
});
// Add items for existing entry
const auto entries = m_exposedGroup->entriesRecursive(false);
for (const auto& entry : entries) {
onEntryAdded(entry, false);
}
// Do not connect to Database::modified signal because we only want signals for the subset under m_exposedGroup
connect(m_backend->database()->metadata(), &Metadata::modified, this, &Collection::collectionChanged);
connectGroupSignalRecursive(m_exposedGroup);
}
void Collection::onDatabaseExposedGroupChanged()
{
// delete all items
// this has to be done because the backend is actually still there
// just we don't expose them
for (const auto& item : asConst(m_items)) {
item->removeFromDBus();
}
// repopulate
if (!backendLocked()) {
populateContents();
}
}
void Collection::onEntryAdded(Entry* entry, bool emitSignal)
{
if (inRecycleBin(entry)) {
return;
}
auto item = Item::Create(this, entry);
m_items << item;
m_entryToItem[entry] = item;
// forward delete signals
connect(entry->group(), &Group::entryAboutToRemove, item, [item](Entry* toBeRemoved) {
if (item->backend() == toBeRemoved) {
item->removeFromDBus();
}
});
// relay signals
connect(item, &Item::itemChanged, this, [this, item]() { emit itemChanged(item); });
connect(item, &Item::itemAboutToDelete, this, [this, item]() {
m_items.removeAll(item);
m_entryToItem.remove(item->backend());
emit itemDeleted(item);
});
if (emitSignal) {
emit itemCreated(item);
}
}
void Collection::connectGroupSignalRecursive(Group* group)
{
if (inRecycleBin(group)) {
return;
}
connect(group, &Group::modified, this, &Collection::collectionChanged);
connect(group, &Group::entryAdded, this, [this](Entry* entry) { onEntryAdded(entry, true); });
const auto children = group->children();
for (const auto& cg : children) {
connectGroupSignalRecursive(cg);
}
}
Service* Collection::service() const
{
return qobject_cast<Service*>(parent());
}
bool Collection::doLock()
{
Q_ASSERT(m_backend);
// do not call m_backend->lock() directly
// let the service handle locking which handles concurrent calls
return service()->doLockDatabase(m_backend);
}
void Collection::doUnlock()
{
Q_ASSERT(m_backend);
return service()->doUnlockDatabaseInDialog(m_backend);
}
bool Collection::doDelete()
{
Q_ASSERT(m_backend);
return service()->doCloseDatabase(m_backend);
}
void Collection::removeFromDBus()
{
if (!m_backend) {
// I'm already deleted
return;
}
emit collectionAboutToDelete();
// remove from dbus early
dbus()->unregisterObject(this);
// remove alias manually to trigger signal
for (const auto& a : aliases()) {
removeAlias(a).okOrDie();
}
// cleanup connection on Database
cleanupConnections();
// cleanup connection on Backend itself
m_backend->disconnect(this);
parent()->disconnect(this);
m_exposedGroup = nullptr;
// reset backend and delete self
m_backend = nullptr;
deleteLater();
// items will be removed automatically as they are children objects
}
void Collection::cleanupConnections()
{
m_backend->database()->metadata()->customData()->disconnect(this);
if (m_exposedGroup) {
for (const auto group : m_exposedGroup->groupsRecursive(true)) {
group->disconnect(this);
}
}
m_items.clear();
}
QString Collection::backendFilePath() const
{
return m_backendPath;
}
Group* Collection::exposedRootGroup() const
{
return m_exposedGroup;
}
bool Collection::backendLocked() const
{
return !m_backend || !m_backend->database()->isInitialized() || m_backend->isLocked();
}
bool Collection::doDeleteEntry(Entry* entry)
{
// Confirm entry removal before moving forward
bool permanent = inRecycleBin(entry) || !m_backend->database()->metadata()->recycleBinEnabled();
if (FdoSecrets::settings()->confirmDeleteItem()
&& !GuiTools::confirmDeleteEntries(m_backend, {entry}, permanent)) {
return false;
}
auto num = GuiTools::deleteEntriesResolveReferences(m_backend, {entry}, permanent);
return num != 0;
}
Group* Collection::findCreateGroupByPath(const QString& groupPath)
{
auto group = m_exposedGroup->findGroupByPath(groupPath);
if (group) {
return group;
}
// groupPath can't be empty here, because otherwise it will match m_exposedGroup and was returned above
Q_ASSERT(!groupPath.isEmpty());
auto groups = groupPath.split('/', QString::SkipEmptyParts);
auto groupName = groups.takeLast();
// create parent group
auto parentGroup = findCreateGroupByPath(groups.join('/'));
// create this group
group = new Group();
group->setUuid(QUuid::createUuid());
group->setName(groupName);
group->setIcon(Group::DefaultIconNumber);
group->setParent(parentGroup);
return group;
}
bool Collection::inRecycleBin(Group* group) const
{
Q_ASSERT(m_backend);
if (!group) {
// the root group's parent is nullptr, we treat it as not in recycle bin.
return false;
}
if (!m_backend->database()->metadata()) {
return false;
}
auto recycleBin = m_backend->database()->metadata()->recycleBin();
if (!recycleBin) {
return false;
}
return group->uuid() == recycleBin->uuid() || group->isRecycled();
}
bool Collection::inRecycleBin(Entry* entry) const
{
Q_ASSERT(entry);
return inRecycleBin(entry->group());
}
Item* Collection::doNewItem(const DBusClientPtr& client, QString itemPath)
{
Q_ASSERT(m_backend);
// normalize itemPath
itemPath = (itemPath.startsWith('/') ? QString{} : QStringLiteral("/")) + itemPath;
// split itemPath to groupPath and itemName
auto components = itemPath.split('/');
Q_ASSERT(components.size() >= 2);
auto itemName = components.takeLast();
Group* group = findCreateGroupByPath(components.join('/'));
// create new Entry in backend
auto* entry = new Entry();
entry->setUuid(QUuid::createUuid());
entry->setTitle(itemName);
entry->setUsername(m_backend->database()->metadata()->defaultUserName());
group->applyGroupIconOnCreateTo(entry);
entry->setGroup(group);
// the item was just created so there is no point in having it not authorized
client->setItemAuthorized(entry->uuid(), AuthDecision::Allowed);
// when creation finishes in backend, we will already have item
auto created = m_entryToItem.value(entry, nullptr);
return created;
}
} // namespace FdoSecrets