Files
keepassxc/src/fdosecrets/objects/Prompt.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

494 lines
16 KiB
C++

/*
* Copyright (C) 2019 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 "Prompt.h"
#include "fdosecrets/objects/Collection.h"
#include "fdosecrets/objects/Item.h"
#include "fdosecrets/objects/Service.h"
#include "fdosecrets/objects/Session.h"
#include "fdosecrets/widgets/AccessControlDialog.h"
#include "core/Entry.h"
#include "gui/MessageBox.h"
#include <QThread>
#include <QTimer>
#include <QWindow>
namespace FdoSecrets
{
const PromptResult PromptResult::Pending{PromptResult::AsyncPending};
PromptBase::PromptBase(Service* parent)
: DBusObject(parent)
{
connect(this, &PromptBase::completed, this, &PromptBase::deleteLater);
}
QWindow* PromptBase::findWindow(const QString& windowId)
{
// find parent window, or nullptr if not found
bool ok = false;
WId wid = windowId.toULongLong(&ok, 0);
QWindow* parent = nullptr;
if (ok) {
parent = QWindow::fromWinId(wid);
}
if (parent) {
// parent is not the child of any object, so make sure it gets deleted at some point
QObject::connect(this, &QObject::destroyed, parent, &QObject::deleteLater);
}
return parent;
}
Service* PromptBase::service() const
{
return qobject_cast<Service*>(parent());
}
DBusResult PromptBase::prompt(const DBusClientPtr& client, const QString& windowId)
{
if (thread() != QThread::currentThread()) {
DBusResult ret;
QMetaObject::invokeMethod(
this, "prompt", Qt::BlockingQueuedConnection, Q_ARG(QString, windowId), Q_RETURN_ARG(DBusResult, ret));
return ret;
}
QWeakPointer<DBusClient> weak = client;
// execute the actual prompt method in event loop to avoid block this method
QTimer::singleShot(0, this, [this, weak, windowId]() {
auto c = weak.lock();
if (!c) {
return;
}
if (m_signalSent) {
return;
}
auto res = promptSync(c, windowId);
if (!res.isPending()) {
finishPrompt(res.isDismiss());
}
});
return {};
}
DBusResult PromptBase::dismiss()
{
finishPrompt(true);
return {};
}
QVariant PromptBase::currentResult() const
{
return "";
}
void PromptBase::finishPrompt(bool dismissed)
{
if (m_signalSent) {
return;
}
m_signalSent = true;
emit completed(dismissed, currentResult());
}
DeleteCollectionPrompt::DeleteCollectionPrompt(Service* parent, Collection* coll)
: PromptBase(parent)
, m_collection(coll)
{
}
PromptResult DeleteCollectionPrompt::promptSync(const DBusClientPtr&, const QString& windowId)
{
MessageBox::OverrideParent override(findWindow(windowId));
// if m_collection is already gone then treat as deletion accepted
auto accepted = true;
if (m_collection) {
accepted = m_collection->doDelete();
}
return PromptResult::accepted(accepted);
}
CreateCollectionPrompt::CreateCollectionPrompt(Service* parent, QVariantMap properties, QString alias)
: PromptBase(parent)
, m_properties(std::move(properties))
, m_alias(std::move(alias))
{
}
QVariant CreateCollectionPrompt::currentResult() const
{
return QVariant::fromValue(DBusMgr::objectPathSafe(m_coll));
}
PromptResult CreateCollectionPrompt::promptSync(const DBusClientPtr&, const QString& windowId)
{
MessageBox::OverrideParent override(findWindow(windowId));
bool created = false;
// collection with the alias may be created since the prompt was created
auto ret = service()->readAlias(m_alias, m_coll);
if (ret.err()) {
return ret;
}
if (!m_coll) {
created = true;
m_coll = service()->doNewDatabase();
if (!m_coll) {
return PromptResult::accepted(false);
}
}
ret = m_coll->setProperties(m_properties);
if (ret.err()) {
if (created) {
m_coll->removeFromDBus();
}
return ret;
}
if (!m_alias.isEmpty()) {
ret = m_coll->addAlias(m_alias);
if (ret.err()) {
if (created) {
m_coll->removeFromDBus();
}
return ret;
}
}
return {};
}
LockCollectionsPrompt::LockCollectionsPrompt(Service* parent, const QList<Collection*>& colls)
: PromptBase(parent)
{
m_collections.reserve(colls.size());
for (const auto& c : asConst(colls)) {
m_collections << c;
}
}
QVariant LockCollectionsPrompt::currentResult() const
{
return QVariant::fromValue(m_locked);
}
PromptResult LockCollectionsPrompt::promptSync(const DBusClientPtr&, const QString& windowId)
{
MessageBox::OverrideParent override(findWindow(windowId));
for (const auto& c : asConst(m_collections)) {
if (c) {
auto accepted = c->doLock();
if (accepted) {
m_locked << c->objectPath();
}
}
}
return PromptResult::accepted(m_locked.size() == m_collections.size());
}
UnlockPrompt::UnlockPrompt(Service* parent, const QSet<Collection*>& colls, const QSet<Item*>& items)
: PromptBase(parent)
{
m_collections.reserve(colls.size());
for (const auto& coll : asConst(colls)) {
m_collections << coll;
connect(coll, &Collection::doneUnlockCollection, this, &UnlockPrompt::collectionUnlockFinished);
}
for (const auto& item : asConst(items)) {
m_items[item->collection()] << item;
}
}
QVariant UnlockPrompt::currentResult() const
{
return QVariant::fromValue(m_unlocked);
}
PromptResult UnlockPrompt::promptSync(const DBusClientPtr& client, const QString& windowId)
{
MessageBox::OverrideParent override(findWindow(windowId));
// for use in unlockItems
m_windowId = windowId;
m_client = client;
// first unlock any collections
bool waitingForCollections = false;
for (const auto& c : asConst(m_collections)) {
if (c) {
// doUnlock is nonblocking, execution will continue in collectionUnlockFinished
// it is ok to call doUnlock multiple times before it's actually unlocked by the user
c->doUnlock();
waitingForCollections = true;
}
}
// unlock items directly if no collection unlocking pending
// o.w. do it in collectionUnlockFinished
if (!waitingForCollections) {
unlockItems();
}
return PromptResult::Pending;
}
void UnlockPrompt::collectionUnlockFinished(bool accepted)
{
auto coll = qobject_cast<Collection*>(sender());
if (!coll) {
return;
}
// one shot
coll->disconnect(this);
if (!m_collections.contains(coll)) {
// should not happen
return;
}
if (accepted) {
m_unlocked << coll->objectPath();
} else {
m_numRejected += 1;
// no longer need to unlock the item if its containing collection didn't unlock.
m_items.remove(coll);
}
// if we got response for all collections
if (m_numRejected + m_unlocked.size() == m_collections.size()) {
// next step is to unlock items
unlockItems();
}
}
void UnlockPrompt::unlockItems()
{
auto client = m_client.lock();
if (!client) {
// client already gone
return;
}
// flatten to list of entries
QList<Entry*> entries;
for (const auto& itemsPerColl : asConst(m_items)) {
for (const auto& item : itemsPerColl) {
if (!item) {
m_numRejected += 1;
continue;
}
auto entry = item->backend();
if (client->itemKnown(entry->uuid())) {
if (!client->itemAuthorized(entry->uuid())) {
m_numRejected += 1;
}
continue;
}
// attach a temporary property so later we can get the item
// back from the dialog's result
entry->setProperty(FdoSecretsBackend, QVariant::fromValue(item.data()));
entries << entry;
}
}
if (!entries.isEmpty()) {
QString app = tr("%1 (PID: %2)").arg(client->name()).arg(client->pid());
auto ac = new AccessControlDialog(
findWindow(m_windowId), entries, app, client->processInfo(), AuthOption::Remember);
connect(ac, &AccessControlDialog::finished, this, &UnlockPrompt::itemUnlockFinished);
connect(ac, &AccessControlDialog::finished, ac, &AccessControlDialog::deleteLater);
ac->open();
} else {
itemUnlockFinished({});
}
}
void UnlockPrompt::itemUnlockFinished(const QHash<Entry*, AuthDecision>& decisions)
{
auto client = m_client.lock();
if (!client) {
// client already gone
qDebug() << "DBus client gone before item unlocking finish";
return;
}
for (auto it = decisions.constBegin(); it != decisions.constEnd(); ++it) {
auto entry = it.key();
// get back the corresponding item
auto item = entry->property(FdoSecretsBackend).value<Item*>();
entry->setProperty(FdoSecretsBackend, {});
Q_ASSERT(item);
// set auth
client->setItemAuthorized(entry->uuid(), it.value());
if (client->itemAuthorized(entry->uuid())) {
m_unlocked += item->objectPath();
} else {
m_numRejected += 1;
}
}
// if anything is not unlocked, treat the whole prompt as dismissed
// so the client has a chance to handle the error
finishPrompt(m_numRejected > 0);
}
DeleteItemPrompt::DeleteItemPrompt(Service* parent, Item* item)
: PromptBase(parent)
, m_item(item)
{
}
PromptResult DeleteItemPrompt::promptSync(const DBusClientPtr&, const QString& windowId)
{
MessageBox::OverrideParent override(findWindow(windowId));
// if m_item is gone, assume it's already deleted
bool deleted = true;
if (m_item) {
deleted = m_item->doDelete();
}
return PromptResult::accepted(deleted);
}
CreateItemPrompt::CreateItemPrompt(Service* parent,
Collection* coll,
QVariantMap properties,
Secret secret,
bool replace)
: PromptBase(parent)
, m_coll(coll)
, m_properties(std::move(properties))
, m_secret(std::move(secret))
, m_replace(replace)
, m_item(nullptr)
// session aliveness also need to be tracked, for potential use later in updateItem
, m_sess(m_secret.session)
{
}
QVariant CreateItemPrompt::currentResult() const
{
return QVariant::fromValue(DBusMgr::objectPathSafe(m_item));
}
PromptResult CreateItemPrompt::promptSync(const DBusClientPtr& client, const QString& windowId)
{
if (!m_coll) {
return PromptResult::accepted(false);
}
bool locked = true;
auto ret = m_coll->locked(locked);
if (locked) {
// collection was locked
return DBusResult{DBUS_ERROR_SECRET_IS_LOCKED};
}
// save a weak reference to the client which may be used asynchronously later
m_client = client;
// get itemPath to create item and
// try finding an existing item using attributes
QString itemPath{};
auto iterAttr = m_properties.find(DBUS_INTERFACE_SECRET_ITEM + ".Attributes");
if (iterAttr != m_properties.end()) {
// the actual value in iterAttr.value() is QDBusArgument, which represents a structure
// and qt has no idea what this corresponds to.
// we thus force a conversion to StringStringMap here. The conversion is registered in
// DBusTypes.cpp
auto attributes = iterAttr.value().value<StringStringMap>();
itemPath = attributes.value(ItemAttributes::PathKey);
// check existing item using attributes
QList<Item*> existing;
ret = m_coll->searchItems(client, attributes, existing);
if (ret.err()) {
return ret;
}
if (!existing.isEmpty() && m_replace) {
m_item = existing.front();
}
}
if (!m_item) {
// the item doesn't exist yet, create it
m_item = m_coll->doNewItem(client, itemPath);
if (!m_item) {
// may happen if entry somehow ends up in recycle bin
return DBusResult{DBUS_ERROR_SECRET_NO_SUCH_OBJECT};
}
}
// the item may be locked due to authorization
ret = m_item->locked(client, locked);
if (ret.err()) {
return ret;
}
if (locked) {
// give the user a chance to unlock the item
auto prompt = PromptBase::Create<UnlockPrompt>(service(), QSet<Collection*>{}, QSet<Item*>{m_item});
if (!prompt) {
return DBusResult{QDBusError::InternalError};
}
// postpone anything after the confirmation
connect(prompt, &PromptBase::completed, this, [this]() {
auto res = updateItem();
finishPrompt(res.err());
});
ret = prompt->prompt(client, windowId);
if (ret.err()) {
return ret;
}
return PromptResult::Pending;
}
// the item can be updated directly
return updateItem();
}
DBusResult CreateItemPrompt::updateItem()
{
auto client = m_client.lock();
if (!client) {
// client already gone
return {};
}
if (!m_sess || m_sess != m_secret.session) {
return DBusResult(DBUS_ERROR_SECRET_NO_SESSION);
}
if (!m_item) {
return {};
}
auto ret = m_item->setProperties(m_properties);
if (ret.err()) {
return ret;
}
ret = m_item->setSecret(client, m_secret);
if (ret.err()) {
return ret;
}
return {};
}
} // namespace FdoSecrets