Passkeys: Add support for importing Passkey to entry (#9987)

---------
Co-authored-by: Jonathan White <support@dmapps.us>
This commit is contained in:
Sami Vänttinen 2023-11-23 06:11:25 +02:00 committed by GitHub
parent 013db199cb
commit 13c88e1013
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 459 additions and 176 deletions

View File

@ -959,6 +959,15 @@ Do you want to delete the entry?
<source>%1 (Passkey)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>KeePassXC: Update Passkey</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Entry already has a Passkey.
Do you want to overwrite the Passkey in %1 - %2?</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>BrowserSettingsWidget</name>
@ -5992,10 +6001,6 @@ Do you want to overwrite it?
<source>KeePassXC - Passkey Import</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Do you want to import the Passkey?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>URL: %1</source>
<translation type="unfinished"></translation>
@ -6004,10 +6009,6 @@ Do you want to overwrite it?
<source>Username: %1</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Use default group (Imported Passkeys)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Group</source>
<translation type="unfinished"></translation>
@ -6016,10 +6017,6 @@ Do you want to overwrite it?
<source>Database</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Select Database</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Import Passkey</source>
<translation type="unfinished"></translation>
@ -6033,11 +6030,23 @@ Do you want to overwrite it?
<translation type="unfinished"></translation>
</message>
<message>
<source>Database: %1</source>
<source>Import the following Passkey:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Group:</source>
<source>Entry</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Import the following Passkey to this entry:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Create new entry</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Default Passkeys group (Imported Passkeys)</source>
<translation type="unfinished"></translation>
</message>
</context>
@ -6075,6 +6084,12 @@ Do you want to overwrite it?
<source>Cannot import Passkey file &quot;%1&quot;. Private key is missing or malformed.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cannot import Passkey file &quot;%1&quot;.
The following data is missing:
%2</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>PasswordEditWidget</name>

View File

@ -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<Entry*> BrowserService::getPasskeyEntries(const QString& rpId, const Strin
{
QList<Entry*> 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<Database> BrowserService::getDatabase()
QSharedPointer<Database> 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<QSharedPointer<Database>> BrowserService::getOpenDatabases()
{
QList<QSharedPointer<Database>> databaseList;
for (auto dbWidget : getMainWindow()->getOpenDatabases()) {
if (!dbWidget->isLocked()) {
databaseList << dbWidget->database();
}
}
return databaseList;
}
QSharedPointer<Database> BrowserService::selectedDatabase()
{
QList<DatabaseWidget*> databaseWidgets;

View File

@ -84,7 +84,9 @@ public:
QString getCurrentTotp(const QString& uuid);
void showPasswordGenerator(const KeyPairMessage& keyPairMessage);
bool isPasswordGeneratorRequested() const;
QSharedPointer<Database> getDatabase(const QUuid& rootGroupUuid = {});
QSharedPointer<Database> selectedDatabase();
QList<QSharedPointer<Database>> getOpenDatabases();
#ifdef WITH_XC_BROWSER_PASSKEYS
QJsonObject
showPasskeysRegisterPrompt(const QJsonObject& publicKey, const QString& origin, const StringPairList& keyList);
@ -124,7 +126,6 @@ public:
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:
const QString& siteUrl,
const QString& formUrl,
const bool omitWwwSubdomain = false);
QSharedPointer<Database> getDatabase();
QString getDatabaseRootUuid();
QString getDatabaseRecycleBinUuid();
bool checkLegacySettings(QSharedPointer<Database> db);

View File

@ -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()) {

View File

@ -121,6 +121,7 @@ public:
void setExcludeFromReports(bool state);
bool hasTotp() const;
bool hasPasskey() const;
bool isExpired() const;
bool willExpireInDays(int days) const;
bool isRecycled() const;

View File

@ -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<QString> EntryAttributes::customKeys() const
{
QList<QString> customKeys;

View File

@ -1,6 +1,6 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2012 Felix Geyer <debfx@fobos.de>
* Copyright (C) 2017 KeePassXC Team <team@keepassxc.org>
*
* 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 @@ public:
explicit EntryAttributes(QObject* parent = nullptr);
QList<QString> keys() const;
bool hasKey(const QString& key) const;
bool hasPasskey() const;
QList<QString> customKeys() const;
QString value(const QString& key) const;
QList<QString> values(const QList<QString>& keys) const;
@ -61,6 +62,7 @@ public:
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);

View File

@ -22,6 +22,7 @@
#include "core/Global.h"
#include <QDateTime>
#include <QList>
#include <QProcessEnvironment>
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 <typename T> QList<T> getMissingValuesFromList(const QList<T>& list, const QList<T>& required)
{
QList<T> 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);

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* 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;
}
}

View File

@ -564,7 +564,12 @@ void DatabaseTabWidget::showPasskeys()
void DatabaseTabWidget::importPasskey()
{
currentDatabaseWidget()->switchToImportPasskey();
currentDatabaseWidget()->showImportPasskeyDialog();
}
void DatabaseTabWidget::importPasskeyToEntry()
{
currentDatabaseWidget()->showImportPasskeyDialog(true);
}
#endif

View File

@ -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();

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -342,6 +342,8 @@
<addaction name="separator"/>
<addaction name="actionEntryAutoType"/>
<addaction name="separator"/>
<addaction name="actionEntryImportPasskey"/>
<addaction name="separator"/>
<addaction name="actionEntryOpenUrl"/>
<addaction name="actionEntryDownloadIcon"/>
<addaction name="separator"/>
@ -730,6 +732,14 @@
<string>Perform &amp;Auto-Type</string>
</property>
</action>
<action name="actionEntryImportPasskey">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Import Passkey</string>
</property>
</action>
<action name="actionEntryAutoTypeUsername">
<property name="enabled">
<bool>false</bool>

View File

@ -1,6 +1,6 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2010 Felix Geyer <debfx@fobos.de>
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
*
* 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++;
}

View File

@ -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));

View File

@ -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>& database)
void PasskeyImportDialog::setInfo(const QString& url,
const QString& username,
const QSharedPointer<Database>& 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,34 +69,96 @@ void PasskeyImportDialog::setInfo(const QString& url, const QString& username, c
openDatabaseCount++;
}
}
m_ui->selectDatabaseButton->setEnabled(openDatabaseCount > 1);
m_ui->selectDatabaseCombobBox->setEnabled(openDatabaseCount > 1);
}
QSharedPointer<Database> PasskeyImportDialog::getSelectedDatabase()
QSharedPointer<Database> 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>& database) const
bool PasskeyImportDialog::createNewEntry() const
{
return QFileInfo(database->filePath()).fileName();
return m_selectedEntryUuid.isNull();
}
void PasskeyImportDialog::addGroups(const QSharedPointer<Database>& database)
void PasskeyImportDialog::addDatabases()
{
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<QPair<QString, QUuid>> entries;
for (const auto entry : group->entries()) {
if (!entry || entry->isRecycled()) {
continue;
}
entries.append({entry->resolveMultiplePlaceholders(entry->title()), entry->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::addGroups()
{
if (!m_selectedDatabase) {
return;
}
m_ui->selectGroupComboBox->clear();
for (const auto& group : database->rootGroup()->groupsRecursive(true)) {
if (!group || group->isRecycled() || group == database->metadata()->recycleBin()) {
m_ui->selectGroupComboBox->addItem(tr("Default Passkeys group (Imported Passkeys)"), {});
for (const auto& group : m_selectedDatabase->rootGroup()->groupsRecursive(true)) {
if (!group || group->isRecycled() || group == m_selectedDatabase->metadata()->recycleBin()) {
continue;
}
@ -96,26 +166,20 @@ void PasskeyImportDialog::addGroups(const QSharedPointer<Database>& database)
}
}
void PasskeyImportDialog::selectDatabase()
void PasskeyImportDialog::changeDatabase(int index)
{
auto selectedDatabase = browserService()->selectedDatabase();
if (!selectedDatabase) {
return;
}
m_selectedDatabaseUuid = m_ui->selectDatabaseCombobBox->itemData(index).value<QUuid>();
m_selectedDatabase = browserService()->getDatabase(m_selectedDatabaseUuid);
emit updateGroups();
}
m_selectedDatabase = selectedDatabase;
m_ui->selectDatabaseLabel->setText(QString("Database: %1").arg(getDatabaseName(m_selectedDatabase)));
addGroups(m_selectedDatabase);
void PasskeyImportDialog::changeEntry(int index)
{
m_selectedEntryUuid = m_ui->selectEntryComboBox->itemData(index).value<QUuid>();
}
void PasskeyImportDialog::changeGroup(int index)
{
m_selectedGroupUuid = m_ui->selectGroupComboBox->itemData(index).value<QUuid>();
}
void PasskeyImportDialog::useDefaultGroupChanged()
{
m_ui->selectGroupComboBox->setEnabled(!m_ui->useDefaultGroupCheckbox->isChecked());
m_useDefaultGroup = m_ui->useDefaultGroupCheckbox->isChecked();
emit updateEntries();
}

View File

@ -36,25 +36,33 @@ public:
explicit PasskeyImportDialog(QWidget* parent = nullptr);
~PasskeyImportDialog() override;
void setInfo(const QString& url, const QString& username, const QSharedPointer<Database>& database);
QSharedPointer<Database> getSelectedDatabase();
QUuid getSelectedGroupUuid();
bool useDefaultGroup();
void setInfo(const QString& url, const QString& username, const QSharedPointer<Database>& database, bool isEntry);
QSharedPointer<Database> getSelectedDatabase() const;
QUuid getSelectedEntryUuid() const;
QUuid getSelectedGroupUuid() const;
bool useDefaultGroup() const;
bool createNewEntry() const;
private:
QString getDatabaseName(const QSharedPointer<Database>& database) const;
void addGroups(const QSharedPointer<Database>& 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<Ui::PasskeyImportDialog> m_ui;
QSharedPointer<Database> m_selectedDatabase;
QUuid m_selectedDatabaseUuid;
QUuid m_selectedEntryUuid;
QUuid m_selectedGroupUuid;
bool m_useDefaultGroup;
};
#endif // KEEPASSXC_PASSKEYIMPORTDIALOG_H

View File

@ -6,10 +6,22 @@
<rect>
<x>0</x>
<y>0</y>
<width>405</width>
<height>227</height>
<width>500</width>
<height>300</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>400</width>
<height>300</height>
</size>
</property>
<property name="windowTitle">
<string>KeePassXC - Passkey Import</string>
</property>
@ -24,7 +36,7 @@
</font>
</property>
<property name="text">
<string>Do you want to import the Passkey?</string>
<string>Import the following Passkey:</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
@ -52,80 +64,62 @@
</widget>
</item>
<item>
<widget class="QCheckBox" name="useDefaultGroupCheckbox">
<property name="text">
<string>Use default group (Imported Passkeys)</string>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="checked">
<bool>false</bool>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QFormLayout" name="selectGroupHorLayout">
<item row="0" column="0">
<widget class="QLabel" name="databaseLabel">
<property name="text">
<string>Database</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="selectDatabaseCombobBox"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="groupLabel">
<property name="text">
<string>Group</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="selectGroupComboBox"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="entryLabel">
<property name="text">
<string>Entry</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="selectEntryComboBox"/>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="selectGroupHorLayout">
<item>
<widget class="QLabel" name="selectGroupLabel">
<property name="text">
<string>Group</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QComboBox" name="selectGroupComboBox"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="selectDatabaseHorLayout">
<item>
<widget class="QLabel" name="selectDatabaseLabel">
<property name="text">
<string>Database</string>
</property>
<property name="scaledContents">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="selectDatabaseButton">
<property name="text">
<string>Select Database</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">

View File

@ -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 <QFileInfo>
@ -29,7 +30,7 @@
static const QString IMPORTED_PASSKEYS_GROUP = QStringLiteral("Imported Passkeys");
void PasskeyImporter::importPasskey(QSharedPointer<Database>& database)
void PasskeyImporter::importPasskey(QSharedPointer<Database>& 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>& database)
return;
}
importSelectedFile(file, database);
importSelectedFile(file, database, entry);
}
void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer<Database>& database)
void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer<Database>& database, Entry* entry)
{
const auto fileData = file.readAll();
const auto passkeyObject = browserMessageBuilder()->getJsonObject(fileData);
@ -61,18 +62,20 @@ void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer<Database>&
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();
const auto missingKeys = Tools::getMissingValuesFromList<QString>(passkeyObject.keys(),
QStringList() << "relyingParty"
<< "url"
<< "username"
<< "credentialId"
<< "userHandle"
<< "privateKey");
if (relyingParty.isEmpty() || username.isEmpty() || credentialId.isEmpty() || userHandle.isEmpty()
|| privateKey.isEmpty()) {
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<Database>&
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>& 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>& 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>& database,
group, url, relyingParty, relyingParty, username, credentialId, userHandle, privateKey);
}
Group* PasskeyImporter::getDefaultGroup(QSharedPointer<Database>& database)
Group* PasskeyImporter::getDefaultGroup(QSharedPointer<Database>& database) const
{
auto defaultGroup = database->rootGroup()->findGroupByPath(IMPORTED_PASSKEYS_GROUP);

View File

@ -21,6 +21,7 @@
#include "core/Database.h"
#include <QFile>
#include <QObject>
#include <QUuid>
class Entry;
@ -31,18 +32,19 @@ class PasskeyImporter : public QObject
public:
explicit PasskeyImporter() = default;
void importPasskey(QSharedPointer<Database>& database);
void importPasskey(QSharedPointer<Database>& database, Entry* entry = nullptr);
private:
void importSelectedFile(QFile& file, QSharedPointer<Database>& database);
void importSelectedFile(QFile& file, QSharedPointer<Database>& database, Entry* entry);
void showImportDialog(QSharedPointer<Database>& database,
const QString& url,
const QString& relyingParty,
const QString& username,
const QString& credentialId,
const QString& userHandle,
const QString& privateKey);
Group* getDefaultGroup(QSharedPointer<Database>& database);
const QString& privateKey,
Entry* entry);
Group* getDefaultGroup(QSharedPointer<Database>& database) const;
};
#endif // KEEPASSXC_PASSKEYIMPORTER_H

View File

@ -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(

View File

@ -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 <QJsonArray>
@ -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());
}

View File

@ -43,5 +43,7 @@ private slots:
void testExtensions();
void testParseFlags();
void testSetFlags();
void testEntry();
};
#endif // KEEPASSXC_TESTPASSKEYS_H

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* 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<int>(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<QString>(values,
QStringList() << "first"
<< "second"
<< "none");
QCOMPARE(result1.length(), 1);
QCOMPARE(result1.first(), QString("none"));
// All found
const auto result2 = Tools::getMissingValuesFromList<QString>(values,
QStringList() << "first"
<< "second"
<< "third");
QCOMPARE(result2.length(), 0);
// None are found
const auto numberValues = QList<int>({1, 2, 3, 4, 5});
const auto result3 = Tools::getMissingValuesFromList<int>(numberValues, QList<int>({6, 7, 8}));
QCOMPARE(result3.length(), 3);
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* 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