From 13c88e101321975c20aab9de08d53bdc09209686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Thu, 23 Nov 2023 06:11:25 +0200 Subject: [PATCH] Passkeys: Add support for importing Passkey to entry (#9987) --------- Co-authored-by: Jonathan White --- share/translations/keepassxc_en.ts | 39 ++++--- src/browser/BrowserService.cpp | 47 +++++++- src/browser/BrowserService.h | 4 +- src/core/Entry.cpp | 8 +- src/core/Entry.h | 1 + src/core/EntryAttributes.cpp | 13 +++ src/core/EntryAttributes.h | 4 +- src/core/Tools.h | 14 +++ src/format/OpVaultReaderBandEntry.cpp | 5 +- src/gui/DatabaseTabWidget.cpp | 7 +- src/gui/DatabaseTabWidget.h | 1 + src/gui/DatabaseWidget.cpp | 14 ++- src/gui/DatabaseWidget.h | 2 +- src/gui/MainWindow.cpp | 9 ++ src/gui/MainWindow.ui | 10 ++ src/gui/entry/EditEntryWidget.cpp | 6 +- src/gui/entry/EntryURLModel.cpp | 4 +- src/gui/passkeys/PasskeyImportDialog.cpp | 128 ++++++++++++++++------ src/gui/passkeys/PasskeyImportDialog.h | 26 +++-- src/gui/passkeys/PasskeyImportDialog.ui | 132 +++++++++++------------ src/gui/passkeys/PasskeyImporter.cpp | 64 ++++++++--- src/gui/passkeys/PasskeyImporter.h | 10 +- tests/TestBrowser.cpp | 18 ++-- tests/TestPasskeys.cpp | 27 +++++ tests/TestPasskeys.h | 2 + tests/TestTools.cpp | 29 ++++- tests/TestTools.h | 3 +- 27 files changed, 455 insertions(+), 172 deletions(-) diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 52237dd2a1..b7d5892882 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -959,6 +959,15 @@ Do you want to delete the entry? %1 (Passkey) + + KeePassXC: Update Passkey + + + + Entry already has a Passkey. +Do you want to overwrite the Passkey in %1 - %2? + + BrowserSettingsWidget @@ -5993,51 +6002,51 @@ Do you want to overwrite it? - Do you want to import the Passkey? + URL: %1 - URL: %1 + Username: %1 - Username: %1 + Group - Use default group (Imported Passkeys) + Database - Group + Import Passkey - Database + Import - Select Database + Cancel - Import Passkey + Import the following Passkey: - Import + Entry - Cancel + Import the following Passkey to this entry: - Database: %1 + Create new entry - Group: + Default Passkeys group (Imported Passkeys) @@ -6075,6 +6084,12 @@ Do you want to overwrite it? Cannot import Passkey file "%1". Private key is missing or malformed. + + Cannot import Passkey file "%1". +The following data is missing: +%2 + + PasswordEditWidget diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index d2104ed695..8f663a7c7f 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -64,8 +64,6 @@ const QString BrowserService::OPTION_HIDE_ENTRY = QStringLiteral("BrowserHideEnt const QString BrowserService::OPTION_ONLY_HTTP_AUTH = QStringLiteral("BrowserOnlyHttpAuth"); const QString BrowserService::OPTION_NOT_HTTP_AUTH = QStringLiteral("BrowserNotHttpAuth"); const QString BrowserService::OPTION_OMIT_WWW = QStringLiteral("BrowserOmitWww"); -// Multiple URL's -const QString BrowserService::ADDITIONAL_URL = QStringLiteral("KP2A_URL"); Q_GLOBAL_STATIC(BrowserService, s_browserService); @@ -775,6 +773,20 @@ void BrowserService::addPasskeyToEntry(Entry* entry, return; } + // Ask confirmation if entry already contains a Passkey + if (entry->hasPasskey()) { + if (MessageBox::question( + m_currentDatabaseWidget, + tr("KeePassXC: Update Passkey"), + tr("Entry already has a Passkey.\nDo you want to overwrite the Passkey in %1 - %2?") + .arg(entry->title(), entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USERNAME)), + MessageBox::Overwrite | MessageBox::Cancel, + MessageBox::Cancel) + != MessageBox::Overwrite) { + return; + } + } + entry->beginUpdate(); entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_USERNAME, username); @@ -1084,7 +1096,13 @@ void BrowserService::denyEntry(Entry* entry, const QString& siteHost, const QStr QJsonObject BrowserService::prepareEntry(const Entry* entry) { QJsonObject res; +#ifdef WITH_XC_BROWSER_PASSKEYS + // Use Passkey's username instead if found + res["login"] = entry->hasPasskey() ? entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USERNAME) + : entry->resolveMultiplePlaceholders(entry->username()); +#else res["login"] = entry->resolveMultiplePlaceholders(entry->username()); +#endif res["password"] = entry->resolveMultiplePlaceholders(entry->password()); res["name"] = entry->resolveMultiplePlaceholders(entry->title()); res["uuid"] = entry->resolveMultiplePlaceholders(entry->uuidToHex()); @@ -1291,8 +1309,7 @@ QList BrowserService::getPasskeyEntries(const QString& rpId, const Strin { QList entries; for (const auto& entry : searchEntries(rpId, "", keyList, true)) { - if (entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM) - && entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) == rpId) { + if (entry->hasPasskey() && entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) == rpId) { entries << entry; } } @@ -1419,14 +1436,34 @@ bool BrowserService::handleURL(const QString& entryUrl, return false; } -QSharedPointer BrowserService::getDatabase() +QSharedPointer BrowserService::getDatabase(const QUuid& rootGroupUuid) { + if (!rootGroupUuid.isNull()) { + const auto openDatabases = getOpenDatabases(); + for (const auto& db : openDatabases) { + if (db->rootGroup()->uuid() == rootGroupUuid) { + return db; + } + } + } + if (m_currentDatabaseWidget) { return m_currentDatabaseWidget->database(); } return {}; } +QList> BrowserService::getOpenDatabases() +{ + QList> databaseList; + for (auto dbWidget : getMainWindow()->getOpenDatabases()) { + if (!dbWidget->isLocked()) { + databaseList << dbWidget->database(); + } + } + return databaseList; +} + QSharedPointer BrowserService::selectedDatabase() { QList databaseWidgets; diff --git a/src/browser/BrowserService.h b/src/browser/BrowserService.h index 05f46f468a..7550ad4945 100644 --- a/src/browser/BrowserService.h +++ b/src/browser/BrowserService.h @@ -84,7 +84,9 @@ class BrowserService : public QObject QString getCurrentTotp(const QString& uuid); void showPasswordGenerator(const KeyPairMessage& keyPairMessage); bool isPasswordGeneratorRequested() const; + QSharedPointer getDatabase(const QUuid& rootGroupUuid = {}); QSharedPointer selectedDatabase(); + QList> getOpenDatabases(); #ifdef WITH_XC_BROWSER_PASSKEYS QJsonObject showPasskeysRegisterPrompt(const QJsonObject& publicKey, const QString& origin, const StringPairList& keyList); @@ -124,7 +126,6 @@ class BrowserService : public QObject static const QString OPTION_ONLY_HTTP_AUTH; static const QString OPTION_NOT_HTTP_AUTH; static const QString OPTION_OMIT_WWW; - static const QString ADDITIONAL_URL; signals: void requestUnlock(); @@ -191,7 +192,6 @@ private slots: const QString& siteUrl, const QString& formUrl, const bool omitWwwSubdomain = false); - QSharedPointer getDatabase(); QString getDatabaseRootUuid(); QString getDatabaseRecycleBinUuid(); bool checkLegacySettings(QSharedPointer db); diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 7e238f0494..973aa189dc 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -385,7 +385,8 @@ QStringList Entry::getAllUrls() const } for (const auto& key : m_attributes->keys()) { - if (key.startsWith("KP2A_URL")) { + if (key.startsWith(EntryAttributes::AdditionalUrlAttribute) + || key == QString("%1_RELYING_PARTY").arg(EntryAttributes::PasskeyAttribute)) { auto additionalUrl = m_attributes->value(key); if (!additionalUrl.isEmpty()) { urlList << resolveMultiplePlaceholders(additionalUrl); @@ -545,6 +546,11 @@ bool Entry::hasTotp() const return !m_data.totpSettings.isNull(); } +bool Entry::hasPasskey() const +{ + return m_attributes->hasPasskey(); +} + QString Entry::totp() const { if (hasTotp()) { diff --git a/src/core/Entry.h b/src/core/Entry.h index 88f878e98a..3a03892d0a 100644 --- a/src/core/Entry.h +++ b/src/core/Entry.h @@ -121,6 +121,7 @@ class Entry : public ModifiableObject void setExcludeFromReports(bool state); bool hasTotp() const; + bool hasPasskey() const; bool isExpired() const; bool willExpireInDays(int days) const; bool isRecycled() const; diff --git a/src/core/EntryAttributes.cpp b/src/core/EntryAttributes.cpp index 13207e1688..49c243ec17 100644 --- a/src/core/EntryAttributes.cpp +++ b/src/core/EntryAttributes.cpp @@ -34,6 +34,7 @@ const QString EntryAttributes::SearchInGroupName = "SearchIn"; const QString EntryAttributes::SearchTextGroupName = "SearchText"; const QString EntryAttributes::RememberCmdExecAttr = "_EXEC_CMD"; +const QString EntryAttributes::AdditionalUrlAttribute = "KP2A_URL"; const QString EntryAttributes::PasskeyAttribute = "KPEX_PASSKEY"; EntryAttributes::EntryAttributes(QObject* parent) @@ -52,6 +53,18 @@ bool EntryAttributes::hasKey(const QString& key) const return m_attributes.contains(key); } +bool EntryAttributes::hasPasskey() const +{ + const auto keyList = keys(); + for (const auto& key : keyList) { + if (isPasskeyAttribute(key)) { + return true; + } + } + + return false; +} + QList EntryAttributes::customKeys() const { QList customKeys; diff --git a/src/core/EntryAttributes.h b/src/core/EntryAttributes.h index 2e7f8c05c0..fecf6a993b 100644 --- a/src/core/EntryAttributes.h +++ b/src/core/EntryAttributes.h @@ -1,6 +1,6 @@ /* + * Copyright (C) 2023 KeePassXC Team * Copyright (C) 2012 Felix Geyer - * Copyright (C) 2017 KeePassXC Team * * 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 @@ -33,6 +33,7 @@ class EntryAttributes : public ModifiableObject explicit EntryAttributes(QObject* parent = nullptr); QList keys() const; bool hasKey(const QString& key) const; + bool hasPasskey() const; QList customKeys() const; QString value(const QString& key) const; QList values(const QList& keys) const; @@ -61,6 +62,7 @@ class EntryAttributes : public ModifiableObject static const QString NotesKey; static const QStringList DefaultAttributes; static const QString RememberCmdExecAttr; + static const QString AdditionalUrlAttribute; static const QString PasskeyAttribute; static bool isDefaultAttribute(const QString& key); static bool isPasskeyAttribute(const QString& key); diff --git a/src/core/Tools.h b/src/core/Tools.h index 4316a44e84..85c1b53c09 100644 --- a/src/core/Tools.h +++ b/src/core/Tools.h @@ -22,6 +22,7 @@ #include "core/Global.h" #include +#include #include class QIODevice; @@ -100,6 +101,19 @@ namespace Tools return version; } + // Checks if all values are found inside the list. Returns a list of values not found. + template QList getMissingValuesFromList(const QList& list, const QList& required) + { + QList missingValues; + for (const auto& r : required) { + if (!list.contains(r)) { + missingValues << r; + } + } + + return missingValues; + } + QVariantMap qo2qvm(const QObject* object, const QStringList& ignoredProperties = {"objectName"}); QString substituteBackupFilePath(QString pattern, const QString& databasePath); diff --git a/src/format/OpVaultReaderBandEntry.cpp b/src/format/OpVaultReaderBandEntry.cpp index 6f79dd637c..3a9774b68a 100644 --- a/src/format/OpVaultReaderBandEntry.cpp +++ b/src/format/OpVaultReaderBandEntry.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * 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 @@ -243,7 +243,8 @@ bool OpVaultReader::fillAttributes(Entry* entry, const QJsonObject& bandEntry) auto newUrl = urlObj["u"].toString(); if (newUrl != url) { // Add this url if it isn't the base one - entry->attributes()->set(QString("KP2A_URL_%1").arg(i), newUrl); + entry->attributes()->set( + QString("%1_%2").arg(EntryAttributes::AdditionalUrlAttribute, QString::number(i)), newUrl); ++i; } } diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index 4454266339..73a61fac39 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.cpp @@ -564,7 +564,12 @@ void DatabaseTabWidget::showPasskeys() void DatabaseTabWidget::importPasskey() { - currentDatabaseWidget()->switchToImportPasskey(); + currentDatabaseWidget()->showImportPasskeyDialog(); +} + +void DatabaseTabWidget::importPasskeyToEntry() +{ + currentDatabaseWidget()->showImportPasskeyDialog(true); } #endif diff --git a/src/gui/DatabaseTabWidget.h b/src/gui/DatabaseTabWidget.h index 6b4b121af7..8f27038786 100644 --- a/src/gui/DatabaseTabWidget.h +++ b/src/gui/DatabaseTabWidget.h @@ -88,6 +88,7 @@ public slots: #ifdef WITH_XC_BROWSER_PASSKEYS void showPasskeys(); void importPasskey(); + void importPasskeyToEntry(); #endif void performGlobalAutoType(const QString& search); void performBrowserUnlock(); diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index d42292d348..b20f5a240b 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -1407,10 +1407,20 @@ void DatabaseWidget::switchToPasskeys() m_reportsDialog->activatePasskeysPage(); } -void DatabaseWidget::switchToImportPasskey() +void DatabaseWidget::showImportPasskeyDialog(bool isEntry) { PasskeyImporter passkeyImporter; - passkeyImporter.importPasskey(m_db); + + if (isEntry) { + auto currentEntry = currentSelectedEntry(); + if (!currentEntry) { + return; + } + + passkeyImporter.importPasskey(m_db, currentEntry); + } else { + passkeyImporter.importPasskey(m_db); + } } #endif diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index 4f79ebd2f8..1102245bde 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -214,7 +214,7 @@ public slots: void switchToDatabaseSettings(); #ifdef WITH_XC_BROWSER_PASSKEYS void switchToPasskeys(); - void switchToImportPasskey(); + void showImportPasskeyDialog(bool isEntry = false); #endif void switchToOpenDatabase(); void switchToOpenDatabase(const QString& filePath); diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 27dada98d6..a41f631e4a 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -135,6 +135,10 @@ MainWindow::MainWindow() m_entryContextMenu->addSeparator(); m_entryContextMenu->addAction(m_ui->actionEntryAutoType); m_entryContextMenu->addSeparator(); +#ifdef WITH_XC_BROWSER_PASSKEYS + m_entryContextMenu->addAction(m_ui->actionEntryImportPasskey); + m_entryContextMenu->addSeparator(); +#endif m_entryContextMenu->addAction(m_ui->actionEntryEdit); m_entryContextMenu->addAction(m_ui->actionEntryClone); m_entryContextMenu->addAction(m_ui->actionEntryDelete); @@ -441,6 +445,7 @@ MainWindow::MainWindow() #ifdef WITH_XC_BROWSER_PASSKEYS m_ui->actionPasskeys->setIcon(icons()->icon("passkey")); m_ui->actionImportPasskey->setIcon(icons()->icon("document-import")); + m_ui->actionEntryImportPasskey->setIcon(icons()->icon("document-import")); #endif m_actionMultiplexer.connect( @@ -491,6 +496,7 @@ MainWindow::MainWindow() #ifdef WITH_XC_BROWSER_PASSKEYS connect(m_ui->actionPasskeys, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showPasskeys())); connect(m_ui->actionImportPasskey, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importPasskey())); + connect(m_ui->actionEntryImportPasskey, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importPasskeyToEntry())); #endif connect(m_ui->actionImportCsv, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importCsv())); connect(m_ui->actionImportKeePass1, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importKeePass1Database())); @@ -989,6 +995,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) #ifdef WITH_XC_BROWSER_PASSKEYS m_ui->actionPasskeys->setEnabled(true); m_ui->actionImportPasskey->setEnabled(true); + m_ui->actionEntryImportPasskey->setEnabled(true); #endif #ifdef WITH_XC_SSHAGENT bool singleEntryHasSshKey = @@ -1060,9 +1067,11 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) #ifdef WITH_XC_BROWSER_PASSKEYS m_ui->actionPasskeys->setEnabled(false); m_ui->actionImportPasskey->setEnabled(false); + m_ui->actionEntryImportPasskey->setEnabled(false); #else m_ui->actionPasskeys->setVisible(false); m_ui->actionImportPasskey->setVisible(false); + m_ui->actionEntryImportPasskey->setVisible(false); #endif m_searchWidgetAction->setEnabled(false); diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui index e6fbfc22ca..a0b8d6e687 100644 --- a/src/gui/MainWindow.ui +++ b/src/gui/MainWindow.ui @@ -342,6 +342,8 @@ + + @@ -730,6 +732,14 @@ Perform &Auto-Type + + + false + + + Import Passkey + + false diff --git a/src/gui/entry/EditEntryWidget.cpp b/src/gui/entry/EditEntryWidget.cpp index 1a71db4458..9466d09189 100644 --- a/src/gui/entry/EditEntryWidget.cpp +++ b/src/gui/entry/EditEntryWidget.cpp @@ -1,6 +1,6 @@ /* + * Copyright (C) 2023 KeePassXC Team * Copyright (C) 2010 Felix Geyer - * Copyright (C) 2021 KeePassXC Team * * 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 @@ -329,11 +329,11 @@ void EditEntryWidget::insertURL() { Q_ASSERT(!m_history); - QString name(BrowserService::ADDITIONAL_URL); + QString name(EntryAttributes::AdditionalUrlAttribute); int i = 1; while (m_entryAttributes->keys().contains(name)) { - name = QString("%1_%2").arg(BrowserService::ADDITIONAL_URL, QString::number(i)); + name = QString("%1_%2").arg(EntryAttributes::AdditionalUrlAttribute, QString::number(i)); i++; } diff --git a/src/gui/entry/EntryURLModel.cpp b/src/gui/entry/EntryURLModel.cpp index 9a4340f5cd..046fbee619 100644 --- a/src/gui/entry/EntryURLModel.cpp +++ b/src/gui/entry/EntryURLModel.cpp @@ -70,7 +70,7 @@ QVariant EntryURLModel::data(const QModelIndex& index, int role) const const auto urlValid = urlTools()->isUrlValid(value); // Check for duplicate URLs in the attribute list. Excludes the current key/value from the comparison. - auto customAttributeKeys = m_entryAttributes->customKeys().filter(BrowserService::ADDITIONAL_URL); + auto customAttributeKeys = m_entryAttributes->customKeys().filter(EntryAttributes::AdditionalUrlAttribute); customAttributeKeys.removeOne(key); const auto duplicateUrl = @@ -148,7 +148,7 @@ void EntryURLModel::updateAttributes() const auto attributesKeyList = m_entryAttributes->keys(); for (const auto& key : attributesKeyList) { - if (!EntryAttributes::isDefaultAttribute(key) && key.contains(BrowserService::ADDITIONAL_URL)) { + if (!EntryAttributes::isDefaultAttribute(key) && key.contains(EntryAttributes::AdditionalUrlAttribute)) { const auto value = m_entryAttributes->value(key); m_urls.append(qMakePair(key, value)); diff --git a/src/gui/passkeys/PasskeyImportDialog.cpp b/src/gui/passkeys/PasskeyImportDialog.cpp index 2d54ba5bad..0c1c31e6d0 100644 --- a/src/gui/passkeys/PasskeyImportDialog.cpp +++ b/src/gui/passkeys/PasskeyImportDialog.cpp @@ -27,33 +27,41 @@ PasskeyImportDialog::PasskeyImportDialog(QWidget* parent) : QDialog(parent) , m_ui(new Ui::PasskeyImportDialog()) - , m_useDefaultGroup(true) { setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint); m_ui->setupUi(this); - m_ui->useDefaultGroupCheckbox->setChecked(true); - m_ui->selectGroupComboBox->setEnabled(false); + connect(this, SIGNAL(updateGroups()), this, SLOT(addGroups())); + connect(this, SIGNAL(updateEntries()), this, SLOT(addEntries())); connect(m_ui->importButton, SIGNAL(clicked()), SLOT(accept())); connect(m_ui->cancelButton, SIGNAL(clicked()), SLOT(reject())); - connect(m_ui->selectDatabaseButton, SIGNAL(clicked()), SLOT(selectDatabase())); + connect(m_ui->selectDatabaseCombobBox, SIGNAL(currentIndexChanged(int)), SLOT(changeDatabase(int))); + connect(m_ui->selectEntryComboBox, SIGNAL(currentIndexChanged(int)), SLOT(changeEntry(int))); connect(m_ui->selectGroupComboBox, SIGNAL(currentIndexChanged(int)), SLOT(changeGroup(int))); - connect(m_ui->useDefaultGroupCheckbox, SIGNAL(stateChanged(int)), SLOT(useDefaultGroupChanged())); } PasskeyImportDialog::~PasskeyImportDialog() { } -void PasskeyImportDialog::setInfo(const QString& url, const QString& username, const QSharedPointer& database) +void PasskeyImportDialog::setInfo(const QString& url, + const QString& username, + const QSharedPointer& database, + bool isEntry) { m_ui->urlLabel->setText(tr("URL: %1").arg(url)); m_ui->usernameLabel->setText(tr("Username: %1").arg(username)); - m_ui->selectDatabaseLabel->setText(tr("Database: %1").arg(getDatabaseName(database))); - m_ui->selectGroupLabel->setText(tr("Group:")); - addGroups(database); + if (isEntry) { + m_ui->verticalLayout->setSizeConstraint(QLayout::SetFixedSize); + m_ui->infoLabel->setText(tr("Import the following Passkey to this entry:")); + m_ui->groupBox->setVisible(false); + } + + m_selectedDatabase = database; + addDatabases(); + addGroups(); auto openDatabaseCount = 0; for (auto dbWidget : getMainWindow()->getOpenDatabases()) { @@ -61,61 +69,117 @@ void PasskeyImportDialog::setInfo(const QString& url, const QString& username, c openDatabaseCount++; } } - m_ui->selectDatabaseButton->setEnabled(openDatabaseCount > 1); + m_ui->selectDatabaseCombobBox->setEnabled(openDatabaseCount > 1); } -QSharedPointer PasskeyImportDialog::getSelectedDatabase() +QSharedPointer PasskeyImportDialog::getSelectedDatabase() const { return m_selectedDatabase; } -QUuid PasskeyImportDialog::getSelectedGroupUuid() +QUuid PasskeyImportDialog::getSelectedEntryUuid() const +{ + return m_selectedEntryUuid; +} + +QUuid PasskeyImportDialog::getSelectedGroupUuid() const { return m_selectedGroupUuid; } -bool PasskeyImportDialog::useDefaultGroup() +bool PasskeyImportDialog::useDefaultGroup() const { - return m_useDefaultGroup; + return m_selectedGroupUuid.isNull(); } -QString PasskeyImportDialog::getDatabaseName(const QSharedPointer& database) const +bool PasskeyImportDialog::createNewEntry() const { - return QFileInfo(database->filePath()).fileName(); + return m_selectedEntryUuid.isNull(); } -void PasskeyImportDialog::addGroups(const QSharedPointer& database) +void PasskeyImportDialog::addDatabases() { - m_ui->selectGroupComboBox->clear(); - for (const auto& group : database->rootGroup()->groupsRecursive(true)) { - if (!group || group->isRecycled() || group == database->metadata()->recycleBin()) { + auto currentDatabaseIndex = 0; + const auto openDatabases = browserService()->getOpenDatabases(); + const auto currentDatabase = browserService()->getDatabase(); + + m_ui->selectDatabaseCombobBox->clear(); + for (const auto& db : openDatabases) { + m_ui->selectDatabaseCombobBox->addItem(db->metadata()->name(), db->rootGroup()->uuid()); + if (db->rootGroup()->uuid() == currentDatabase->rootGroup()->uuid()) { + currentDatabaseIndex = m_ui->selectDatabaseCombobBox->count() - 1; + } + } + + m_ui->selectDatabaseCombobBox->setCurrentIndex(currentDatabaseIndex); +} + +void PasskeyImportDialog::addEntries() +{ + if (!m_selectedDatabase || !m_selectedDatabase->rootGroup()) { + return; + } + + m_ui->selectEntryComboBox->clear(); + m_ui->selectEntryComboBox->addItem(tr("Create new entry"), {}); + + const auto group = m_selectedDatabase->rootGroup()->findGroupByUuid(m_selectedGroupUuid); + if (!group) { + return; + } + + // Collect all entries in the group and resolve the title + QList> entries; + for (const auto entry : group->entries()) { + if (!entry || entry->isRecycled()) { continue; } + entries.append({entry->resolveMultiplePlaceholders(entry->title()), entry->uuid()}); + } - m_ui->selectGroupComboBox->addItem(group->fullPath(), group->uuid()); + // Sort entries by title + std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) { + return a.first.compare(b.first, Qt::CaseInsensitive) < 0; + }); + + // Add sorted entries to the combobox + for (const auto& pair : entries) { + m_ui->selectEntryComboBox->addItem(pair.first, pair.second); } } -void PasskeyImportDialog::selectDatabase() +void PasskeyImportDialog::addGroups() { - auto selectedDatabase = browserService()->selectedDatabase(); - if (!selectedDatabase) { + if (!m_selectedDatabase) { return; } - m_selectedDatabase = selectedDatabase; - m_ui->selectDatabaseLabel->setText(QString("Database: %1").arg(getDatabaseName(m_selectedDatabase))); + m_ui->selectGroupComboBox->clear(); + m_ui->selectGroupComboBox->addItem(tr("Default Passkeys group (Imported Passkeys)"), {}); - addGroups(m_selectedDatabase); + for (const auto& group : m_selectedDatabase->rootGroup()->groupsRecursive(true)) { + if (!group || group->isRecycled() || group == m_selectedDatabase->metadata()->recycleBin()) { + continue; + } + + m_ui->selectGroupComboBox->addItem(group->fullPath(), group->uuid()); + } } -void PasskeyImportDialog::changeGroup(int index) +void PasskeyImportDialog::changeDatabase(int index) { - m_selectedGroupUuid = m_ui->selectGroupComboBox->itemData(index).value(); + m_selectedDatabaseUuid = m_ui->selectDatabaseCombobBox->itemData(index).value(); + m_selectedDatabase = browserService()->getDatabase(m_selectedDatabaseUuid); + emit updateGroups(); +} + +void PasskeyImportDialog::changeEntry(int index) +{ + m_selectedEntryUuid = m_ui->selectEntryComboBox->itemData(index).value(); } -void PasskeyImportDialog::useDefaultGroupChanged() +void PasskeyImportDialog::changeGroup(int index) { - m_ui->selectGroupComboBox->setEnabled(!m_ui->useDefaultGroupCheckbox->isChecked()); - m_useDefaultGroup = m_ui->useDefaultGroupCheckbox->isChecked(); + m_selectedGroupUuid = m_ui->selectGroupComboBox->itemData(index).value(); + emit updateEntries(); } diff --git a/src/gui/passkeys/PasskeyImportDialog.h b/src/gui/passkeys/PasskeyImportDialog.h index 7b316721e7..705c6d187f 100644 --- a/src/gui/passkeys/PasskeyImportDialog.h +++ b/src/gui/passkeys/PasskeyImportDialog.h @@ -36,25 +36,33 @@ class PasskeyImportDialog : public QDialog explicit PasskeyImportDialog(QWidget* parent = nullptr); ~PasskeyImportDialog() override; - void setInfo(const QString& url, const QString& username, const QSharedPointer& database); - QSharedPointer getSelectedDatabase(); - QUuid getSelectedGroupUuid(); - bool useDefaultGroup(); + void setInfo(const QString& url, const QString& username, const QSharedPointer& database, bool isEntry); + QSharedPointer getSelectedDatabase() const; + QUuid getSelectedEntryUuid() const; + QUuid getSelectedGroupUuid() const; + bool useDefaultGroup() const; + bool createNewEntry() const; private: - QString getDatabaseName(const QSharedPointer& database) const; - void addGroups(const QSharedPointer& database); + void addDatabases(); + +signals: + void updateEntries(); + void updateGroups(); private slots: - void selectDatabase(); + void addEntries(); + void addGroups(); + void changeDatabase(int index); + void changeEntry(int index); void changeGroup(int index); - void useDefaultGroupChanged(); private: QScopedPointer m_ui; QSharedPointer m_selectedDatabase; + QUuid m_selectedDatabaseUuid; + QUuid m_selectedEntryUuid; QUuid m_selectedGroupUuid; - bool m_useDefaultGroup; }; #endif // KEEPASSXC_PASSKEYIMPORTDIALOG_H diff --git a/src/gui/passkeys/PasskeyImportDialog.ui b/src/gui/passkeys/PasskeyImportDialog.ui index ffc80d1419..ec8de7e062 100755 --- a/src/gui/passkeys/PasskeyImportDialog.ui +++ b/src/gui/passkeys/PasskeyImportDialog.ui @@ -6,10 +6,22 @@ 0 0 - 405 - 227 + 500 + 300 + + + 0 + 0 + + + + + 400 + 300 + + KeePassXC - Passkey Import @@ -24,7 +36,7 @@ - Do you want to import the Passkey? + Import the following Passkey: Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter @@ -52,80 +64,62 @@ - - - Use default group (Imported Passkeys) + + + Qt::Vertical - - false + + + 20 + 10 + - - - - - - - - Group - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - + - - - - - Database - - - false - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Select Database - - - - + + + + + + + + Database + + + + + + + + + + Group + + + + + + + + + + Entry + + + + + + + + + + - + diff --git a/src/gui/passkeys/PasskeyImporter.cpp b/src/gui/passkeys/PasskeyImporter.cpp index 0b48c102df..77e37c689b 100644 --- a/src/gui/passkeys/PasskeyImporter.cpp +++ b/src/gui/passkeys/PasskeyImporter.cpp @@ -22,6 +22,7 @@ #include "browser/BrowserService.h" #include "core/Entry.h" #include "core/Group.h" +#include "core/Tools.h" #include "gui/FileDialog.h" #include "gui/MessageBox.h" #include @@ -29,7 +30,7 @@ static const QString IMPORTED_PASSKEYS_GROUP = QStringLiteral("Imported Passkeys"); -void PasskeyImporter::importPasskey(QSharedPointer& database) +void PasskeyImporter::importPasskey(QSharedPointer& database, Entry* entry) { auto filter = QString("%1 (*.passkey);;%2 (*)").arg(tr("Passkey file"), tr("All files")); auto fileName = @@ -47,10 +48,10 @@ void PasskeyImporter::importPasskey(QSharedPointer& database) return; } - importSelectedFile(file, database); + importSelectedFile(file, database, entry); } -void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer& database) +void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer& database, Entry* entry) { const auto fileData = file.readAll(); const auto passkeyObject = browserMessageBuilder()->getJsonObject(fileData); @@ -61,18 +62,20 @@ void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer& return; } - const auto relyingParty = passkeyObject["relyingParty"].toString(); - const auto url = passkeyObject["url"].toString(); - const auto username = passkeyObject["username"].toString(); - const auto credentialId = passkeyObject["credentialId"].toString(); - const auto userHandle = passkeyObject["userHandle"].toString(); const auto privateKey = passkeyObject["privateKey"].toString(); - - if (relyingParty.isEmpty() || username.isEmpty() || credentialId.isEmpty() || userHandle.isEmpty() - || privateKey.isEmpty()) { + const auto missingKeys = Tools::getMissingValuesFromList(passkeyObject.keys(), + QStringList() << "relyingParty" + << "url" + << "username" + << "credentialId" + << "userHandle" + << "privateKey"); + + if (!missingKeys.isEmpty()) { MessageBox::information(nullptr, tr("Cannot import Passkey"), - tr("Cannot import Passkey file \"%1\". Data is missing.").arg(file.fileName())); + tr("Cannot import Passkey file \"%1\".\nThe following data is missing:\n%2") + .arg(file.fileName(), missingKeys.join(", "))); } else if (!privateKey.startsWith("-----BEGIN PRIVATE KEY-----") || !privateKey.trimmed().endsWith("-----END PRIVATE KEY-----")) { MessageBox::information( @@ -80,7 +83,12 @@ void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer& tr("Cannot import Passkey"), tr("Cannot import Passkey file \"%1\". Private key is missing or malformed.").arg(file.fileName())); } else { - showImportDialog(database, url, relyingParty, username, credentialId, userHandle, privateKey); + const auto relyingParty = passkeyObject["relyingParty"].toString(); + const auto url = passkeyObject["url"].toString(); + const auto username = passkeyObject["username"].toString(); + const auto credentialId = passkeyObject["credentialId"].toString(); + const auto userHandle = passkeyObject["userHandle"].toString(); + showImportDialog(database, url, relyingParty, username, credentialId, userHandle, privateKey, entry); } } @@ -90,10 +98,11 @@ void PasskeyImporter::showImportDialog(QSharedPointer& database, const QString& username, const QString& credentialId, const QString& userHandle, - const QString& privateKey) + const QString& privateKey, + Entry* entry) { PasskeyImportDialog passkeyImportDialog; - passkeyImportDialog.setInfo(relyingParty, username, database); + passkeyImportDialog.setInfo(relyingParty, username, database, entry != nullptr); auto ret = passkeyImportDialog.exec(); if (ret != QDialog::Accepted) { @@ -105,6 +114,29 @@ void PasskeyImporter::showImportDialog(QSharedPointer& database, db = database; } + // Store to entry if given directly + if (entry) { + browserService()->addPasskeyToEntry( + entry, relyingParty, relyingParty, username, credentialId, userHandle, privateKey); + return; + } + + // Import to entry selected instead of creating a new one + if (!passkeyImportDialog.createNewEntry()) { + auto groupUuid = passkeyImportDialog.getSelectedGroupUuid(); + auto group = db->rootGroup()->findGroupByUuid(groupUuid); + + if (group) { + auto selectedEntry = group->findEntryByUuid(passkeyImportDialog.getSelectedEntryUuid()); + if (selectedEntry) { + browserService()->addPasskeyToEntry( + selectedEntry, relyingParty, relyingParty, username, credentialId, userHandle, privateKey); + } + } + + return; + } + // Group settings. Use default group "Imported Passkeys" if user did not select a specific one. Group* group = nullptr; @@ -123,7 +155,7 @@ void PasskeyImporter::showImportDialog(QSharedPointer& database, group, url, relyingParty, relyingParty, username, credentialId, userHandle, privateKey); } -Group* PasskeyImporter::getDefaultGroup(QSharedPointer& database) +Group* PasskeyImporter::getDefaultGroup(QSharedPointer& database) const { auto defaultGroup = database->rootGroup()->findGroupByPath(IMPORTED_PASSKEYS_GROUP); diff --git a/src/gui/passkeys/PasskeyImporter.h b/src/gui/passkeys/PasskeyImporter.h index 093da53e22..9cc7fab48f 100644 --- a/src/gui/passkeys/PasskeyImporter.h +++ b/src/gui/passkeys/PasskeyImporter.h @@ -21,6 +21,7 @@ #include "core/Database.h" #include #include +#include class Entry; @@ -31,18 +32,19 @@ class PasskeyImporter : public QObject public: explicit PasskeyImporter() = default; - void importPasskey(QSharedPointer& database); + void importPasskey(QSharedPointer& database, Entry* entry = nullptr); private: - void importSelectedFile(QFile& file, QSharedPointer& database); + void importSelectedFile(QFile& file, QSharedPointer& database, Entry* entry); void showImportDialog(QSharedPointer& database, const QString& url, const QString& relyingParty, const QString& username, const QString& credentialId, const QString& userHandle, - const QString& privateKey); - Group* getDefaultGroup(QSharedPointer& database); + const QString& privateKey, + Entry* entry); + Group* getDefaultGroup(QSharedPointer& database) const; }; #endif // KEEPASSXC_PASSKEYIMPORTER_H diff --git a/tests/TestBrowser.cpp b/tests/TestBrowser.cpp index aa084921ed..19dbba8f48 100644 --- a/tests/TestBrowser.cpp +++ b/tests/TestBrowser.cpp @@ -341,8 +341,8 @@ void TestBrowser::testSearchEntriesByReference() auto secondEntryUuid = entries[1]->uuidToHex(); auto fullReference = QString("{REF:A@I:%1}").arg(firstEntryUuid); auto partialReference = QString("https://subdomain.{REF:A@I:%1}").arg(secondEntryUuid); - entries[2]->attributes()->set(BrowserService::ADDITIONAL_URL, fullReference); - entries[3]->attributes()->set(BrowserService::ADDITIONAL_URL, partialReference); + entries[2]->attributes()->set(EntryAttributes::AdditionalUrlAttribute, fullReference); + entries[3]->attributes()->set(EntryAttributes::AdditionalUrlAttribute, partialReference); entries[4]->setUrl(fullReference); entries[5]->setUrl(partialReference); @@ -351,11 +351,13 @@ void TestBrowser::testSearchEntriesByReference() QCOMPARE(result[0]->url(), urls[0]); QCOMPARE(result[1]->url(), urls[1]); QCOMPARE(result[2]->url(), urls[2]); - QCOMPARE(result[2]->resolveMultiplePlaceholders(result[2]->attributes()->value(BrowserService::ADDITIONAL_URL)), - urls[0]); + QCOMPARE( + result[2]->resolveMultiplePlaceholders(result[2]->attributes()->value(EntryAttributes::AdditionalUrlAttribute)), + urls[0]); QCOMPARE(result[3]->url(), urls[3]); - QCOMPARE(result[3]->resolveMultiplePlaceholders(result[3]->attributes()->value(BrowserService::ADDITIONAL_URL)), - urls[0]); + QCOMPARE( + result[3]->resolveMultiplePlaceholders(result[3]->attributes()->value(EntryAttributes::AdditionalUrlAttribute)), + urls[0]); QCOMPARE(result[4]->url(), fullReference); QCOMPARE(result[4]->resolveMultiplePlaceholders(result[4]->url()), urls[0]); // Should be resolved to the main entry QCOMPARE(result[5]->url(), partialReference); @@ -386,7 +388,7 @@ void TestBrowser::testSearchEntriesWithAdditionalURLs() auto entries = createEntries(urls, root); // Add an additional URL to the first entry - entries.first()->attributes()->set(BrowserService::ADDITIONAL_URL, "https://keepassxc.org"); + entries.first()->attributes()->set(EntryAttributes::AdditionalUrlAttribute, "https://keepassxc.org"); auto result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session"); QCOMPARE(result.length(), 1); @@ -663,7 +665,7 @@ void TestBrowser::testBestMatchingWithAdditionalURLs() browserSettings()->setBestMatchOnly(true); // Add an additional URL to the first entry - entries.first()->attributes()->set(BrowserService::ADDITIONAL_URL, "https://test.github.com/anotherpage"); + entries.first()->attributes()->set(EntryAttributes::AdditionalUrlAttribute, "https://test.github.com/anotherpage"); // The first entry should be triggered auto result = m_browserService->searchEntries( diff --git a/tests/TestPasskeys.cpp b/tests/TestPasskeys.cpp index 556e287d70..4e5db2803b 100644 --- a/tests/TestPasskeys.cpp +++ b/tests/TestPasskeys.cpp @@ -19,6 +19,9 @@ #include "browser/BrowserCbor.h" #include "browser/BrowserMessageBuilder.h" #include "browser/BrowserService.h" +#include "core/Database.h" +#include "core/Entry.h" +#include "core/Group.h" #include "crypto/Crypto.h" #include @@ -469,3 +472,27 @@ void TestPasskeys::testSetFlags() auto discouragedResult = browserPasskeys()->setFlagsFromJson(discouragedJson); QCOMPARE(discouragedResult, 0x01); } + +void TestPasskeys::testEntry() +{ + Database db; + auto* root = db.rootGroup(); + root->setUuid(QUuid::createUuid()); + + auto* group1 = new Group(); + group1->setUuid(QUuid::createUuid()); + group1->setParent(root); + + auto* entry = new Entry(); + entry->setGroup(root); + + browserService()->addPasskeyToEntry(entry, + QString("example.com"), + QString("example.com"), + QString("username"), + QString("userId"), + QString("userHandle"), + QString("privateKey")); + + QVERIFY(entry->hasPasskey()); +} diff --git a/tests/TestPasskeys.h b/tests/TestPasskeys.h index ef2b68c24c..3d702e84a5 100644 --- a/tests/TestPasskeys.h +++ b/tests/TestPasskeys.h @@ -43,5 +43,7 @@ private slots: void testExtensions(); void testParseFlags(); void testSetFlags(); + + void testEntry(); }; #endif // KEEPASSXC_TESTPASSKEYS_H diff --git a/tests/TestTools.cpp b/tests/TestTools.cpp index f1cba482bf..56b3e593bf 100644 --- a/tests/TestTools.cpp +++ b/tests/TestTools.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * 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 @@ -239,3 +239,30 @@ void TestTools::testConvertToRegex_data() << input << static_cast(Tools::RegexConvertOpts::WILDCARD_UNLIMITED_MATCH) << QString(R"(te\|st.*t\?\[5\]\^\(test\)\;\'\,\.)"); } + +void TestTools::testArrayContainsValues() +{ + const auto values = QStringList() << "first" + << "second" + << "third"; + + // One missing + const auto result1 = Tools::getMissingValuesFromList(values, + QStringList() << "first" + << "second" + << "none"); + QCOMPARE(result1.length(), 1); + QCOMPARE(result1.first(), QString("none")); + + // All found + const auto result2 = Tools::getMissingValuesFromList(values, + QStringList() << "first" + << "second" + << "third"); + QCOMPARE(result2.length(), 0); + + // None are found + const auto numberValues = QList({1, 2, 3, 4, 5}); + const auto result3 = Tools::getMissingValuesFromList(numberValues, QList({6, 7, 8})); + QCOMPARE(result3.length(), 3); +} diff --git a/tests/TestTools.h b/tests/TestTools.h index 2e8cbb8bb0..377b00fdb8 100644 --- a/tests/TestTools.h +++ b/tests/TestTools.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * 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 @@ -35,6 +35,7 @@ private slots: void testEscapeRegex_data(); void testConvertToRegex(); void testConvertToRegex_data(); + void testArrayContainsValues(); }; #endif // KEEPASSX_TESTTOOLS_H