From d528c3a2fcb944a8f63397052a67f2d21fedc267 Mon Sep 17 00:00:00 2001 From: varjolintu Date: Mon, 19 Sep 2022 13:32:59 +0300 Subject: [PATCH 1/3] Add basic support for WebAuthn (Passkeys) --- CMakeLists.txt | 8 + COPYING | 3 +- INSTALL.md | 1 + .../application/scalable/actions/passkey.svg | 1 + share/icons/icons.qrc | 1 + share/translations/keepassxc_en.ts | 324 +++++++++++- src/CMakeLists.txt | 30 +- src/browser/BrowserAccessControlDialog.h | 6 +- src/browser/BrowserAction.cpp | 107 +++- src/browser/BrowserAction.h | 17 +- src/browser/BrowserCbor.cpp | 253 ++++++++++ src/browser/BrowserCbor.h | 70 +++ src/browser/BrowserEntryConfig.h | 6 +- src/browser/BrowserEntrySaveDialog.h | 6 +- src/browser/BrowserEntrySaveDialog.ui | 2 +- src/browser/BrowserHost.h | 6 +- src/browser/BrowserMessageBuilder.cpp | 78 ++- src/browser/BrowserMessageBuilder.h | 22 +- src/browser/BrowserPasskeys.cpp | 465 +++++++++++++++++ src/browser/BrowserPasskeys.h | 143 ++++++ .../BrowserPasskeysConfirmationDialog.cpp | 150 ++++++ .../BrowserPasskeysConfirmationDialog.h | 66 +++ .../BrowserPasskeysConfirmationDialog.ui | 115 +++++ src/browser/BrowserService.cpp | 273 +++++++++- src/browser/BrowserService.h | 56 ++- src/browser/BrowserSettings.h | 6 +- src/browser/CMakeLists.txt | 11 +- src/config-keepassx.h.cmake | 1 + src/core/EntryAttributes.cpp | 11 +- src/core/EntryAttributes.h | 2 + src/core/Group.cpp | 13 + src/core/Group.h | 1 + src/core/Tools.cpp | 18 + src/core/Tools.h | 1 + src/gui/DatabaseTabWidget.cpp | 12 + src/gui/DatabaseTabWidget.h | 5 + src/gui/DatabaseWidget.cpp | 18 + src/gui/DatabaseWidget.h | 4 + src/gui/MainWindow.cpp | 21 + src/gui/MainWindow.ui | 31 ++ src/gui/passkeys/PasskeyExportDialog.cpp | 91 ++++ src/gui/passkeys/PasskeyExportDialog.h | 52 ++ src/gui/passkeys/PasskeyExportDialog.ui | 121 +++++ src/gui/passkeys/PasskeyExporter.cpp | 94 ++++ src/gui/passkeys/PasskeyExporter.h | 39 ++ src/gui/passkeys/PasskeyImportDialog.cpp | 121 +++++ src/gui/passkeys/PasskeyImportDialog.h | 60 +++ src/gui/passkeys/PasskeyImportDialog.ui | 174 +++++++ src/gui/passkeys/PasskeyImporter.cpp | 135 +++++ src/gui/passkeys/PasskeyImporter.h | 48 ++ src/gui/reports/ReportsDialog.cpp | 32 +- src/gui/reports/ReportsDialog.h | 11 +- src/gui/reports/ReportsPagePasskeys.cpp | 52 ++ src/gui/reports/ReportsPagePasskeys.h | 40 ++ .../ReportsWidgetBrowserStatistics.cpp | 16 +- .../reports/ReportsWidgetBrowserStatistics.ui | 20 +- src/gui/reports/ReportsWidgetHealthcheck.cpp | 39 +- src/gui/reports/ReportsWidgetHealthcheck.h | 2 +- src/gui/reports/ReportsWidgetHealthcheck.ui | 10 +- src/gui/reports/ReportsWidgetPasskeys.cpp | 294 +++++++++++ src/gui/reports/ReportsWidgetPasskeys.h | 76 +++ src/gui/reports/ReportsWidgetPasskeys.ui | 102 ++++ src/gui/styles/base/basestyle.qss | 4 + tests/CMakeLists.txt | 13 +- tests/TestPasskeys.cpp | 474 ++++++++++++++++++ tests/TestPasskeys.h | 47 ++ 66 files changed, 4394 insertions(+), 137 deletions(-) create mode 100644 share/icons/application/scalable/actions/passkey.svg create mode 100644 src/browser/BrowserCbor.cpp create mode 100644 src/browser/BrowserCbor.h create mode 100644 src/browser/BrowserPasskeys.cpp create mode 100644 src/browser/BrowserPasskeys.h create mode 100644 src/browser/BrowserPasskeysConfirmationDialog.cpp create mode 100644 src/browser/BrowserPasskeysConfirmationDialog.h create mode 100755 src/browser/BrowserPasskeysConfirmationDialog.ui create mode 100644 src/gui/passkeys/PasskeyExportDialog.cpp create mode 100644 src/gui/passkeys/PasskeyExportDialog.h create mode 100755 src/gui/passkeys/PasskeyExportDialog.ui create mode 100644 src/gui/passkeys/PasskeyExporter.cpp create mode 100644 src/gui/passkeys/PasskeyExporter.h create mode 100644 src/gui/passkeys/PasskeyImportDialog.cpp create mode 100644 src/gui/passkeys/PasskeyImportDialog.h create mode 100755 src/gui/passkeys/PasskeyImportDialog.ui create mode 100644 src/gui/passkeys/PasskeyImporter.cpp create mode 100644 src/gui/passkeys/PasskeyImporter.h create mode 100644 src/gui/reports/ReportsPagePasskeys.cpp create mode 100644 src/gui/reports/ReportsPagePasskeys.h create mode 100644 src/gui/reports/ReportsWidgetPasskeys.cpp create mode 100644 src/gui/reports/ReportsWidgetPasskeys.h create mode 100644 src/gui/reports/ReportsWidgetPasskeys.ui create mode 100644 tests/TestPasskeys.cpp create mode 100644 tests/TestPasskeys.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 074c709330..252e61fbd6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,7 @@ set(WITH_XC_ALL OFF CACHE BOOL "Build in all available plugins") option(WITH_XC_AUTOTYPE "Include Auto-Type." ON) option(WITH_XC_NETWORKING "Include networking code (e.g. for downloading website icons)." OFF) option(WITH_XC_BROWSER "Include browser integration with keepassxc-browser." OFF) +option(WITH_XC_BROWSER_PASSKEYS "Passkeys support for browser integration." OFF) option(WITH_XC_YUBIKEY "Include YubiKey support." OFF) option(WITH_XC_SSHAGENT "Include SSH agent support." OFF) option(WITH_XC_KEESHARE "Sharing integration with KeeShare" OFF) @@ -98,6 +99,7 @@ if(WITH_XC_ALL) set(WITH_XC_AUTOTYPE ON) set(WITH_XC_NETWORKING ON) set(WITH_XC_BROWSER ON) + set(WITH_XC_BROWSER_PASSKEYS ON) set(WITH_XC_YUBIKEY ON) set(WITH_XC_SSHAGENT ON) set(WITH_XC_KEESHARE ON) @@ -514,6 +516,12 @@ if(Qt5Core_VERSION VERSION_LESS "5.2.0") message(FATAL_ERROR "Qt version 5.2.0 or higher is required") endif() +# CBOR for Passkeys requires Qt 5.12 +if(Qt5Core_VERSION VERSION_LESS "5.12.0") + message(STATUS "Qt version 5.12.0 or higher is required for Passkeys support") + set(WITH_XC_BROWSER_PASSKEYS OFF) +endif() + get_filename_component(Qt5_PREFIX ${Qt5_DIR}/../../.. REALPATH) if(APPLE) # Add includes under Qt5 Prefix in case Qt6 is also installed diff --git a/COPYING b/COPYING index 04d4376f02..3da23d212d 100644 --- a/COPYING +++ b/COPYING @@ -1,5 +1,5 @@ KeePassXC - http://www.keepassxc.org/ -Copyright (C) 2016-2020 KeePassXC Team +Copyright (C) 2016-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 @@ -194,6 +194,7 @@ Files: share/icons/application/scalable/actions/application-exit.svg share/icons/application/scalable/actions/object-unlocked.svg share/icons/application/scalable/actions/paperclip.svg share/icons/application/scalable/actions/password-copy.svg + share/icons/application/scalable/actions/passkey.svg share/icons/application/scalable/actions/password-generator.svg share/icons/application/scalable/actions/password-show-off.svg share/icons/application/scalable/actions/password-show-on.svg diff --git a/INSTALL.md b/INSTALL.md index 17bcdae9f1..bc1a4ed5b6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -112,6 +112,7 @@ KeePassXC comes with a variety of build options that can turn on/off features. M -DWITH_XC_AUTOTYPE=[ON|OFF] Enable/Disable Auto-Type (default: ON) -DWITH_XC_YUBIKEY=[ON|OFF] Enable/Disable YubiKey HMAC-SHA1 authentication support (default: OFF) -DWITH_XC_BROWSER=[ON|OFF] Enable/Disable KeePassXC-Browser extension support (default: OFF) +-DWITH_XC_BROWSER_PASSKEYS=[ON|OFF] Enable/Disable Passkeys support for browser integration (default: OFF) -DWITH_XC_NETWORKING=[ON|OFF] Enable/Disable Networking support (e.g., favicon downloading) (default: OFF) -DWITH_XC_SSHAGENT=[ON|OFF] Enable/Disable SSHAgent support (default: OFF) -DWITH_XC_FDOSECRETS=[ON|OFF] (Linux Only) Enable/Disable Freedesktop.org Secrets Service support (default:OFF) diff --git a/share/icons/application/scalable/actions/passkey.svg b/share/icons/application/scalable/actions/passkey.svg new file mode 100644 index 0000000000..c1345f1f20 --- /dev/null +++ b/share/icons/application/scalable/actions/passkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc index 4982e3b0e3..92753125c4 100644 --- a/share/icons/icons.qrc +++ b/share/icons/icons.qrc @@ -59,6 +59,7 @@ application/scalable/actions/object-locked.svg application/scalable/actions/object-unlocked.svg application/scalable/actions/paperclip.svg + application/scalable/actions/passkey.svg application/scalable/actions/password-copy.svg application/scalable/actions/password-generator.svg application/scalable/actions/password-show-off.svg diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index bb55d604d1..cfc478e15e 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -827,10 +827,6 @@ Ctrl+4 - Use Virtual Keyboard (Windows Only)</p> BrowserEntrySaveDialog - - KeePassXC-Browser Save Entry - - Ok @@ -844,6 +840,60 @@ Ctrl+4 - Use Virtual Keyboard (Windows Only)</p> Please select the correct database for saving credentials. + + KeePassXC - Select Database + + + + + BrowserPasskeysConfirmationDialog + + KeePassXC: Passkey credentials + + + + Cancel + + + + Update + + + + Authenticate + + + + Do you want to register Passkey credentials for: +%1 (%2)? + + + + Existing Passkey found. +Do you want to register a new Passkey for: +%1 (%2)? +Select the existing Passkey and press Update to replace it. + + + + Register new + + + + Register + + + + Authenticate Passkey credentials for:%1? + + + + Timeout in <b>%n</b> seconds... + + + + + BrowserService @@ -900,6 +950,10 @@ Do you want to delete the entry? + + Passkey + + BrowserSettingsWidget @@ -5474,6 +5528,18 @@ We recommend you use the AppImage available on our downloads page. Allow Screen Capture + + Passkeys… + + + + Passkeys + + + + Import Passkey + + ManageDatabase @@ -5859,6 +5925,148 @@ We recommend you use the AppImage available on our downloads page. + + PasskeyExportDialog + + KeePassXC - Passkey Export + + + + Export the following Passkey entries. + + + + Filenames will be generated with title and .passkey file extension. + + + + Export entries + + + + Export Selected + + + + Cancel + + + + Export to folder + + + + + PasskeyExporter + + KeePassXC: Passkey Export + + + + File "%1.passkey" already exists. +Do you want to overwrite it? + + + + + Cannot open file + + + + Cannot open file "%1" for writing. + + + + + PasskeyImportDialog + + KeePassXC - Passkey Import + + + + Do you want to import the Passkey? + + + + URL: %1 + + + + Username: %1 + + + + Use default group (Imported Passkeys) + + + + Group + + + + Database + + + + Select Database + + + + Import Passkey + + + + Import + + + + Cancel + + + + URL + + + + Username + + + + + PasskeyImporter + + Passkey file + + + + All files + + + + Open Passkey file + + + + Cannot open file + + + + Cannot open file "%1" for reading. + + + + Cannot import Passkey + + + + Cannot import Passkey file "%1". Data is missing. + + + + Cannot import Passkey file "%1". Private key is missing or malformed. + + + PasswordEditWidget @@ -8031,6 +8239,10 @@ Kernel: %3 %4 Failed to decrypt key data. + + Passkeys + + QtIOCompressor @@ -8068,18 +8280,6 @@ Kernel: %3 %4 ReportsWidgetBrowserStatistics - - Exclude expired entries from the report - - - - Show only entries which have URL set - - - - Show only entries which have browser settings in custom data - - Double-click entries to edit. @@ -8147,17 +8347,25 @@ Kernel: %3 %4 Exclude from reports - - - ReportsWidgetHealthcheck - Exclude expired entries from the report + Only show entries that have a URL - Also show entries that have been excluded from reports + Only show entries that have been explicitly allowed or denied + + + + Show expired entries + + + + (Expired) + + + ReportsWidgetHealthcheck Hover over reason to show additional details. Double-click entries to edit. @@ -8236,6 +8444,18 @@ Kernel: %3 %4 Exclude from reports + + Show expired entries + + + + Show entries that have been excluded from reports + + + + (Expired) + + ReportsWidgetHibp @@ -8335,6 +8555,68 @@ Kernel: %3 %4 + + ReportsWidgetPasskeys + + Export + + + + Import + + + + List of entry URLs + + + + Please wait, list of entries with Passkeys is being updated… + + + + No entries with Passkeys. + + + + Title + + + + Path + + + + Username + + + + URLs + + + + Edit Entry… + + + + Delete Entry(s)… + + + + + + + Relying Party + + + + Show expired entries + + + + (Expired) + + + ReportsWidgetStatistics diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 298355e8d7..15b6d947ac 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -259,6 +259,7 @@ set(keepassx_SOURCES_MAINEXE main.cpp) add_feature_info(Auto-Type WITH_XC_AUTOTYPE "Automatic password typing") add_feature_info(Networking WITH_XC_NETWORKING "Compile KeePassXC with network access code (e.g. for downloading website icons)") add_feature_info(KeePassXC-Browser WITH_XC_BROWSER "Browser integration with KeePassXC-Browser") +add_feature_info(Passkeys WITH_XC_BROWSER_PASSKEYS "Passkeys support for browser integration") add_feature_info(SSHAgent WITH_XC_SSHAGENT "SSH agent integration compatible with KeeAgent") add_feature_info(KeeShare WITH_XC_KEESHARE "Sharing integration with KeeShare") add_feature_info(YubiKey WITH_XC_YUBIKEY "YubiKey HMAC-SHA1 challenge-response") @@ -271,10 +272,21 @@ add_subdirectory(browser) add_subdirectory(proxy) if(WITH_XC_BROWSER) set(keepassxcbrowser_LIB keepassxcbrowser) - set(keepassx_SOURCES ${keepassx_SOURCES} gui/dbsettings/DatabaseSettingsWidgetBrowser.cpp) - set(keepassx_SOURCES ${keepassx_SOURCES} gui/entry/EntryURLModel.cpp) - set(keepassx_SOURCES ${keepassx_SOURCES} gui/reports/ReportsWidgetBrowserStatistics.cpp) - set(keepassx_SOURCES ${keepassx_SOURCES} gui/reports/ReportsPageBrowserStatistics.cpp) + set(keepassx_SOURCES ${keepassx_SOURCES} + gui/dbsettings/DatabaseSettingsWidgetBrowser.cpp + gui/entry/EntryURLModel.cpp + gui/reports/ReportsWidgetBrowserStatistics.cpp + gui/reports/ReportsPageBrowserStatistics.cpp) +endif() + +if(WITH_XC_BROWSER_PASSKEYS) + set(keepassx_SOURCES ${keepassx_SOURCES} + gui/reports/ReportsWidgetPasskeys.cpp + gui/reports/ReportsPagePasskeys.cpp + gui/passkeys/PasskeyExporter.cpp + gui/passkeys/PasskeyExportDialog.cpp + gui/passkeys/PasskeyImporter.cpp + gui/passkeys/PasskeyImportDialog.cpp) endif() add_subdirectory(autotype) @@ -315,14 +327,14 @@ if(WIN32) endif() if(WITH_XC_YUBIKEY) - list(APPEND keepassx_SOURCES + list(APPEND keepassx_SOURCES keys/drivers/YubiKey.h - keys/drivers/YubiKey.cpp - keys/drivers/YubiKeyInterface.cpp - keys/drivers/YubiKeyInterfaceUSB.cpp + keys/drivers/YubiKey.cpp + keys/drivers/YubiKeyInterface.cpp + keys/drivers/YubiKeyInterfaceUSB.cpp keys/drivers/YubiKeyInterfacePCSC.cpp) else() - list(APPEND keepassx_SOURCES + list(APPEND keepassx_SOURCES keys/drivers/YubiKey.h keys/drivers/YubiKeyStub.cpp) endif() diff --git a/src/browser/BrowserAccessControlDialog.h b/src/browser/BrowserAccessControlDialog.h index 57156fce65..3ecf5b506e 100644 --- a/src/browser/BrowserAccessControlDialog.h +++ b/src/browser/BrowserAccessControlDialog.h @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERACCESSCONTROLDIALOG_H -#define BROWSERACCESSCONTROLDIALOG_H +#ifndef KEEPASSXC_BROWSERACCESSCONTROLDIALOG_H +#define KEEPASSXC_BROWSERACCESSCONTROLDIALOG_H #include #include @@ -64,4 +64,4 @@ private slots: QList m_entriesToConfirm; }; -#endif // BROWSERACCESSCONTROLDIALOG_H +#endif // KEEPASSXC_BROWSERACCESSCONTROLDIALOG_H diff --git a/src/browser/BrowserAction.cpp b/src/browser/BrowserAction.cpp index 79ff82c571..8fe2244490 100644 --- a/src/browser/BrowserAction.cpp +++ b/src/browser/BrowserAction.cpp @@ -16,7 +16,10 @@ */ #include "BrowserAction.h" -#include "BrowserService.h" +#include "BrowserMessageBuilder.h" +#ifdef WITH_XC_BROWSER_PASSKEYS +#include "BrowserPasskeys.h" +#endif #include "BrowserSettings.h" #include "core/Global.h" #include "core/Tools.h" @@ -36,6 +39,8 @@ static const QString BROWSER_REQUEST_GET_DATABASE_GROUPS = QStringLiteral("get-d static const QString BROWSER_REQUEST_GET_LOGINS = QStringLiteral("get-logins"); static const QString BROWSER_REQUEST_GET_TOTP = QStringLiteral("get-totp"); static const QString BROWSER_REQUEST_LOCK_DATABASE = QStringLiteral("lock-database"); +static const QString BROWSER_REQUEST_PASSKEYS_GET = QStringLiteral("passkeys-get"); +static const QString BROWSER_REQUEST_PASSKEYS_REGISTER = QStringLiteral("passkeys-register"); static const QString BROWSER_REQUEST_REQUEST_AUTOTYPE = QStringLiteral("request-autotype"); static const QString BROWSER_REQUEST_SET_LOGIN = QStringLiteral("set-login"); static const QString BROWSER_REQUEST_TEST_ASSOCIATE = QStringLiteral("test-associate"); @@ -104,6 +109,12 @@ QJsonObject BrowserAction::handleAction(QLocalSocket* socket, const QJsonObject& return handleGlobalAutoType(json, action); } else if (action.compare("get-database-entries", Qt::CaseSensitive) == 0) { return handleGetDatabaseEntries(json, action); +#ifdef WITH_XC_BROWSER_PASSKEYS + } else if (action.compare(BROWSER_REQUEST_PASSKEYS_GET) == 0) { + return handlePasskeysGet(json, action); + } else if (action.compare(BROWSER_REQUEST_PASSKEYS_REGISTER) == 0) { + return handlePasskeysRegister(json, action); +#endif } // Action was not recognized @@ -226,18 +237,11 @@ QJsonObject BrowserAction::handleGetLogins(const QJsonObject& json, const QStrin return getErrorReply(action, ERROR_KEEPASS_NO_URL_PROVIDED); } - const auto keys = browserRequest.getArray("keys"); - - StringPairList keyList; - for (const auto val : keys) { - const auto keyObject = val.toObject(); - keyList.push_back(qMakePair(keyObject.value("id").toString(), keyObject.value("key").toString())); - } - const auto id = browserRequest.getString("id"); const auto formUrl = browserRequest.getString("submitUrl"); const auto auth = browserRequest.getString("httpAuth"); const bool httpAuth = auth.compare(TRUE_STR) == 0; + const auto keyList = getConnectionKeys(browserRequest); EntryParameters entryParameters; entryParameters.dbid = id; @@ -384,10 +388,6 @@ QJsonObject BrowserAction::handleGetDatabaseGroups(const QJsonObject& json, cons QJsonObject BrowserAction::handleGetDatabaseEntries(const QJsonObject& json, const QString& action) { - const QString hash = browserService()->getDatabaseHash(); - const QString nonce = json.value("nonce").toString(); - const QString encrypted = json.value("message").toString(); - if (!m_associated) { return getErrorReply(action, ERROR_KEEPASS_ASSOCIATION_FAILED); } @@ -516,6 +516,74 @@ QJsonObject BrowserAction::handleGlobalAutoType(const QJsonObject& json, const Q return buildResponse(action, browserRequest.incrementedNonce); } +#ifdef WITH_XC_BROWSER_PASSKEYS +QJsonObject BrowserAction::handlePasskeysGet(const QJsonObject& json, const QString& action) +{ + if (!m_associated) { + return getErrorReply(action, ERROR_KEEPASS_ASSOCIATION_FAILED); + } + + const auto browserRequest = decodeRequest(json); + if (browserRequest.isEmpty()) { + return getErrorReply(action, ERROR_KEEPASS_CANNOT_DECRYPT_MESSAGE); + } + + const auto command = browserRequest.getString("action"); + if (command.isEmpty() || command.compare(BROWSER_REQUEST_PASSKEYS_GET) != 0) { + return getErrorReply(action, ERROR_KEEPASS_INCORRECT_ACTION); + } + + const auto publicKey = browserRequest.getObject("publicKey"); + if (publicKey.isEmpty()) { + return getErrorReply(action, ERROR_PASSKEYS_EMPTY_PUBLIC_KEY); + } + + const auto origin = browserRequest.getString("origin"); + if (!origin.startsWith("https://")) { + return getErrorReply(action, ERROR_PASSKEYS_INVALID_URL_PROVIDED); + } + + const auto keyList = getConnectionKeys(browserRequest); + const auto response = browserService()->showPasskeysAuthenticationPrompt(publicKey, origin, keyList); + + const Parameters params{{"response", response}}; + return buildResponse(action, browserRequest.incrementedNonce, params); +} + +QJsonObject BrowserAction::handlePasskeysRegister(const QJsonObject& json, const QString& action) +{ + if (!m_associated) { + return getErrorReply(action, ERROR_KEEPASS_ASSOCIATION_FAILED); + } + + const auto browserRequest = decodeRequest(json); + if (browserRequest.isEmpty()) { + return getErrorReply(action, ERROR_KEEPASS_CANNOT_DECRYPT_MESSAGE); + } + + const auto command = browserRequest.getString("action"); + if (command.isEmpty() || command.compare(BROWSER_REQUEST_PASSKEYS_REGISTER) != 0) { + return getErrorReply(action, ERROR_KEEPASS_INCORRECT_ACTION); + } + + const auto publicKey = browserRequest.getObject("publicKey"); + if (publicKey.isEmpty()) { + return getErrorReply(action, ERROR_PASSKEYS_EMPTY_PUBLIC_KEY); + } + + const auto origin = browserRequest.getString("origin"); + if (!origin.startsWith("https://")) { + return getErrorReply(action, ERROR_KEEPASS_ACTION_CANCELLED_OR_DENIED); + } + + const auto keyList = getConnectionKeys(browserRequest); + const auto response = browserService()->showPasskeysRegisterPrompt(publicKey, origin, keyList); + + const Parameters params{{"response", response}}; + return buildResponse(action, browserRequest.incrementedNonce, params); +} +#endif + QJsonObject BrowserAction::decryptMessage(const QString& message, const QString& nonce) { return browserMessageBuilder()->decryptMessage(message, nonce, m_clientPublicKey, m_secretKey); @@ -541,3 +609,16 @@ BrowserRequest BrowserAction::decodeRequest(const QJsonObject& json) browserMessageBuilder()->incrementNonce(nonce), decryptMessage(encrypted, nonce)}; } + +StringPairList BrowserAction::getConnectionKeys(const BrowserRequest& browserRequest) +{ + const auto keys = browserRequest.getArray("keys"); + + StringPairList keyList; + for (const auto val : keys) { + const auto keyObject = val.toObject(); + keyList.push_back(qMakePair(keyObject.value("id").toString(), keyObject.value("key").toString())); + } + + return keyList; +} diff --git a/src/browser/BrowserAction.h b/src/browser/BrowserAction.h index fe65c977a9..a493073d69 100644 --- a/src/browser/BrowserAction.h +++ b/src/browser/BrowserAction.h @@ -15,10 +15,11 @@ * along with this program. If not, see . */ -#ifndef BROWSERACTION_H -#define BROWSERACTION_H +#ifndef KEEPASSXC_BROWSERACTION_H +#define KEEPASSXC_BROWSERACTION_H #include "BrowserMessageBuilder.h" +#include "BrowserService.h" #include #include @@ -43,6 +44,11 @@ struct BrowserRequest return decrypted.value(param).toArray(); } + inline QJsonObject getObject(const QString& param) const + { + return decrypted.value(param).toObject(); + } + inline QString getString(const QString& param) const { return decrypted.value(param).toString(); @@ -73,12 +79,17 @@ class BrowserAction QJsonObject handleGetTotp(const QJsonObject& json, const QString& action); QJsonObject handleDeleteEntry(const QJsonObject& json, const QString& action); QJsonObject handleGlobalAutoType(const QJsonObject& json, const QString& action); +#ifdef WITH_XC_BROWSER_PASSKEYS + QJsonObject handlePasskeysGet(const QJsonObject& json, const QString& action); + QJsonObject handlePasskeysRegister(const QJsonObject& json, const QString& action); +#endif private: QJsonObject buildResponse(const QString& action, const QString& nonce, const Parameters& params = {}); QJsonObject getErrorReply(const QString& action, const int errorCode) const; QJsonObject decryptMessage(const QString& message, const QString& nonce); BrowserRequest decodeRequest(const QJsonObject& json); + StringPairList getConnectionKeys(const BrowserRequest& browserRequest); private: static const int MaxUrlLength; @@ -91,4 +102,4 @@ class BrowserAction friend class TestBrowser; }; -#endif // BROWSERACTION_H +#endif // KEEPASSXC_BROWSERACTION_H diff --git a/src/browser/BrowserCbor.cpp b/src/browser/BrowserCbor.cpp new file mode 100644 index 0000000000..c4731881cc --- /dev/null +++ b/src/browser/BrowserCbor.cpp @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2022 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#include "BrowserCbor.h" +#include "BrowserMessageBuilder.h" +#include +#include +#include + +// https://w3c.github.io/webauthn/#sctn-none-attestation +// https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object +QByteArray BrowserCbor::cborEncodeAttestation(const QByteArray& authData) const +{ + QByteArray result; + QCborStreamWriter writer(&result); + + writer.startMap(3); + + writer.append("fmt"); + writer.append("none"); + + writer.append("attStmt"); + writer.startMap(0); + writer.endMap(); + + writer.append("authData"); + writer.appendByteString(authData.constData(), authData.size()); + + writer.endMap(); + + return result; +} + +// https://w3c.github.io/webauthn/#authdata-attestedcredentialdata-credentialpublickey +QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, const QByteArray& second) const +{ + QByteArray result; + QCborStreamWriter writer(&result); + + if (alg == WebAuthnAlgorithms::ES256) { + writer.startMap(5); + + // Key type + writer.append(1); + writer.append(getCoseKeyType(alg)); + + // Signature algorithm + writer.append(3); + writer.append(alg); + + // Curve parameter + writer.append(-1); + writer.append(getCurveParameter(alg)); + + // Key x-coordinate + writer.append(-2); + writer.append(first); + + // Key y-coordinate + writer.append(-3); + writer.append(second); + + writer.endMap(); + } else if (alg == WebAuthnAlgorithms::RS256) { + writer.startMap(4); + + // Key type + writer.append(1); + writer.append(getCoseKeyType(alg)); + + // Signature algorithm + writer.append(3); + writer.append(alg); + + // Key modulus + writer.append(-1); + writer.append(first); + + // Key exponent + writer.append(-2); + writer.append(second); + + writer.endMap(); + } else if (alg == WebAuthnAlgorithms::EDDSA) { + // https://www.rfc-editor.org/rfc/rfc8152#section-13.2 + writer.startMap(3); + + // Curve parameter + writer.append(-1); + writer.append(getCurveParameter(alg)); + + // Public key + writer.append(-2); + writer.append(first); + + // Private key + writer.append(-4); + writer.append(second); + + writer.endMap(); + } + + return result; +} + +// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#user-verification-methods +QByteArray BrowserCbor::cborEncodeExtensionData(const QJsonObject& extensions) const +{ + if (extensions.empty()) { + return {}; + } + + QByteArray result; + QCborStreamWriter writer(&result); + + writer.startMap(extensions.keys().count()); + if (extensions["credProps"].toBool()) { + writer.append("credProps"); + writer.startMap(1); + writer.append("rk"); + writer.append(true); + writer.endMap(); + } + + if (extensions["uvm"].toBool()) { + writer.append("uvm"); + + writer.startArray(1); + writer.startArray(3); + + // userVerificationMethod (USER_VERIFY_PRESENCE_INTERNAL "presence_internal", 0x00000001) + writer.append(quint32(1)); + + // keyProtectionType (KEY_PROTECTION_SOFTWARE "software", 0x0001) + writer.append(quint16(1)); + + // matcherProtectionType (MATCHER_PROTECTION_SOFTWARE "software", 0x0001) + writer.append(quint16(1)); + + writer.endArray(); + writer.endArray(); + } + writer.endMap(); + + return result; +} + +QJsonObject BrowserCbor::getJsonFromCborData(const QByteArray& byteArray) const +{ + auto reader = QCborStreamReader(byteArray); + auto contents = QCborValue::fromCbor(reader); + if (reader.lastError()) { + return {}; + } + + const auto ret = handleCborValue(contents); + + // Parse variant result to QJsonDocument + const auto jsonDocument = QJsonDocument::fromVariant(ret); + if (jsonDocument.isNull() || jsonDocument.isEmpty()) { + return {}; + } + + return jsonDocument.object(); +} + +QVariant BrowserCbor::handleCborArray(const QCborArray& array) const +{ + QVariantList result; + result.reserve(array.size()); + + for (auto a : array) { + result.append(handleCborValue(a)); + } + + return result; +} + +QVariant BrowserCbor::handleCborMap(const QCborMap& map) const +{ + QVariantMap result; + for (auto pair : map) { + result.insert(handleCborValue(pair.first).toString(), handleCborValue(pair.second)); + } + + return QVariant::fromValue(result); +} + +QVariant BrowserCbor::handleCborValue(const QCborValue& value) const +{ + if (value.isArray()) { + return handleCborArray(value.toArray()); + } else if (value.isMap()) { + return handleCborMap(value.toMap()); + } else if (value.isByteArray()) { + auto ba = value.toByteArray(); + + // Return base64 instead of raw byte array + auto base64Str = browserMessageBuilder()->getBase64FromArray(ba); + return QVariant::fromValue(base64Str); + } + + return value.toVariant(); +} + +// https://www.rfc-editor.org/rfc/rfc8152#section-13.1 +unsigned int BrowserCbor::getCurveParameter(int alg) const +{ + switch (alg) { + case WebAuthnAlgorithms::ES256: + return WebAuthnCurveKey::P256; + case WebAuthnAlgorithms::ES384: + return WebAuthnCurveKey::P384; + case WebAuthnAlgorithms::ES512: + return WebAuthnCurveKey::P521; + case WebAuthnAlgorithms::EDDSA: + return WebAuthnCurveKey::ED25519; + default: + return WebAuthnCurveKey::P256; + } +} + +// See: https://www.rfc-editor.org/rfc/rfc8152 +// AES/HMAC/ChaCha20 etc. carries symmetric keys (4) and OKP not supported currently. +unsigned int BrowserCbor::getCoseKeyType(int alg) const +{ + switch (alg) { + case WebAuthnAlgorithms::ES256: + case WebAuthnAlgorithms::ES384: + case WebAuthnAlgorithms::ES512: + return WebAuthnCoseKeyType::EC2; + case WebAuthnAlgorithms::EDDSA: + return WebAuthnCoseKeyType::OKP; + case WebAuthnAlgorithms::RS256: + return WebAuthnCoseKeyType::RSA; + default: + return WebAuthnCoseKeyType::EC2; + } +} diff --git a/src/browser/BrowserCbor.h b/src/browser/BrowserCbor.h new file mode 100644 index 0000000000..6117e5c45b --- /dev/null +++ b/src/browser/BrowserCbor.h @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2022 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#ifndef KEEPASSXC_BROWSERCBOR_H +#define KEEPASSXC_BROWSERCBOR_H + +#include +#include +#include +#include + +enum WebAuthnAlgorithms : int +{ + ES256 = -7, + EDDSA = -8, + ES384 = -35, + ES512 = -36, + RS256 = -257 +}; + +// https://www.rfc-editor.org/rfc/rfc9053#section-7.1 +enum WebAuthnCurveKey : int +{ + P256 = 1, // EC2, NIST P-256, also known as secp256r1 + P384 = 2, // EC2, NIST P-384, also known as secp384r1 + P521 = 3, // EC2, NIST P-521, also known as secp521r1 + X25519 = 4, // OKP, X25519 for use w/ ECDH only + X448 = 5, // OKP, X448 for use w/ ECDH only + ED25519 = 6, // OKP, Ed25519 for use w/ EdDSA only + ED448 = 7 // OKP, Ed448 for use w/ EdDSA only +}; + +// https://www.rfc-editor.org/rfc/rfc8152 +// For RSA: https://www.rfc-editor.org/rfc/rfc8230#section-4 +enum WebAuthnCoseKeyType : int +{ + OKP = 1, // Octet Keypair + EC2 = 2, // Elliptic Curve + RSA = 3 // RSA +}; + +class BrowserCbor +{ +public: + QByteArray cborEncodeAttestation(const QByteArray& authData) const; + QByteArray cborEncodePublicKey(int alg, const QByteArray& first, const QByteArray& second) const; + QByteArray cborEncodeExtensionData(const QJsonObject& extensions) const; + QJsonObject getJsonFromCborData(const QByteArray& byteArray) const; + QVariant handleCborArray(const QCborArray& array) const; + QVariant handleCborMap(const QCborMap& map) const; + QVariant handleCborValue(const QCborValue& value) const; + unsigned int getCoseKeyType(int alg) const; + unsigned int getCurveParameter(int alg) const; +}; + +#endif // KEEPASSXC_BROWSERCBOR_H diff --git a/src/browser/BrowserEntryConfig.h b/src/browser/BrowserEntryConfig.h index 6de4b0bc5c..2cb76d5f53 100644 --- a/src/browser/BrowserEntryConfig.h +++ b/src/browser/BrowserEntryConfig.h @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERENTRYCONFIG_H -#define BROWSERENTRYCONFIG_H +#ifndef KEEPASSXC_BROWSERENTRYCONFIG_H +#define KEEPASSXC_BROWSERENTRYCONFIG_H #include #include @@ -55,4 +55,4 @@ class BrowserEntryConfig : public QObject QString m_realm; }; -#endif // BROWSERENTRYCONFIG_H +#endif // KEEPASSXC_BROWSERENTRYCONFIG_H diff --git a/src/browser/BrowserEntrySaveDialog.h b/src/browser/BrowserEntrySaveDialog.h index 8675e36faa..44b3d6601f 100644 --- a/src/browser/BrowserEntrySaveDialog.h +++ b/src/browser/BrowserEntrySaveDialog.h @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERENTRYSAVEDIALOG_H -#define BROWSERENTRYSAVEDIALOG_H +#ifndef KEEPASSXC_BROWSERENTRYSAVEDIALOG_H +#define KEEPASSXC_BROWSERENTRYSAVEDIALOG_H #include "gui/DatabaseTabWidget.h" @@ -45,4 +45,4 @@ class BrowserEntrySaveDialog : public QDialog QScopedPointer m_ui; }; -#endif // BROWSERENTRYSAVEDIALOG_H +#endif // KEEPASSXC_BROWSERENTRYSAVEDIALOG_H diff --git a/src/browser/BrowserEntrySaveDialog.ui b/src/browser/BrowserEntrySaveDialog.ui index 5beb6fab8f..2928f70ada 100755 --- a/src/browser/BrowserEntrySaveDialog.ui +++ b/src/browser/BrowserEntrySaveDialog.ui @@ -11,7 +11,7 @@ - KeePassXC-Browser Save Entry + KeePassXC - Select Database diff --git a/src/browser/BrowserHost.h b/src/browser/BrowserHost.h index 86f20f1e2d..f3620c04cc 100644 --- a/src/browser/BrowserHost.h +++ b/src/browser/BrowserHost.h @@ -15,8 +15,8 @@ * along with this program. If not, see . */ -#ifndef NATIVEMESSAGINGHOST_H -#define NATIVEMESSAGINGHOST_H +#ifndef KEEPASSXC_NATIVEMESSAGINGHOST_H +#define KEEPASSXC_NATIVEMESSAGINGHOST_H #include #include @@ -56,4 +56,4 @@ private slots: QList m_socketList; }; -#endif // NATIVEMESSAGINGHOST_H +#endif // KEEPASSXC_NATIVEMESSAGINGHOST_H diff --git a/src/browser/BrowserMessageBuilder.cpp b/src/browser/BrowserMessageBuilder.cpp index efbaf8cc23..583b9f33e5 100644 --- a/src/browser/BrowserMessageBuilder.cpp +++ b/src/browser/BrowserMessageBuilder.cpp @@ -19,11 +19,14 @@ #include "BrowserShared.h" #include "config-keepassx.h" #include "core/Global.h" -#include "core/Tools.h" +#include #include #include #include +#ifdef QT_DEBUG +#include +#endif #include @@ -243,6 +246,11 @@ QJsonObject BrowserMessageBuilder::getJsonObject(const uchar* pArray, const uint QByteArray arr = getQByteArray(pArray, len); QJsonParseError err; QJsonDocument doc(QJsonDocument::fromJson(arr, &err)); +#ifdef QT_DEBUG + if (doc.isNull()) { + qWarning() << "Cannot create QJsonDocument: " << err.errorString(); + } +#endif return doc.object(); } @@ -250,6 +258,12 @@ QJsonObject BrowserMessageBuilder::getJsonObject(const QByteArray& ba) const { QJsonParseError err; QJsonDocument doc(QJsonDocument::fromJson(ba, &err)); +#ifdef QT_DEBUG + if (doc.isNull()) { + qWarning() << "Cannot create QJsonDocument: " << err.errorString(); + } +#endif + return doc.object(); } @@ -266,3 +280,65 @@ QString BrowserMessageBuilder::incrementNonce(const QString& nonce) sodium_increment(n.data(), n.size()); return getQByteArray(n.data(), n.size()).toBase64(); } + +QString BrowserMessageBuilder::getRandomBytesAsBase64(int bytes) const +{ + if (bytes == 0) { + return {}; + } + + std::shared_ptr buf(new unsigned char[bytes]); + Botan::Sodium::randombytes_buf(buf.get(), bytes); + + return getBase64FromArray(reinterpret_cast(buf.get()), bytes); +} + +QString BrowserMessageBuilder::getBase64FromArray(const char* arr, int len) const +{ + if (len < 1) { + return {}; + } + + auto data = QByteArray::fromRawData(arr, len); + return getBase64FromArray(data); +} + +// Returns URL encoded base64 with trailing removed +QString BrowserMessageBuilder::getBase64FromArray(const QByteArray& byteArray) const +{ + if (byteArray.length() < 1) { + return {}; + } + + return byteArray.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); +} + +QString BrowserMessageBuilder::getBase64FromJson(const QJsonObject& jsonObject) const +{ + if (jsonObject.isEmpty()) { + return {}; + } + + const auto dataArray = QJsonDocument(jsonObject).toJson(QJsonDocument::Compact); + return getBase64FromArray(dataArray); +} + +QByteArray BrowserMessageBuilder::getArrayFromHexString(const QString& hexString) const +{ + return QByteArray::fromHex(hexString.toUtf8()); +} + +QByteArray BrowserMessageBuilder::getArrayFromBase64(const QString& base64str) const +{ + return QByteArray::fromBase64(base64str.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); +} + +QByteArray BrowserMessageBuilder::getSha256Hash(const QString& str) const +{ + return QCryptographicHash::hash(str.toUtf8(), QCryptographicHash::Sha256); +} + +QString BrowserMessageBuilder::getSha256HashAsBase64(const QString& str) const +{ + return getBase64FromArray(QCryptographicHash::hash(str.toUtf8(), QCryptographicHash::Sha256)); +} diff --git a/src/browser/BrowserMessageBuilder.h b/src/browser/BrowserMessageBuilder.h index 1248522afd..b9e172380b 100644 --- a/src/browser/BrowserMessageBuilder.h +++ b/src/browser/BrowserMessageBuilder.h @@ -15,8 +15,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERMESSAGEBUILDER_H -#define BROWSERMESSAGEBUILDER_H +#ifndef KEEPASSXC_BROWSERMESSAGEBUILDER_H +#define KEEPASSXC_BROWSERMESSAGEBUILDER_H #include #include @@ -48,7 +48,13 @@ namespace ERROR_KEEPASS_NO_GROUPS_FOUND = 16, ERROR_KEEPASS_CANNOT_CREATE_NEW_GROUP = 17, ERROR_KEEPASS_NO_VALID_UUID_PROVIDED = 18, - ERROR_KEEPASS_ACCESS_TO_ALL_ENTRIES_DENIED = 19 + ERROR_KEEPASS_ACCESS_TO_ALL_ENTRIES_DENIED = 19, + ERROR_PASSKEYS_ATTESTATION_NOT_SUPPORTED = 20, + ERROR_PASSKEYS_CREDENTIAL_IS_EXCLUDED = 21, + ERROR_PASSKEYS_REQUEST_CANCELED = 22, + ERROR_PASSKEYS_INVALID_USER_VERIFICATION = 23, + ERROR_PASSKEYS_EMPTY_PUBLIC_KEY = 24, + ERROR_PASSKEYS_INVALID_URL_PROVIDED = 25 }; } @@ -84,6 +90,14 @@ class BrowserMessageBuilder QJsonObject getJsonObject(const QByteArray& ba) const; QByteArray base64Decode(const QString& str); QString incrementNonce(const QString& nonce); + QString getRandomBytesAsBase64(int bytes) const; + QString getBase64FromArray(const char* arr, int len) const; + QString getBase64FromArray(const QByteArray& byteArray) const; + QString getBase64FromJson(const QJsonObject& jsonObject) const; + QByteArray getArrayFromHexString(const QString& hexString) const; + QByteArray getArrayFromBase64(const QString& base64str) const; + QByteArray getSha256Hash(const QString& str) const; + QString getSha256HashAsBase64(const QString& str) const; private: Q_DISABLE_COPY(BrowserMessageBuilder); @@ -96,4 +110,4 @@ static inline BrowserMessageBuilder* browserMessageBuilder() return BrowserMessageBuilder::instance(); } -#endif // BROWSERMESSAGEBUILDER_H +#endif // KEEPASSXC_BROWSERMESSAGEBUILDER_H diff --git a/src/browser/BrowserPasskeys.cpp b/src/browser/BrowserPasskeys.cpp new file mode 100644 index 0000000000..4df78b1fe1 --- /dev/null +++ b/src/browser/BrowserPasskeys.cpp @@ -0,0 +1,465 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#include "BrowserPasskeys.h" +#include "BrowserMessageBuilder.h" +#include "BrowserService.h" +#include "crypto/Random.h" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +Q_GLOBAL_STATIC(BrowserPasskeys, s_browserPasskeys); + +const QString BrowserPasskeys::PUBLIC_KEY = QStringLiteral("public-key"); +const QString BrowserPasskeys::REQUIREMENT_DISCOURAGED = QStringLiteral("discouraged"); +const QString BrowserPasskeys::REQUIREMENT_PREFERRED = QStringLiteral("preferred"); +const QString BrowserPasskeys::REQUIREMENT_REQUIRED = QStringLiteral("required"); + +const QString BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT = QStringLiteral("direct"); +const QString BrowserPasskeys::PASSKEYS_ATTESTATION_NONE = QStringLiteral("none"); + +const QString BrowserPasskeys::KPEX_PASSKEY_USERNAME = QStringLiteral("KPEX_PASSKEY_USERNAME"); +const QString BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID = QStringLiteral("KPEX_PASSKEY_GENERATED_USER_ID"); +const QString BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM = QStringLiteral("KPEX_PASSKEY_PRIVATE_KEY_PEM"); +const QString BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY = QStringLiteral("KPEX_PASSKEY_RELYING_PARTY"); +const QString BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE = QStringLiteral("KPEX_PASSKEY_USER_HANDLE"); + +BrowserPasskeys* BrowserPasskeys::instance() +{ + return s_browserPasskeys; +} + +PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJsonObject& publicKeyCredentialOptions, + const QString& origin, + const TestingVariables& testingVariables) +{ + QJsonObject publicKeyCredential; + const auto id = testingVariables.credentialId.isEmpty() ? browserMessageBuilder()->getRandomBytesAsBase64(ID_BYTES) + : testingVariables.credentialId; + + // Extensions + auto extensionObject = publicKeyCredentialOptions["extensions"].toObject(); + const auto extensionData = buildExtensionData(extensionObject); + const auto extensions = browserMessageBuilder()->getBase64FromArray(extensionData); + + // Response + QJsonObject responseObject; + const auto clientData = buildClientDataJson(publicKeyCredentialOptions, origin, false); + const auto attestationObject = buildAttestationObject(publicKeyCredentialOptions, extensions, id, testingVariables); + responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromJson(clientData); + responseObject["attestationObject"] = browserMessageBuilder()->getBase64FromArray(attestationObject.cborEncoded); + + // PublicKeyCredential + publicKeyCredential["authenticatorAttachment"] = QString("platform"); + publicKeyCredential["id"] = id; + publicKeyCredential["response"] = responseObject; + publicKeyCredential["type"] = PUBLIC_KEY; + + return {id, publicKeyCredential, attestationObject.pem}; +} + +QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& publicKeyCredentialRequestOptions, + const QString& origin, + const QString& userId, + const QString& userHandle, + const QString& privateKeyPem) +{ + const auto authenticatorData = buildGetAttestationObject(publicKeyCredentialRequestOptions); + const auto clientData = buildClientDataJson(publicKeyCredentialRequestOptions, origin, true); + const auto clientDataArray = QJsonDocument(clientData).toJson(QJsonDocument::Compact); + const auto signature = buildSignature(authenticatorData, clientDataArray, privateKeyPem); + + QJsonObject responseObject; + responseObject["authenticatorData"] = browserMessageBuilder()->getBase64FromArray(authenticatorData); + responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromArray(clientDataArray); + responseObject["signature"] = browserMessageBuilder()->getBase64FromArray(signature); + responseObject["userHandle"] = userHandle; + + QJsonObject publicKeyCredential; + publicKeyCredential["authenticatorAttachment"] = QString("platform"); + publicKeyCredential["id"] = userId; + publicKeyCredential["response"] = responseObject; + publicKeyCredential["type"] = PUBLIC_KEY; + + return publicKeyCredential; +} + +bool BrowserPasskeys::isUserVerificationValid(const QString& userVerification) const +{ + return QStringList({REQUIREMENT_PREFERRED, REQUIREMENT_REQUIRED, REQUIREMENT_DISCOURAGED}) + .contains(userVerification); +} + +// See https://w3c.github.io/webauthn/#sctn-createCredential for default timeout values when not set in the request +int BrowserPasskeys::getTimeout(const QString& userVerification, int timeout) const +{ + if (timeout == 0) { + return userVerification == REQUIREMENT_DISCOURAGED ? DEFAULT_DISCOURAGED_TIMEOUT : DEFAULT_TIMEOUT; + } + + return timeout; +} + +QStringList BrowserPasskeys::getAllowedCredentialsFromPublicKey(const QJsonObject& publicKey) const +{ + QStringList allowedCredentials; + for (const auto& cred : publicKey["allowCredentials"].toArray()) { + const auto c = cred.toObject(); + const auto id = c["id"].toString(); + + if (c["type"].toString() == PUBLIC_KEY && !id.isEmpty()) { + allowedCredentials << id; + } + } + + return allowedCredentials; +} + +QJsonObject BrowserPasskeys::buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get) +{ + QJsonObject clientData; + clientData["challenge"] = publicKey["challenge"]; + clientData["crossOrigin"] = false; + clientData["origin"] = origin; + clientData["type"] = get ? QString("webauthn.get") : QString("webauthn.create"); + + return clientData; +} + +// https://w3c.github.io/webauthn/#attestation-object +PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey, + const QString& extensions, + const QString& id, + const TestingVariables& testingVariables) +{ + QByteArray result; + + // Create SHA256 hash from rpId + const auto rpIdHash = browserMessageBuilder()->getSha256Hash(publicKey["rp"]["id"].toString()); + result.append(rpIdHash); + + // Use default flags + const auto flags = + setFlagsFromJson(QJsonObject({{"ED", !extensions.isEmpty()}, + {"AT", true}, + {"BS", false}, + {"BE", false}, + {"UV", publicKey["userVerification"].toString() != REQUIREMENT_DISCOURAGED}, + {"UP", true}})); + result.append(flags); + + // Signature counter (not supported, always 0 + const char counter[4] = {0x00, 0x00, 0x00, 0x00}; + result.append(QByteArray::fromRawData(counter, 4)); + + // AAGUID (use the default/non-set) + result.append("\x01\x02\x03\x04\x05\x06\x07\b\x01\x02\x03\x04\x05\x06\x07\b"); + + // Credential length + const char credentialLength[2] = {0x00, 0x20}; + result.append(QByteArray::fromRawData(credentialLength, 2)); + + // Credential Id + result.append(QByteArray::fromBase64( + testingVariables.credentialId.isEmpty() ? id.toUtf8() : testingVariables.credentialId.toUtf8(), + QByteArray::Base64UrlEncoding)); + + // Credential private key + const auto alg = getAlgorithmFromPublicKey(publicKey); + const auto credentialPublicKey = buildCredentialPrivateKey(alg, testingVariables.first, testingVariables.second); + result.append(credentialPublicKey.cborEncoded); + + // Add extension data if available + if (!extensions.isEmpty()) { + result.append(browserMessageBuilder()->getArrayFromBase64(extensions)); + } + + // The final result should be CBOR encoded + return {m_browserCbor.cborEncodeAttestation(result), credentialPublicKey.pem}; +} + +// Build a short version of the attestation object for webauthn.get +QByteArray BrowserPasskeys::buildGetAttestationObject(const QJsonObject& publicKey) +{ + QByteArray result; + + const auto rpIdHash = browserMessageBuilder()->getSha256Hash(publicKey["rpId"].toString()); + result.append(rpIdHash); + + const auto flags = + setFlagsFromJson(QJsonObject({{"ED", false}, + {"AT", false}, + {"BS", false}, + {"BE", false}, + {"UV", publicKey["userVerification"].toString() != REQUIREMENT_DISCOURAGED}, + {"UP", true}})); + result.append(flags); + + // Signature counter (not supported, always 0 + const char counter[4] = {0x00, 0x00, 0x00, 0x00}; + result.append(QByteArray::fromRawData(counter, 4)); + + return result; +} + +// See: https://w3c.github.io/webauthn/#sctn-encoded-credPubKey-examples +PrivateKey +BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFirst, const QString& predefinedSecond) +{ + // Only support -7, P256 (EC), -8 (EdDSA) and -257 (RSA) for now + if (alg != WebAuthnAlgorithms::ES256 && alg != WebAuthnAlgorithms::RS256 && alg != WebAuthnAlgorithms::EDDSA) { + return {}; + } + + QByteArray firstPart; + QByteArray secondPart; + QByteArray pem; + + if (!predefinedFirst.isEmpty() && !predefinedSecond.isEmpty()) { + firstPart = browserMessageBuilder()->getArrayFromBase64(predefinedFirst); + secondPart = browserMessageBuilder()->getArrayFromBase64(predefinedSecond); + } else { + if (alg == WebAuthnAlgorithms::ES256) { + try { + Botan::ECDSA_PrivateKey privateKey(*randomGen()->getRng(), Botan::EC_Group("secp256r1")); + const auto& publicPoint = privateKey.public_point(); + auto x = publicPoint.get_affine_x(); + auto y = publicPoint.get_affine_y(); + firstPart = bigIntToQByteArray(x); + secondPart = bigIntToQByteArray(y); + + auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey); + pem = QByteArray::fromStdString(privateKeyPem); + } catch (std::exception& e) { + qWarning("BrowserWebAuthn::buildCredentialPrivateKey: Could not create EC2 private key: %s", e.what()); + return {}; + } + } else if (alg == WebAuthnAlgorithms::RS256) { + try { + Botan::RSA_PrivateKey privateKey(*randomGen()->getRng(), RSA_BITS, RSA_EXPONENT); + auto modulus = privateKey.get_n(); + auto exponent = privateKey.get_e(); + firstPart = bigIntToQByteArray(modulus); + secondPart = bigIntToQByteArray(exponent); + + auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey); + pem = QByteArray::fromStdString(privateKeyPem); + } catch (std::exception& e) { + qWarning("BrowserWebAuthn::buildCredentialPrivateKey: Could not create RSA private key: %s", e.what()); + return {}; + } + } else if (alg == WebAuthnAlgorithms::EDDSA) { + try { + Botan::Ed25519_PrivateKey key(*randomGen()->getRng()); + auto publicKey = key.get_public_key(); + auto privateKey = key.get_private_key(); + firstPart = browserMessageBuilder()->getQByteArray(publicKey.data(), publicKey.size()); + secondPart = browserMessageBuilder()->getQByteArray(privateKey.data(), privateKey.size()); + + auto privateKeyPem = Botan::PKCS8::PEM_encode(key); + pem = QByteArray::fromStdString(privateKeyPem); + } catch (std::exception& e) { + qWarning("BrowserWebAuthn::buildCredentialPrivateKey: Could not create EdDSA private key: %s", + e.what()); + return {}; + } + } + } + + auto result = m_browserCbor.cborEncodePublicKey(alg, firstPart, secondPart); + return {result, pem}; +} + +QByteArray BrowserPasskeys::buildSignature(const QByteArray& authenticatorData, + const QByteArray& clientData, + const QString& privateKeyPem) +{ + const auto clientDataHash = browserMessageBuilder()->getSha256Hash(clientData); + const auto attToBeSigned = authenticatorData + clientDataHash; + + try { + const auto privateKeyArray = privateKeyPem.toUtf8(); + Botan::DataSource_Memory dataSource(reinterpret_cast(privateKeyArray.constData()), + privateKeyArray.size()); + + const auto key = Botan::PKCS8::load_key(dataSource).release(); + const auto privateKeyBytes = key->private_key_bits(); + const auto algName = key->algo_name(); + const auto algId = key->algorithm_identifier(); + + std::vector rawSignature; + if (algName == "ECDSA") { + Botan::ECDSA_PrivateKey privateKey(algId, privateKeyBytes); +#ifdef WITH_XC_BOTAN3 + Botan::PK_Signer signer( + privateKey, *randomGen()->getRng(), "EMSA1(SHA-256)", Botan::Signature_Format::DerSequence); +#else + Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "EMSA1(SHA-256)", Botan::DER_SEQUENCE); +#endif + + signer.update(reinterpret_cast(attToBeSigned.constData()), attToBeSigned.size()); + rawSignature = signer.signature(*randomGen()->getRng()); + } else if (algName == "RSA") { + Botan::RSA_PrivateKey privateKey(algId, privateKeyBytes); + Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "EMSA3(SHA-256)"); + + signer.update(reinterpret_cast(attToBeSigned.constData()), attToBeSigned.size()); + rawSignature = signer.signature(*randomGen()->getRng()); + } else if (algName == "Ed25519") { + Botan::Ed25519_PrivateKey privateKey(algId, privateKeyBytes); + Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "SHA-512"); + + signer.update(reinterpret_cast(attToBeSigned.constData()), attToBeSigned.size()); + rawSignature = signer.signature(*randomGen()->getRng()); + } else { + qWarning("BrowserWebAuthn::buildSignature: Algorithm not supported"); + return {}; + } + + auto signature = QByteArray(reinterpret_cast(rawSignature.data()), rawSignature.size()); + return signature; + } catch (std::exception& e) { + qWarning("BrowserWebAuthn::buildSignature: Could not sign key: %s", e.what()); + return {}; + } +} + +QByteArray BrowserPasskeys::buildExtensionData(QJsonObject& extensionObject) const +{ + // Only supports "credProps" and "uvm" for now + const QStringList allowedKeys = {"credProps", "uvm"}; + + // Remove unsupported keys + for (const auto& key : extensionObject.keys()) { + if (!allowedKeys.contains(key)) { + extensionObject.remove(key); + } + } + + auto extensionData = m_browserCbor.cborEncodeExtensionData(extensionObject); + if (!extensionData.isEmpty()) { + return extensionData; + } + + return {}; +} + +// Parse authentication data byte array to JSON +// See: https://www.w3.org/TR/webauthn/images/fido-attestation-structures.svg +// And: https://w3c.github.io/webauthn/#attested-credential-data +QJsonObject BrowserPasskeys::parseAuthData(const QByteArray& authData) const +{ + auto rpIdHash = authData.mid(AuthDataOffsets::RPIDHASH, HASH_BYTES); + auto flags = authData.mid(AuthDataOffsets::FLAGS, 1); + auto counter = authData.mid(AuthDataOffsets::SIGNATURE_COUNTER, 4); + auto aaGuid = authData.mid(AuthDataOffsets::AAGUID, 16); + auto credentialLength = authData.mid(AuthDataOffsets::CREDENTIAL_LENGTH, 2); + auto credLen = qFromBigEndian(credentialLength.data()); + auto credentialId = authData.mid(AuthDataOffsets::CREDENTIAL_ID, credLen); + auto publicKey = authData.mid(AuthDataOffsets::CREDENTIAL_ID + credLen); + + QJsonObject credentialDataJson({{"aaguid", browserMessageBuilder()->getBase64FromArray(aaGuid)}, + {"credentialId", browserMessageBuilder()->getBase64FromArray(credentialId)}, + {"publicKey", m_browserCbor.getJsonFromCborData(publicKey)}}); + + QJsonObject result({{"credentialData", credentialDataJson}, + {"flags", parseFlags(flags)}, + {"rpIdHash", browserMessageBuilder()->getBase64FromArray(rpIdHash)}, + {"signatureCounter", QJsonValue(qFromBigEndian(counter))}}); + + return result; +} + +// See: https://w3c.github.io/webauthn/#table-authData +QJsonObject BrowserPasskeys::parseFlags(const QByteArray& flags) const +{ + if (flags.isEmpty()) { + return {}; + } + + auto flagsByte = static_cast(flags[0]); + std::bitset<8> flagBits(flagsByte); + + return QJsonObject({{"ED", flagBits.test(AuthenticatorFlags::ED)}, + {"AT", flagBits.test(AuthenticatorFlags::AT)}, + {"BS", flagBits.test(AuthenticatorFlags::BS)}, + {"BE", flagBits.test(AuthenticatorFlags::BE)}, + {"UV", flagBits.test(AuthenticatorFlags::UV)}, + {"UP", flagBits.test(AuthenticatorFlags::UP)}}); +} + +char BrowserPasskeys::setFlagsFromJson(const QJsonObject& flags) const +{ + if (flags.isEmpty()) { + return 0; + } + + char flagBits = 0x00; + auto setFlag = [&](const char* key, unsigned char bit) { + if (flags[key].toBool()) { + flagBits |= 1 << bit; + } + }; + + setFlag("ED", AuthenticatorFlags::ED); + setFlag("AT", AuthenticatorFlags::AT); + setFlag("BS", AuthenticatorFlags::BS); + setFlag("BE", AuthenticatorFlags::BE); + setFlag("UV", AuthenticatorFlags::UV); + setFlag("UP", AuthenticatorFlags::UP); + + return flagBits; +} + +// Returns the first supported algorithm from the pubKeyCredParams list (only support ES256, RS256 and EdDSA for now) +WebAuthnAlgorithms BrowserPasskeys::getAlgorithmFromPublicKey(const QJsonObject& publicKey) const +{ + const auto pubKeyCredParams = publicKey["pubKeyCredParams"].toArray(); + if (!pubKeyCredParams.isEmpty()) { + const auto alg = pubKeyCredParams.first()["alg"].toInt(); + if (alg == WebAuthnAlgorithms::ES256 || alg == WebAuthnAlgorithms::RS256 || alg == WebAuthnAlgorithms::EDDSA) { + return static_cast(alg); + } + } + + return WebAuthnAlgorithms::ES256; +} + +QByteArray BrowserPasskeys::bigIntToQByteArray(Botan::BigInt& bigInt) const +{ + auto hexString = QString(bigInt.to_hex_string().c_str()); + + // Botan might add a leading "0x" to the hex string depending on the version. Remove it. + if (hexString.startsWith(("0x"))) { + hexString.remove(0, 2); + } + + return browserMessageBuilder()->getArrayFromHexString(hexString); +} diff --git a/src/browser/BrowserPasskeys.h b/src/browser/BrowserPasskeys.h new file mode 100644 index 0000000000..530029b079 --- /dev/null +++ b/src/browser/BrowserPasskeys.h @@ -0,0 +1,143 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#ifndef BROWSERPASSKEYS_H +#define BROWSERPASSKEYS_H + +#include "BrowserCbor.h" +#include +#include + +#include +#include + +#define ID_BYTES 32 +#define HASH_BYTES 32 +#define DEFAULT_TIMEOUT 300000 +#define DEFAULT_DISCOURAGED_TIMEOUT 120000 +#define RSA_BITS 2048 +#define RSA_EXPONENT 65537 + +enum AuthDataOffsets : int +{ + RPIDHASH = 0, + FLAGS = 32, + SIGNATURE_COUNTER = 33, + AAGUID = 37, + CREDENTIAL_LENGTH = 53, + CREDENTIAL_ID = 55 +}; + +enum AuthenticatorFlags +{ + UP = 0, + UV = 2, + BE = 3, + BS = 4, + AT = 6, + ED = 7 +}; + +struct PublicKeyCredential +{ + QString id; + QJsonObject response; + QByteArray key; +}; + +struct PrivateKey +{ + QByteArray cborEncoded; + QByteArray pem; +}; + +// Predefined variables used for testing the class +struct TestingVariables +{ + QString credentialId; + QString first; + QString second; +}; + +class BrowserPasskeys : public QObject +{ + Q_OBJECT + +public: + explicit BrowserPasskeys() = default; + ~BrowserPasskeys() = default; + static BrowserPasskeys* instance(); + + PublicKeyCredential buildRegisterPublicKeyCredential(const QJsonObject& publicKeyCredentialOptions, + const QString& origin, + const TestingVariables& predefinedVariables = {}); + QJsonObject buildGetPublicKeyCredential(const QJsonObject& publicKeyCredentialRequestOptions, + const QString& origin, + const QString& userId, + const QString& userHandle, + const QString& privateKeyPem); + bool isUserVerificationValid(const QString& userVerification) const; + int getTimeout(const QString& userVerification, int timeout) const; + QStringList getAllowedCredentialsFromPublicKey(const QJsonObject& publicKey) const; + + static const QString PUBLIC_KEY; + static const QString REQUIREMENT_DISCOURAGED; + static const QString REQUIREMENT_PREFERRED; + static const QString REQUIREMENT_REQUIRED; + + static const QString PASSKEYS_ATTESTATION_DIRECT; + static const QString PASSKEYS_ATTESTATION_NONE; + + static const QString KPEX_PASSKEY_USERNAME; + static const QString KPEX_PASSKEY_GENERATED_USER_ID; + static const QString KPEX_PASSKEY_PRIVATE_KEY_PEM; + static const QString KPEX_PASSKEY_RELYING_PARTY; + static const QString KPEX_PASSKEY_USER_HANDLE; + +private: + QJsonObject buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get); + PrivateKey buildAttestationObject(const QJsonObject& publicKey, + const QString& extensions, + const QString& id, + const TestingVariables& predefinedVariables = {}); + QByteArray buildGetAttestationObject(const QJsonObject& publicKey); + PrivateKey buildCredentialPrivateKey(int alg, + const QString& predefinedFirst = QString(), + const QString& predefinedSecond = QString()); + QByteArray + buildSignature(const QByteArray& authenticatorData, const QByteArray& clientData, const QString& privateKeyPem); + QByteArray buildExtensionData(QJsonObject& extensionObject) const; + QJsonObject parseAuthData(const QByteArray& authData) const; + QJsonObject parseFlags(const QByteArray& flags) const; + char setFlagsFromJson(const QJsonObject& flags) const; + WebAuthnAlgorithms getAlgorithmFromPublicKey(const QJsonObject& publicKey) const; + QByteArray bigIntToQByteArray(Botan::BigInt& bigInt) const; + + Q_DISABLE_COPY(BrowserPasskeys); + + friend class TestPasskeys; + +private: + BrowserCbor m_browserCbor; +}; + +static inline BrowserPasskeys* browserPasskeys() +{ + return BrowserPasskeys::instance(); +} + +#endif // BROWSERPASSKEYS_H diff --git a/src/browser/BrowserPasskeysConfirmationDialog.cpp b/src/browser/BrowserPasskeysConfirmationDialog.cpp new file mode 100644 index 0000000000..a612f324e0 --- /dev/null +++ b/src/browser/BrowserPasskeysConfirmationDialog.cpp @@ -0,0 +1,150 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#include "BrowserPasskeysConfirmationDialog.h" +#include "ui_BrowserPasskeysConfirmationDialog.h" + +#include "core/Entry.h" +#include +#include + +#define STEP 1000 + +BrowserPasskeysConfirmationDialog::BrowserPasskeysConfirmationDialog(QWidget* parent) + : QDialog(parent) + , m_ui(new Ui::BrowserPasskeysConfirmationDialog()) + , m_passkeyUpdated(false) +{ + setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint); + + m_ui->setupUi(this); + m_ui->updateButton->setVisible(false); + + connect(m_ui->credentialsTable, SIGNAL(cellDoubleClicked(int, int)), this, SLOT(accept())); + connect(m_ui->confirmButton, SIGNAL(clicked()), SLOT(accept())); + connect(m_ui->cancelButton, SIGNAL(clicked()), SLOT(reject())); + connect(m_ui->updateButton, SIGNAL(clicked()), SLOT(updatePasskey())); + + connect(&m_timer, SIGNAL(timeout()), this, SLOT(updateProgressBar())); + connect(&m_timer, SIGNAL(timeout()), this, SLOT(updateSeconds())); +} + +BrowserPasskeysConfirmationDialog::~BrowserPasskeysConfirmationDialog() +{ +} + +void BrowserPasskeysConfirmationDialog::registerCredential(const QString& username, + const QString& siteId, + const QList& existingEntries, + int timeout) +{ + m_ui->confirmationLabel->setText( + tr("Do you want to register Passkey credentials for:\n%1 (%2)?").arg(username, siteId)); + + if (!existingEntries.isEmpty()) { + m_ui->confirmationLabel->setText(tr("Existing Passkey found.\nDo you want to register a new Passkey for:\n%1 " + "(%2)?\nSelect the existing Passkey and press Update to replace it.") + .arg(username, siteId)); + m_ui->updateButton->setVisible(true); + m_ui->confirmButton->setText(tr("Register new")); + updateEntriesToTable(existingEntries); + } else { + m_ui->confirmButton->setText(tr("Register")); + m_ui->credentialsTable->setVisible(false); + } + + startCounter(timeout); +} + +void BrowserPasskeysConfirmationDialog::authenticateCredential(const QList& entries, + const QString& origin, + int timeout) +{ + m_ui->confirmationLabel->setText(tr("Authenticate Passkey credentials for:%1?").arg(origin)); + updateEntriesToTable(entries); + startCounter(timeout); +} + +Entry* BrowserPasskeysConfirmationDialog::getSelectedEntry() const +{ + auto selectedItem = m_ui->credentialsTable->currentItem(); + return selectedItem ? m_entries[selectedItem->row()] : nullptr; +} + +bool BrowserPasskeysConfirmationDialog::isPasskeyUpdated() const +{ + return m_passkeyUpdated; +} + +void BrowserPasskeysConfirmationDialog::updatePasskey() +{ + m_passkeyUpdated = true; + emit accept(); +} + +void BrowserPasskeysConfirmationDialog::updateProgressBar() +{ + if (m_counter < m_ui->progressBar->maximum()) { + m_ui->progressBar->setValue(m_ui->progressBar->maximum() - m_counter); + m_ui->progressBar->update(); + } else { + emit reject(); + } +} + +void BrowserPasskeysConfirmationDialog::updateSeconds() +{ + ++m_counter; + updateTimeoutLabel(); +} + +void BrowserPasskeysConfirmationDialog::startCounter(int timeout) +{ + m_counter = 0; + m_ui->progressBar->setMaximum(timeout / STEP); + updateProgressBar(); + updateTimeoutLabel(); + m_timer.start(STEP); +} + +void BrowserPasskeysConfirmationDialog::updateTimeoutLabel() +{ + m_ui->timeoutLabel->setText(tr("Timeout in %n seconds...", "", m_ui->progressBar->maximum() - m_counter)); +} + +void BrowserPasskeysConfirmationDialog::updateEntriesToTable(const QList& entries) +{ + m_entries = entries; + m_ui->credentialsTable->setRowCount(entries.count()); + m_ui->credentialsTable->setColumnCount(1); + + int row = 0; + for (const auto& entry : entries) { + auto item = new QTableWidgetItem(); + item->setText(entry->title() + " - " + entry->username()); + m_ui->credentialsTable->setItem(row, 0, item); + + if (row == 0) { + item->setSelected(true); + } + + ++row; + } + + m_ui->credentialsTable->resizeColumnsToContents(); + m_ui->credentialsTable->horizontalHeader()->setStretchLastSection(true); +} \ No newline at end of file diff --git a/src/browser/BrowserPasskeysConfirmationDialog.h b/src/browser/BrowserPasskeysConfirmationDialog.h new file mode 100644 index 0000000000..189c066a52 --- /dev/null +++ b/src/browser/BrowserPasskeysConfirmationDialog.h @@ -0,0 +1,66 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#ifndef KEEPASSXC_BROWSERPASSKEYSCONFIRMATIONDIALOG_H +#define KEEPASSXC_BROWSERPASSKEYSCONFIRMATIONDIALOG_H + +#include +#include +#include + +class Entry; + +namespace Ui +{ + class BrowserPasskeysConfirmationDialog; +} + +class BrowserPasskeysConfirmationDialog : public QDialog +{ + Q_OBJECT + +public: + explicit BrowserPasskeysConfirmationDialog(QWidget* parent = nullptr); + ~BrowserPasskeysConfirmationDialog() override; + + void registerCredential(const QString& username, + const QString& siteId, + const QList& existingEntries, + int timeout); + void authenticateCredential(const QList& entries, const QString& origin, int timeout); + Entry* getSelectedEntry() const; + bool isPasskeyUpdated() const; + +private slots: + void updatePasskey(); + void updateProgressBar(); + void updateSeconds(); + +private: + void startCounter(int timeout); + void updateTimeoutLabel(); + void updateEntriesToTable(const QList& entries); + +private: + QScopedPointer m_ui; + QList m_entries; + QTimer m_timer; + int m_counter; + bool m_passkeyUpdated; +}; + +#endif // KEEPASSXC_BROWSERPASSKEYSCONFIRMATIONDIALOG_H diff --git a/src/browser/BrowserPasskeysConfirmationDialog.ui b/src/browser/BrowserPasskeysConfirmationDialog.ui new file mode 100755 index 0000000000..70077a6490 --- /dev/null +++ b/src/browser/BrowserPasskeysConfirmationDialog.ui @@ -0,0 +1,115 @@ + + + BrowserPasskeysConfirmationDialog + + + + 0 + 0 + 405 + 243 + + + + KeePassXC: Passkey credentials + + + + + + + true + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + QAbstractItemView::NoEditTriggers + + + false + + + QAbstractItemView::SingleSelection + + + false + + + false + + + false + + + + + + + 0 + + + false + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + true + + + + + + + Update + + + + + + + Authenticate + + + + + + + + + + diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index cff27209a7..749f60502b 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -29,6 +29,10 @@ #include "gui/MainWindow.h" #include "gui/MessageBox.h" #include "gui/osutils/OSUtils.h" +#ifdef WITH_XC_BROWSER_PASSKEYS +#include "BrowserPasskeys.h" +#include "BrowserPasskeysConfirmationDialog.h" +#endif #ifdef Q_OS_MACOS #include "gui/osutils/macutils/MacUtils.h" #endif @@ -48,6 +52,9 @@ const QString BrowserService::KEEPASSXCBROWSER_NAME = QStringLiteral("KeePassXC- const QString BrowserService::KEEPASSXCBROWSER_OLD_NAME = QStringLiteral("keepassxc-browser Settings"); static const QString KEEPASSXCBROWSER_GROUP_NAME = QStringLiteral("KeePassXC-Browser Passwords"); static int KEEPASSXCBROWSER_DEFAULT_ICON = 1; +#ifdef WITH_XC_BROWSER_PASSKEYS +static int KEEPASSXCBROWSER_PASSKEY_ICON = 13; +#endif // These are for the settings and password conversion static const QString KEEPASSHTTP_NAME = QStringLiteral("KeePassHttp Settings"); static const QString KEEPASSHTTP_GROUP_NAME = QStringLiteral("KeePassHttp Passwords"); @@ -607,6 +614,177 @@ QString BrowserService::getKey(const QString& id) return db->metadata()->customData()->value(CustomData::BrowserKeyPrefix + id); } +#ifdef WITH_XC_BROWSER_PASSKEYS +// Passkey registration +QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& publicKey, + const QString& origin, + const StringPairList& keyList) +{ + auto db = selectedDatabase(); + if (!db) { + return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED); + } + + const auto userJson = publicKey["user"].toObject(); + const auto username = userJson["name"].toString(); + const auto userHandle = userJson["id"].toString(); + const auto rpId = publicKey["rp"]["id"].toString(); + const auto rpName = publicKey["rp"]["name"].toString(); + const auto timeoutValue = publicKey["timeout"].toInt(); + const auto excludeCredentials = publicKey["excludeCredentials"].toArray(); + const auto attestation = publicKey["attestation"].toString(); + + // Only support these two for now + if (attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_NONE + && attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT) { + return getPasskeyError(ERROR_PASSKEYS_ATTESTATION_NOT_SUPPORTED); + } + + const auto authenticatorSelection = publicKey["authenticatorSelection"].toObject(); + const auto userVerification = authenticatorSelection["userVerification"].toString(); + if (!browserPasskeys()->isUserVerificationValid(userVerification)) { + return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION); + } + + if (!excludeCredentials.isEmpty() && isPasskeyCredentialExcluded(excludeCredentials, origin, keyList)) { + return getPasskeyError(ERROR_PASSKEYS_CREDENTIAL_IS_EXCLUDED); + } + + const auto existingEntries = getPasskeyEntries(rpId, keyList); + const auto timeout = browserPasskeys()->getTimeout(userVerification, timeoutValue); + + raiseWindow(); + BrowserPasskeysConfirmationDialog confirmDialog; + confirmDialog.registerCredential(username, rpId, existingEntries, timeout); + + auto dialogResult = confirmDialog.exec(); + if (dialogResult == QDialog::Accepted) { + const auto publicKeyCredentials = browserPasskeys()->buildRegisterPublicKeyCredential(publicKey, origin); + + if (confirmDialog.isPasskeyUpdated()) { + addPasskeyToEntry(confirmDialog.getSelectedEntry(), + rpId, + rpName, + username, + publicKeyCredentials.id, + userHandle, + publicKeyCredentials.key); + } else { + addPasskeyToGroup( + nullptr, origin, rpId, rpName, username, publicKeyCredentials.id, userHandle, publicKeyCredentials.key); + } + + hideWindow(); + return publicKeyCredentials.response; + } + + hideWindow(); + return getPasskeyError(ERROR_PASSKEYS_REQUEST_CANCELED); +} + +// Passkey authentication +QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& publicKey, + const QString& origin, + const StringPairList& keyList) +{ + auto db = selectedDatabase(); + if (!db) { + return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED); + } + + const auto userVerification = publicKey["userVerification"].toString(); + if (!browserPasskeys()->isUserVerificationValid(userVerification)) { + return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION); + } + + // Parse "allowCredentials" + const auto rpId = publicKey["rpId"].toString(); + const auto entries = getPasskeyAllowedEntries(publicKey, rpId, keyList); + if (entries.isEmpty()) { + return getPasskeyError(ERROR_KEEPASS_NO_LOGINS_FOUND); + } + + // With single entry, if no verification is needed, return directly + if (entries.count() == 1 && userVerification == BrowserPasskeys::REQUIREMENT_DISCOURAGED) { + return getPublicKeyCredentialFromEntry(entries.first(), publicKey, origin); + } + + const auto timeout = publicKey["timeout"].toInt(); + + raiseWindow(); + BrowserPasskeysConfirmationDialog confirmDialog; + confirmDialog.authenticateCredential(entries, origin, timeout); + auto dialogResult = confirmDialog.exec(); + if (dialogResult == QDialog::Accepted) { + hideWindow(); + const auto selectedEntry = confirmDialog.getSelectedEntry(); + return getPublicKeyCredentialFromEntry(selectedEntry, publicKey, origin); + } + + hideWindow(); + return getPasskeyError(ERROR_PASSKEYS_REQUEST_CANCELED); +} + +void BrowserService::addPasskeyToGroup(Group* group, + const QString& url, + const QString& rpId, + const QString& rpName, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey) +{ + // If no group provided, use the default browser group of the selected database + if (!group) { + auto db = selectedDatabase(); + if (!db) { + return; + } + group = getDefaultEntryGroup(db); + } + + auto* entry = new Entry(); + entry->setUuid(QUuid::createUuid()); + entry->setGroup(group); + entry->setTitle(QString("%1 (%2)").arg(rpName, tr("Passkey"))); + entry->setUsername(username); + entry->setUrl(url); + entry->setIcon(KEEPASSXCBROWSER_PASSKEY_ICON); + + addPasskeyToEntry(entry, rpId, rpName, username, userId, userHandle, privateKey); + + // Remove blank entry history + entry->removeHistoryItems(entry->historyItems()); +} + +void BrowserService::addPasskeyToEntry(Entry* entry, + const QString& rpId, + const QString& rpName, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey) +{ + // Reserved for future use + Q_UNUSED(rpName) + + Q_ASSERT(entry); + if (!entry) { + return; + } + + entry->beginUpdate(); + + entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_USERNAME, username); + entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID, userId, true); + entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM, privateKey, true); + entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY, rpId); + entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE, userHandle, true); + + entry->endUpdate(); +} +#endif + void BrowserService::addEntry(const EntryParameters& entryParameters, const QString& group, const QString& groupUuid, @@ -621,7 +799,7 @@ void BrowserService::addEntry(const EntryParameters& entryParameters, auto* entry = new Entry(); entry->setUuid(QUuid::createUuid()); - entry->setTitle(QUrl(entryParameters.siteUrl).host()); + entry->setTitle(entryParameters.title.isEmpty() ? QUrl(entryParameters.siteUrl).host() : entryParameters.title); entry->setUrl(entryParameters.siteUrl); entry->setIcon(KEEPASSXCBROWSER_DEFAULT_ICON); entry->setUsername(entryParameters.login); @@ -667,7 +845,7 @@ bool BrowserService::updateEntry(const EntryParameters& entryParameters, const Q return false; } - Entry* entry = db->rootGroup()->findEntryByUuid(Tools::hexToUuid(uuid)); + auto entry = db->rootGroup()->findEntryByUuid(Tools::hexToUuid(uuid)); if (!entry) { // If entry is not found for update, add a new one to the selected database addEntry(entryParameters, "", "", false, db); @@ -746,8 +924,10 @@ bool BrowserService::deleteEntry(const QString& uuid) return true; } -QList -BrowserService::searchEntries(const QSharedPointer& db, const QString& siteUrl, const QString& formUrl) +QList BrowserService::searchEntries(const QSharedPointer& db, + const QString& siteUrl, + const QString& formUrl, + bool passkey) { QList entries; auto* rootGroup = db->rootGroup(); @@ -771,9 +951,16 @@ BrowserService::searchEntries(const QSharedPointer& db, const QString& continue; } - if (!shouldIncludeEntry(entry, siteUrl, formUrl, omitWwwSubdomain)) { + if (!passkey && !shouldIncludeEntry(entry, siteUrl, formUrl, omitWwwSubdomain)) { + continue; + } + +#ifdef WITH_XC_BROWSER_PASSKEYS + // With Passkeys, check for the Relying Party instead of URL + if (passkey && entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) != siteUrl) { continue; } +#endif // Additional URL check may have already inserted the entry to the list if (!entries.contains(entry)) { @@ -785,8 +972,10 @@ BrowserService::searchEntries(const QSharedPointer& db, const QString& return entries; } -QList -BrowserService::searchEntries(const QString& siteUrl, const QString& formUrl, const StringPairList& keyList) +QList BrowserService::searchEntries(const QString& siteUrl, + const QString& formUrl, + const StringPairList& keyList, + bool passkey) { // Check if database is connected with KeePassXC-Browser auto databaseConnected = [&](const QSharedPointer& db) { @@ -820,7 +1009,7 @@ BrowserService::searchEntries(const QString& siteUrl, const QString& formUrl, co QList entries; do { for (const auto& db : databases) { - entries << searchEntries(db, siteUrl, formUrl); + entries << searchEntries(db, siteUrl, formUrl, passkey); } } while (entries.isEmpty() && removeFirstDomain(hostname)); @@ -1094,6 +1283,74 @@ bool BrowserService::shouldIncludeEntry(Entry* entry, return false; } +#ifdef WITH_XC_BROWSER_PASSKEYS +// Returns all Passkey entries for the current Relying Party +QList BrowserService::getPasskeyEntries(const QString& rpId, const StringPairList& keyList) +{ + 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) { + entries << entry; + } + } + + return entries; +} + +// Get all entries for the site that are allowed by the server +QList BrowserService::getPasskeyAllowedEntries(const QJsonObject& publicKey, + const QString& rpId, + const StringPairList& keyList) +{ + QList entries; + const auto allowedCredentials = browserPasskeys()->getAllowedCredentialsFromPublicKey(publicKey); + + for (const auto& entry : getPasskeyEntries(rpId, keyList)) { + // If allowedCredentials.isEmpty() check if entry contains an extra attribute for user handle. + // If that is found, the entry should be allowed. + // See: https://w3c.github.io/webauthn/#dom-authenticatorassertionresponse-userhandle + if (allowedCredentials.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID)) + || (allowedCredentials.isEmpty() + && entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE))) { + entries << entry; + } + } + + return entries; +} + +QJsonObject +BrowserService::getPublicKeyCredentialFromEntry(const Entry* entry, const QJsonObject& publicKey, const QString& origin) +{ + const auto privateKeyPem = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM); + const auto userId = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID); + const auto userHandle = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE); + return browserPasskeys()->buildGetPublicKeyCredential(publicKey, origin, userId, userHandle, privateKeyPem); +} + +// Checks if the same user ID already exists for the current site +bool BrowserService::isPasskeyCredentialExcluded(const QJsonArray& excludeCredentials, + const QString& origin, + const StringPairList& keyList) +{ + QStringList allIds; + for (const auto& cred : excludeCredentials) { + allIds << cred["id"].toString(); + } + + const auto passkeyEntries = getPasskeyEntries(origin, keyList); + return std::any_of(passkeyEntries.begin(), passkeyEntries.end(), [&](const auto& entry) { + return allIds.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID)); + }); +} + +QJsonObject BrowserService::getPasskeyError(int errorCode) const +{ + return QJsonObject({{"errorCode", errorCode}}); +} +#endif + bool BrowserService::handleURL(const QString& entryUrl, const QString& siteUrl, const QString& formUrl, diff --git a/src/browser/BrowserService.h b/src/browser/BrowserService.h index ca3579e024..01daaee852 100644 --- a/src/browser/BrowserService.h +++ b/src/browser/BrowserService.h @@ -17,10 +17,11 @@ * along with this program. If not, see . */ -#ifndef BROWSERSERVICE_H -#define BROWSERSERVICE_H +#ifndef KEEPASSXC_BROWSERSERVICE_H +#define KEEPASSXC_BROWSERSERVICE_H #include "BrowserAccessControlDialog.h" +#include "config-keepassx.h" #include "core/Entry.h" #include "gui/PasswordGeneratorWidget.h" @@ -45,6 +46,7 @@ struct KeyPairMessage struct EntryParameters { QString dbid; + QString title; QString login; QString password; QString realm; @@ -82,7 +84,30 @@ class BrowserService : public QObject QString getCurrentTotp(const QString& uuid); void showPasswordGenerator(const KeyPairMessage& keyPairMessage); bool isPasswordGeneratorRequested() const; - + bool isUrlIdentical(const QString& first, const QString& second) const; + QSharedPointer selectedDatabase(); +#ifdef WITH_XC_BROWSER_PASSKEYS + QJsonObject + showPasskeysRegisterPrompt(const QJsonObject& publicKey, const QString& origin, const StringPairList& keyList); + QJsonObject showPasskeysAuthenticationPrompt(const QJsonObject& publicKey, + const QString& origin, + const StringPairList& keyList); + void addPasskeyToGroup(Group* group, + const QString& url, + const QString& rpId, + const QString& rpName, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey); + void addPasskeyToEntry(Entry* entry, + const QString& rpId, + const QString& rpName, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey); +#endif void addEntry(const EntryParameters& entryParameters, const QString& group, const QString& groupUuid, @@ -129,8 +154,12 @@ private slots: Hidden }; - QList searchEntries(const QSharedPointer& db, const QString& siteUrl, const QString& formUrl); - QList searchEntries(const QString& siteUrl, const QString& formUrl, const StringPairList& keyList); + QList searchEntries(const QSharedPointer& db, + const QString& siteUrl, + const QString& formUrl, + bool passkey = false); + QList + searchEntries(const QString& siteUrl, const QString& formUrl, const StringPairList& keyList, bool passkey = false); QList sortEntries(QList& entries, const QString& siteUrl, const QString& formUrl); QList confirmEntries(QList& entriesToConfirm, const EntryParameters& entryParameters, @@ -148,12 +177,22 @@ private slots: bool removeFirstDomain(QString& hostname); bool shouldIncludeEntry(Entry* entry, const QString& url, const QString& submitUrl, const bool omitWwwSubdomain = false); +#ifdef WITH_XC_BROWSER_PASSKEYS + QList getPasskeyEntries(const QString& rpId, const StringPairList& keyList); + QList + getPasskeyAllowedEntries(const QJsonObject& publicKey, const QString& rpId, const StringPairList& keyList); + QJsonObject + getPublicKeyCredentialFromEntry(const Entry* entry, const QJsonObject& publicKey, const QString& origin); + bool isPasskeyCredentialExcluded(const QJsonArray& excludeCredentials, + const QString& origin, + const StringPairList& keyList); + QJsonObject getPasskeyError(int errorCode) const; +#endif bool handleURL(const QString& entryUrl, const QString& siteUrl, const QString& formUrl, const bool omitWwwSubdomain = false); QSharedPointer getDatabase(); - QSharedPointer selectedDatabase(); QString getDatabaseRootUuid(); QString getDatabaseRecycleBinUuid(); bool checkLegacySettings(QSharedPointer db); @@ -176,6 +215,9 @@ private slots: Q_DISABLE_COPY(BrowserService); friend class TestBrowser; +#ifdef WITH_XC_BROWSER_PASSKEYS + friend class TestPasskeys; +#endif }; static inline BrowserService* browserService() @@ -183,4 +225,4 @@ static inline BrowserService* browserService() return BrowserService::instance(); } -#endif // BROWSERSERVICE_H +#endif // KEEPASSXC_BROWSERSERVICE_H diff --git a/src/browser/BrowserSettings.h b/src/browser/BrowserSettings.h index b61c8d40cf..cecf1cba7c 100644 --- a/src/browser/BrowserSettings.h +++ b/src/browser/BrowserSettings.h @@ -17,8 +17,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERSETTINGS_H -#define BROWSERSETTINGS_H +#ifndef KEEPASSXC_BROWSERSETTINGS_H +#define KEEPASSXC_BROWSERSETTINGS_H #include "NativeMessageInstaller.h" @@ -92,4 +92,4 @@ inline BrowserSettings* browserSettings() return BrowserSettings::instance(); } -#endif // BROWSERSETTINGS_H +#endif // KEEPASSXC_BROWSERSETTINGS_H diff --git a/src/browser/CMakeLists.txt b/src/browser/CMakeLists.txt index ef70080be1..656b5a5288 100755 --- a/src/browser/CMakeLists.txt +++ b/src/browser/CMakeLists.txt @@ -1,5 +1,4 @@ # Copyright (C) 2023 KeePassXC Team -# Copyright (C) 2017 Sami Vänttinen # # 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 @@ -30,8 +29,14 @@ if(WITH_XC_BROWSER) BrowserSettings.cpp BrowserShared.cpp CustomTableWidget.cpp - NativeMessageInstaller.cpp - ) + NativeMessageInstaller.cpp) + + if(WITH_XC_BROWSER_PASSKEYS) + list(APPEND keepassxcbrowser_SOURCES + BrowserCbor.cpp + BrowserPasskeys.cpp + BrowserPasskeysConfirmationDialog.cpp) + endif() add_library(keepassxcbrowser STATIC ${keepassxcbrowser_SOURCES}) target_link_libraries(keepassxcbrowser Qt5::Core Qt5::Concurrent Qt5::Widgets Qt5::Network ${BOTAN_LIBRARIES}) diff --git a/src/config-keepassx.h.cmake b/src/config-keepassx.h.cmake index 840ba0d5e2..1b45315f68 100644 --- a/src/config-keepassx.h.cmake +++ b/src/config-keepassx.h.cmake @@ -15,6 +15,7 @@ #cmakedefine WITH_XC_AUTOTYPE #cmakedefine WITH_XC_NETWORKING #cmakedefine WITH_XC_BROWSER +#cmakedefine WITH_XC_BROWSER_PASSKEYS #cmakedefine WITH_XC_YUBIKEY #cmakedefine WITH_XC_SSHAGENT #cmakedefine WITH_XC_KEESHARE diff --git a/src/core/EntryAttributes.cpp b/src/core/EntryAttributes.cpp index 6dfc8adbaa..13207e1688 100644 --- a/src/core/EntryAttributes.cpp +++ b/src/core/EntryAttributes.cpp @@ -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 @@ -17,7 +17,6 @@ */ #include "EntryAttributes.h" - #include "core/Global.h" #include @@ -35,6 +34,7 @@ const QString EntryAttributes::SearchInGroupName = "SearchIn"; const QString EntryAttributes::SearchTextGroupName = "SearchText"; const QString EntryAttributes::RememberCmdExecAttr = "_EXEC_CMD"; +const QString EntryAttributes::PasskeyAttribute = "KPEX_PASSKEY"; EntryAttributes::EntryAttributes(QObject* parent) : ModifiableObject(parent) @@ -57,7 +57,7 @@ QList EntryAttributes::customKeys() const QList customKeys; const QList keyList = keys(); for (const QString& key : keyList) { - if (!isDefaultAttribute(key)) { + if (!isDefaultAttribute(key) && !isPasskeyAttribute(key)) { customKeys.append(key); } } @@ -321,3 +321,8 @@ bool EntryAttributes::isDefaultAttribute(const QString& key) { return DefaultAttributes.contains(key); } + +bool EntryAttributes::isPasskeyAttribute(const QString& key) +{ + return key.startsWith(PasskeyAttribute); +} diff --git a/src/core/EntryAttributes.h b/src/core/EntryAttributes.h index a9fcf7f60c..2e7f8c05c0 100644 --- a/src/core/EntryAttributes.h +++ b/src/core/EntryAttributes.h @@ -61,7 +61,9 @@ class EntryAttributes : public ModifiableObject static const QString NotesKey; static const QStringList DefaultAttributes; static const QString RememberCmdExecAttr; + static const QString PasskeyAttribute; static bool isDefaultAttribute(const QString& key); + static bool isPasskeyAttribute(const QString& key); static const QString WantedFieldGroupName; static const QString SearchInGroupName; diff --git a/src/core/Group.cpp b/src/core/Group.cpp index 9240d0c098..6498331788 100644 --- a/src/core/Group.cpp +++ b/src/core/Group.cpp @@ -131,6 +131,19 @@ QString Group::tags() const return m_data.tags; } +QString Group::fullPath() const +{ + QString fullPath; + auto group = this; + + do { + fullPath.insert(0, "/" + group->name()); + group = group->parentGroup(); + } while (group); + + return fullPath; +} + int Group::iconNumber() const { return m_data.iconNumber; diff --git a/src/core/Group.h b/src/core/Group.h index 8e04a6062b..ac28f73e0e 100644 --- a/src/core/Group.h +++ b/src/core/Group.h @@ -85,6 +85,7 @@ class Group : public ModifiableObject QString name() const; QString notes() const; QString tags() const; + QString fullPath() const; int iconNumber() const; const QUuid& iconUuid() const; const TimeInfo& timeInfo() const; diff --git a/src/core/Tools.cpp b/src/core/Tools.cpp index 824f9ff924..3b621fa005 100644 --- a/src/core/Tools.cpp +++ b/src/core/Tools.cpp @@ -94,6 +94,9 @@ namespace Tools #ifdef WITH_XC_BROWSER extensions += "\n- " + QObject::tr("Browser Integration"); #endif +#ifdef WITH_XC_BROWSER_PASSKEYS + extensions += "\n- " + QObject::tr("Passkeys"); +#endif #ifdef WITH_XC_SSHAGENT extensions += "\n- " + QObject::tr("SSH Agent"); #endif @@ -408,6 +411,21 @@ namespace Tools return subbed; } + QString cleanFilename(QString filename) + { + // Remove forward slash from title on all platforms + filename.replace("/", "_"); +#ifdef Q_OS_WIN + // Remove invalid characters on Windows + filename.remove(QRegularExpression("[:*?\"<>|]")); +#endif +#ifdef Q_OS_MACOS + // MacOS does not support colons + filename.replace(":", ""); +#endif + return filename.trimmed(); + } + QVariantMap qo2qvm(const QObject* object, const QStringList& ignoredProperties) { QVariantMap result; diff --git a/src/core/Tools.h b/src/core/Tools.h index 3df2ca0085..4316a44e84 100644 --- a/src/core/Tools.h +++ b/src/core/Tools.h @@ -43,6 +43,7 @@ namespace Tools bool isValidUuid(const QString& uuidStr); QString envSubstitute(const QString& filepath, QProcessEnvironment environment = QProcessEnvironment::systemEnvironment()); + QString cleanFilename(QString filename); /** * Escapes all characters in regex such that they do not receive any special treatment when used diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index 4e422ebb2f..4454266339 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.cpp @@ -556,6 +556,18 @@ void DatabaseTabWidget::showDatabaseSettings() currentDatabaseWidget()->switchToDatabaseSettings(); } +#ifdef WITH_XC_BROWSER_PASSKEYS +void DatabaseTabWidget::showPasskeys() +{ + currentDatabaseWidget()->switchToPasskeys(); +} + +void DatabaseTabWidget::importPasskey() +{ + currentDatabaseWidget()->switchToImportPasskey(); +} +#endif + bool DatabaseTabWidget::isModified(int index) const { if (count() == 0) { diff --git a/src/gui/DatabaseTabWidget.h b/src/gui/DatabaseTabWidget.h index 58f5408e3a..6b4b121af7 100644 --- a/src/gui/DatabaseTabWidget.h +++ b/src/gui/DatabaseTabWidget.h @@ -19,6 +19,7 @@ #define KEEPASSX_DATABASETABWIDGET_H #include "DatabaseOpenDialog.h" +#include "config-keepassx.h" #include "gui/MessageWidget.h" #include @@ -84,6 +85,10 @@ public slots: void showDatabaseSecurity(); void showDatabaseReports(); void showDatabaseSettings(); +#ifdef WITH_XC_BROWSER_PASSKEYS + void showPasskeys(); + void importPasskey(); +#endif void performGlobalAutoType(const QString& search); void performBrowserUnlock(); diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index 530c5e799a..d42292d348 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -64,6 +64,10 @@ #include "sshagent/SSHAgent.h" #endif +#ifdef WITH_XC_BROWSER_PASSKEYS +#include "gui/passkeys/PasskeyImporter.h" +#endif + DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) : QStackedWidget(parent) , m_db(std::move(db)) @@ -1396,6 +1400,20 @@ void DatabaseWidget::switchToDatabaseSecurity() m_databaseSettingDialog->showDatabaseKeySettings(); } +#ifdef WITH_XC_BROWSER_PASSKEYS +void DatabaseWidget::switchToPasskeys() +{ + switchToDatabaseReports(); + m_reportsDialog->activatePasskeysPage(); +} + +void DatabaseWidget::switchToImportPasskey() +{ + PasskeyImporter passkeyImporter; + passkeyImporter.importPasskey(m_db); +} +#endif + void DatabaseWidget::performUnlockDatabase(const QString& password, const QString& keyfile) { if (password.isEmpty() && keyfile.isEmpty()) { diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index 676bf63a2d..4f79ebd2f8 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -212,6 +212,10 @@ public slots: void switchToDatabaseSecurity(); void switchToDatabaseReports(); void switchToDatabaseSettings(); +#ifdef WITH_XC_BROWSER_PASSKEYS + void switchToPasskeys(); + void switchToImportPasskey(); +#endif void switchToOpenDatabase(); void switchToOpenDatabase(const QString& filePath); void switchToOpenDatabase(const QString& filePath, const QString& password, const QString& keyFile); diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index c03bd43005..27dada98d6 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -438,6 +438,11 @@ MainWindow::MainWindow() m_ui->actionKeyboardShortcuts->setIcon(icons()->icon("keyboard-shortcuts")); m_ui->actionCheckForUpdates->setIcon(icons()->icon("system-software-update")); +#ifdef WITH_XC_BROWSER_PASSKEYS + m_ui->actionPasskeys->setIcon(icons()->icon("passkey")); + m_ui->actionImportPasskey->setIcon(icons()->icon("document-import")); +#endif + m_actionMultiplexer.connect( SIGNAL(currentModeChanged(DatabaseWidget::Mode)), this, SLOT(setMenuActionState(DatabaseWidget::Mode))); m_actionMultiplexer.connect(SIGNAL(groupChanged()), this, SLOT(setMenuActionState())); @@ -483,6 +488,10 @@ MainWindow::MainWindow() connect(m_ui->actionDatabaseSecurity, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showDatabaseSecurity())); connect(m_ui->actionReports, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showDatabaseReports())); connect(m_ui->actionDatabaseSettings, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showDatabaseSettings())); +#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())); +#endif connect(m_ui->actionImportCsv, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importCsv())); connect(m_ui->actionImportKeePass1, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importKeePass1Database())); connect(m_ui->actionImportOpVault, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importOpVaultDatabase())); @@ -977,6 +986,10 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionExportHtml->setEnabled(true); m_ui->actionExportXML->setEnabled(true); m_ui->actionDatabaseMerge->setEnabled(m_ui->tabWidget->currentIndex() != -1); +#ifdef WITH_XC_BROWSER_PASSKEYS + m_ui->actionPasskeys->setEnabled(true); + m_ui->actionImportPasskey->setEnabled(true); +#endif #ifdef WITH_XC_SSHAGENT bool singleEntryHasSshKey = singleEntrySelected && sshAgent()->isEnabled() && dbWidget->currentEntryHasSshKey(); @@ -1044,6 +1057,14 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionEntryRemoveFromAgent->setVisible(false); m_ui->actionGroupEmptyRecycleBin->setVisible(false); +#ifdef WITH_XC_BROWSER_PASSKEYS + m_ui->actionPasskeys->setEnabled(false); + m_ui->actionImportPasskey->setEnabled(false); +#else + m_ui->actionPasskeys->setVisible(false); + m_ui->actionImportPasskey->setVisible(false); +#endif + m_searchWidgetAction->setEnabled(false); break; } diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui index cbaea45a3c..e6fbfc22ca 100644 --- a/src/gui/MainWindow.ui +++ b/src/gui/MainWindow.ui @@ -262,6 +262,9 @@ + + + @@ -620,6 +623,34 @@ QAction::NoRole + + + false + + + Passkeys… + + + Passkeys + + + QAction::NoRole + + + + + false + + + Import Passkey + + + Import Passkey + + + QAction::NoRole + + false diff --git a/src/gui/passkeys/PasskeyExportDialog.cpp b/src/gui/passkeys/PasskeyExportDialog.cpp new file mode 100644 index 0000000000..84e984df60 --- /dev/null +++ b/src/gui/passkeys/PasskeyExportDialog.cpp @@ -0,0 +1,91 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#include "PasskeyExportDialog.h" +#include "ui_PasskeyExportDialog.h" + +#include "core/Entry.h" +#include "gui/FileDialog.h" + +PasskeyExportDialog::PasskeyExportDialog(QWidget* parent) + : QDialog(parent) + , m_ui(new Ui::PasskeyExportDialog()) +{ + setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint); + + m_ui->setupUi(this); + + connect(m_ui->exportButton, SIGNAL(clicked()), SLOT(accept())); + connect(m_ui->cancelButton, SIGNAL(clicked()), SLOT(reject())); + connect(m_ui->itemsTable->selectionModel(), + SIGNAL(selectionChanged(QItemSelection, QItemSelection)), + this, + SLOT(selectionChanged())); +} + +PasskeyExportDialog::~PasskeyExportDialog() +{ +} + +void PasskeyExportDialog::setEntries(const QList& items) +{ + m_ui->itemsTable->setRowCount(items.count()); + m_ui->itemsTable->setColumnCount(1); + + int row = 0; + for (const auto& entry : items) { + auto item = new QTableWidgetItem(); + item->setText(entry->title() + " - " + entry->username()); + item->setData(Qt::UserRole, row); + item->setFlags(item->flags() | Qt::ItemIsSelectable); + m_ui->itemsTable->setItem(row, 0, item); + + ++row; + } + m_ui->itemsTable->resizeColumnsToContents(); + m_ui->itemsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + m_ui->itemsTable->selectAll(); + m_ui->exportButton->setFocus(); +} + +QList PasskeyExportDialog::getSelectedItems() const +{ + QList selected; + for (int i = 0; i < m_ui->itemsTable->rowCount(); ++i) { + auto item = m_ui->itemsTable->item(i, 0); + if (item->isSelected()) { + selected.append(item); + } + } + return selected; +} + +void PasskeyExportDialog::selectionChanged() +{ + auto indexes = m_ui->itemsTable->selectionModel()->selectedIndexes(); + m_ui->exportButton->setEnabled(!indexes.isEmpty()); + + if (indexes.isEmpty()) { + m_ui->exportButton->clearFocus(); + m_ui->cancelButton->setFocus(); + } +} + +QString PasskeyExportDialog::selectExportFolder() +{ + return fileDialog()->getExistingDirectory(this, tr("Export to folder"), FileDialog::getLastDir("passkey")); +} diff --git a/src/gui/passkeys/PasskeyExportDialog.h b/src/gui/passkeys/PasskeyExportDialog.h new file mode 100644 index 0000000000..7104583ad8 --- /dev/null +++ b/src/gui/passkeys/PasskeyExportDialog.h @@ -0,0 +1,52 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#ifndef KEEPASSXC_PASSKEYEXPORTDIALOG_H +#define KEEPASSXC_PASSKEYEXPORTDIALOG_H + +#include +#include + +class Entry; + +namespace Ui +{ + class PasskeyExportDialog; +} + +class PasskeyExportDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PasskeyExportDialog(QWidget* parent = nullptr); + ~PasskeyExportDialog() override; + + void setEntries(const QList& items); + QList getSelectedItems() const; + QString selectExportFolder(); + +private slots: + void selectionChanged(); + +private: + QScopedPointer m_ui; + QList m_entriesToConfirm; + QList m_allowedEntries; +}; + +#endif // KEEPASSXC_PASSKEYEXPORTDIALOG_H diff --git a/src/gui/passkeys/PasskeyExportDialog.ui b/src/gui/passkeys/PasskeyExportDialog.ui new file mode 100755 index 0000000000..4d4d7f5dbc --- /dev/null +++ b/src/gui/passkeys/PasskeyExportDialog.ui @@ -0,0 +1,121 @@ + + + PasskeyExportDialog + + + + 0 + 0 + 540 + 320 + + + + KeePassXC - Passkey Export + + + + + + + 75 + true + + + + Export the following Passkey entries. + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 75 + true + + + + Filenames will be generated with title and .passkey file extension. + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + false + + + false + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Export entries + + + Export Selected + + + true + + + true + + + + + + + Cancel + + + true + + + + + + + + + + diff --git a/src/gui/passkeys/PasskeyExporter.cpp b/src/gui/passkeys/PasskeyExporter.cpp new file mode 100644 index 0000000000..d546f1c059 --- /dev/null +++ b/src/gui/passkeys/PasskeyExporter.cpp @@ -0,0 +1,94 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#include "PasskeyExporter.h" +#include "PasskeyExportDialog.h" + +#include "browser/BrowserPasskeys.h" +#include "core/Entry.h" +#include "core/Tools.h" +#include "gui/MessageBox.h" +#include +#include + +void PasskeyExporter::showExportDialog(const QList& items) +{ + if (items.isEmpty()) { + return; + } + + PasskeyExportDialog passkeyExportDialog; + passkeyExportDialog.setEntries(items); + auto ret = passkeyExportDialog.exec(); + + if (ret == QDialog::Accepted) { + // Select folder + auto folder = passkeyExportDialog.selectExportFolder(); + if (folder.isEmpty()) { + return; + } + + const auto selectedItems = passkeyExportDialog.getSelectedItems(); + for (const auto& item : selectedItems) { + auto entry = items[item->row()]; + exportSelectedEntry(entry, folder); + } + } +} + +/** + * Creates an export file for a Passkey credential + * + * File contents: + * + * + * + * + */ +void PasskeyExporter::exportSelectedEntry(const Entry* entry, const QString& folder) +{ + const auto fullPath = QString("%1/%2.passkey").arg(folder, Tools::cleanFilename(entry->title())); + if (QFile::exists(fullPath)) { + auto dialogResult = MessageBox::warning(nullptr, + tr("KeePassXC: Passkey Export"), + tr("File \"%1.passkey\" already exists.\n" + "Do you want to overwrite it?\n") + .arg(entry->title()), + MessageBox::Yes | MessageBox::No); + + if (dialogResult != MessageBox::Yes) { + return; + } + } + + QFile passkeyFile(fullPath); + if (!passkeyFile.open(QIODevice::WriteOnly)) { + MessageBox::information( + nullptr, tr("Cannot open file"), tr("Cannot open file \"%1\" for writing.").arg(fullPath)); + return; + } + + QTextStream fileStream(&passkeyFile); + fileStream << entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) << "\n"; + fileStream << entry->url() << "\n"; + fileStream << entry->username() << "\n"; + fileStream << entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID) << "\n"; + fileStream << entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE) << "\n"; + fileStream << entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM); + + passkeyFile.close(); +} diff --git a/src/gui/passkeys/PasskeyExporter.h b/src/gui/passkeys/PasskeyExporter.h new file mode 100644 index 0000000000..4214cbea33 --- /dev/null +++ b/src/gui/passkeys/PasskeyExporter.h @@ -0,0 +1,39 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#ifndef KEEPASSXC_PASSKEYEXPORTER_H +#define KEEPASSXC_PASSKEYEXPORTER_H + +#include +#include + +class Entry; + +class PasskeyExporter : public QObject +{ + Q_OBJECT + +public: + explicit PasskeyExporter() = default; + + void showExportDialog(const QList& items); + +private: + void exportSelectedEntry(const Entry* entry, const QString& folder); +}; + +#endif // KEEPASSXC_PASSKEYEXPORTER_H diff --git a/src/gui/passkeys/PasskeyImportDialog.cpp b/src/gui/passkeys/PasskeyImportDialog.cpp new file mode 100644 index 0000000000..347529ad71 --- /dev/null +++ b/src/gui/passkeys/PasskeyImportDialog.cpp @@ -0,0 +1,121 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#include "PasskeyImportDialog.h" +#include "ui_PasskeyImportDialog.h" + +#include "browser/BrowserService.h" +#include "core/Metadata.h" +#include "gui/MainWindow.h" +#include +#include + +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(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->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) +{ + m_ui->urlLabel->setText(QString("%1: %2").arg(tr("URL"), url)); + m_ui->usernameLabel->setText(QString("%1: %2").arg(tr("Username"), username)); + m_ui->selectDatabaseLabel->setText(QString("%1: %2").arg(tr("Database"), getDatabaseName(database))); + m_ui->selectGroupLabel->setText(QString("%1:").arg(tr("Group"))); + + addGroups(database); + + auto openDatabaseCount = 0; + for (auto dbWidget : getMainWindow()->getOpenDatabases()) { + if (dbWidget && !dbWidget->isLocked()) { + openDatabaseCount++; + } + } + m_ui->selectDatabaseButton->setEnabled(openDatabaseCount > 1); +} + +QSharedPointer PasskeyImportDialog::getSelectedDatabase() +{ + return m_selectedDatabase; +} + +QUuid PasskeyImportDialog::getSelectedGroupUuid() +{ + return m_selectedGroupUuid; +} + +bool PasskeyImportDialog::useDefaultGroup() +{ + return m_useDefaultGroup; +} + +QString PasskeyImportDialog::getDatabaseName(const QSharedPointer& database) const +{ + return QFileInfo(database->filePath()).fileName(); +} + +void PasskeyImportDialog::addGroups(const QSharedPointer& database) +{ + m_ui->selectGroupComboBox->clear(); + for (const auto& group : database->rootGroup()->groupsRecursive(true)) { + if (!group || group->isRecycled() || group == database->metadata()->recycleBin()) { + continue; + } + + m_ui->selectGroupComboBox->addItem(group->fullPath(), group->uuid()); + } +} + +void PasskeyImportDialog::selectDatabase() +{ + auto selectedDatabase = browserService()->selectedDatabase(); + if (!selectedDatabase) { + return; + } + + m_selectedDatabase = selectedDatabase; + m_ui->selectDatabaseLabel->setText(QString("%1: %2").arg(tr("Database"), getDatabaseName(m_selectedDatabase))); + + addGroups(m_selectedDatabase); +} + +void PasskeyImportDialog::changeGroup(int index) +{ + m_selectedGroupUuid = m_ui->selectGroupComboBox->itemData(index).value(); +} + +void PasskeyImportDialog::useDefaultGroupChanged() +{ + m_ui->selectGroupComboBox->setEnabled(!m_ui->useDefaultGroupCheckbox->isChecked()); + m_useDefaultGroup = m_ui->useDefaultGroupCheckbox->isChecked(); +} diff --git a/src/gui/passkeys/PasskeyImportDialog.h b/src/gui/passkeys/PasskeyImportDialog.h new file mode 100644 index 0000000000..7b316721e7 --- /dev/null +++ b/src/gui/passkeys/PasskeyImportDialog.h @@ -0,0 +1,60 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#ifndef KEEPASSXC_PASSKEYIMPORTDIALOG_H +#define KEEPASSXC_PASSKEYIMPORTDIALOG_H + +#include "core/Database.h" +#include "core/Group.h" +#include +#include + +namespace Ui +{ + class PasskeyImportDialog; +} + +class PasskeyImportDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PasskeyImportDialog(QWidget* parent = nullptr); + ~PasskeyImportDialog() override; + + void setInfo(const QString& url, const QString& username, const QSharedPointer& database); + QSharedPointer getSelectedDatabase(); + QUuid getSelectedGroupUuid(); + bool useDefaultGroup(); + +private: + QString getDatabaseName(const QSharedPointer& database) const; + void addGroups(const QSharedPointer& database); + +private slots: + void selectDatabase(); + void changeGroup(int index); + void useDefaultGroupChanged(); + +private: + QScopedPointer m_ui; + QSharedPointer m_selectedDatabase; + QUuid m_selectedGroupUuid; + bool m_useDefaultGroup; +}; + +#endif // KEEPASSXC_PASSKEYIMPORTDIALOG_H diff --git a/src/gui/passkeys/PasskeyImportDialog.ui b/src/gui/passkeys/PasskeyImportDialog.ui new file mode 100755 index 0000000000..ffc80d1419 --- /dev/null +++ b/src/gui/passkeys/PasskeyImportDialog.ui @@ -0,0 +1,174 @@ + + + PasskeyImportDialog + + + + 0 + 0 + 405 + 227 + + + + KeePassXC - Passkey Import + + + + + + + + + true + + + + Do you want to import the Passkey? + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + URL: %1 + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + Username: %1 + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + Use default group (Imported Passkeys) + + + false + + + + + + + + + Group + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Database + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Select Database + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Import Passkey + + + Import + + + true + + + true + + + + + + + Cancel + + + true + + + + + + + + + + diff --git a/src/gui/passkeys/PasskeyImporter.cpp b/src/gui/passkeys/PasskeyImporter.cpp new file mode 100644 index 0000000000..a0231e150d --- /dev/null +++ b/src/gui/passkeys/PasskeyImporter.cpp @@ -0,0 +1,135 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#include "PasskeyImporter.h" +#include "PasskeyImportDialog.h" +#include "browser/BrowserPasskeys.h" +#include "browser/BrowserService.h" +#include "core/Entry.h" +#include "core/Group.h" +#include "gui/FileDialog.h" +#include "gui/MessageBox.h" +#include +#include +#include + +static const QString IMPORTED_PASSKEYS_GROUP = QStringLiteral("Imported Passkeys"); + +void PasskeyImporter::importPasskey(QSharedPointer& database) +{ + auto filter = QString("%1 (*.passkey);;%2 (*)").arg(tr("Passkey file"), tr("All files")); + auto fileName = + fileDialog()->getOpenFileName(nullptr, tr("Open Passkey file"), FileDialog::getLastDir("passkey"), filter); + if (fileName.isEmpty()) { + return; + } + + FileDialog::saveLastDir("passkey", fileName, true); + + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + MessageBox::information( + nullptr, tr("Cannot open file"), tr("Cannot open file \"%1\" for reading.").arg(fileName)); + return; + } + + importSelectedFile(file, database); +} + +void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer& database) +{ + QTextStream fileStream(&file); + const auto relyingParty = fileStream.readLine(); + const auto url = fileStream.readLine(); + const auto username = fileStream.readLine(); + const auto password = fileStream.readLine(); + const auto userHandle = fileStream.readLine(); + + QString privateKey; + while (!fileStream.atEnd()) { + privateKey.append(fileStream.readLine() + "\n"); + } + + if (relyingParty.isEmpty() || username.isEmpty() || password.isEmpty() || userHandle.isEmpty() + || privateKey.isEmpty()) { + MessageBox::information(nullptr, + tr("Cannot import Passkey"), + tr("Cannot import Passkey file \"%1\". Data is missing.").arg(file.fileName())); + } else if (!privateKey.startsWith("-----BEGIN PRIVATE KEY-----") + || !privateKey.trimmed().endsWith("-----END PRIVATE KEY-----")) { + MessageBox::information( + nullptr, + 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, password, userHandle, privateKey); + } +} + +void PasskeyImporter::showImportDialog(QSharedPointer& database, + const QString& url, + const QString& relyingParty, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey) +{ + PasskeyImportDialog passkeyImportDialog; + passkeyImportDialog.setInfo(relyingParty, username, database); + + auto ret = passkeyImportDialog.exec(); + if (ret != QDialog::Accepted) { + return; + } + + auto db = passkeyImportDialog.getSelectedDatabase(); + if (!db) { + db = database; + } + + // Group settings. Use default group "Imported Passkeys" if user did not select a specific one. + Group* group = nullptr; + + // Attempt to use the selected group + if (!passkeyImportDialog.useDefaultGroup()) { + auto groupUuid = passkeyImportDialog.getSelectedGroupUuid(); + group = db->rootGroup()->findGroupByUuid(groupUuid); + } + + // Use default group if requested or if the selected group does not exist + if (!group) { + group = getDefaultGroup(db); + } + + browserService()->addPasskeyToGroup( + group, url, relyingParty, relyingParty, username, userId, userHandle, privateKey); +} + +Group* PasskeyImporter::getDefaultGroup(QSharedPointer& database) +{ + auto defaultGroup = database->rootGroup()->findGroupByPath(IMPORTED_PASSKEYS_GROUP); + + // Create the default group if it does not exist + if (!defaultGroup) { + defaultGroup = new Group(); + defaultGroup->setName(IMPORTED_PASSKEYS_GROUP); + defaultGroup->setUuid(QUuid::createUuid()); + defaultGroup->setParent(database->rootGroup()); + } + + return defaultGroup; +} diff --git a/src/gui/passkeys/PasskeyImporter.h b/src/gui/passkeys/PasskeyImporter.h new file mode 100644 index 0000000000..c1523cbc18 --- /dev/null +++ b/src/gui/passkeys/PasskeyImporter.h @@ -0,0 +1,48 @@ +/* + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#ifndef KEEPASSXC_PASSKEYIMPORTER_H +#define KEEPASSXC_PASSKEYIMPORTER_H + +#include "core/Database.h" +#include +#include + +class Entry; + +class PasskeyImporter : public QObject +{ + Q_OBJECT + +public: + explicit PasskeyImporter() = default; + + void importPasskey(QSharedPointer& database); + +private: + void importSelectedFile(QFile& file, QSharedPointer& database); + void showImportDialog(QSharedPointer& database, + const QString& url, + const QString& relyingParty, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey); + Group* getDefaultGroup(QSharedPointer& database); +}; + +#endif // KEEPASSXC_PASSKEYIMPORTER_H diff --git a/src/gui/reports/ReportsDialog.cpp b/src/gui/reports/ReportsDialog.cpp index 22a7425d57..bdbeca8a9e 100644 --- a/src/gui/reports/ReportsDialog.cpp +++ b/src/gui/reports/ReportsDialog.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 @@ -25,6 +25,10 @@ #include "ReportsPageBrowserStatistics.h" #include "ReportsWidgetBrowserStatistics.h" #endif +#ifdef WITH_XC_BROWSER_PASSKEYS +#include "ReportsPagePasskeys.h" +#include "ReportsWidgetPasskeys.h" +#endif #include "ReportsWidgetHealthcheck.h" #include "ReportsWidgetHibp.h" @@ -61,6 +65,9 @@ ReportsDialog::ReportsDialog(QWidget* parent) , m_statPage(new ReportsPageStatistics()) #ifdef WITH_XC_BROWSER , m_browserStatPage(new ReportsPageBrowserStatistics()) +#endif +#ifdef WITH_XC_BROWSER_PASSKEYS + , m_passkeysPage(new ReportsPagePasskeys()) #endif , m_editEntryWidget(new EditEntryWidget(this)) { @@ -68,10 +75,13 @@ ReportsDialog::ReportsDialog(QWidget* parent) connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(reject())); addPage(m_statPage); + addPage(m_healthPage); +#ifdef WITH_XC_BROWSER_PASSKEYS + addPage(m_passkeysPage); +#endif #ifdef WITH_XC_BROWSER addPage(m_browserStatPage); #endif - addPage(m_healthPage); addPage(m_hibpPage); m_ui->stackedWidget->setCurrentIndex(0); @@ -88,6 +98,10 @@ ReportsDialog::ReportsDialog(QWidget* parent) connect(m_browserStatPage->m_browserWidget, SIGNAL(entryActivated(Entry*)), SLOT(entryActivationSignalReceived(Entry*))); +#endif +#ifdef WITH_XC_BROWSER_PASSKEYS + connect( + m_passkeysPage->m_passkeysWidget, SIGNAL(entryActivated(Entry*)), SLOT(entryActivationSignalReceived(Entry*))); #endif connect(m_editEntryWidget, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool))); } @@ -114,6 +128,15 @@ void ReportsDialog::addPage(QSharedPointer page) m_ui->categoryList->setCurrentCategory(category); } +#ifdef WITH_XC_BROWSER_PASSKEYS +void ReportsDialog::activatePasskeysPage() +{ + m_ui->stackedWidget->setCurrentWidget(m_passkeysPage->m_passkeysWidget); + auto index = m_ui->stackedWidget->currentIndex(); + m_ui->categoryList->setCurrentCategory(index); +} +#endif + void ReportsDialog::reject() { emit editFinished(true); @@ -148,6 +171,11 @@ void ReportsDialog::switchToMainView(bool previousDialogAccepted) if (m_sender == m_browserStatPage->m_browserWidget) { m_browserStatPage->m_browserWidget->calculateBrowserStatistics(); } +#endif +#ifdef WITH_XC_BROWSER_PASSKEYS + if (m_sender == m_passkeysPage->m_passkeysWidget) { + m_passkeysPage->m_passkeysWidget->updateEntries(); + } #endif } diff --git a/src/gui/reports/ReportsDialog.h b/src/gui/reports/ReportsDialog.h index 915c20eb99..6400787b44 100644 --- a/src/gui/reports/ReportsDialog.h +++ b/src/gui/reports/ReportsDialog.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 @@ -32,6 +32,9 @@ class ReportsPageStatistics; #ifdef WITH_XC_BROWSER class ReportsPageBrowserStatistics; #endif +#ifdef WITH_XC_BROWSER_PASSKEYS +class ReportsPagePasskeys; +#endif namespace Ui { @@ -60,6 +63,9 @@ class ReportsDialog : public DialogyWidget void load(const QSharedPointer& db); void addPage(QSharedPointer page); +#ifdef WITH_XC_BROWSER_PASSKEYS + void activatePasskeysPage(); +#endif signals: void editFinished(bool accepted); @@ -77,6 +83,9 @@ private slots: const QSharedPointer m_statPage; #ifdef WITH_XC_BROWSER const QSharedPointer m_browserStatPage; +#endif +#ifdef WITH_XC_BROWSER_PASSKEYS + const QSharedPointer m_passkeysPage; #endif QPointer m_editEntryWidget; QWidget* m_sender = nullptr; diff --git a/src/gui/reports/ReportsPagePasskeys.cpp b/src/gui/reports/ReportsPagePasskeys.cpp new file mode 100644 index 0000000000..01af722664 --- /dev/null +++ b/src/gui/reports/ReportsPagePasskeys.cpp @@ -0,0 +1,52 @@ +/* + * 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 + * 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 . + */ + +#include "ReportsPagePasskeys.h" +#include "ReportsWidgetPasskeys.h" +#include "gui/Icons.h" + +ReportsPagePasskeys::ReportsPagePasskeys() + : m_passkeysWidget(new ReportsWidgetPasskeys()) +{ +} + +QString ReportsPagePasskeys::name() +{ + return QObject::tr("Passkeys"); +} + +QIcon ReportsPagePasskeys::icon() +{ + return icons()->icon("passkey"); +} + +QWidget* ReportsPagePasskeys::createWidget() +{ + return m_passkeysWidget; +} + +void ReportsPagePasskeys::loadSettings(QWidget* widget, QSharedPointer db) +{ + const auto settingsWidget = reinterpret_cast(widget); + settingsWidget->loadSettings(db); +} + +void ReportsPagePasskeys::saveSettings(QWidget* widget) +{ + const auto settingsWidget = reinterpret_cast(widget); + settingsWidget->saveSettings(); +} diff --git a/src/gui/reports/ReportsPagePasskeys.h b/src/gui/reports/ReportsPagePasskeys.h new file mode 100644 index 0000000000..8be0aa7d08 --- /dev/null +++ b/src/gui/reports/ReportsPagePasskeys.h @@ -0,0 +1,40 @@ +/* + * 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 + * 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 . + */ + +#ifndef KEEPASSXC_REPORTSPAGEPASSKEYS_H +#define KEEPASSXC_REPORTSPAGEPASSKEYS_H + +#include "ReportsDialog.h" +#include "ReportsWidgetPasskeys.h" + +class ReportsWidgetBrowserStatistics; + +class ReportsPagePasskeys : public IReportsPage +{ +public: + ReportsWidgetPasskeys* m_passkeysWidget; + + ReportsPagePasskeys(); + + QString name() override; + QIcon icon() override; + QWidget* createWidget() override; + void loadSettings(QWidget* widget, QSharedPointer db) override; + void saveSettings(QWidget* widget) override; +}; + +#endif // KEEPASSXC_REPORTSPAGEPASSKEYS_H diff --git a/src/gui/reports/ReportsWidgetBrowserStatistics.cpp b/src/gui/reports/ReportsWidgetBrowserStatistics.cpp index 1a3ad5d7ef..a7724a7e41 100644 --- a/src/gui/reports/ReportsWidgetBrowserStatistics.cpp +++ b/src/gui/reports/ReportsWidgetBrowserStatistics.cpp @@ -112,8 +112,8 @@ ReportsWidgetBrowserStatistics::ReportsWidgetBrowserStatistics(QWidget* parent) connect( m_ui->browserStatisticsTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); connect(m_ui->showEntriesWithUrlOnlyCheckBox, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); - connect(m_ui->showConnectedOnlyCheckBox, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); - connect(m_ui->excludeExpired, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); + connect(m_ui->showAllowDenyCheckBox, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); + connect(m_ui->showExpired, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); new QShortcut(Qt::Key_Delete, this, SLOT(deleteSelectedEntries())); } @@ -144,6 +144,9 @@ void ReportsWidgetBrowserStatistics::addStatisticsRow(bool hasUrls, if (excluded) { title.append(tr(" (Excluded)")); } + if (entry->isExpired()) { + title.append(tr(" (Expired)")); + } auto row = QList(); row << new QStandardItem(Icons::entryIconPixmap(entry), title); @@ -196,16 +199,15 @@ void ReportsWidgetBrowserStatistics::calculateBrowserStatistics() const QScopedPointer browserStatistics( AsyncTask::runAndWaitForFuture([this] { return new BrowserStatistics(m_db); })); - const auto showExcluded = m_ui->showConnectedOnlyCheckBox->isChecked(); + const auto showExpired = m_ui->showExpired->isChecked(); const auto showEntriesWithUrlOnly = m_ui->showEntriesWithUrlOnlyCheckBox->isChecked(); - const auto showOnlyEntriesWithSettings = m_ui->showConnectedOnlyCheckBox->isChecked(); + const auto showOnlyEntriesWithSettings = m_ui->showAllowDenyCheckBox->isChecked(); // Display the entries m_rowToEntry.clear(); for (const auto& item : browserStatistics->items()) { - auto excluded = item->exclude || (item->entry->isExpired() && m_ui->excludeExpired->isChecked()); - if (excluded && !showExcluded) { - // Exclude this entry from the report + // Check if the entry should be displayed + if (!showExpired && item->entry->isExpired()) { continue; } diff --git a/src/gui/reports/ReportsWidgetBrowserStatistics.ui b/src/gui/reports/ReportsWidgetBrowserStatistics.ui index 4236da6e17..9f631bef65 100644 --- a/src/gui/reports/ReportsWidgetBrowserStatistics.ui +++ b/src/gui/reports/ReportsWidgetBrowserStatistics.ui @@ -10,7 +10,7 @@ 379 - + 0 @@ -55,23 +55,26 @@ - + - Exclude expired entries from the report + Only show entries that have a URL + + + true - + - Show only entries which have URL set + Only show entries that have been explicitly allowed or denied - + - Show only entries which have browser settings in custom data + Show expired entries @@ -91,9 +94,8 @@ browserStatisticsTableView - excludeExpired showEntriesWithUrlOnlyCheckBox - showConnectedOnlyCheckBox + showAllowDenyCheckBox diff --git a/src/gui/reports/ReportsWidgetHealthcheck.cpp b/src/gui/reports/ReportsWidgetHealthcheck.cpp index 19da39e02e..53cf150bcd 100644 --- a/src/gui/reports/ReportsWidgetHealthcheck.cpp +++ b/src/gui/reports/ReportsWidgetHealthcheck.cpp @@ -64,16 +64,16 @@ namespace return m_items; } - bool anyKnownBad() const + bool anyExcludedEntries() const { - return m_anyKnownBad; + return m_anyExcludedEntries; } private: QSharedPointer m_db; HealthChecker m_checker; QList> m_items; - bool m_anyKnownBad = false; + bool m_anyExcludedEntries = false; }; class ReportSortProxyModel : public QSortFilterProxyModel @@ -121,7 +121,7 @@ Health::Health(QSharedPointer db) // Evaluate this entry const auto item = QSharedPointer(new Item(group, entry, m_checker.evaluate(entry))); if (item->exclude) { - m_anyKnownBad = true; + m_anyExcludedEntries = true; } // Add entry if its password isn't at least "good" @@ -152,8 +152,8 @@ ReportsWidgetHealthcheck::ReportsWidgetHealthcheck(QWidget* parent) connect(m_ui->healthcheckTableView, SIGNAL(customContextMenuRequested(QPoint)), SLOT(customMenuRequested(QPoint))); connect(m_ui->healthcheckTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); - connect(m_ui->showKnownBadCheckBox, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth())); - connect(m_ui->excludeExpired, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth())); + connect(m_ui->showExcluded, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth())); + connect(m_ui->showExpired, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth())); new QShortcut(Qt::Key_Delete, this, SLOT(deleteSelectedEntries())); } @@ -163,7 +163,7 @@ ReportsWidgetHealthcheck::~ReportsWidgetHealthcheck() = default; void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer health, Group* group, Entry* entry, - bool knownBad) + bool excluded) { QString descr, tip; QColor qualityColor; @@ -195,9 +195,12 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer healt } auto title = entry->title(); - if (knownBad) { + if (excluded) { title.append(tr(" (Excluded)")); } + if (entry->isExpired()) { + title.append(tr(" (Expired)")); + } auto row = QList(); row << new QStandardItem(descr); @@ -215,7 +218,7 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer healt // Set tooltips row[0]->setToolTip(tip); - if (knownBad) { + if (excluded) { row[1]->setToolTip(tr("This entry is being excluded from reports")); } row[4]->setToolTip(health->scoreDetails()); @@ -255,15 +258,12 @@ void ReportsWidgetHealthcheck::calculateHealth() // Perform the health check const QScopedPointer health(AsyncTask::runAndWaitForFuture([this] { return new Health(m_db); })); - // Display entries that are marked as "known bad"? - const auto showExcluded = m_ui->showKnownBadCheckBox->isChecked(); - // Display the entries m_rowToEntry.clear(); for (const auto& item : health->items()) { - auto excluded = item->exclude || (item->entry->isExpired() && m_ui->excludeExpired->isChecked()); - if (excluded && !showExcluded) { - // Exclude this entry from the report + // Check if the entry should be displayed + if ((!m_ui->showExcluded->isChecked() && item->exclude) + || (!m_ui->showExpired->isChecked() && item->entry->isExpired())) { continue; } @@ -283,13 +283,8 @@ void ReportsWidgetHealthcheck::calculateHealth() m_ui->healthcheckTableView->resizeColumnsToContents(); m_ui->healthcheckTableView->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); - // Show the "show known bad entries" checkbox if there's any known - // bad entry in the database. - if (health->anyKnownBad()) { - m_ui->showKnownBadCheckBox->show(); - } else { - m_ui->showKnownBadCheckBox->hide(); - } + // Only show the "show excluded" checkbox if there are any excluded entries in the database + m_ui->showExcluded->setVisible(health->anyExcludedEntries()); } void ReportsWidgetHealthcheck::emitEntryActivated(const QModelIndex& index) diff --git a/src/gui/reports/ReportsWidgetHealthcheck.h b/src/gui/reports/ReportsWidgetHealthcheck.h index 2046326a11..21d121b00b 100644 --- a/src/gui/reports/ReportsWidgetHealthcheck.h +++ b/src/gui/reports/ReportsWidgetHealthcheck.h @@ -56,7 +56,7 @@ public slots: void deleteSelectedEntries(); private: - void addHealthRow(QSharedPointer, Group*, Entry*, bool knownBad); + void addHealthRow(QSharedPointer, Group*, Entry*, bool excluded); QScopedPointer m_ui; diff --git a/src/gui/reports/ReportsWidgetHealthcheck.ui b/src/gui/reports/ReportsWidgetHealthcheck.ui index e2ed44e1b8..5bc2aa1184 100644 --- a/src/gui/reports/ReportsWidgetHealthcheck.ui +++ b/src/gui/reports/ReportsWidgetHealthcheck.ui @@ -55,16 +55,16 @@ - + - Exclude expired entries from the report + Show expired entries - + - Also show entries that have been excluded from reports + Show entries that have been excluded from reports @@ -84,7 +84,7 @@ healthcheckTableView - showKnownBadCheckBox + showExcluded diff --git a/src/gui/reports/ReportsWidgetPasskeys.cpp b/src/gui/reports/ReportsWidgetPasskeys.cpp new file mode 100644 index 0000000000..a50576be6a --- /dev/null +++ b/src/gui/reports/ReportsWidgetPasskeys.cpp @@ -0,0 +1,294 @@ +/* + * 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 + * 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 . + */ + +#include "ReportsWidgetPasskeys.h" +#include "ui_ReportsWidgetPasskeys.h" + +#include "browser/BrowserPasskeys.h" +#include "core/AsyncTask.h" +#include "core/Group.h" +#include "core/Metadata.h" +#include "gui/GuiTools.h" +#include "gui/Icons.h" +#include "gui/passkeys/PasskeyExporter.h" +#include "gui/passkeys/PasskeyImporter.h" +#include "gui/styles/StateColorPalette.h" + +#include +#include +#include +#include + +namespace +{ + class PasskeyList + { + public: + struct Item + { + QPointer group; + QPointer entry; + + Item(Group* g, Entry* e) + : group(g) + , entry(e) + { + } + }; + + explicit PasskeyList(const QSharedPointer&); + + const QList>& items() const + { + return m_items; + } + + private: + QSharedPointer m_db; + QList> m_items; + }; +} // namespace + +PasskeyList::PasskeyList(const QSharedPointer& db) + : m_db(db) +{ + for (auto group : db->rootGroup()->groupsRecursive(true)) { + // Skip recycle bin + if (group->isRecycled()) { + continue; + } + + for (auto entry : group->entries()) { + if (entry->isRecycled() || !entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM)) { + continue; + } + + const auto item = QSharedPointer(new Item(group, entry)); + m_items.append(item); + } + } +} + +ReportsWidgetPasskeys::ReportsWidgetPasskeys(QWidget* parent) + : QWidget(parent) + , m_ui(new Ui::ReportsWidgetPasskeys()) + , m_referencesModel(new QStandardItemModel(this)) + , m_modelProxy(new QSortFilterProxyModel(this)) +{ + m_ui->setupUi(this); + + m_modelProxy->setSourceModel(m_referencesModel.data()); + m_modelProxy->setSortLocaleAware(true); + m_ui->passkeysTableView->setModel(m_modelProxy.data()); + m_ui->passkeysTableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); + m_ui->passkeysTableView->verticalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + + connect(m_ui->passkeysTableView, SIGNAL(customContextMenuRequested(QPoint)), SLOT(customMenuRequested(QPoint))); + connect(m_ui->passkeysTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); + connect(m_ui->passkeysTableView->selectionModel(), + SIGNAL(selectionChanged(QItemSelection, QItemSelection)), + this, + SLOT(selectionChanged())); + connect(m_ui->showExpired, SIGNAL(stateChanged(int)), this, SLOT(updateEntries())); + connect(m_ui->exportButton, SIGNAL(clicked(bool)), this, SLOT(exportPasskey())); + connect(m_ui->importButton, SIGNAL(clicked(bool)), this, SLOT(importPasskey())); + + m_ui->exportButton->setEnabled(false); + + new QShortcut(Qt::Key_Delete, this, SLOT(deleteSelectedEntries())); +} + +ReportsWidgetPasskeys::~ReportsWidgetPasskeys() +{ +} + +void ReportsWidgetPasskeys::addPasskeyRow(Group* group, Entry* entry) +{ + StateColorPalette statePalette; + + auto urlList = entry->getAllUrls(); + auto urlToolTip = tr("List of entry URLs"); + + auto title = entry->title(); + if (entry->isExpired()) { + title.append(tr(" (Expired)")); + } + + auto row = QList(); + row << new QStandardItem(Icons::entryIconPixmap(entry), title); + row << new QStandardItem(Icons::groupIconPixmap(group), group->hierarchy().join("/")); + row << new QStandardItem(entry->username()); + row << new QStandardItem(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY)); + row << new QStandardItem(urlList.join('\n')); + + // Set tooltips + row[2]->setToolTip(urlToolTip); + + // Store entry pointer per table row (used in double click handler) + m_referencesModel->appendRow(row); + m_rowToEntry.append({group, entry}); +} + +void ReportsWidgetPasskeys::loadSettings(QSharedPointer db) +{ + m_db = std::move(db); + m_entriesUpdated = false; + m_referencesModel->clear(); + m_rowToEntry.clear(); + + auto row = QList(); + row << new QStandardItem(tr("Please wait, list of entries with Passkeys is being updated…")); + m_referencesModel->appendRow(row); +} + +void ReportsWidgetPasskeys::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + + if (!m_entriesUpdated) { + // Perform stats calculation on next event loop to allow widget to appear + m_entriesUpdated = true; + QTimer::singleShot(0, this, SLOT(updateEntries())); + } +} + +void ReportsWidgetPasskeys::updateEntries() +{ + m_referencesModel->clear(); + + // Perform the statistics check + const QScopedPointer browserStatistics( + AsyncTask::runAndWaitForFuture([this] { return new PasskeyList(m_db); })); + + // Display the entries + m_rowToEntry.clear(); + for (const auto& item : browserStatistics->items()) { + // Exclude expired entries from report if not requested + if (!m_ui->showExpired->isChecked() && item->entry->isExpired()) { + continue; + } + + addPasskeyRow(item->group, item->entry); + } + + // Set the table header + if (m_referencesModel->rowCount() == 0) { + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("No entries with Passkeys.")); + } else { + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Title") << tr("Path") << tr("Username") + << tr("Relying Party") << tr("URLs")); + m_ui->passkeysTableView->sortByColumn(0, Qt::AscendingOrder); + } + + m_ui->passkeysTableView->resizeColumnsToContents(); +} + +void ReportsWidgetPasskeys::emitEntryActivated(const QModelIndex& index) +{ + if (!index.isValid()) { + return; + } + + auto mappedIndex = m_modelProxy->mapToSource(index); + const auto row = m_rowToEntry[mappedIndex.row()]; + const auto group = row.first; + const auto entry = row.second; + + if (group && entry) { + emit entryActivated(entry); + } +} + +void ReportsWidgetPasskeys::customMenuRequested(QPoint pos) +{ + auto selected = m_ui->passkeysTableView->selectionModel()->selectedRows(); + if (selected.isEmpty()) { + return; + } + + // Create the context menu + const auto menu = new QMenu(this); + + // Create the "edit entry" menu item (only if 1 row is selected) + if (selected.size() == 1) { + const auto edit = new QAction(icons()->icon("entry-edit"), tr("Edit Entry…"), this); + menu->addAction(edit); + connect(edit, &QAction::triggered, edit, [this, selected] { + auto row = m_modelProxy->mapToSource(selected[0]).row(); + auto entry = m_rowToEntry[row].second; + emit entryActivated(entry); + }); + } + + // Create the "delete entry" menu item + const auto delEntry = new QAction(icons()->icon("entry-delete"), tr("Delete Entry(s)…", "", selected.size()), this); + menu->addAction(delEntry); + connect(delEntry, &QAction::triggered, this, &ReportsWidgetPasskeys::deleteSelectedEntries); + + // Show the context menu + menu->popup(m_ui->passkeysTableView->viewport()->mapToGlobal(pos)); +} + +void ReportsWidgetPasskeys::saveSettings() +{ + // Nothing to do - the tab is passive +} + +void ReportsWidgetPasskeys::deleteSelectedEntries() +{ + auto selectedEntries = getSelectedEntries(); + bool permanent = !m_db->metadata()->recycleBinEnabled(); + + if (GuiTools::confirmDeleteEntries(this, selectedEntries, permanent)) { + GuiTools::deleteEntriesResolveReferences(this, selectedEntries, permanent); + } + + updateEntries(); +} + +QList ReportsWidgetPasskeys::getSelectedEntries() +{ + QList selectedEntries; + for (auto index : m_ui->passkeysTableView->selectionModel()->selectedRows()) { + auto row = m_modelProxy->mapToSource(index).row(); + auto entry = m_rowToEntry[row].second; + if (entry) { + selectedEntries << entry; + } + } + + return selectedEntries; +} + +void ReportsWidgetPasskeys::selectionChanged() +{ + m_ui->exportButton->setEnabled(!m_ui->passkeysTableView->selectionModel()->selectedIndexes().isEmpty()); +} + +void ReportsWidgetPasskeys::importPasskey() +{ + PasskeyImporter passkeyImporter; + passkeyImporter.importPasskey(m_db); + + updateEntries(); +} + +void ReportsWidgetPasskeys::exportPasskey() +{ + PasskeyExporter passkeyExporter; + passkeyExporter.showExportDialog(getSelectedEntries()); +} diff --git a/src/gui/reports/ReportsWidgetPasskeys.h b/src/gui/reports/ReportsWidgetPasskeys.h new file mode 100644 index 0000000000..3d0593350c --- /dev/null +++ b/src/gui/reports/ReportsWidgetPasskeys.h @@ -0,0 +1,76 @@ +/* + * 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 + * 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 . + */ + +#ifndef KEEPASSXC_REPORTSWIDGETPASSKEYS_H +#define KEEPASSXC_REPORTSWIDGETPASSKEYS_H + +#include "gui/entry/EntryModel.h" +#include + +class Database; +class Entry; +class Group; +class PasswordHealth; +class QSortFilterProxyModel; +class QStandardItemModel; + +namespace Ui +{ + class ReportsWidgetPasskeys; +} + +class ReportsWidgetPasskeys : public QWidget +{ + Q_OBJECT +public: + explicit ReportsWidgetPasskeys(QWidget* parent = nullptr); + ~ReportsWidgetPasskeys() override; + + void loadSettings(QSharedPointer db); + void saveSettings(); + +protected: + void showEvent(QShowEvent* event) override; + +signals: + void entryActivated(Entry*); + +public slots: + void updateEntries(); + void emitEntryActivated(const QModelIndex& index); + void customMenuRequested(QPoint); + void deleteSelectedEntries(); + +private slots: + void selectionChanged(); + void importPasskey(); + void exportPasskey(); + +private: + void addPasskeyRow(Group*, Entry*); + QList getSelectedEntries(); + + QScopedPointer m_ui; + + bool m_entriesUpdated = false; + QScopedPointer m_referencesModel; + QScopedPointer m_modelProxy; + QSharedPointer m_db; + QList> m_rowToEntry; +}; + +#endif // KEEPASSXC_REPORTSWIDGETPASSKEYS_H diff --git a/src/gui/reports/ReportsWidgetPasskeys.ui b/src/gui/reports/ReportsWidgetPasskeys.ui new file mode 100644 index 0000000000..c1e321fc8b --- /dev/null +++ b/src/gui/reports/ReportsWidgetPasskeys.ui @@ -0,0 +1,102 @@ + + + ReportsWidgetPasskeys + + + + 0 + 0 + 505 + 379 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::CustomContextMenu + + + QAbstractScrollArea::AdjustToContents + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + QAbstractItemView::SelectRows + + + Qt::ElideRight + + + true + + + true + + + false + + + + + + + Show expired entries + + + + + + + + + Import + + + + + + + Export + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + diff --git a/src/gui/styles/base/basestyle.qss b/src/gui/styles/base/basestyle.qss index fa68589895..2c98c48087 100644 --- a/src/gui/styles/base/basestyle.qss +++ b/src/gui/styles/base/basestyle.qss @@ -25,6 +25,10 @@ QCheckBox, QRadioButton { spacing: 10px; } +ReportsDialog QTableView::item { + padding: 4px; +} + DatabaseWidget, DatabaseWidget #groupView, DatabaseWidget #tagView { background-color: palette(window); border: none; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1abe869a41..4c311b69ef 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,4 @@ -# Copyright (C) 2018 KeePassXC Team +# Copyright (C) 2023 KeePassXC Team # Copyright (C) 2010 Felix Geyer # # This program is free software: you can redistribute it and/or modify @@ -233,6 +233,17 @@ endif() if(WITH_XC_BROWSER) add_unit_test(NAME testbrowser SOURCES TestBrowser.cpp LIBS ${TEST_LIBRARIES}) + + if(WITH_XC_BROWSER_PASSKEYS) + # Prevent duplicate linking with macOS + if(APPLE) + add_unit_test(NAME testpasskeys SOURCES TestPasskeys.cpp + LIBS ${TEST_LIBRARIES}) + else() + add_unit_test(NAME testpasskeys SOURCES TestPasskeys.cpp + LIBS keepassxcbrowser ${TEST_LIBRARIES}) + endif() + endif() endif() add_unit_test(NAME testcli SOURCES TestCli.cpp diff --git a/tests/TestPasskeys.cpp b/tests/TestPasskeys.cpp new file mode 100644 index 0000000000..d668d0250f --- /dev/null +++ b/tests/TestPasskeys.cpp @@ -0,0 +1,474 @@ +/* + * 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 + * 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 . + */ + +#include "TestPasskeys.h" +#include "browser/BrowserCbor.h" +#include "browser/BrowserMessageBuilder.h" +#include "browser/BrowserService.h" +#include "crypto/Crypto.h" + +#include +#include +#include +#include + +using namespace Botan::Sodium; + +QTEST_GUILESS_MAIN(TestPasskeys) + +// Register request +// clang-format off +const QString PublicKeyCredentialOptions = + QString("{" + "\"attestation\": \"none\"," + "\"authenticatorSelection\": {" + "\"residentKey\": \"preferred\"," + "\"requireResidentKey\": false," + "\"userVerification\": \"required\"" + "}," + "\"challenge\": ""\"lVeHzVxWsr8MQxMkZF0ti6FXhdgMljqKzgA-q_zk2Mnii3eJ47VF97sqUoYktVC85WAZ1uIASm-a_lDFZwsLfw\"," + "\"pubKeyCredParams\": [" + "{" + "\"type\": \"public-key\"," + "\"alg\": -7" + "}," + "{" + "\"type\": \"public-key\"," + "\"alg\": -257" + "}" + "]," + "\"rp\": {" + "\"name\": \"webauthn.io\"," + "\"id\": \"webauthn.io\"" + "}," + "\"timeout\": 60000," + "\"excludeCredentials\": []," + "\"user\": {" + "\"displayName\": \"Test User\"," + "\"id\": \"VkdWemRDQlZjMlZ5\"," + "\"name\": \"Test User\"" + "}" + "}"); + +// Register response +const QString PublicKeyCredential = + QString("{" + "\"authenticatorAttachment\": \"platform\"," + "\"id\": \"yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8\"," + "\"rawId\": \"cabcc52799707294f060c39d5d29b11796f9718425a813336db53f77ea052cef\"," + "\"response\": {" + "\"attestationObject\": " + "\"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFAAAAAAECAwQFBgcIAQID" + "BAUGBwgAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIAbsrzRbYpFhbRlZA6ZQKsoxxJWoaeXwh-" + "XUuDLNCIXdIlgg4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M\"," + "\"clientDataJSON\": " + "\"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibFZlSHpWeFdzcjhNUXhNa1pGMHRpNkZYaGRnTWxqcUt6Z0EtcV96" + "azJNbmlpM2VKNDdWRjk3c3FVb1lrdFZDODVXQVoxdUlBU20tYV9sREZad3NMZnciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIi" + "wiY3Jvc3NPcmlnaW4iOmZhbHNlfQ\"" + "}," + "\"type\": \"public-key\"" + "}"); + +// Get request +const QString PublicKeyCredentialRequestOptions = + QString("{" + "\"allowCredentials\": [" + "{" + "\"id\": \"yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8\"," + "\"transports\": [\"internal\"]," + "\"type\": \"public-key\"" + "}" + "]," + "\"challenge\": ""\"9z36vTfQTL95Lf7WnZgyte7ohGeF-XRiLxkL-LuGU1zopRmMIUA1LVwzGpyIm1fOBn1QnRa0QH27ADAaJGHysQ\"," + "\"rpId\": \"webauthn.io\"," + "\"timeout\": 60000," + "\"userVerification\": \"required\"" + "}"); +// clang-format on + +void TestPasskeys::initTestCase() +{ + QVERIFY(Crypto::init()); +} + +void TestPasskeys::init() +{ +} + +void TestPasskeys::testBase64WithHexStrings() +{ + const size_t bufSize = 64; + unsigned char buf[bufSize] = {31, 141, 30, 29, 142, 73, 5, 239, 242, 84, 187, 202, 40, 54, 15, 223, + 201, 0, 108, 109, 209, 104, 207, 239, 160, 89, 208, 117, 134, 66, 42, 12, + 31, 66, 163, 248, 221, 88, 241, 164, 6, 55, 182, 97, 186, 243, 162, 162, + 81, 220, 55, 60, 93, 207, 170, 222, 56, 234, 227, 45, 115, 175, 138, 182}; + + auto base64FromArray = browserMessageBuilder()->getBase64FromArray(reinterpret_cast(buf), bufSize); + QCOMPARE(base64FromArray, + QString("H40eHY5JBe_yVLvKKDYP38kAbG3RaM_voFnQdYZCKgwfQqP43VjxpAY3tmG686KiUdw3PF3Pqt446uMtc6-Ktg")); + + auto arrayFromBase64 = browserMessageBuilder()->getArrayFromBase64(base64FromArray); + QCOMPARE(arrayFromBase64.size(), bufSize); + + for (size_t i = 0; i < bufSize; i++) { + QCOMPARE(static_cast(arrayFromBase64.at(i)), buf[i]); + } + + auto randomDataBase64 = browserMessageBuilder()->getRandomBytesAsBase64(24); + QCOMPARE(randomDataBase64.isEmpty(), false); +} + +void TestPasskeys::testDecodeResponseData() +{ + const auto publicKeyCredential = browserMessageBuilder()->getJsonObject(PublicKeyCredential.toUtf8()); + auto response = publicKeyCredential["response"].toObject(); + auto clientDataJson = response["clientDataJSON"].toString(); + auto attestationObject = response["attestationObject"].toString(); + + QVERIFY(!clientDataJson.isEmpty()); + QVERIFY(!attestationObject.isEmpty()); + + // Parse clientDataJSON + auto clientDataByteArray = browserMessageBuilder()->getArrayFromBase64(clientDataJson); + auto clientDataJsonObject = browserMessageBuilder()->getJsonObject(clientDataByteArray); + QCOMPARE(clientDataJsonObject["challenge"], + QString("lVeHzVxWsr8MQxMkZF0ti6FXhdgMljqKzgA-q_zk2Mnii3eJ47VF97sqUoYktVC85WAZ1uIASm-a_lDFZwsLfw")); + QCOMPARE(clientDataJsonObject["origin"], QString("https://webauthn.io")); + QCOMPARE(clientDataJsonObject["type"], QString("webauthn.create")); + + // Parse attestationObject (CBOR decoding needed) + BrowserCbor browserCbor; + auto attestationByteArray = browserMessageBuilder()->getArrayFromBase64(attestationObject); + auto attestationJsonObject = browserCbor.getJsonFromCborData(attestationByteArray); + + // Parse authData + auto authDataJsonObject = attestationJsonObject["authData"].toString(); + auto authDataArray = browserMessageBuilder()->getArrayFromBase64(authDataJsonObject); + QVERIFY(authDataArray.size() >= 37); + + auto authData = browserPasskeys()->parseAuthData(authDataArray); + auto credentialData = authData["credentialData"].toObject(); + auto flags = authData["flags"].toObject(); + auto publicKey = credentialData["publicKey"].toObject(); + + // The attestationObject should include the same ID after decoding with the response root + QCOMPARE(credentialData["credentialId"].toString(), publicKeyCredential["id"].toString()); + QCOMPARE(credentialData["aaguid"].toString(), QString("AQIDBAUGBwgBAgMEBQYHCA")); + QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + QCOMPARE(flags["AT"], true); + QCOMPARE(flags["UP"], true); + QCOMPARE(publicKey["1"], 2); + QCOMPARE(publicKey["3"], -7); + QCOMPARE(publicKey["-1"], 1); + QCOMPARE(publicKey["-2"], QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0")); + QCOMPARE(publicKey["-3"], QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M")); +} + +void TestPasskeys::testLoadingECPrivateKeyFromPem() +{ + const auto publicKeyCredentialRequestOptions = + browserMessageBuilder()->getJsonObject(PublicKeyCredentialRequestOptions.toUtf8()); + const auto privateKeyPem = QString("-----BEGIN PRIVATE KEY-----" + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg5DX2R6I37nMSZqCp" + "XfHlE3UeitkGGE03FqGsdfxIBoOhRANCAAQG7K80W2KRYW0ZWQOmUCrKMcSVqGnl" + "8Ifl1LgyzQiF3eLuf+kPDukdB4NKAwnbQiSxC9Ml/xgy4VOZtx1CBOeD" + "-----END PRIVATE KEY-----"); + + const auto authenticatorData = + browserMessageBuilder()->getArrayFromBase64("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAFAAAAAA"); + const auto clientData = browserMessageBuilder()->getArrayFromBase64( + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiOXozNnZUZlFUTDk1TGY3V25aZ3l0ZTdvaEdlRi1YUmlMeGtMLUx1R1Uxem9wUm" + "1NSVVBMUxWd3pHcHlJbTFmT0JuMVFuUmEwUUgyN0FEQWFKR0h5c1EiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmln" + "aW4iOmZhbHNlfQ"); + + const auto signature = browserPasskeys()->buildSignature(authenticatorData, clientData, privateKeyPem); + QCOMPARE( + browserMessageBuilder()->getBase64FromArray(signature), + QString("MEYCIQCpbDaYJ4b2ofqWBxfRNbH3XCpsyao7Iui5lVuJRU9HIQIhAPl5moNZgJu5zmurkKK_P900Ct6wd3ahVIqCEqTeeRdE")); +} + +void TestPasskeys::testLoadingRSAPrivateKeyFromPem() +{ + const auto privateKeyPem = QString("-----BEGIN PRIVATE KEY-----" + "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC5OHjBHQaRfxxX\n4WHRmqq7e7JgT" + "FRs1bd4dIOFAOZnhNE3vAg2IF5VurmeB+9ye9xh7frw8ubrL0cv\nsBWiJfN5CY3SYGRLbGTtBC0fZ6" + "OhhhjwvVM1GW6nVeRU66atzuo4NBfYXJWIYECd\npRBU4+xsDL4vJnn1mj05+v/Tqp6Uo1HrEPx9+Dc" + "oYJD+cw7+OQ83XeGmjD+Dtm5z\nNIyYdweaafVR4PEUlB3CYZuOq9xcpxay3ps2MuYT1zGoiQqk6fla" + "d+0tBWGY8Lwp\nCVulXCv7ljNJ4gxgQtOqWX8j2hC0hBxeqNYDYbrkECid3TsMTEMcV5uaVJXULg4t" + "\nn6UItA11AgMBAAECggEAC3B0WBxHuieIfllOOOC4H9/7S7fDH2f7+W2cFtQ6pqo9\nCq0WBmkYMmw" + "Xx9hpHoq4TnhhHyL9WzPzuKYD0Vx4gvacV/ckkppFScnQKJ2hF+99\nLax1DbU+UImSknfDDFPYbYld" + "b1CD2rpJG1i6X2fRQ6NuK+F7jE05mqcIyE+ZajK+\nIpx8XFmE+tI1EEWsn3CzxMLiTQfXyFt/drM9i" + "GYfcDjYY+q5vzGU3Kxj68gjc96A\nOra79DGOmwX+4zIwo5sSzI3noHnhWPLsaRtE5jWu21Qkb+1BvB" + "jPmbQfN274OQfy\n8/BNNR/NZM1mJm/8x4Mt+h5d946XlIo0AkyYZXY/UQKBgQDYI3G3BCwaYk6MDMe" + "T\nIamRZo25phPtr3if47dhT2xFWJplIt35sW+6KjD6c1Qpb2aGOUh7JPmb57H54OgJ\nmojkS5tv9Y" + "EQZFfgCCZoeuqBx+ArqtJdkXOiNEFS0dpt44I+eO3Do5pnwKRemH+Y\ncqJ/eMH96UMzYDO7WNsyOyo" + "5UQKBgQDbYU0KbGbTrMEV4T9Q41rZ2TnWzs5moqn2\nCRtB8LOdKAZUG7FRsw5KgC1CvFn3Xuk+qphY" + "GUQeJvv7FjxMRUv4BktNpXju6eUj\n3tWHzI2QOkHaeq/XibwbNomfkdyTjtLX2+v8DBHcZnCSlukxc" + "JISyPqZ6CnTjXGE\nEGB+itBI5QKBgQCA+gWttOusguVkZWvivL+3aH9CPXy+5WsR3o1boE13xDu+Bm" + "R3\n0A5gBTVc/t1GLJf9mMlL0vCwvD5UYoWU1YbC1OtYkCQIaBiYM8TXrCGseF2pMTJ/\na4CZVp10k" + "o3J7W2XYgpgKIzHRQnQ+SeLDT0y3BjHMB9N1SaJsah7/RphQQKBgQCr\nL+4yKAzFOJUjQbVqpT8Lp5" + "qeqJofNOdzef+vIOjHxafKkiF4I0UPlZ276cY6ZfGU\nWQKwHGcvMDSI5fz/d0OksySn3mvT4uhPaV8" + "urMv6s7sXhY0Zn/0NLy2NOwDolBar\nIo2vDKwTVEyb1u75CWKzDemfl66ryj++Uhk6JZAKkQKBgQCc" + "NYVe7m648DzD0nu9\n3lgetBTaAS1zZmMs8Cinj44v0ksfqxrRBzBZcO9kCQqiJZ7uCAaVYcQ+PwkY+" + "05C\n+w1+KvdGcKM+8TQYTQM3s2B9IyKExRS/dbQf9F7stJL+k5vbt6OUerwfmbNI9R3t\ngDZ4DEfo" + "pPivs9dnequ9wfaPOw==" + "-----END PRIVATE KEY-----"); + + const auto authenticatorData = + browserMessageBuilder()->getArrayFromBase64("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAFAAAAAA"); + const auto clientData = browserMessageBuilder()->getArrayFromBase64( + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiOXozNnZUZlFUTDk1TGY3V25aZ3l0ZTdvaEdlRi1YUmlMeGtMLUx1R1Uxem9wUm" + "1NSVVBMUxWd3pHcHlJbTFmT0JuMVFuUmEwUUgyN0FEQWFKR0h5c1EiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmln" + "aW4iOmZhbHNlfQ"); + + const auto signature = browserPasskeys()->buildSignature(authenticatorData, clientData, privateKeyPem); + QCOMPARE( + browserMessageBuilder()->getBase64FromArray(signature), + QString("MOGw6KrerCgPf2mPig7FOTFIUDXYAU1v2uZj89_NgQTg2UddWnAB3JId3pa4zXghj8CkjjadVOI_LvweJGCEpmPQnRby71yFXnja6j" + "Y3woX2b2klG2fB2alGZHHrVg6yVEmnAii4kYSdmoWxI7SmzLftoZfCJNFPFHujx2Pbr-6dIB02sZhtncetT0cpyWobtj9r7C5dIGfm" + "J5n-LccP-F9gXGqtbN605VrIkC2WNztjdk3dAt5FGM_dlIwSe-vP1dKfIuNqAEbgr2IVZAUFn_ZfzUo-XbXTysksuz9JZfEopJBiUi" + "9tjQDNvrYQFqB6wDPqkZAomkbRCohUb3TzCg")); +} + +void TestPasskeys::testCreatingAttestationObjectWithEC() +{ + // Predefined values for a desired outcome + const auto id = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); + const auto predefinedFirst = QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0"); + const auto predefinedSecond = QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M"); + + const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8()); + + auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); + QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + + TestingVariables testingVariables = {id, predefinedFirst, predefinedSecond}; + auto result = browserPasskeys()->buildAttestationObject(publicKeyCredentialOptions, "", id, testingVariables); + QCOMPARE( + QString(result.cborEncoded), + QString("\xA3" + "cfmtdnonegattStmt\xA0hauthDataX\xA4t\xA6\xEA\x92\x13\xC9\x9C/t\xB2$\x92\xB3 \xCF@&*\x94\xC1\xA9P\xA0" + "9\x7F)%\x0B`\x84\x1E\xF0" + "E\x00\x00\x00\x01\x01\x02\x03\x04\x05\x06\x07\b\x01\x02\x03\x04\x05\x06\x07\b\x00 \x8B\xB0\xCA" + "6\x17\xD6\xDE\x01\x11|\xEA\x94\r\xA0R\xC0\x80_\xF3r\xFBr\xB5\x02\x03:" + "\xBAr\x0Fi\x81\xFE\xA5\x01\x02\x03& \x01!X " + "e\xE2\xF2\x1F:cq\xD3G\xEA\xE0\xF7\x1F\xCF\xFA\\\xABO\xF6\x86\x88\x80\t\xAE\x81\x8BT\xB2\x9B\x15\x85~" + "\"X \\\x8E\x1E@\xDB\x97T-\xF8\x9B\xB0\xAD" + "5\xDC\x12^\xC3\x95\x05\xC6\xDF^\x03\xCB\xB4Q\x91\xFF|\xDB\x94\xB7")); + + // Double check that the result can be decoded + BrowserCbor browserCbor; + auto attestationJsonObject = browserCbor.getJsonFromCborData(result.cborEncoded); + + // Parse authData + auto authDataJsonObject = attestationJsonObject["authData"].toString(); + auto authDataArray = browserMessageBuilder()->getArrayFromBase64(authDataJsonObject); + QVERIFY(authDataArray.size() >= 37); + + auto authData = browserPasskeys()->parseAuthData(authDataArray); + auto credentialData = authData["credentialData"].toObject(); + auto flags = authData["flags"].toObject(); + auto publicKey = credentialData["publicKey"].toObject(); + + // The attestationObject should include the same ID after decoding with the response root + QCOMPARE(credentialData["credentialId"].toString(), QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8")); + QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + QCOMPARE(flags["AT"], true); + QCOMPARE(flags["UP"], true); + QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::EC2); + QCOMPARE(publicKey["3"], WebAuthnAlgorithms::ES256); + QCOMPARE(publicKey["-1"], 1); + QCOMPARE(publicKey["-2"], predefinedFirst); + QCOMPARE(publicKey["-3"], predefinedSecond); +} + +void TestPasskeys::testCreatingAttestationObjectWithRSA() +{ + // Predefined values for a desired outcome + const auto id = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); + const auto predefinedModulus = QString("vUhOZnyn8yn7U-nuHlsXZ6WDWLuYvevWWnwtoHxDEQq27vlp7yAfeVvAPkcvhxRcwoCEUespoa5" + "5IDbkpp2Ypd6b15KbB4C-_4gM4r2FK9gfXghLPAXsMhstYv4keNFb4ghdlY5oUU3JCqUSMyOpmd" + "HeX-RikLL0wgGv_tLT2DaDiWeyQCAtiDblr6COuTAU2kTpLc3Bn35geV9Iqw4iT8DwBQ-f8vjnI" + "EDANXKUiRPojfy1q7WwEl-zMv6Ke2jFHxf68u82BSy3u9DOQaa24FAHoCm8Yd0n5IazMyoxyttl" + "tRt8un8myVOGxcXMiR9_kQb9pu1RRLQMQLd-icE1Qw"); + const auto predefinedExponent = QString("AQAB"); + + // Force algorithm to RSA + QJsonArray pubKeyCredParams; + pubKeyCredParams.append(QJsonObject({{"type", "public-key"}, {"alg", -257}})); + + auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8()); + publicKeyCredentialOptions["pubKeyCredParams"] = pubKeyCredParams; + + auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); + QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + + TestingVariables testingVariables = {id, predefinedModulus, predefinedExponent}; + auto result = browserPasskeys()->buildAttestationObject(publicKeyCredentialOptions, "", id, testingVariables); + + // Double check that the result can be decoded + BrowserCbor browserCbor; + auto attestationJsonObject = browserCbor.getJsonFromCborData(result.cborEncoded); + + // Parse authData + auto authDataJsonObject = attestationJsonObject["authData"].toString(); + auto authDataArray = browserMessageBuilder()->getArrayFromBase64(authDataJsonObject); + QVERIFY(authDataArray.size() >= 37); + + auto authData = browserPasskeys()->parseAuthData(authDataArray); + auto credentialData = authData["credentialData"].toObject(); + auto flags = authData["flags"].toObject(); + auto publicKey = credentialData["publicKey"].toObject(); + + // The attestationObject should include the same ID after decoding with the response root + QCOMPARE(credentialData["credentialId"].toString(), QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8")); + QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + QCOMPARE(flags["AT"], true); + QCOMPARE(flags["UP"], true); + QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::RSA); + QCOMPARE(publicKey["3"], WebAuthnAlgorithms::RS256); + QCOMPARE(publicKey["-1"], predefinedModulus); + QCOMPARE(publicKey["-2"], predefinedExponent); +} + +void TestPasskeys::testRegister() +{ + // Predefined values for a desired outcome + const auto predefinedId = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); + const auto predefinedX = QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0"); + const auto predefinedY = QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M"); + const auto origin = QString("https://webauthn.io"); + const auto testDataPublicKey = browserMessageBuilder()->getJsonObject(PublicKeyCredential.toUtf8()); + const auto testDataResponse = testDataPublicKey["response"]; + const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8()); + + TestingVariables testingVariables = {predefinedId, predefinedX, predefinedY}; + auto result = + browserPasskeys()->buildRegisterPublicKeyCredential(publicKeyCredentialOptions, origin, testingVariables); + auto publicKeyCredential = result.response; + QCOMPARE(publicKeyCredential["type"], QString("public-key")); + QCOMPARE(publicKeyCredential["authenticatorAttachment"], QString("platform")); + QCOMPARE(publicKeyCredential["id"], QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8")); + + auto response = publicKeyCredential["response"].toObject(); + auto attestationObject = response["attestationObject"].toString(); + auto clientDataJson = response["clientDataJSON"].toString(); + QCOMPARE(attestationObject, testDataResponse["attestationObject"].toString()); + + // Parse clientDataJSON + auto clientDataByteArray = browserMessageBuilder()->getArrayFromBase64(clientDataJson); + auto clientDataJsonObject = browserMessageBuilder()->getJsonObject(clientDataByteArray); + QCOMPARE(clientDataJsonObject["challenge"], + QString("lVeHzVxWsr8MQxMkZF0ti6FXhdgMljqKzgA-q_zk2Mnii3eJ47VF97sqUoYktVC85WAZ1uIASm-a_lDFZwsLfw")); + QCOMPARE(clientDataJsonObject["origin"], origin); + QCOMPARE(clientDataJsonObject["type"], QString("webauthn.create")); +} + +void TestPasskeys::testGet() +{ + const auto privateKeyPem = QString("-----BEGIN PRIVATE KEY-----" + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg5DX2R6I37nMSZqCp" + "XfHlE3UeitkGGE03FqGsdfxIBoOhRANCAAQG7K80W2KRYW0ZWQOmUCrKMcSVqGnl" + "8Ifl1LgyzQiF3eLuf+kPDukdB4NKAwnbQiSxC9Ml/xgy4VOZtx1CBOeD" + "-----END PRIVATE KEY-----"); + const auto origin = QString("https://webauthn.io"); + const auto id = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); + const auto publicKeyCredentialRequestOptions = + browserMessageBuilder()->getJsonObject(PublicKeyCredentialRequestOptions.toUtf8()); + + auto publicKeyCredential = browserPasskeys()->buildGetPublicKeyCredential( + publicKeyCredentialRequestOptions, origin, id, {}, privateKeyPem); + QVERIFY(!publicKeyCredential.isEmpty()); + QCOMPARE(publicKeyCredential["id"].toString(), id); + + auto response = publicKeyCredential["response"].toObject(); + QCOMPARE(response["authenticatorData"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAFAAAAAA")); + QCOMPARE(response["clientDataJSON"].toString(), + QString("eyJjaGFsbGVuZ2UiOiI5ejM2dlRmUVRMOTVMZjdXblpneXRlN29oR2VGLVhSaUx4a0wtTHVHVTF6b3BSbU1JVUExTFZ3ekdwe" + "UltMWZPQm4xUW5SYTBRSDI3QURBYUpHSHlzUSIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdX" + "Robi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ")); + QCOMPARE( + response["signature"].toString(), + QString("MEUCIHFv0lOOGGloi_XoH5s3QDSs__8yAp9ZTMEjNiacMpOxAiEA04LAfO6TE7j12XNxd3zHQpn4kZN82jQFPntPiPBSD5c")); + + auto clientDataJson = response["clientDataJSON"].toString(); + auto clientDataByteArray = browserMessageBuilder()->getArrayFromBase64(clientDataJson); + auto clientDataJsonObject = browserMessageBuilder()->getJsonObject(clientDataByteArray); + QCOMPARE(clientDataJsonObject["challenge"].toString(), publicKeyCredentialRequestOptions["challenge"].toString()); +} + +void TestPasskeys::testExtensions() +{ + auto extensions = QJsonObject({{"credProps", true}, {"uvm", true}}); + auto result = browserPasskeys()->buildExtensionData(extensions); + + BrowserCbor cbor; + auto extensionJson = cbor.getJsonFromCborData(result); + auto uvmArray = extensionJson["uvm"].toArray(); + QCOMPARE(extensionJson["credProps"].toObject()["rk"].toBool(), true); + QCOMPARE(uvmArray.size(), 1); + QCOMPARE(uvmArray.first().toArray().size(), 3); + + auto partial = QJsonObject({{"props", true}, {"uvm", true}}); + auto faulty = QJsonObject({{"uvx", true}}); + auto partialData = browserPasskeys()->buildExtensionData(partial); + auto faultyData = browserPasskeys()->buildExtensionData(faulty); + + auto partialJson = cbor.getJsonFromCborData(partialData); + QCOMPARE(partialJson["uvm"].toArray().size(), 1); + + auto faultyJson = cbor.getJsonFromCborData(faultyData); + QCOMPARE(faultyJson.size(), 0); +} + +void TestPasskeys::testParseFlags() +{ + auto registerResult = browserPasskeys()->parseFlags("\x45"); + QCOMPARE(registerResult["ED"], false); + QCOMPARE(registerResult["AT"], true); + QCOMPARE(registerResult["BS"], false); + QCOMPARE(registerResult["BE"], false); + QCOMPARE(registerResult["UV"], true); + QCOMPARE(registerResult["UP"], true); + + auto getResult = browserPasskeys()->parseFlags("\x05"); // Only UP and UV + QCOMPARE(getResult["ED"], false); + QCOMPARE(getResult["AT"], false); + QCOMPARE(getResult["BS"], false); + QCOMPARE(getResult["BE"], false); + QCOMPARE(getResult["UV"], true); + QCOMPARE(getResult["UP"], true); +} + +void TestPasskeys::testSetFlags() +{ + auto registerJson = + QJsonObject({{"ED", false}, {"AT", true}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}}); + auto registerResult = browserPasskeys()->setFlagsFromJson(registerJson); + QCOMPARE(registerResult, 0x45); + + auto getJson = + QJsonObject({{"ED", false}, {"AT", false}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}}); + auto getResult = browserPasskeys()->setFlagsFromJson(getJson); + QCOMPARE(getResult, 0x05); + + // With "discouraged", so UV is false + auto discouragedJson = + QJsonObject({{"ED", false}, {"AT", false}, {"BS", false}, {"BE", false}, {"UV", false}, {"UP", true}}); + auto discouragedResult = browserPasskeys()->setFlagsFromJson(discouragedJson); + QCOMPARE(discouragedResult, 0x01); +} diff --git a/tests/TestPasskeys.h b/tests/TestPasskeys.h new file mode 100644 index 0000000000..ef2b68c24c --- /dev/null +++ b/tests/TestPasskeys.h @@ -0,0 +1,47 @@ +/* + * 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 + * 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 . + */ + +#ifndef KEEPASSXC_TESTPASSKEYS_H +#define KEEPASSXC_TESTPASSKEYS_H + +#include + +#include "browser/BrowserPasskeys.h" + +class TestPasskeys : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void init(); + + void testBase64WithHexStrings(); + void testDecodeResponseData(); + + void testLoadingECPrivateKeyFromPem(); + void testLoadingRSAPrivateKeyFromPem(); + void testCreatingAttestationObjectWithEC(); + void testCreatingAttestationObjectWithRSA(); + void testRegister(); + void testGet(); + + void testExtensions(); + void testParseFlags(); + void testSetFlags(); +}; +#endif // KEEPASSXC_TESTPASSKEYS_H From 4a63e37d88b68fd3c99a9fb0d86bf531f61ed0dc Mon Sep 17 00:00:00 2001 From: varjolintu Date: Tue, 24 Oct 2023 17:36:24 +0300 Subject: [PATCH 2/3] Code cleaning --- share/translations/keepassxc_en.ts | 45 ++++--- src/browser/BrowserCbor.cpp | 2 +- src/browser/BrowserCbor.h | 2 +- .../BrowserPasskeysConfirmationDialog.cpp | 15 ++- .../BrowserPasskeysConfirmationDialog.ui | 50 +++++++- src/browser/BrowserService.cpp | 2 +- src/gui/passkeys/PasskeyExporter.cpp | 37 ++++-- src/gui/passkeys/PasskeyImportDialog.cpp | 10 +- src/gui/passkeys/PasskeyImporter.cpp | 26 ++-- tests/TestPasskeys.cpp | 121 +++++++++--------- 10 files changed, 189 insertions(+), 121 deletions(-) diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index cfc478e15e..52237dd2a1 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -864,35 +864,40 @@ Please select the correct database for saving credentials. - Do you want to register Passkey credentials for: -%1 (%2)? + Register new - Existing Passkey found. -Do you want to register a new Passkey for: -%1 (%2)? -Select the existing Passkey and press Update to replace it. + Register + + Timeout in <b>%n</b> seconds... + + + + + - Register new + Do you want to register Passkey for: - Register + %1 (%2) - Authenticate Passkey credentials for:%1? + Existing Passkey found. +Do you want to register a new Passkey for: - - Timeout in <b>%n</b> seconds... - - - - + + Select the existing Passkey and press Update to replace it. + + + + Authenticate Passkey credentials for: + @@ -951,7 +956,7 @@ Do you want to delete the entry? - Passkey + %1 (Passkey) @@ -5976,6 +5981,10 @@ Do you want to overwrite it? Cannot open file "%1" for writing. + + Cannot write to file + + PasskeyImportDialog @@ -6024,11 +6033,11 @@ Do you want to overwrite it? - URL + Database: %1 - Username + Group: diff --git a/src/browser/BrowserCbor.cpp b/src/browser/BrowserCbor.cpp index c4731881cc..bcc7043ce0 100644 --- a/src/browser/BrowserCbor.cpp +++ b/src/browser/BrowserCbor.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 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 diff --git a/src/browser/BrowserCbor.h b/src/browser/BrowserCbor.h index 6117e5c45b..9fcb685335 100644 --- a/src/browser/BrowserCbor.h +++ b/src/browser/BrowserCbor.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 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 diff --git a/src/browser/BrowserPasskeysConfirmationDialog.cpp b/src/browser/BrowserPasskeysConfirmationDialog.cpp index a612f324e0..af62488f16 100644 --- a/src/browser/BrowserPasskeysConfirmationDialog.cpp +++ b/src/browser/BrowserPasskeysConfirmationDialog.cpp @@ -52,13 +52,14 @@ void BrowserPasskeysConfirmationDialog::registerCredential(const QString& userna const QList& existingEntries, int timeout) { - m_ui->confirmationLabel->setText( - tr("Do you want to register Passkey credentials for:\n%1 (%2)?").arg(username, siteId)); + m_ui->firstLabel->setText(tr("Do you want to register Passkey for:")); + m_ui->dataLabel->setText(tr("%1 (%2)").arg(username, siteId)); + m_ui->secondLabel->setText(""); if (!existingEntries.isEmpty()) { - m_ui->confirmationLabel->setText(tr("Existing Passkey found.\nDo you want to register a new Passkey for:\n%1 " - "(%2)?\nSelect the existing Passkey and press Update to replace it.") - .arg(username, siteId)); + m_ui->firstLabel->setText(tr("Existing Passkey found.\nDo you want to register a new Passkey for:")); + m_ui->secondLabel->setText(tr("Select the existing Passkey and press Update to replace it.")); + m_ui->updateButton->setVisible(true); m_ui->confirmButton->setText(tr("Register new")); updateEntriesToTable(existingEntries); @@ -74,7 +75,9 @@ void BrowserPasskeysConfirmationDialog::authenticateCredential(const QListconfirmationLabel->setText(tr("Authenticate Passkey credentials for:%1?").arg(origin)); + m_ui->firstLabel->setText(tr("Authenticate Passkey credentials for:")); + m_ui->dataLabel->setText(origin); + m_ui->secondLabel->setText(""); updateEntriesToTable(entries); startCounter(timeout); } diff --git a/src/browser/BrowserPasskeysConfirmationDialog.ui b/src/browser/BrowserPasskeysConfirmationDialog.ui index 70077a6490..5b566d725b 100755 --- a/src/browser/BrowserPasskeysConfirmationDialog.ui +++ b/src/browser/BrowserPasskeysConfirmationDialog.ui @@ -7,25 +7,69 @@ 0 0 405 - 243 + 282 + + + 0 + 0 + + KeePassXC: Passkey credentials - + + + + true + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + Qt::PlainText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + true - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 10 + + + + diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index 749f60502b..0a2ec6eaf3 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -746,7 +746,7 @@ void BrowserService::addPasskeyToGroup(Group* group, auto* entry = new Entry(); entry->setUuid(QUuid::createUuid()); entry->setGroup(group); - entry->setTitle(QString("%1 (%2)").arg(rpName, tr("Passkey"))); + entry->setTitle(tr("%1 (Passkey)").arg(rpName)); entry->setUsername(username); entry->setUrl(url); entry->setIcon(KEEPASSXCBROWSER_PASSKEY_ICON); diff --git a/src/gui/passkeys/PasskeyExporter.cpp b/src/gui/passkeys/PasskeyExporter.cpp index d546f1c059..36fa2e4499 100644 --- a/src/gui/passkeys/PasskeyExporter.cpp +++ b/src/gui/passkeys/PasskeyExporter.cpp @@ -23,7 +23,8 @@ #include "core/Tools.h" #include "gui/MessageBox.h" #include -#include +#include +#include void PasskeyExporter::showExportDialog(const QList& items) { @@ -53,11 +54,15 @@ void PasskeyExporter::showExportDialog(const QList& items) /** * Creates an export file for a Passkey credential * - * File contents: - * - * - * - * + * File contents in JSON: + * { + * "privateKey": , + * "relyingParty: , + * "url": , + * "userHandle": , + * "userId": , + * "username:" + * } */ void PasskeyExporter::exportSelectedEntry(const Entry* entry, const QString& folder) { @@ -82,13 +87,19 @@ void PasskeyExporter::exportSelectedEntry(const Entry* entry, const QString& fol return; } - QTextStream fileStream(&passkeyFile); - fileStream << entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) << "\n"; - fileStream << entry->url() << "\n"; - fileStream << entry->username() << "\n"; - fileStream << entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID) << "\n"; - fileStream << entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE) << "\n"; - fileStream << entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM); + QJsonObject passkeyObject; + passkeyObject["relyingParty"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY); + passkeyObject["url"] = entry->url(); + passkeyObject["username"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USERNAME); + passkeyObject["userId"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID); + passkeyObject["userHandle"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE); + passkeyObject["privateKey"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM); + + QJsonDocument document(passkeyObject); + if (passkeyFile.write(document.toJson()) < 0) { + MessageBox::information( + nullptr, tr("Cannot write to file"), tr("Cannot open file \"%1\" for writing.").arg(fullPath)); + } passkeyFile.close(); } diff --git a/src/gui/passkeys/PasskeyImportDialog.cpp b/src/gui/passkeys/PasskeyImportDialog.cpp index 347529ad71..2d54ba5bad 100644 --- a/src/gui/passkeys/PasskeyImportDialog.cpp +++ b/src/gui/passkeys/PasskeyImportDialog.cpp @@ -48,10 +48,10 @@ PasskeyImportDialog::~PasskeyImportDialog() void PasskeyImportDialog::setInfo(const QString& url, const QString& username, const QSharedPointer& database) { - m_ui->urlLabel->setText(QString("%1: %2").arg(tr("URL"), url)); - m_ui->usernameLabel->setText(QString("%1: %2").arg(tr("Username"), username)); - m_ui->selectDatabaseLabel->setText(QString("%1: %2").arg(tr("Database"), getDatabaseName(database))); - m_ui->selectGroupLabel->setText(QString("%1:").arg(tr("Group"))); + 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); @@ -104,7 +104,7 @@ void PasskeyImportDialog::selectDatabase() } m_selectedDatabase = selectedDatabase; - m_ui->selectDatabaseLabel->setText(QString("%1: %2").arg(tr("Database"), getDatabaseName(m_selectedDatabase))); + m_ui->selectDatabaseLabel->setText(QString("Database: %1").arg(getDatabaseName(m_selectedDatabase))); addGroups(m_selectedDatabase); } diff --git a/src/gui/passkeys/PasskeyImporter.cpp b/src/gui/passkeys/PasskeyImporter.cpp index a0231e150d..103b1df4e7 100644 --- a/src/gui/passkeys/PasskeyImporter.cpp +++ b/src/gui/passkeys/PasskeyImporter.cpp @@ -17,6 +17,7 @@ #include "PasskeyImporter.h" #include "PasskeyImportDialog.h" +#include "browser/BrowserMessageBuilder.h" #include "browser/BrowserPasskeys.h" #include "browser/BrowserService.h" #include "core/Entry.h" @@ -24,7 +25,6 @@ #include "gui/FileDialog.h" #include "gui/MessageBox.h" #include -#include #include static const QString IMPORTED_PASSKEYS_GROUP = QStringLiteral("Imported Passkeys"); @@ -52,18 +52,22 @@ void PasskeyImporter::importPasskey(QSharedPointer& database) void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer& database) { - QTextStream fileStream(&file); - const auto relyingParty = fileStream.readLine(); - const auto url = fileStream.readLine(); - const auto username = fileStream.readLine(); - const auto password = fileStream.readLine(); - const auto userHandle = fileStream.readLine(); - - QString privateKey; - while (!fileStream.atEnd()) { - privateKey.append(fileStream.readLine() + "\n"); + const auto fileData = file.readAll(); + const auto passkeyObject = browserMessageBuilder()->getJsonObject(fileData); + if (passkeyObject.isEmpty()) { + MessageBox::information(nullptr, + tr("Cannot import Passkey"), + tr("Cannot import Passkey file \"%1\". Data is missing.").arg(file.fileName())); + return; } + const auto relyingParty = passkeyObject["relyingParty"].toString(); + const auto url = passkeyObject["url"].toString(); + const auto username = passkeyObject["username"].toString(); + const auto password = passkeyObject["userId"].toString(); + const auto userHandle = passkeyObject["userHandle"].toString(); + const auto privateKey = passkeyObject["privateKey"].toString(); + if (relyingParty.isEmpty() || username.isEmpty() || password.isEmpty() || userHandle.isEmpty() || privateKey.isEmpty()) { MessageBox::information(nullptr, diff --git a/tests/TestPasskeys.cpp b/tests/TestPasskeys.cpp index d668d0250f..556e287d70 100644 --- a/tests/TestPasskeys.cpp +++ b/tests/TestPasskeys.cpp @@ -32,72 +32,69 @@ QTEST_GUILESS_MAIN(TestPasskeys) // Register request // clang-format off -const QString PublicKeyCredentialOptions = - QString("{" - "\"attestation\": \"none\"," - "\"authenticatorSelection\": {" - "\"residentKey\": \"preferred\"," - "\"requireResidentKey\": false," - "\"userVerification\": \"required\"" - "}," - "\"challenge\": ""\"lVeHzVxWsr8MQxMkZF0ti6FXhdgMljqKzgA-q_zk2Mnii3eJ47VF97sqUoYktVC85WAZ1uIASm-a_lDFZwsLfw\"," - "\"pubKeyCredParams\": [" - "{" - "\"type\": \"public-key\"," - "\"alg\": -7" - "}," - "{" - "\"type\": \"public-key\"," - "\"alg\": -257" - "}" - "]," - "\"rp\": {" - "\"name\": \"webauthn.io\"," - "\"id\": \"webauthn.io\"" - "}," - "\"timeout\": 60000," - "\"excludeCredentials\": []," - "\"user\": {" - "\"displayName\": \"Test User\"," - "\"id\": \"VkdWemRDQlZjMlZ5\"," - "\"name\": \"Test User\"" - "}" - "}"); +const QString PublicKeyCredentialOptions = R"( + { + "attestation": "none", + "authenticatorSelection": { + "residentKey": "preferred", + "requireResidentKey": false, + "userVerification": "required" + }, + "challenge": "lVeHzVxWsr8MQxMkZF0ti6FXhdgMljqKzgA-q_zk2Mnii3eJ47VF97sqUoYktVC85WAZ1uIASm-a_lDFZwsLfw", + "pubKeyCredParams": [ + { + "type": "public-key", + "alg": -7 + }, + { + "type": "public-key", + "alg": -257 + } + ], + "rp": { + "name": "webauthn.io", + "id": "webauthn.io" + }, + "timeout": 60000, + "excludeCredentials": [], + "user": { + "displayName": "Test User", + "id": "VkdWemRDQlZjMlZ5", + "name": "Test User" + } + } +)"; // Register response -const QString PublicKeyCredential = - QString("{" - "\"authenticatorAttachment\": \"platform\"," - "\"id\": \"yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8\"," - "\"rawId\": \"cabcc52799707294f060c39d5d29b11796f9718425a813336db53f77ea052cef\"," - "\"response\": {" - "\"attestationObject\": " - "\"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFAAAAAAECAwQFBgcIAQID" - "BAUGBwgAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIAbsrzRbYpFhbRlZA6ZQKsoxxJWoaeXwh-" - "XUuDLNCIXdIlgg4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M\"," - "\"clientDataJSON\": " - "\"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibFZlSHpWeFdzcjhNUXhNa1pGMHRpNkZYaGRnTWxqcUt6Z0EtcV96" - "azJNbmlpM2VKNDdWRjk3c3FVb1lrdFZDODVXQVoxdUlBU20tYV9sREZad3NMZnciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIi" - "wiY3Jvc3NPcmlnaW4iOmZhbHNlfQ\"" - "}," - "\"type\": \"public-key\"" - "}"); +const QString PublicKeyCredential = R"( + { + "authenticatorAttachment": "platform", + "id": "yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8", + "rawId": "cabcc52799707294f060c39d5d29b11796f9718425a813336db53f77ea052cef", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFAAAAAAECAwQFBgcIAQIDBAUGBwgAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIAbsrzRbYpFhbRlZA6ZQKsoxxJWoaeXwh-XUuDLNCIXdIlgg4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibFZlSHpWeFdzcjhNUXhNa1pGMHRpNkZYaGRnTWxqcUt6Z0EtcV96azJNbmlpM2VKNDdWRjk3c3FVb1lrdFZDODVXQVoxdUlBU20tYV9sREZad3NMZnciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ" + }, + "type": "public-key" + } +)"; // Get request -const QString PublicKeyCredentialRequestOptions = - QString("{" - "\"allowCredentials\": [" - "{" - "\"id\": \"yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8\"," - "\"transports\": [\"internal\"]," - "\"type\": \"public-key\"" - "}" - "]," - "\"challenge\": ""\"9z36vTfQTL95Lf7WnZgyte7ohGeF-XRiLxkL-LuGU1zopRmMIUA1LVwzGpyIm1fOBn1QnRa0QH27ADAaJGHysQ\"," - "\"rpId\": \"webauthn.io\"," - "\"timeout\": 60000," - "\"userVerification\": \"required\"" - "}"); +const QString PublicKeyCredentialRequestOptions = R"( + { + "allowCredentials": [ + { + "id": "yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8", + "transports": ["internal"], + "type": "public-key" + } + ], + "challenge": "9z36vTfQTL95Lf7WnZgyte7ohGeF-XRiLxkL-LuGU1zopRmMIUA1LVwzGpyIm1fOBn1QnRa0QH27ADAaJGHysQ", + "rpId": "webauthn.io", + "timeout": 60000, + "userVerification": "required" + } +)"; // clang-format on void TestPasskeys::initTestCase() From 62d35ab7d76dee856329c0e854c7b0e1700ff205 Mon Sep 17 00:00:00 2001 From: varjolintu Date: Tue, 24 Oct 2023 20:56:11 +0300 Subject: [PATCH 3/3] cleanFilename() adjustments --- src/core/Tools.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/core/Tools.cpp b/src/core/Tools.cpp index 3b621fa005..cefb0448d9 100644 --- a/src/core/Tools.cpp +++ b/src/core/Tools.cpp @@ -415,14 +415,9 @@ namespace Tools { // Remove forward slash from title on all platforms filename.replace("/", "_"); -#ifdef Q_OS_WIN - // Remove invalid characters on Windows + // Remove invalid characters filename.remove(QRegularExpression("[:*?\"<>|]")); -#endif -#ifdef Q_OS_MACOS - // MacOS does not support colons - filename.replace(":", ""); -#endif + return filename.trimmed(); }